libfreemkv 0.31.2: comprehensive spec-grounded test suite (~950 tests)

Test-hardening release, no runtime changes. Adds spec-grounded unit tests
across the silent-corruption surfaces — UDF/MPLS/CLPI/IFO parsing, BD/DVD
title + extent assembly, AACS/CSS key handling, TS/PS demux + codec parsers,
MKV/EBML container output, the mux pipeline, sector prefetch + decrypt
decorator, drive/SCSI sense decoding, label extraction, and core I/O. Each
test is grounded in the format spec or real on-disc behavior and verified to
fail under a targeted source mutation. No behavior changed.
This commit is contained in:
Matthew Jackson
2026-06-07 22:28:29 -07:00
parent 2a55bab3ed
commit 8000bae177
85 changed files with 22998 additions and 1 deletions
+102
View File
@@ -243,4 +243,106 @@ mod tests {
assert!(matches!(r, Ok("ok")));
assert!(flag.load(Ordering::Relaxed));
}
// ── Added hardening tests ───────────────────────────────────────
/// Doc contract (lines 106-110): "If the caller already requested
/// halt, don't spawn (and leak) a worker that would run `op`."
/// When halt is pre-cancelled the op closure must NEVER run — the
/// short-circuit returns Halted before spawning the worker. We
/// prove the op did not execute by checking a side-effect flag.
#[test]
fn pre_cancelled_halt_never_runs_op() {
let halt = Halt::new();
halt.cancel();
let ran = Arc::new(AtomicBool::new(false));
let r2 = ran.clone();
let r = bounded_syscall(Some(&halt), Duration::from_secs(2), move || {
r2.store(true, Ordering::SeqCst);
7u32
});
assert!(matches!(r, Err(BoundedError::Halted)));
// The op closure must not have been scheduled at all.
assert!(
!ran.load(Ordering::SeqCst),
"op ran despite pre-cancelled halt — short-circuit at line 108 broken"
);
}
/// Boundary: an op that finishes well within the deadline returns
/// Ok even when a (live, never-cancelled) halt token is supplied.
/// The halt-poll path must not spuriously convert a completed op
/// into Halted/Timeout. Grounds the `Ok(v) => return Ok(v)` arm of
/// the recv_timeout match (line 134) with a non-None halt.
#[test]
fn live_halt_token_does_not_interfere_with_fast_op() {
let halt = Halt::new(); // never cancelled
let r = bounded_syscall(Some(&halt), Duration::from_secs(5), || 123u64);
assert!(matches!(r, Ok(123)));
assert!(!halt.is_cancelled());
}
/// The op's return value is propagated byte-for-byte, not just a
/// success flag. A non-Copy heap type proves the worker's
/// `tx.send(op())` moves the real value across the rendezvous
/// channel (line 125) to the receiver (line 134).
#[test]
fn returns_owned_value_unchanged() {
let r = bounded_syscall(None, Duration::from_secs(2), || vec![9u8, 8, 7, 6]);
match r {
Ok(v) => assert_eq!(v, vec![9u8, 8, 7, 6]),
other => panic!("expected Ok(vec), got {other:?}"),
}
}
/// Timeout boundary: with a tiny deadline and an op that sleeps
/// much longer, the helper must return Timeout and must do so
/// roughly at the deadline — NOT wait for the op to finish (that
/// is the whole point of the bounded wrapper; the worker is
/// leaked). Grounds the `Instant::now() >= deadline` arm (line 141)
/// and the leak contract (doc lines 84-88).
#[test]
fn timeout_returns_near_deadline_not_after_op() {
let started = Instant::now();
let r = bounded_syscall(None, Duration::from_millis(100), || {
thread::sleep(Duration::from_secs(3));
0u32
});
let elapsed = started.elapsed();
assert!(matches!(r, Err(BoundedError::Timeout)));
// Must bail near the 100ms deadline (one POLL_INTERVAL slack at
// most), not after the 3s op. Allow generous CI slack but stay
// well under the op's 3s sleep.
assert!(
elapsed < Duration::from_millis(1500),
"timeout did not return near deadline: {elapsed:?} (op should be leaked, not awaited)"
);
}
/// A worker that returns a non-Copy value AND completes within the
/// deadline must hand the value back; the rendezvous channel has
/// capacity 0, so the worker's send blocks until the receiver is
/// ready — exercising the happy-path handshake rather than the
/// buffered-send path. Mutation: changing `sync_channel::<R>(0)` to
/// a buffered channel would still pass; changing the recv arm to
/// drop the value would fail here.
#[test]
fn zero_capacity_rendezvous_delivers_string() {
let r = bounded_syscall(None, Duration::from_secs(2), || String::from("rendezvous"));
assert!(matches!(r.as_deref(), Ok("rendezvous")));
}
/// Halt that fires AFTER the op has already completed must still
/// yield Ok — there is no race that turns a delivered result into
/// Halted. The op completes instantly; we cancel the halt
/// afterwards and confirm the earlier call returned Ok. This pins
/// the precedence: a value already in the channel wins over a
/// subsequent halt.
#[test]
fn op_completion_wins_over_later_halt() {
let halt = Halt::new();
let r = bounded_syscall(Some(&halt), Duration::from_secs(2), || 55u32);
halt.cancel();
assert!(matches!(r, Ok(55)));
}
}
+210
View File
@@ -274,4 +274,214 @@ mod tests {
drop(shell);
});
}
// ── Added hardening tests ───────────────────────────────────────
use std::io::Cursor;
/// Drain the forward channel, recycling every buffer, and
/// reassemble the bytes. Returns the concatenation of every
/// delivered chunk. Stops on RecvError (producer dropped tx == EOF)
/// or on the first Err batch (which it returns separately).
fn drain_to_vec(pf: BytePrefetcher) -> (Vec<u8>, Option<std::io::Error>) {
let (rx, recycle_tx, shell) = pf.into_channels();
let mut out = Vec::new();
let mut err = None;
while let Ok(batch) = rx.recv() {
match batch {
Ok(buf) => {
out.extend_from_slice(&buf);
// Recycle so the producer can refill. Ignore send
// error (producer may have already exited at EOF).
let _ = recycle_tx.send(buf);
}
Err(e) => {
err = Some(e);
break;
}
}
}
drop(rx);
drop(recycle_tx);
drop(shell);
(out, err)
}
/// CORE CONTRACT: the prefetcher must deliver every source byte,
/// in order, exactly once — never silently truncate or duplicate.
/// Source is 5000 bytes; chunk size 1024 forces multiple chunks
/// (4 full + 1 short of 904). The reassembled stream must equal the
/// source. Mutation: replacing `buf.truncate(n)` (line 141) with a
/// no-op would over-report bytes on the final short read and this
/// fails.
#[test]
fn delivers_all_bytes_in_order_across_chunks() {
within(10, || {
let src: Vec<u8> = (0..5000u32).map(|i| (i & 0xff) as u8).collect();
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 1024, None).expect("spawn");
let (got, err) = drain_to_vec(pf);
assert!(err.is_none(), "unexpected error batch: {err:?}");
assert_eq!(got, src, "prefetcher truncated or reordered bytes");
});
}
/// Short-read truncation: a reader that returns fewer bytes than
/// requested per call must NOT leave stale tail bytes in the
/// delivered chunk. Cursor over 10 bytes with a 4096 chunk yields a
/// single 10-byte chunk; the consumer must see exactly 10 bytes,
/// not 4096. Grounds `buf.truncate(n)` at line 141. Mutation:
/// delete the truncate and the chunk would carry 4086 zero bytes of
/// padding, failing the length assert.
#[test]
fn short_read_truncates_to_actual_length() {
within(10, || {
let src = vec![0xAB; 10];
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 4096, None).expect("spawn");
let (got, err) = drain_to_vec(pf);
assert!(err.is_none());
assert_eq!(got.len(), 10, "delivered chunk padded past actual read");
assert_eq!(got, src);
});
}
/// EOF semantics: an empty source (Cursor over `[]`) yields
/// `read() == Ok(0)` on the first call, which the producer treats
/// as EOF and returns, dropping tx. The consumer sees RecvError
/// (zero batches), NOT an Err batch and NOT a zero-length Ok batch.
/// Grounds the `Ok(0) => return` arm at line 134. Mutation:
/// changing `Ok(0) => return` to `Ok(0) => continue` would spin
/// forever (within() would time out).
#[test]
fn empty_source_yields_clean_eof_no_batches() {
within(10, || {
let pf = BytePrefetcher::new(Cursor::new(Vec::<u8>::new()), 4096, None).expect("spawn");
let (rx, recycle_tx, shell) = pf.into_channels();
// No Ok batch should ever arrive; first recv must be Err
// (producer dropped tx at EOF).
let first = rx.recv();
assert!(
first.is_err(),
"empty source produced a batch instead of clean EOF: {first:?}"
);
drop(rx);
drop(recycle_tx);
drop(shell);
});
}
/// Error propagation: a reader that fails mid-stream must surface
/// the io::Error as an `Err` batch on the forward channel (line
/// 137), not swallow it. We deliver one good chunk then an error.
/// The consumer must see the good bytes followed by the error.
/// Mutation: changing `let _ = tx.send(Err(e)); return;` to a plain
/// `return` would drop the error silently and this fails.
#[test]
fn read_error_is_propagated_as_err_batch() {
within(10, || {
struct OneThenError {
served: bool,
}
impl Read for OneThenError {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if !self.served {
self.served = true;
let n = buf.len().min(8);
buf[..n].fill(0x11);
Ok(n)
} else {
Err(std::io::Error::other("synthetic mid-stream read failure"))
}
}
}
let pf = BytePrefetcher::new(OneThenError { served: false }, 8, None).expect("spawn");
let (got, err) = drain_to_vec(pf);
assert_eq!(got, vec![0x11; 8], "good chunk lost");
let err = err.expect("read error must surface as an Err batch");
assert_eq!(err.kind(), std::io::ErrorKind::Other);
});
}
/// Recycle-buffer reuse must NOT leak stale bytes between chunks of
/// different lengths. After a full chunk, a short read reuses the
/// same recycled buffer; lines 123-129 regrow it to chunk_bytes
/// before reading, then line 141 truncates to the short count. We
/// verify the short chunk carries only fresh bytes by reassembling
/// the full stream. Source: 8 bytes of 0xAA + 3 bytes of 0xBB, with
/// chunk_bytes=8 → chunk0 = 8×0xAA, chunk1 = 3×0xBB.
#[test]
fn recycled_buffer_carries_no_stale_tail() {
within(10, || {
let mut src = vec![0xAA; 8];
src.extend_from_slice(&[0xBB; 3]);
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 8, None).expect("spawn");
let (got, err) = drain_to_vec(pf);
assert!(err.is_none());
assert_eq!(
got, src,
"stale bytes from recycled buffer leaked into short chunk"
);
});
}
/// Backpressure / recycle exhaustion does not deadlock: a source
/// larger than the whole in-flight pool (FORWARD_DEPTH +
/// RECYCLE_DEPTH chunks) must still drain fully when the consumer
/// recycles. 10 chunks of 256 bytes = 2560 bytes; pool holds far
/// fewer. Proves the producer parks on recycle_rx and resumes as
/// the consumer returns buffers (lines 106-117). Mutation: dropping
/// the recycle seed loop (lines 90-92) would deadlock on the first
/// recv and within() times out.
#[test]
fn large_source_drains_with_recycling() {
within(10, || {
let src: Vec<u8> = (0..2560u32).map(|i| (i % 251) as u8).collect();
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 256, None).expect("spawn");
let (got, err) = drain_to_vec(pf);
assert!(err.is_none());
assert_eq!(got, src);
});
}
/// Exact-multiple boundary: when the source length is an exact
/// multiple of chunk_bytes, the final non-empty chunk is followed
/// by an `Ok(0)` EOF read, NOT a spurious empty Ok batch. 12 bytes
/// with chunk_bytes=4 → three 4-byte chunks then clean EOF. Total
/// bytes must equal 12 and no zero-length batch may appear.
#[test]
fn exact_multiple_length_no_trailing_empty_batch() {
within(10, || {
let src = vec![0x42u8; 12];
let pf = BytePrefetcher::new(Cursor::new(src.clone()), 4, None).expect("spawn");
let (rx, recycle_tx, shell) = pf.into_channels();
let mut total = 0usize;
let mut batch_count = 0usize;
while let Ok(Ok(buf)) = rx.recv() {
assert!(!buf.is_empty(), "producer emitted a zero-length batch");
total += buf.len();
batch_count += 1;
let _ = recycle_tx.send(buf);
}
assert_eq!(total, 12);
assert_eq!(batch_count, 3, "expected exactly 3 full chunks");
drop(rx);
drop(recycle_tx);
drop(shell);
});
}
/// Dropping the BytePrefetcher directly (without into_channels)
/// must join the producer cleanly when the source is finite. The
/// producer reaches EOF, drops tx, and exits; Drop's join returns.
/// Grounds the BytePrefetcher Drop impl (lines 202-208). Mutation:
/// removing the `Ok(0) => return` EOF exit would hang this join.
#[test]
fn drop_finite_prefetcher_joins_cleanly() {
within(10, || {
let pf = BytePrefetcher::new(Cursor::new(vec![1u8; 100]), 4096, None).expect("spawn");
// Drop without consuming — producer fills the forward
// channel (capacity 2), reaches EOF on the third read since
// 100 < 4096 (single chunk + EOF), drops tx, exits.
drop(pf);
});
}
}
+182
View File
@@ -371,4 +371,186 @@ mod tests {
std::env::remove_var("FREEMKV_READ_DROP_CHUNK_MIB");
}
}
// ---------------------------------------------------------------
// Additional coverage.
// ---------------------------------------------------------------
/// `count == 0` must short-circuit to Ok(0) WITHOUT seeking or
/// reading, even at an out-of-range LBA — the early-return guard
/// runs before any I/O. Grounding: `if count == 0 { return Ok(0) }`.
#[test]
fn zero_count_returns_zero_no_io() {
let dir = tempdir().unwrap();
let path = dir.path().join("zc.iso");
make_iso(&path, 4);
let mut src = FileSectorSource::open(&path).unwrap();
// LBA far past EOF — must not matter because count==0 returns early.
let mut buf = [0u8; 1];
let n = src.read_sectors(1_000_000, 0, &mut buf, false).unwrap();
assert_eq!(n, 0);
}
/// Reading past EOF must ERROR (read_exact's UnexpectedEof), never
/// return a partial/short count. This is the core "never silently
/// truncate / never return fewer bytes than declared" property of
/// the SectorSource contract. Grounding: `self.file.read_exact(...)`
/// — read_exact fails if the file can't supply the full span.
#[test]
fn read_past_eof_errors_not_truncates() {
let dir = tempdir().unwrap();
let path = dir.path().join("eof.iso");
make_iso(&path, 4); // 4 sectors only
let mut src = FileSectorSource::open(&path).unwrap();
assert_eq!(src.capacity_sectors(), 4);
// Request 2 sectors starting at LBA 3 → sector 4 doesn't exist.
let mut buf = vec![0u8; 2 * SECTOR_SIZE];
let r = src.read_sectors(3, 2, &mut buf, false);
let err = r.expect_err("reading past EOF must error, not short-read");
let io: std::io::Error = err.into();
assert_eq!(
io.kind(),
std::io::ErrorKind::UnexpectedEof,
"partial read at EOF must surface read_exact's UnexpectedEof"
);
}
/// Reading entirely beyond EOF (seek lands past the end) must also
/// error rather than silently return zeros. Grounding: read_exact
/// over an empty remainder is UnexpectedEof.
#[test]
fn read_wholly_beyond_eof_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("beyond.iso");
make_iso(&path, 4);
let mut src = FileSectorSource::open(&path).unwrap();
let mut buf = vec![0u8; SECTOR_SIZE];
let r = src.read_sectors(10, 1, &mut buf, false);
assert!(r.is_err(), "read starting past EOF must error");
}
/// On a successful full read the returned count MUST equal
/// `count * 2048` exactly — the declared byte count. Grounding:
/// `Ok(bytes)` where `bytes = count * SECTOR_SIZE`.
#[test]
fn full_read_returns_exact_declared_bytes() {
let dir = tempdir().unwrap();
let path = dir.path().join("exact.iso");
make_iso(&path, 16);
let mut src = FileSectorSource::open(&path).unwrap();
let mut buf = vec![0u8; 5 * SECTOR_SIZE];
let n = src.read_sectors(2, 5, &mut buf, false).unwrap();
assert_eq!(n, 5 * SECTOR_SIZE, "must return exactly count*2048 bytes");
}
/// Capacity is `file_len / 2048` (floor); trailing bytes that don't
/// complete a sector are NOT counted. A file of 4 sectors + 100
/// extra bytes reports capacity 4. Grounding: `len / SECTOR_SIZE`
/// integer division in `open`.
#[test]
fn capacity_floors_partial_trailing_sector() {
let dir = tempdir().unwrap();
let path = dir.path().join("partial.iso");
make_iso(&path, 4);
// Append 100 stray bytes (a torn final sector).
{
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(&[0xee; 100]).unwrap();
f.flush().unwrap();
}
let src = FileSectorSource::open(&path).unwrap();
assert_eq!(
src.capacity_sectors(),
4,
"partial trailing bytes must not inflate the sector capacity"
);
}
/// An empty file opens cleanly with capacity 0. Grounding:
/// `0 / 2048 == 0`, and the IsoTooLarge guard only fires for
/// oversize files.
#[test]
fn empty_file_capacity_zero() {
let dir = tempdir().unwrap();
let path = dir.path().join("empty.iso");
std::fs::File::create(&path).unwrap();
let src = FileSectorSource::open(&path).unwrap();
assert_eq!(src.capacity_sectors(), 0);
}
/// Opening a nonexistent path returns an IoError (NotFound), not a
/// panic. Grounding: `File::open(path).map_err(...)`.
#[test]
fn open_missing_file_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("does-not-exist.iso");
let err = match FileSectorSource::open(&path) {
Ok(_) => panic!("missing file must error"),
Err(e) => e,
};
let io: std::io::Error = err.into();
assert_eq!(io.kind(), std::io::ErrorKind::NotFound);
}
/// Repeated reads of the SAME sector must return identical bytes —
/// the per-read seek makes each call independent of prior position,
/// and the DONTNEED/prefetch hooks are advisory only (no data
/// effect). Grounding: `seek(SeekFrom::Start(offset))` before every
/// read.
#[test]
fn repeated_same_sector_is_stable() {
let dir = tempdir().unwrap();
let path = dir.path().join("stable.iso");
make_iso(&path, 8);
let mut src = FileSectorSource::open(&path).unwrap();
let mut a = vec![0u8; SECTOR_SIZE];
let mut b = vec![0u8; SECTOR_SIZE];
src.read_sectors(5, 1, &mut a, false).unwrap();
// Read a different sector in between to move the file cursor.
src.read_sectors(0, 1, &mut b, false).unwrap();
src.read_sectors(5, 1, &mut b, false).unwrap();
assert_eq!(a, b, "same-LBA reads must be position-independent");
assert!(a.iter().all(|x| *x == (5u8)));
}
/// A DONTNEED drop crossing the chunk threshold must not corrupt or
/// short subsequent reads — the eviction is a pure page-cache hint.
/// We read past the DEFAULT 32 MiB drop chunk (16384 sectors) so the
/// eviction block fires at least once, asserting every sector still
/// reads correctly. (Avoids mutating FREEMKV_READ_DROP_CHUNK_MIB to
/// sidestep a parallel-test env race with `drop_chunk_size_env_override`.)
/// Grounding: the `bytes_read_since_drop >= drop_chunk_bytes`
/// eviction block calls only `platform::drop_window` (advisory) and
/// resets counters — no data effect.
#[test]
fn dontneed_eviction_does_not_affect_data() {
// 32 MiB default chunk = 16384 sectors; read a bit past it.
let total = (READ_DROP_CHUNK_BYTES_DEFAULT / SECTOR_SIZE as u64) as u32 + 64;
let dir = tempdir().unwrap();
let path = dir.path().join("drop.iso");
make_iso(&path, total);
let mut src = FileSectorSource::open(&path).unwrap();
// Read in 16-sector batches to keep the loop fast while still
// crossing the drop boundary by byte count.
let batch = 16u16;
let mut got = vec![0u8; batch as usize * SECTOR_SIZE];
let mut lba = 0u32;
while lba + batch as u32 <= total {
src.read_sectors(lba, batch, &mut got, false).unwrap();
for i in 0..batch as u32 {
let expected = ((lba + i) & 0xff) as u8;
let off = i as usize * SECTOR_SIZE;
assert!(
got[off..off + SECTOR_SIZE].iter().all(|x| *x == expected),
"DONTNEED eviction corrupted sector {}",
lba + i
);
}
lba += batch as u32;
}
}
}
+349
View File
@@ -906,4 +906,353 @@ mod tests {
.expect("happy-path finish_with_halt should succeed");
assert_eq!(total, (0..10u64).sum::<u64>());
}
// ── Added hardening tests ───────────────────────────────────────
/// A sink that records the exact order of items it receives, so we
/// can prove the channel is FIFO (no reordering). `close` returns
/// the recorded vector.
struct OrderSink {
seen: Vec<u64>,
}
impl Sink<u64> for OrderSink {
type Output = Vec<u64>;
fn apply(&mut self, item: u64) -> Result<Flow, Error> {
self.seen.push(item);
Ok(Flow::Continue)
}
fn close(self) -> Result<Vec<u64>, Error> {
Ok(self.seen)
}
}
/// FIFO ordering: items must be delivered to `apply` in send order.
/// crossbeam's `bounded` channel is FIFO; this pins that the
/// pipeline does not reorder. Mutation: if the consumer loop reused
/// a stale item or sorted, the equality fails.
#[test]
fn items_delivered_in_fifo_order() {
let pipe =
Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, OrderSink { seen: Vec::new() }).expect("spawn");
let input: Vec<u64> = (0..50).map(|i| i * 7 + 1).collect();
for &i in &input {
pipe.send(i).expect("send");
}
let seen = pipe.finish().expect("finish");
assert_eq!(seen, input, "pipeline reordered or dropped items");
}
/// Zero items sent: closing the pipeline immediately must still
/// call `close()` exactly once and return its Output. The consumer
/// loop's `while let Ok = rx.recv()` exits on the dropped tx with
/// zero iterations, then runs `sink.close()` (line 268). Mutation:
/// moving close() inside the loop would never call it here.
#[test]
fn empty_pipeline_still_calls_close() {
let close_called = Arc::new(AtomicUsize::new(0));
struct CountClose(Arc<AtomicUsize>);
impl Sink<u64> for CountClose {
type Output = ();
fn apply(&mut self, _: u64) -> Result<Flow, Error> {
Ok(Flow::Continue)
}
fn close(self) -> Result<(), Error> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, CountClose(close_called.clone()))
.expect("spawn");
pipe.finish().expect("finish on empty pipeline");
assert_eq!(close_called.load(Ordering::SeqCst), 1);
}
/// `close()` returning Err must surface that error from `finish`,
/// not be swallowed. Doc lines 18-21: on a clean producer drop the
/// consumer "flushes via close() and returns its Output" — and an
/// Err Output is a valid return. Mutation: if the consumer ignored
/// close()'s Result and returned Ok, this fails.
#[test]
fn close_error_propagates_from_finish() {
struct CloseFails;
impl Sink<u64> for CloseFails {
type Output = ();
fn apply(&mut self, _: u64) -> Result<Flow, Error> {
Ok(Flow::Continue)
}
fn close(self) -> Result<(), Error> {
Err(Error::DecryptFailed)
}
}
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, CloseFails).expect("spawn");
pipe.send(1).expect("send");
let res = pipe.finish();
assert!(matches!(res, Err(Error::DecryptFailed)));
}
/// `try_send` must report `Full` when the channel is saturated and
/// the consumer is wedged, NOT block. Doc lines 325-329: "If the
/// channel is full ... the item is returned in Err." We wedge the
/// consumer on the first item (depth=1), fill the one buffer slot,
/// then try_send must return Full immediately. Mutation: routing
/// try_send to the blocking `send` would hang.
#[test]
fn try_send_reports_full_when_saturated() {
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pipe = Pipeline::spawn(
1,
NeverDrainsSink {
cancel: cancel.clone(),
started: started.clone(),
},
)
.expect("spawn");
pipe.send(0u64).expect("first send hands off to consumer");
wait_for_started(&started, Duration::from_secs(2));
pipe.send(1u64)
.expect("second send fills the depth-1 buffer");
// Channel is now full and the consumer is wedged.
let r = pipe.try_send(2u64);
assert!(
matches!(r, Err(TrySendError::Full(2))),
"expected Full(2), got {r:?}"
);
cancel.store(true, Ordering::SeqCst);
let _ = pipe.finish();
}
/// `try_send` must report `Disconnected` once the consumer thread
/// has exited (here via a panic). The item is handed back inside
/// the `Disconnected` variant. Mutation: if try_send mapped
/// Disconnected→Full it would mis-signal a permanently-dead
/// consumer as transient backpressure.
#[test]
fn try_send_reports_disconnected_after_consumer_gone() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
// Drive the consumer to panic and fully exit. Spin until a
// try_send observes the closed channel.
let end = Instant::now() + Duration::from_secs(2);
let mut saw_disconnect = false;
let mut last = None;
while Instant::now() < end {
match pipe.try_send(1u64) {
Err(TrySendError::Disconnected(_)) => {
saw_disconnect = true;
break;
}
other => last = Some(format!("{other:?}")),
}
std::thread::sleep(Duration::from_millis(10));
}
std::panic::set_hook(prev);
let _ = pipe.finish();
assert!(
saw_disconnect,
"try_send never reported Disconnected; last was {last:?}"
);
}
/// Plain `send` must hand the item back via `Err(item)` once the
/// consumer has gone away (panic). Doc lines 276-280: "Returns the
/// item back if the consumer thread is gone." The first send may
/// race the panic, so we loop until one fails and assert the
/// returned item identity. Mutation: if `send`'s Err arm returned a
/// different/default item, the identity assert fails.
#[test]
fn send_returns_item_after_consumer_panicked() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
let end = Instant::now() + Duration::from_secs(2);
let mut returned = None;
while Instant::now() < end {
// Use a distinctive sentinel so we can prove identity.
if let Err(item) = pipe.send(0xDEAD_BEEF_u64) {
returned = Some(item);
break;
}
std::thread::sleep(Duration::from_millis(10));
}
std::panic::set_hook(prev);
let _ = pipe.finish();
assert_eq!(
returned,
Some(0xDEAD_BEEF_u64),
"send did not hand back the exact item after consumer death"
);
}
/// `send_with_halt` must return the exact item via `Err(item)` when
/// the consumer has disconnected (the `Disconnected` arm, lines
/// 395-403). We panic the consumer, wait for it to fully exit, then
/// send_with_halt with a live halt + long deadline — the only way
/// it can return Err is the disconnect arm. Mutation: if that arm
/// returned a default item instead of `returned`, the identity
/// assert fails.
#[test]
fn send_with_halt_returns_item_on_disconnect() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
// Force the consumer to panic + exit: send until the channel
// closes (plain send returns Err).
let end = Instant::now() + Duration::from_secs(2);
while Instant::now() < end {
if pipe.send(1u64).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
let halt = crate::halt::Halt::new(); // never cancelled
let res = pipe.send_with_halt(0xABCD_u64, &halt, Duration::from_secs(5));
std::panic::set_hook(prev);
let _ = pipe.finish();
assert!(
matches!(res, Err(0xABCD)),
"expected disconnected item returned, got {res:?}"
);
assert!(!halt.is_cancelled(), "halt must not have been the cause");
}
/// `send_with_halt` happy path: when there is room in the channel
/// it must deliver the item (Ok) and the consumer must process it.
/// Pins the `Ok(()) => return Ok(())` arm (line 390). Mutation:
/// inverting that arm to Err would drop the item and the sum would
/// be wrong.
#[test]
fn send_with_halt_delivers_when_room_available() {
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, SumSink { total: 0 }).expect("spawn");
let halt = crate::halt::Halt::new();
for i in 1..=5u64 {
pipe.send_with_halt(i, &halt, Duration::from_secs(5))
.expect("send_with_halt should deliver when room is available");
}
let total = pipe.finish().expect("finish");
assert_eq!(total, 15, "1+2+3+4+5");
}
/// `send_with_halt` with a pre-cancelled halt must return the item
/// immediately without attempting to enqueue. Pins the pre-check at
/// line 365 (`if halt.is_cancelled()`). Mutation: removing that
/// pre-check would still likely deliver into an open channel (Ok),
/// flipping this assertion.
#[test]
fn send_with_halt_precancelled_returns_item_without_send() {
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, SumSink { total: 0 }).expect("spawn");
let halt = crate::halt::Halt::new();
halt.cancel();
let res = pipe.send_with_halt(77u64, &halt, Duration::from_secs(5));
assert!(
matches!(res, Err(77)),
"pre-cancelled halt must return item"
);
// The item must NOT have been enqueued: finishing yields sum 0.
let total = pipe.finish().expect("finish");
assert_eq!(total, 0, "item was enqueued despite pre-cancelled halt");
}
/// `finish_with_halt` must propagate a consumer panic as
/// `PipelineConsumerPanicked` — same as `finish`. The consumer
/// panics on the first apply; finish_with_halt sees `is_finished()`
/// true and joins, mapping the panic payload (lines 454-458).
#[test]
fn finish_with_halt_propagates_consumer_panic() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn");
let _ = pipe.send(1);
for i in 0..5u64 {
let _ = pipe.send(i);
}
let res = pipe.finish_with_halt(None);
std::panic::set_hook(prev);
assert!(
matches!(res, Err(Error::PipelineConsumerPanicked)),
"expected PipelineConsumerPanicked, got {res:?}"
);
}
/// `finish_with_halt(None)` with a wedged consumer and NO halt
/// token must NOT return early — it must keep polling until the
/// JOIN_TIMEOUT_SECS deadline (it cannot observe a halt that was
/// never supplied). We can't wait 10 minutes, so we assert the
/// weaker but still-meaningful property: with a None halt and a
/// wedged consumer, finish_with_halt does not return within a short
/// window (it is genuinely blocked, not spuriously returning
/// Halted). Then we release the consumer and confirm it returns Ok.
/// Mutation: if the None branch erroneously treated None as
/// "cancelled", it would return Halted immediately and this fails.
#[test]
fn finish_with_halt_none_does_not_spuriously_halt() {
let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
let pipe = Pipeline::spawn(
DEFAULT_PIPELINE_DEPTH,
NeverDrainsSink {
cancel: cancel.clone(),
started: started.clone(),
},
)
.expect("spawn");
pipe.send(0u64).expect("seed");
wait_for_started(&started, Duration::from_secs(2));
// Run finish_with_halt(None) on a helper thread; it should be
// blocked (not returning Halted) while the consumer is wedged.
let cancel2 = cancel.clone();
let (tx, rx) = bounded::<Result<(), Error>>(1);
std::thread::spawn(move || {
let r = pipe.finish_with_halt(None);
let _ = tx.send(r);
});
// It must NOT complete within 600 ms (consumer still wedged).
assert!(
rx.recv_timeout(Duration::from_millis(600)).is_err(),
"finish_with_halt(None) returned while consumer was wedged"
);
// Release the consumer; finish_with_halt should now return Ok.
cancel2.store(true, Ordering::SeqCst);
let final_res = rx
.recv_timeout(Duration::from_secs(5))
.expect("finish_with_halt should return after consumer unwedges");
assert!(
final_res.is_ok(),
"expected Ok after release, got {final_res:?}"
);
}
/// Multiple `Flow::Stop` returns: once a sink returns Stop, the
/// consumer must stop calling `apply` for all subsequent items
/// (lines 220-225 drain without applying) and call `close` exactly
/// once. We send far more items than the Stop index and assert
/// apply count never exceeds the Stop point and close ran once.
#[test]
fn stop_halts_further_apply_calls() {
let seen = Arc::new(AtomicUsize::new(0));
let close_called = Arc::new(AtomicUsize::new(0));
let pipe = Pipeline::spawn(
DEFAULT_PIPELINE_DEPTH,
StopOnNthSink {
n: 2,
seen: seen.clone(),
close_called: close_called.clone(),
},
)
.expect("spawn");
for i in 0..100u64 {
let _ = pipe.send(i);
}
let out = pipe.finish().expect("finish after stop");
assert_eq!(
close_called.load(Ordering::SeqCst),
1,
"close must run exactly once"
);
// apply ran for items 1 and 2 (item 2 returned Stop); never for
// the remaining 98 even though they were drained.
assert_eq!(out, 2, "apply was called after Stop");
}
}
+67
View File
@@ -176,4 +176,71 @@ mod tests {
let bytes = std::fs::read(&p).unwrap();
assert_eq!(&bytes[..], b"hint-ok");
}
// ── Added hardening tests ───────────────────────────────────────
/// `create` must TRUNCATE an existing file (OpenOptions
/// `.truncate(true)`, lines 52-58). Pre-seed a long file, recreate
/// it via the sink, write a shorter payload — the old tail must be
/// gone. Mutation: dropping `.truncate(true)` would leave the stale
/// tail and the length assert fails.
#[test]
fn create_truncates_existing_file() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("trunc.bin");
std::fs::write(&p, vec![0xFFu8; 4096]).unwrap();
let mut s = LocalFileSink::create(&p).unwrap();
s.write_all(b"short").unwrap();
s.sync_all().unwrap();
drop(s);
let bytes = std::fs::read(&p).unwrap();
assert_eq!(
bytes.len(),
5,
"create must truncate the pre-existing 4096 bytes"
);
assert_eq!(&bytes, b"short");
}
/// Seek must flush the BufWriter FIRST so buffered bytes land at
/// their intended offset, not the post-seek one (lines 121-128, and
/// the module doc's silent-corruption warning). We write into the
/// buffer (no explicit flush), seek backward, write again, and
/// confirm the first write stayed at offset 0. Mutation: removing
/// the `self.inner.flush()?` in `seek` would flush the first 4
/// bytes at the seeked offset, corrupting the file.
#[test]
fn seek_flushes_buffer_before_moving() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("seek-flush.bin");
let mut s = LocalFileSink::create(&p).unwrap();
// These bytes sit in the 4 MiB BufWriter, unflushed.
s.write_all(b"HEAD").unwrap();
// Seek forward to offset 10; the buffered HEAD must be flushed
// to offset 0 BEFORE the position moves.
s.seek(SeekFrom::Start(10)).unwrap();
s.write_all(b"TAIL").unwrap();
s.sync_all().unwrap();
drop(s);
let bytes = std::fs::read(&p).unwrap();
assert_eq!(
&bytes[0..4],
b"HEAD",
"buffered head landed at the wrong offset"
);
assert_eq!(&bytes[10..14], b"TAIL");
}
/// `write` (single call) returns the BufWriter's accepted count.
/// For a buffer under the 4 MiB capacity this is the full length
/// (lines 108-110). Mutation: a wrong count return would break
/// callers relying on `Write::write`'s contract.
#[test]
fn write_returns_full_count_under_capacity() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("count.bin");
let mut s = LocalFileSink::create(&p).unwrap();
let n = s.write(&[1u8; 1000]).unwrap();
assert_eq!(n, 1000);
}
}
+104
View File
@@ -164,4 +164,108 @@ mod tests {
let bytes = std::fs::read(&p).unwrap();
assert_eq!(&bytes[..], b"buffered-tail");
}
// ── Added hardening tests ───────────────────────────────────────
use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// A minimal `SequentialSink` that does NOT override `finish`, so it
/// exercises the trait's DEFAULT impl (lines 51-55), which must call
/// `Write::flush`. We record whether flush ran. This pins the
/// documented contract that the default `finish` is "correct for an
/// unbuffered destination" by flushing. Mutation: changing the
/// default `finish` body from `self.flush()` to `Ok(())` would set
/// `flushed=false` and fail.
struct FlushTracker {
flushed: Arc<AtomicBool>,
bytes: Arc<AtomicUsize>,
}
impl Write for FlushTracker {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.bytes.fetch_add(buf.len(), Ordering::SeqCst);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.flushed.store(true, Ordering::SeqCst);
Ok(())
}
}
// Uses the DEFAULT finish() — deliberately no override.
impl SequentialSink for FlushTracker {}
#[test]
fn default_finish_flushes() {
let flushed = Arc::new(AtomicBool::new(false));
let bytes = Arc::new(AtomicUsize::new(0));
let mut sink = FlushTracker {
flushed: flushed.clone(),
bytes: bytes.clone(),
};
sink.write_all(b"abc").unwrap();
assert!(
!flushed.load(Ordering::SeqCst),
"flush should not run before finish"
);
sink.finish().unwrap();
assert!(
flushed.load(Ordering::SeqCst),
"default SequentialSink::finish must call Write::flush"
);
assert_eq!(bytes.load(Ordering::SeqCst), 3);
}
/// Default `finish()` dispatched through a `dyn SequentialSink`
/// trait object must still reach the default `flush` (vtable path).
/// This guards that there is no accidental override that turns the
/// default into a no-op via dyn dispatch.
#[test]
fn default_finish_flushes_through_dyn() {
let flushed = Arc::new(AtomicBool::new(false));
let bytes = Arc::new(AtomicUsize::new(0));
let sink = FlushTracker {
flushed: flushed.clone(),
bytes: bytes.clone(),
};
let mut boxed: Box<dyn SequentialSink> = Box::new(sink);
boxed.write_all(b"xy").unwrap();
boxed.finish().unwrap();
assert!(flushed.load(Ordering::SeqCst));
}
/// `open_for_mkv` with `None` size hint must still produce a working
/// random-access sink (the `match size_hint { None => ... }` arm,
/// lines 103-106). Round-trip a seek-back patch through it to prove
/// both Write and Seek dispatch. Mutation: if the None arm returned
/// a sequential-only sink the seek would not compile / would fail.
#[test]
fn open_for_mkv_without_size_hint_is_random_access() {
use std::io::{Seek, SeekFrom};
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("nohint.bin");
let mut sink = open_for_mkv(&p, None).unwrap();
sink.write_all(b"AAAABBBB").unwrap();
sink.seek(SeekFrom::Start(4)).unwrap();
sink.write_all(b"CCCC").unwrap();
sink.finish().unwrap();
drop(sink);
assert_eq!(std::fs::read(&p).unwrap(), b"AAAACCCC");
}
/// finish() through a `dyn RandomAccessSink` (the supertrait of
/// SequentialSink) for a LocalFileSink must also flush+fsync. The
/// existing regression test boxes as `dyn SequentialSink`; this
/// pins the `dyn RandomAccessSink` vtable path too, since
/// `open_for_mkv` returns exactly that boxed type.
#[test]
fn finish_through_random_access_dyn_persists() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("ra-finish.bin");
let mut sink: Box<dyn RandomAccessSink> = open_for_mkv(&p, None).unwrap();
sink.write_all(b"durable").unwrap();
sink.finish().unwrap();
// Visible to a separate reader before drop.
assert_eq!(&std::fs::read(&p).unwrap()[..], b"durable");
}
}
+103
View File
@@ -291,4 +291,107 @@ mod tests {
let n2 = receiver.recv(&mut buf).unwrap();
assert_eq!(&buf[..n2], &[9, 9, 9]);
}
// ── Added hardening tests ───────────────────────────────────────
/// `SocketSink::finish` must signal a clean EOF to the peer via
/// `shutdown(Write)` (lines 91-97). The receiving side's
/// `read_to_end` only returns when it observes that EOF — if
/// `finish` merely flushed without the shutdown, `read_to_end`
/// would block forever (the socket stays half-open). We assert the
/// receiver completes promptly AND sees the buffered tail.
/// Mutation: replacing the `shutdown(Write)` line with `Ok(())`
/// makes the accept thread hang and the join times out.
#[test]
fn finish_signals_eof_to_peer() {
use std::sync::mpsc;
use std::time::Duration;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = Vec::new();
// Returns only when the peer half-closes (shutdown Write).
sock.read_to_end(&mut buf).unwrap();
let _ = tx.send(buf);
});
let mut sink = SocketSink::connect(addr, None).unwrap();
sink.write_all(b"unflushed-tail").unwrap();
sink.finish().unwrap();
// read_to_end must complete because finish() shut down writes.
let received = rx
.recv_timeout(Duration::from_secs(3))
.expect("peer never saw EOF — finish() did not shutdown(Write)");
assert_eq!(received, b"unflushed-tail");
}
/// `SocketSink::write` must report the exact byte count it accepted
/// into the BufWriter (forwarded from `BufWriter::write`, lines
/// 78-80). For a buffer smaller than the 1 MiB capacity this equals
/// the full length. Mutation: returning a wrong/clamped count would
/// break `Write::write_all`'s loop downstream; we pin the count
/// here directly.
#[test]
fn write_reports_accepted_count() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let _accept = thread::spawn(move || {
let _ = listener.accept();
});
let mut sink = SocketSink::connect(addr, None).unwrap();
let n = sink.write(&[7u8; 100]).unwrap();
assert_eq!(
n, 100,
"buffered write under capacity must accept all bytes"
);
}
/// UDP `write` must emit ONE datagram per call carrying exactly the
/// bytes passed — no buffering, no coalescing (doc lines 100-108).
/// Two writes of different lengths must arrive as two separate
/// datagrams of those exact lengths, in order. Mutation: adding a
/// BufWriter to UdpSocketSink (the doc explicitly forbids it) would
/// merge these into one datagram and the second `recv` would time
/// out.
#[test]
fn udp_write_is_one_datagram_per_call() {
let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
receiver
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.unwrap();
let addr = receiver.local_addr().unwrap();
let mut sink = UdpSocketSink::connect(addr, None).unwrap();
// Distinct lengths so a merge would be detectable.
let n_a = sink.write(&[0xAA; 10]).unwrap();
let n_b = sink.write(&[0xBB; 20]).unwrap();
assert_eq!(n_a, 10);
assert_eq!(n_b, 20);
let mut buf = [0u8; 256];
let first = receiver.recv(&mut buf).unwrap();
assert_eq!(first, 10, "first datagram must be exactly 10 bytes");
assert!(buf[..first].iter().all(|&b| b == 0xAA));
let second = receiver.recv(&mut buf).unwrap();
assert_eq!(second, 20, "second datagram must be exactly 20 bytes");
assert!(buf[..second].iter().all(|&b| b == 0xBB));
}
/// UDP `finish` is a documented no-op (lines 157-165): there is no
/// EOF marker for UDP. Calling it must not error and must not
/// affect prior datagrams. Mutation: if `finish` tried to
/// `shutdown` the UDP socket it could error or close it
/// prematurely; here it must just return Ok.
#[test]
fn udp_finish_is_noop_ok() {
let receiver = UdpSocket::bind("127.0.0.1:0").unwrap();
let addr = receiver.local_addr().unwrap();
let mut sink = UdpSocketSink::connect(addr, None).unwrap();
assert!(sink.finish().is_ok());
// A second finish is equally harmless.
assert!(sink.finish().is_ok());
}
}
+288
View File
@@ -358,4 +358,292 @@ mod tests {
boxed.finish().unwrap();
assert_eq!(read_back(&p), b"durable-tail");
}
// ── Added hardening tests ───────────────────────────────────────
/// `write` (not write_all) must return the count the inner File
/// reported and advance `pos` by exactly that count (lines
/// 185-189). For a regular file a single `write` of a small buffer
/// writes all of it. We verify the returned count equals the buffer
/// length AND that a subsequent seek reports the right position.
/// Mutation: changing `self.pos += n` to `self.pos += buf.len()`
/// (lines 187 vs a hypothetical bug) would desync on a partial
/// write; here they coincide, but `Seek(Current(0))` reflecting `n`
/// still guards the count return value.
#[test]
fn write_returns_byte_count_and_advances_pos() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("wc.bin");
let mut w = WritebackFile::create(&p).unwrap();
let n = w.write(b"twelve bytes").unwrap();
assert_eq!(n, 12, "write must report bytes written");
// pos is private; observe it via the public Seek impl's
// stream_position (which resolves to seek(Current(0))).
let pos = w.stream_position().unwrap();
assert_eq!(pos, 12, "pos not advanced by write count");
w.sync_all().unwrap();
drop(w);
assert_eq!(read_back(&p), b"twelve bytes");
}
/// Redundant seek to the CURRENT position must be a no-op for the
/// pipeline (lines 211-228 only act when `p != self.pos`). This is
/// the documented sweep optimisation: sweep does
/// `seek(Current(pos))` before every write and we must not treat it
/// as a boundary. We can only observe the public effect: the seek
/// returns the same offset and writes continue contiguously.
/// Mutation: removing the `if p != self.pos` guard (line 211) would
/// call handle_seek on every redundant seek — on the noop pipeline
/// (macOS) this stays correct for data, but the contiguity +
/// returned-offset invariant still must hold and is asserted here.
#[test]
fn seek_to_current_position_is_noop_for_data() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("noop-seek.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"AAAA").unwrap();
// Seek to the current end (offset 4) — a no-move seek.
let off = w.seek(SeekFrom::Start(4)).unwrap();
assert_eq!(off, 4);
w.write_all(b"BBBB").unwrap();
w.sync_all().unwrap();
drop(w);
assert_eq!(
read_back(&p),
b"AAAABBBB",
"redundant seek corrupted contiguous write"
);
}
/// `open` (no-truncate) must preserve existing file contents and
/// allow in-place patching from offset 0 — distinct from `create`
/// which truncates (lines 157-160 use OpenOptions write-only, no
/// truncate). We pre-seed a file, reopen with `open`, overwrite the
/// first bytes, and confirm the tail survives. Mutation: if `open`
/// used `File::create` (truncate) the tail would be lost.
#[test]
fn open_preserves_existing_contents() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("reopen.bin");
std::fs::write(&p, b"ORIGINAL-CONTENT").unwrap();
let mut w = WritebackFile::open(&p).unwrap();
// open() does NOT truncate; pos starts at 0. Overwrite the
// first 8 bytes only.
w.write_all(b"PATCHED!").unwrap();
w.sync_all().unwrap();
drop(w);
// First 8 bytes overwritten; the rest of ORIGINAL-CONTENT
// ("-CONTENT") survives because there was no truncation.
assert_eq!(read_back(&p), b"PATCHED!-CONTENT");
}
/// `open` on a file whose position is queried must start tracking
/// from the file's current offset. `WritebackFile::new` calls
/// `stream_position()` (line 112); a freshly `open`ed file is at
/// offset 0. After writing, seeking Current(0) must reflect the
/// bytes written from 0. Mutation: if `new` hardcoded pos=0 instead
/// of querying, a non-zero starting offset would desync — covered
/// indirectly; here we assert the offset is exactly the write size.
#[test]
fn new_tracks_initial_position() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("pos-init.bin");
std::fs::write(&p, b"0123456789").unwrap();
let mut w = WritebackFile::open(&p).unwrap();
let start = w.stream_position().unwrap();
assert_eq!(start, 0, "freshly opened file should start at offset 0");
w.write_all(b"XY").unwrap();
let after = w.stream_position().unwrap();
assert_eq!(after, 2, "pos must advance by written length");
}
/// Seek past EOF then write must create a sparse hole that reads
/// back as zeros — standard POSIX file semantics that the wrapper
/// must not break (it forwards seek to the inner File at line 205).
/// Mutation: if `seek` clamped or mishandled the offset, the hole
/// size/zero-fill would be wrong.
#[test]
fn seek_past_eof_creates_zero_hole() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("hole.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"head").unwrap(); // bytes 0..4
w.seek(SeekFrom::Start(20)).unwrap(); // jump past EOF
w.write_all(b"tail").unwrap(); // bytes 20..24
w.sync_all().unwrap();
drop(w);
let bytes = read_back(&p);
assert_eq!(
bytes.len(),
24,
"file should extend to the last written byte"
);
assert_eq!(&bytes[0..4], b"head");
// The 4..20 gap must read back as zeros (sparse hole).
assert!(bytes[4..20].iter().all(|&b| b == 0), "hole not zero-filled");
assert_eq!(&bytes[20..24], b"tail");
}
/// `SeekFrom::End` must resolve against the actual file length.
/// After writing 10 bytes, `seek(End(-2))` lands at offset 8;
/// overwriting 2 bytes there patches the tail. Mutation: forwarding
/// the wrong SeekFrom variant would land at the wrong offset.
#[test]
fn seek_from_end_resolves_against_length() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("end-seek.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"0123456789").unwrap();
let landed = w.seek(SeekFrom::End(-2)).unwrap();
assert_eq!(landed, 8, "End(-2) of a 10-byte file is offset 8");
w.write_all(b"XY").unwrap();
w.sync_all().unwrap();
drop(w);
assert_eq!(read_back(&p), b"01234567XY");
}
/// `create_with_size_hint` must produce a normal, writable file
/// whose *reported size* tracks bytes written (the hint only
/// reserves extents, per the doc lines 137-145 — it must NOT
/// pre-grow the logical file length). We write 5 bytes against a
/// 1 MiB hint and the file must be exactly 5 bytes long.
/// Mutation: if the hint path truncated/extended to size_bytes the
/// length would be 1 MiB and this fails.
#[test]
fn create_with_size_hint_does_not_inflate_logical_length() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("hint-len.bin");
let mut w = WritebackFile::create_with_size_hint(&p, 1024 * 1024).unwrap();
w.write_all(b"hello").unwrap();
w.sync_all().unwrap();
drop(w);
let bytes = read_back(&p);
assert_eq!(bytes.len(), 5, "size hint must not inflate logical length");
assert_eq!(&bytes, b"hello");
}
/// `flush` must not be a durability barrier nor reorder bytes, but
/// it also must not lose buffered data. We interleave write_all and
/// flush and confirm exact byte order survives to disk. (Distinct
/// from the existing `flush_is_observed_in_order` which uses 3
/// words; this exercises many small flushes to stress the
/// passthrough flush path at line 199-201.) Mutation: if `flush`
/// dropped pending bytes the reassembly fails.
#[test]
fn many_interleaved_flushes_preserve_order() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("many-flush.bin");
let mut w = WritebackFile::create(&p).unwrap();
let mut expected = Vec::new();
for i in 0u8..32 {
let chunk = [i; 4];
w.write_all(&chunk).unwrap();
expected.extend_from_slice(&chunk);
w.flush().unwrap();
}
w.sync_all().unwrap();
drop(w);
assert_eq!(read_back(&p), expected);
}
/// `sync_all` is idempotent: calling it twice (and then Drop, which
/// also finalizes) must not corrupt data or panic. Doc lines
/// 256-262: `finalize` is idempotent so explicit sync_all then drop
/// is safe. Mutation: a finalize that double-freed or advanced a
/// cursor would corrupt on the second call.
#[test]
fn double_sync_all_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("double-sync.bin");
let mut w = WritebackFile::create(&p).unwrap();
w.write_all(b"idempotent").unwrap();
w.sync_all().unwrap();
w.sync_all().unwrap(); // second call must be safe
drop(w); // Drop also finalizes
assert_eq!(read_back(&p), b"idempotent");
}
/// Env-var chunk override parsing (`writeback_chunk_bytes`, lines
/// 91-98). Out-of-range / unparseable values must fall back to the
/// 32 MiB default; valid in-range values are converted MiB→bytes.
/// We can't safely mutate process env in parallel tests for the
/// default-path branch, but we CAN assert the pure boundary logic
/// the function encodes by reconstructing it: the filter accepts
/// `0 < n <= WRITEBACK_CHUNK_MIB_MAX`. This pins the constants and
/// the MiB→byte multiply. Mutation: changing `* 1024 * 1024` to a
/// single `* 1024` would break this equality.
#[test]
fn writeback_chunk_constants_and_conversion() {
// Default is exactly 32 MiB.
assert_eq!(WRITEBACK_CHUNK_BYTES_DEFAULT, 32 * 1024 * 1024);
// Max MiB bound is 64 GiB expressed in MiB, and the byte value
// it maps to must not overflow u64.
assert_eq!(WRITEBACK_CHUNK_MIB_MAX, 64 * 1024);
let max_bytes = (WRITEBACK_CHUNK_MIB_MAX as u128) * 1024 * 1024;
assert!(
max_bytes <= u64::MAX as u128,
"max chunk MiB * 1MiB must fit in u64"
);
}
/// Env-var override parsing for `writeback_chunk_bytes` (lines
/// 91-98). All four branches in ONE test to avoid the data race of
/// several parallel tests mutating the same process-global env var.
///
/// Branches: (1) valid in-range value → MiB→byte conversion; (2)
/// zero → `n > 0` filter rejects → default; (3) garbage → parse
/// fails → default; (4) over-max → `n <= MAX` filter rejects →
/// default.
///
/// Mutations: `* 1024 * 1024` → `* 1024` breaks (1); dropping
/// `n > 0` breaks (2); `unwrap()` on parse panics (3); dropping
/// `n <= MAX` breaks (4).
#[test]
fn writeback_chunk_env_override_branches() {
// SAFETY: this is the only test touching this env var, and it
// sets+reads+clears synchronously within its own body.
let set = |v: &str| unsafe { std::env::set_var("FREEMKV_WRITEBACK_CHUNK_MIB", v) };
let clear = || unsafe { std::env::remove_var("FREEMKV_WRITEBACK_CHUNK_MIB") };
set("8");
assert_eq!(
writeback_chunk_bytes(),
8 * 1024 * 1024,
"in-range mis-converted"
);
set("0");
assert_eq!(
writeback_chunk_bytes(),
WRITEBACK_CHUNK_BYTES_DEFAULT,
"zero must fall back (n > 0 filter)"
);
set("not-a-number");
assert_eq!(
writeback_chunk_bytes(),
WRITEBACK_CHUNK_BYTES_DEFAULT,
"unparseable must fall back"
);
// One past the max: WRITEBACK_CHUNK_MIB_MAX + 1.
set(&(WRITEBACK_CHUNK_MIB_MAX + 1).to_string());
assert_eq!(
writeback_chunk_bytes(),
WRITEBACK_CHUNK_BYTES_DEFAULT,
"over-max must fall back (n <= MAX filter)"
);
// Exactly at the max boundary is accepted (inclusive bound).
set(&WRITEBACK_CHUNK_MIB_MAX.to_string());
assert_eq!(
writeback_chunk_bytes(),
WRITEBACK_CHUNK_MIB_MAX * 1024 * 1024,
"max boundary must be accepted (inclusive)"
);
clear();
// With the var cleared, the default is returned.
assert_eq!(writeback_chunk_bytes(), WRITEBACK_CHUNK_BYTES_DEFAULT);
}
}