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:
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user