//! The single funnel for every durability-relevant syscall. //! //! Lead-owned (scope 2.3). Nothing else in this crate may call //! `File::sync_all`, `File::sync_data`, `std::fs::rename`, `remove_file`, or //! open a directory descriptor. Two reasons: //! //! 1. `DurabilityCounters` turns "exactly one fence per group" and "no //! per-object fsync" into assertions against observed counts rather than //! claims about the code (Phase 1 exit criteria). //! 2. Fault injection (short write, `ENOSPC`, `EIO` on fence, `EIO` on read) //! has exactly one place to live, so the crash matrix runs unprivileged in //! CI without `dm-flakey`. //! //! The `check-phase1.sh` gate greps the crate for direct calls that bypass //! this module. use std::fs::File; use std::io::{self, IoSlice, Seek, SeekFrom, Write}; use std::path::Path; use std::sync::atomic::Ordering::Relaxed; use crate::types::DurabilityCounters; // --------------------------------------------------------------------------- // Fault injection // --------------------------------------------------------------------------- /// A fault the harness can arm on the next matching call. /// /// Deliberately not a general-purpose filesystem simulator: these are exactly /// the physical failures plan §10's matrix requires ("short writes, fsync /// errors, ENOSPC, corrupt index, corrupt segment, torn tail"). #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Fault { /// Write only `prefix_bytes` of the requested vector and return `Ok`. A /// short write is not an error; it leaves a durable prefix, which is why /// `write_vectored_all` loops and why frame completeness — not a return /// value — decides an outcome (scope 3.7). ShortWrite { prefix_bytes: usize }, /// Fail the write with `ENOSPC`. NoSpace, /// Fail the fence with `EIO`. On Linux this may be reported exactly once, /// after which the kernel has already dropped the dirty pages, so a retry /// can return success while the data is permanently gone. The shard must /// poison and must never retry (scope 3.7). FenceEio, /// Fail a positioned read with `EIO`, modelling a device that lost a write /// it acknowledged. Live on the frozen profile because /// `power_loss_protection = false` (scope 3.8). /// /// Unscoped: consumed by whichever positioned read runs first. That is /// almost never the one a test means, because reopening a shard reads /// `FORMAT`, the manifest, and the journal header before it reaches the /// tail — and an `EIO` on those is legitimately fatal. Use /// [`Fault::ReadEioAtOrAfter`] to target the tail. ReadEio, /// Fail a positioned read with `EIO`, but only at or beyond `offset`. /// /// This exists because the unscoped variant cannot express scope 3.8 step /// 5's central claim — that an `EIO` *in the tail region* ends the tail /// rather than failing the open. A3 found this by writing that test and /// having it fail for the wrong reason: the first `pread` of a reopen ate /// the fault. Scoping by offset is the smallest thing that makes the /// intended state reachable, and it keeps the distinction the rule turns /// on — early read failures stay fatal, tail read failures do not. ReadEioAtOrAfter { offset: u64 }, /// Fail a directory fsync. DirSyncEio, /// Leave the write cursor somewhere other than where the caller expects, /// without failing the write. /// /// Plan §5.2 names "unexpected file position" as a poisoning condition and /// scope 4-A3 requires it be injectable, but no other fault produces it: /// `ShortWrite` returns early with a durable prefix and a *consistent* /// cursor, which is the correct modelling of a short write and therefore /// cannot also model this. Seeks the descriptor after a successful write so /// the post-append position check fails. CursorSkew { delta: i64 }, } #[cfg(feature = "failpoints")] mod fault_state { use super::Fault; use std::sync::Mutex; static ARMED: Mutex> = Mutex::new(None); static SERIAL: Mutex<()> = Mutex::new(()); /// Proof that the holder has exclusive use of the fault registry. /// /// The registry is one process-global slot, so two tests running /// concurrently in the same binary contend for it — and the contention is /// invisible, because the loser does not fail, it merely has someone /// else's fault delivered or its own consumed by an unrelated read. That /// produced a 1-in-5 failure rate in `recovery_eio.rs` that read as /// flakiness for as long as it went unexplained. /// /// Requiring this token in [`arm`] is what makes the invariant hold. A /// lock a test *may* take is a lock a new test will not take: the file /// where this was first diagnosed carried a header saying it was /// deliberately the only test in it, and a second test had been added /// under that comment anyway. A comment cannot fail a build; a parameter /// can. /// /// Hold it for the **whole test body**, not just across `arm`. The /// original defect was a clean `scan_journal` performed *before* arming: /// that scan is itself a funnel read, so it consumed the fault another /// test had armed. Any funnel traffic outside the lock can do this. pub struct FaultSerial(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>); /// Take exclusive use of the fault registry, clearing anything left armed. /// /// Recovers from poisoning rather than propagating it: a test that panics /// while holding this has already reported its own failure, and turning /// that into a cascade of poison errors in every later test would bury the /// real one. Clearing on acquire is the other half — a panicking test /// cannot run its own cleanup, so without this its armed fault would be /// delivered to whichever test ran next. pub fn serial() -> FaultSerial { let guard = SERIAL .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); *ARMED .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; FaultSerial(guard) } pub fn arm(_serial: &FaultSerial, fault: Fault) { *ARMED.lock().expect("fault mutex") = Some(fault); } pub fn disarm(_serial: &FaultSerial) { *ARMED.lock().expect("fault mutex") = None; } /// Consume the armed fault only if this call site is the one it targets. /// /// One-shot, but *selective*. An unconditional take was a real bug: the /// first `write_vectored_all` of a group consumed a fault armed for the /// fence, so `Fault::FenceEio`, `ReadEio`, and `DirSyncEio` could never be /// delivered at all — the whole `EIO`-on-fdatasync half of the fault /// matrix was unreachable through this seam. Every site now takes only its /// own faults, so arming is genuinely positional. pub fn take_if(matches: impl Fn(&Fault) -> bool) -> Option { let mut armed = ARMED.lock().expect("fault mutex"); match armed.as_ref() { Some(fault) if matches(fault) => armed.take(), _ => None, } } } // Reached by A3 through `drive::faults` rather than by making `sys` public; // the module stays private so the funnel invariant holds. #[cfg(feature = "failpoints")] pub use fault_state::{arm, disarm, serial, FaultSerial}; #[cfg(feature = "failpoints")] #[inline] fn take_fault_if(matches: impl Fn(&Fault) -> bool) -> Option { fault_state::take_if(matches) } #[cfg(not(feature = "failpoints"))] #[inline(always)] fn take_fault_if(_matches: impl Fn(&Fault) -> bool) -> Option { None } fn eio(what: &'static str) -> io::Error { io::Error::new(io::ErrorKind::Other, format!("injected EIO during {what}")) } // --------------------------------------------------------------------------- // Write path // --------------------------------------------------------------------------- /// Append a frame group at the file's current position, looping over short /// writes, and verify the resulting position. /// /// The caller owns the descriptor exclusively (one shard thread per shard), /// so the file position is the write cursor. Plan §5.2 names "unexpected file /// position" as a poisoning condition; that check lives here. pub(crate) fn write_vectored_all( file: &mut File, bufs: &[IoSlice<'_>], counters: &DurabilityCounters, ) -> io::Result { let total: usize = bufs.iter().map(|b| b.len()).sum(); let start = file.stream_position()?; // Flatten once. `write_vectored` may consume a partial slice, and // re-slicing `IoSlice`s across a partial consume is where short-write // handling usually goes wrong; a single owned buffer makes the loop // obviously correct at the cost of one copy. A1 may replace this with // proper `IoSlice` advancing once the golden vectors pin the bytes. let mut flat = Vec::with_capacity(total); for buf in bufs { flat.extend_from_slice(buf); } // Only write-path faults. A fault armed for the fence, a read, or a // directory sync must survive this call untouched. let mut fault = take_fault_if(|f| { matches!( f, Fault::ShortWrite { .. } | Fault::NoSpace | Fault::CursorSkew { .. } ) }); let mut written = 0usize; while written < flat.len() { match fault.take() { Some(Fault::NoSpace) => { return Err(io::Error::new( io::ErrorKind::StorageFull, "injected ENOSPC during journal append", )) } Some(Fault::ShortWrite { prefix_bytes }) => { let n = prefix_bytes.min(flat.len() - written); let n = file.write(&flat[written..written + n])?; written += n; counters.write_vectored.fetch_add(1, Relaxed); counters.short_writes.fetch_add(1, Relaxed); counters.bytes_written.fetch_add(n as u64, Relaxed); // The injected short write stands: return with a durable // prefix and no error, exactly as the device may behave. return Ok(start + written as u64); } Some(Fault::CursorSkew { delta }) => { // The write succeeds; only the resulting position is wrong, so // the post-append check below is what must catch it. let n = file.write(&flat[written..])?; counters.write_vectored.fetch_add(1, Relaxed); counters.bytes_written.fetch_add(n as u64, Relaxed); written += n; let here = file.stream_position()?; let moved = here as i64 + delta; file.seek(SeekFrom::Start(moved.max(0) as u64))?; } other => { fault = other; let n = file.write(&flat[written..])?; counters.write_vectored.fetch_add(1, Relaxed); counters.bytes_written.fetch_add(n as u64, Relaxed); if n == 0 { return Err(io::Error::new( io::ErrorKind::WriteZero, "journal append made no progress", )); } if n < flat.len() - written { counters.short_writes.fetch_add(1, Relaxed); } written += n; } } } let end = file.stream_position()?; let expected = start + total as u64; if end != expected { return Err(io::Error::other(format!( "unexpected file position after append: expected {expected}, observed {end}" ))); } Ok(end) } /// The durability fence. Exactly one per group. /// /// `File::sync_data` is `fdatasync(2)` on Linux. A failure here is terminal /// for the shard and must never be retried; see `Fault::FenceEio`. pub(crate) fn fdatasync(file: &File, counters: &DurabilityCounters) -> io::Result<()> { if take_fault_if(|f| matches!(f, Fault::FenceEio)).is_some() { counters.fdatasync.fetch_add(1, Relaxed); return Err(eio("fdatasync")); } counters.fdatasync.fetch_add(1, Relaxed); file.sync_data() } /// Make a directory entry durable. Required after every create, rename, and /// unlink that recovery depends on. 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); return Err(eio("directory fsync")); } counters.fsync_dir.fetch_add(1, Relaxed); File::open(path)?.sync_all() } // --------------------------------------------------------------------------- // Naming // --------------------------------------------------------------------------- /// `renameat2(RENAME_NOREPLACE)`. For every name that must never overwrite: /// new manifests, segments, checkpoints, index runs, and migration/restore /// siblings. Plan §9 forbids a copy fallback. pub(crate) fn rename_noreplace(from: &Path, to: &Path) -> io::Result<()> { rustix::fs::renameat_with( rustix::fs::CWD, from, rustix::fs::CWD, to, rustix::fs::RenameFlags::NOREPLACE, ) .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error())) } /// Plain atomic replacing rename. Exactly one legitimate call site: the /// `CURRENT.tmp` -> `CURRENT` pointer install of scope 3.5, which by /// definition replaces. Every other site uses `rename_noreplace`. pub(crate) fn rename_replace(from: &Path, to: &Path) -> io::Result<()> { std::fs::rename(from, to) } /// Hard-link an existing file under a second name, refusing to overwrite. /// /// Sealing uses this instead of a rename (scope 3.4). A rename would make the /// journal reachable under exactly one name at every instant, so a crash after /// the rename and before the manifest install would leave a fenced, /// acknowledged prefix in a segment that no manifest references and with no /// active journal to scan — losing acknowledged data. Linking keeps the file /// reachable under *both* names across the window; the active name is unlinked /// only after the manifest that references the segment name is durable. pub(crate) fn link_noreplace(existing: &Path, new_name: &Path) -> io::Result<()> { // `link(2)` never replaces an existing destination, so the no-replace // property is inherent rather than a flag. std::fs::hard_link(existing, new_name) } pub(crate) fn unlink(path: &Path) -> io::Result<()> { std::fs::remove_file(path) } // --------------------------------------------------------------------------- // Opening a name this process does not yet own // --------------------------------------------------------------------------- // // These exist for the paths where the store has to look at, and then write // into, a directory it has not established is its own — startup state 1, and // `lock_root` on every path. Plain `File::open` and // `File::options().create(true)` both traverse a symlink at the final // component, so either one at a name an operator can occupy is a write to a // path outside the root. `open`/`stat` are therefore not available on those // paths at all; these are. /// Open `path` read-only for *verification*, refusing to traverse a symlink at /// the final component and refusing anything that is not a regular file. /// /// `Ok(None)` means "there is no regular file at exactly this path": absent, a /// symlink, a directory, a fifo, a socket, a device. Every caller is asking /// whether the bytes at a name are its own, and for all of those the answer is /// no — so they collapse into one variant rather than being distinguished by a /// caller that would treat them identically. /// /// `O_NOFOLLOW` is what makes the symlink case an error rather than a read of /// somebody else's file, and the `fstat` is what makes it a *regular file* /// rather than a name that merely opened: `O_NOFOLLOW` says nothing about /// directories or fifos. The type is read from the descriptor already opened, /// not from a second path lookup, so the answer is about the object this call /// holds and cannot be changed underneath it. /// /// `O_NONBLOCK` is load-bearing rather than incidental. Opening a fifo for /// reading blocks until a writer arrives, so without it a fifo left in a /// configured root would hang startup indefinitely — a denial of service /// 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> { use rustix::fs::{FileType, Mode, OFlags}; let fd = match rustix::fs::open( path, OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, Mode::empty(), ) { Ok(fd) => fd, Err(rustix::io::Errno::NOENT) | Err(rustix::io::Errno::NOTDIR) => return Ok(None), // `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(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)) } else { Ok(None) } } /// Create `path` as a **new** regular file, or report that the name is taken. /// /// `Ok(None)` means the name already exists — as anything at all, including a /// symlink. `O_CREAT | O_EXCL` is specified to fail with `EEXIST` on a symlink /// whether or not the link resolves, so this can never create or truncate a /// file outside the directory it names; `O_NOFOLLOW` is passed as well so the /// intent survives someone later relaxing the `O_EXCL`. /// /// This is the only way anything in this crate may bring a new name into a /// directory the store has not yet established is its own. `create(true)` plus /// `truncate(true)` is the operation it replaces, and the difference is that /// this one cannot destroy a byte it did not write. pub(crate) fn create_new_nofollow(path: &Path) -> io::Result> { use rustix::fs::{Mode, OFlags}; match rustix::fs::open( path, OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::from_raw_mode(0o644), ) { Ok(fd) => Ok(Some(File::from(fd))), Err(rustix::io::Errno::EXIST) => Ok(None), Err(e) => Err(io::Error::from_raw_os_error(e.raw_os_error())), } } /// Open `path` read-write, creating it if absent, refusing to traverse a /// symlink at the final component and refusing anything that is not a regular /// file. Never truncates. /// /// `Ok(None)` means "the name is occupied by something that is not a regular /// file": a symlink, a directory, a fifo, a socket, a device. Absent is not one /// of them — absent is the create case, and it returns the new file. /// /// This is the shape [`open_regular_nofollow`] and [`create_new_nofollow`] do /// not cover: a name the store must *own and keep*, where an existing file is /// adopted rather than replaced and an absent one is brought into being. The /// only caller is `segment::lock_root`, which needs exactly this because /// `/LOCK` is created as a side effect of asking whether a root is busy. /// /// # Why the type check is not cosmetic here /// /// `flock` locks the *inode*, not the name. A symlink at `LOCK` that a /// follow-through open traverses therefore takes the root lock on a foreign /// inode, and the root's own lock file is left unlocked — so a second process /// arriving at the same root takes it too, and both hold what each believes is /// exclusive ownership. That defeats the exclusion scope 3.1 is built on, which /// no amount of care at the `flock` call itself can restore. `O_NOFOLLOW` plus /// this `fstat` is what makes the locked inode provably the one the caller /// named. /// /// One `O_CREAT` open, not an `O_EXCL` create followed by a plain open on /// `EEXIST`: the single call has no window between deciding the name is taken /// and opening what is there, so there is nothing for an adversary replacing /// the name to land in. /// /// # What this still permits /// /// A device node at the name receives one `open(2)` before the `fstat` refuses /// it. `O_NONBLOCK` is passed so that open cannot hang — the fifo case is a /// startup denial of service otherwise — but a driver that acts on being opened /// has still been opened. Closing that would need `O_PATH` for classification /// and a re-open of the same inode, which is more machinery than the hazard /// 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> { use rustix::fs::{FileType, Mode, OFlags}; let fd = match rustix::fs::open( path, OFlags::RDWR | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, Mode::from_raw_mode(0o644), ) { Ok(fd) => fd, // A symlink at the final component: `ELOOP` on Linux, `EMLINK` on some // BSDs. `O_CREAT` through a *dangling* link is the same refusal, which // is what stops this call from creating a file outside the root. Err(rustix::io::Errno::LOOP) | Err(rustix::io::Errno::MLINK) => return Ok(None), // A directory refuses `O_RDWR` before any type check runs. Err(rustix::io::Errno::ISDIR) => return Ok(None), 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)) } else { Ok(None) } } /// Truncate a file to `len`. /// /// In the funnel because it changes what is durable. `journal.rs` was calling /// `File::set_len` directly, which the original guard did not scan for — a /// truncation is a durability-relevant mutation as surely as a write, and one /// that recovery reasons about directly. pub(crate) fn truncate(file: &File, len: u64, counters: &DurabilityCounters) -> io::Result<()> { if take_fault_if(|f| matches!(f, Fault::NoSpace)).is_some() { return Err(io::Error::new( io::ErrorKind::StorageFull, "injected ENOSPC during truncate", )); } counters.write_vectored.fetch_add(1, Relaxed); file.set_len(len) } // --------------------------------------------------------------------------- // Read path and allocation // --------------------------------------------------------------------------- /// Whether an armed read fault applies to a read at `offset`. /// /// An unscoped `ReadEio` fires anywhere; a scoped one fires only at or beyond /// its offset and is left armed until a read actually reaches that far, so a /// reopen's early metadata reads cannot consume a fault aimed at the tail. fn read_fault_fires(offset: u64) -> bool { take_fault_if(|f| match f { Fault::ReadEio => true, Fault::ReadEioAtOrAfter { offset: at } => offset >= *at, Fault::ShortWrite { .. } | Fault::NoSpace | Fault::FenceEio | Fault::DirSyncEio | Fault::CursorSkew { .. } => false, }) .is_some() } /// Bounded positioned read. Segment and journal readers use only this. pub(crate) fn pread_exact(file: &File, offset: u64, buf: &mut [u8]) -> io::Result<()> { if read_fault_fires(offset) { return Err(eio("positioned read")); } use std::os::unix::fs::FileExt; file.read_exact_at(buf, offset) } /// Positioned read that reports a short read rather than failing, so a tail /// scanner can distinguish "the file ends here" from an I/O error. pub(crate) fn pread(file: &File, offset: u64, buf: &mut [u8]) -> io::Result { if read_fault_fires(offset) { return Err(eio("positioned read")); } use std::os::unix::fs::FileExt; file.read_at(buf, offset) } /// Preallocate without `FALLOC_FL_KEEP_SIZE`, so the file has its full size /// with unwritten extents and appends become overwrites within an already /// sized file (scope 3.2). pub(crate) fn preallocate(file: &File, len: u64) -> io::Result<()> { rustix::fs::fallocate(file, rustix::fs::FallocateFlags::empty(), 0, len) .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error())) } /// Non-blocking exclusive lock for `/LOCK`. Failure is refusal, never a /// wait (scope 3.1). pub(crate) fn try_lock_exclusive(file: &File) -> io::Result { match rustix::fs::flock(file, rustix::fs::FlockOperation::NonBlockingLockExclusive) { Ok(()) => Ok(true), Err(rustix::io::Errno::WOULDBLOCK) => Ok(false), Err(e) => Err(io::Error::from_raw_os_error(e.raw_os_error())), } } /// Release a `flock` held on `file`'s **open file description**. /// /// This is not redundant with closing the descriptor. `flock` is a property of /// the open file description, not of the descriptor: any descriptor that /// shares the description keeps the lock alive, and `fork` hands the child /// exactly such a descriptor. `FD_CLOEXEC` closes it at `exec`, not at `fork`, /// so between a concurrent fork and that child's `exec` — or for as long as a /// forked child runs without exec'ing — the parent closing its own descriptor /// releases nothing. Releasing must therefore be an explicit act. /// /// Locking already lives in this module; unlocking stays beside it so the /// durability funnel remains the only place that issues the syscall. pub(crate) fn unlock(file: &File) -> io::Result<()> { rustix::fs::flock(file, rustix::fs::FlockOperation::Unlock) .map_err(|e| io::Error::from_raw_os_error(e.raw_os_error())) } /// Read the file position without moving it. pub(crate) fn cursor(file: &mut File) -> io::Result { file.stream_position() } pub(crate) fn seek_to(file: &mut File, offset: u64) -> io::Result<()> { file.seek(SeekFrom::Start(offset)).map(|_| ()) } #[cfg(test)] mod tests { use super::*; use std::io::Read; fn temp() -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("j"); (dir, path) } #[test] fn write_vectored_all_counts_bytes_and_verifies_position() { let (_dir, path) = temp(); let mut file = File::options() .create(true) .read(true) .write(true) .truncate(true) .open(&path) .expect("open"); let counters = DurabilityCounters::default(); let a = [1u8; 100]; let b = [2u8; 156]; let end = write_vectored_all(&mut file, &[IoSlice::new(&a), IoSlice::new(&b)], &counters) .expect("append"); assert_eq!(end, 256); assert_eq!(counters.snapshot().bytes_written, 256); assert_eq!(counters.snapshot().fdatasync, 0, "append must not fence"); } #[test] fn one_fence_per_group_is_observable() { let (_dir, path) = temp(); let file = File::options() .create(true) .write(true) .truncate(true) .open(&path) .expect("open"); let counters = DurabilityCounters::default(); for _ in 0..7 { fdatasync(&file, &counters).expect("fence"); } assert_eq!(counters.snapshot().fdatasync, 7); } #[test] fn preallocated_region_reads_back_as_zeros() { let (_dir, path) = temp(); let file = File::options() .create(true) .read(true) .write(true) .truncate(true) .open(&path) .expect("open"); preallocate(&file, 8192).expect("fallocate"); assert_eq!(file.metadata().expect("stat").len(), 8192); let mut buf = [0xAAu8; 64]; pread_exact(&file, 4096, &mut buf).expect("read"); assert!( buf.iter().all(|b| *b == 0), "the region past the write cursor must read as zeros, \ which is what stops a tail scan cleanly" ); } #[test] fn rename_noreplace_refuses_an_occupied_destination() { let dir = tempfile::tempdir().expect("tempdir"); let from = dir.path().join("a"); let to = dir.path().join("b"); std::fs::write(&from, b"new").expect("write"); std::fs::write(&to, b"existing").expect("write"); let err = rename_noreplace(&from, &to).expect_err("must refuse"); assert_eq!(err.raw_os_error(), Some(libc_eexist())); let mut kept = String::new(); File::open(&to) .expect("open") .read_to_string(&mut kept) .expect("read"); assert_eq!(kept, "existing", "the destination must be untouched"); } #[test] fn rename_replace_installs_over_an_existing_pointer() { let dir = tempfile::tempdir().expect("tempdir"); let from = dir.path().join("CURRENT.tmp"); let to = dir.path().join("CURRENT"); std::fs::write(&from, b"new").expect("write"); std::fs::write(&to, b"old").expect("write"); rename_replace(&from, &to).expect("install"); assert_eq!(std::fs::read(&to).expect("read"), b"new"); } #[test] fn exclusive_lock_refuses_a_second_holder_without_waiting() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("LOCK"); let a = File::options() .create(true) .write(true) .open(&path) .expect("open a"); let b = File::options() .create(true) .write(true) .open(&path) .expect("open b"); assert!(try_lock_exclusive(&a).expect("lock a")); assert!( !try_lock_exclusive(&b).expect("lock b"), "a second holder must be refused, not blocked" ); } fn libc_eexist() -> i32 { 17 } }