Files
scpcap/src/chunker.rs
T
eric 738ac26210 Fix confirmed_len regression bug in refresh_tail; add verbose tail logging
Root cause of the production hang reported after the previous fix:
refresh_tail unconditionally replaced the chunker's entire `pending` buffer
with the fresh read, discarding pending[..complete_len] -- content already
confirmed real but not yet drained (anything under the 256KiB emit target).
Since a fresh read always starts exactly at confirmed_len (past that
already-confirmed prefix), the next rescan() started from complete_len=0
within a buffer that no longer contained the records needed to reprove it,
silently regressing confirmed_len. In a small buffer this just caused
wasteful oscillation (each poll's full remaining read happened to
re-derive the same progress); at production scale, once a poll's window
failed to independently re-establish the same high-water mark (e.g. capped
by READ_WINDOW, or landing on an unlucky boundary), confirmed_len could
regress and then get permanently wedged well behind the real write cursor,
manifesting as a "torn/corrupt" bail with confirmed_len frozen at a value
far below the real content size, despite the source visibly still growing.

Fix: refresh_tail now truncates pending to complete_len (keeping the
confirmed-but-undrained prefix intact) before appending the fresh bytes,
instead of replacing pending wholesale. Added a regression test
(refresh_tail_never_discards_already_confirmed_undrained_content) that
fails against the old behavior and passes against the fix -- verified by
temporarily reverting the fix and confirming the test catches it.

Also add verbose (deliberately noisy for now) tail.rs diagnostics: a log
line on every confirmed_len advance, and a throttled "stuck" line
(including a byte preview of what's at the confirmed boundary and whether
it changed since the last check) whenever there's more to read but nothing
validates -- both before and after rename, where previously there was no
progress visibility at all before rename was observed. This is what
surfaced the bug: real production logs showed confirmed_len permanently
frozen at a fixed byte count for 4+ minutes while the source kept growing,
which is inconsistent with the "held back by one record" design and
pointed straight at a state-management bug rather than a writer-side or
filesystem-caching issue.
2026-08-28 09:42:42 -04:00

775 lines
31 KiB
Rust

use anyhow::{Result, bail};
use std::time::{Duration, Instant};
pub const GLOBAL_HEADER_LEN: usize = 24;
pub const RECORD_HEADER_LEN: usize = 16;
pub const DEFAULT_FLUSH: Duration = Duration::from_millis(100);
#[allow(dead_code)]
pub const DEFAULT_TARGET_BYTES: usize = 256 * 1024;
/// Sanity bound on a record's declared payload length, per
/// STREAMING_ZSTD_FORMAT.md §8/§9. Only enforced for partial (still-growing)
/// sources -- see `RecordAlignedChunker::partial`.
pub const MAX_INCL_LEN: usize = 262_144;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChunkKind {
Header,
Data,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Endianness {
Little,
Big,
}
fn detect_endianness(header: &[u8]) -> Result<Endianness> {
let magic = u32::from_le_bytes(header[0..4].try_into().unwrap());
match magic {
0xA1B2C3D4 | 0xA1B23C4D => Ok(Endianness::Little),
0xD4C3B2A1 | 0x4D3CB2A1 => Ok(Endianness::Big),
_ => bail!("unrecognized pcap global header magic: {magic:#010x}"),
}
}
fn read_u32(bytes: &[u8], e: Endianness) -> u32 {
let arr: [u8; 4] = bytes.try_into().unwrap();
match e {
Endianness::Little => u32::from_le_bytes(arr),
Endianness::Big => u32::from_be_bytes(arr),
}
}
/// Turns a stream of raw source bytes into chunks ready to hand to the wire
/// protocol. `RawChunker` is a passthrough for the non-compress, non-partial
/// path; `RecordAlignedChunker` cuts on pcap record boundaries for the
/// compress path and/or any partial (still-growing) source.
pub trait Chunker: Send {
fn feed(&mut self, bytes: &[u8]) -> Result<()>;
/// Pop a chunk that's ready by size/target. Call repeatedly until `None`.
fn ready_chunk(&mut self) -> Option<(ChunkKind, Vec<u8>)>;
/// Whether a flush-timer-triggered short chunk should be cut right now.
fn should_flush(&self, now: Instant) -> bool;
/// Force-cut whatever's pending (still respecting record alignment).
fn flush(&mut self) -> Option<(ChunkKind, Vec<u8>)>;
/// Called once, after the source is confirmed fully drained (per the
/// caller's own `confirmed_len() == real final length` check -- this no
/// longer re-derives that itself). Only errors if a complete global
/// header was never read.
fn finish(&mut self) -> Result<Option<(ChunkKind, Vec<u8>)>>;
/// Next source offset the caller should read from to get new bytes via
/// `feed`. Monotonically non-decreasing.
fn read_from(&self) -> u64;
/// Cumulative leading source bytes confirmed to form a complete,
/// plausible global header and/or pcap records. Monotonically
/// non-decreasing. Equal to `read_from()` for a chunker with no
/// "unconfirmed tail" concept.
fn confirmed_len(&self) -> u64;
/// Re-validating feed for a source that may still be overwriting
/// not-yet-confirmed bytes in place (a preallocating writer). `bytes`
/// MUST be a fresh disk read starting exactly at `confirmed_len()`; it
/// replaces whatever was buffered beyond the last confirmed boundary and
/// re-validates from scratch. Only called by tail.rs's partial-source
/// path.
///
/// `trust_trailing_record`: normally a candidate record is only
/// confirmed once the *next* record's header has also been observed and
/// looks real (proof the writer moved past it). When the caller is
/// certain no more bytes will EVER arrive (rename observed and file
/// length stable across repeated checks), it passes `true` to relax
/// that requirement for exactly the trailing record: a record that is
/// itself plausible AND is followed by nothing at all gets confirmed
/// without needing a next-record proof, since there provably isn't one.
///
/// Default delegates to `feed` -- correct for a chunker with no
/// unconfirmed-tail/next-record concept (RawChunker).
fn refresh_tail(&mut self, offset: u64, bytes: &[u8], trust_trailing_record: bool) -> Result<()> {
let _ = (offset, trust_trailing_record);
self.feed(bytes)
}
}
#[derive(Default)]
pub struct RawChunker {
pending: Vec<u8>,
total_fed: u64,
}
impl RawChunker {
pub fn new() -> Self {
Self::default()
}
}
impl Chunker for RawChunker {
fn feed(&mut self, bytes: &[u8]) -> Result<()> {
self.pending.extend_from_slice(bytes);
self.total_fed += bytes.len() as u64;
Ok(())
}
fn ready_chunk(&mut self) -> Option<(ChunkKind, Vec<u8>)> {
if self.pending.is_empty() {
None
} else {
Some((ChunkKind::Data, std::mem::take(&mut self.pending)))
}
}
fn should_flush(&self, _now: Instant) -> bool {
false
}
fn flush(&mut self) -> Option<(ChunkKind, Vec<u8>)> {
None
}
fn finish(&mut self) -> Result<Option<(ChunkKind, Vec<u8>)>> {
Ok(self.ready_chunk())
}
fn read_from(&self) -> u64 {
self.total_fed
}
fn confirmed_len(&self) -> u64 {
self.total_fed
}
}
pub struct RecordAlignedChunker {
header_buf: Vec<u8>,
header_done: bool,
header_ready: bool,
endianness: Option<Endianness>,
snaplen: u32,
pending: Vec<u8>,
complete_len: usize,
emitted_len: u64,
last_growth: Instant,
target: usize,
flush_after: Duration,
/// Whether this source is a still-growing (partial) file. Gates every
/// heuristic below (zero-header wait, zero-ts_sec rejection, incl_len
/// bound, next-record proof requirement) -- a non-partial source is
/// stable and final, so none of these preallocation-writer defenses are
/// needed or safe to apply (they could reject genuinely final content).
partial: bool,
}
impl RecordAlignedChunker {
pub fn new(target: usize, flush_after: Duration, partial: bool) -> Self {
Self {
header_buf: Vec::with_capacity(GLOBAL_HEADER_LEN),
header_done: false,
header_ready: false,
endianness: None,
snaplen: 0,
pending: Vec::new(),
complete_len: 0,
emitted_len: 0,
last_growth: Instant::now(),
target,
flush_after,
partial,
}
}
fn max_incl_len(&self) -> usize {
(self.snaplen as usize).max(MAX_INCL_LEN)
}
fn validate_header(&mut self) -> Result<()> {
// On a partial source, a header that hasn't been written yet reads
// back as zero bytes if the writer preallocated the file before
// writing into it -- treat that as "not yet", not a hard error.
if self.partial && self.header_buf.iter().all(|&b| b == 0) {
return Ok(());
}
let e = detect_endianness(&self.header_buf)?;
self.snaplen = read_u32(&self.header_buf[16..20], e);
self.endianness = Some(e);
self.header_done = true;
self.header_ready = true;
Ok(())
}
fn rescan(&mut self, trust_trailing_record: bool) {
let e = match self.endianness {
Some(e) => e,
None => return,
};
loop {
let remaining = &self.pending[self.complete_len..];
if remaining.len() < RECORD_HEADER_LEN {
break;
}
let ts_sec = read_u32(&remaining[0..4], e);
let incl_len = read_u32(&remaining[8..12], e) as usize;
let record_total = RECORD_HEADER_LEN + incl_len;
if remaining.len() < record_total {
break;
}
if self.partial {
// Heuristic: a live capture's timestamp is never the Unix
// epoch. An all-zero-looking ts_sec is essentially
// certainly unwritten/preallocated disk space, not a real
// record -- treat it exactly like "not enough bytes yet"
// (pause, no error), so a later re-peek can pick up real
// content once the writer's cursor reaches this offset.
if ts_sec == 0 {
break;
}
if incl_len > self.max_incl_len() {
break;
}
// Never trust a record's own header alone: a writer need
// not flush header and payload atomically, so a
// fully-plausible header can still be sitting in front of a
// payload that's still mid-write (or still zero padding).
// Require positive proof the writer has moved past this
// record -- the start of the NEXT record's header, itself
// looking real -- before confirming it. This makes
// complete_len always sit (at least) one record behind
// whatever's currently visible, by construction, for every
// record, not just "the last one" as a special case.
let after = &remaining[record_total..];
let next_is_proven = after.len() >= RECORD_HEADER_LEN
&& read_u32(&after[0..4], e) != 0
&& (read_u32(&after[8..12], e) as usize) <= self.max_incl_len();
// Escape hatch (only when the caller is certain nothing
// more will EVER arrive): a record that is itself plausible
// AND has truly nothing after it (current_len ends exactly
// here) is confirmed without a next-record proof, since
// there provably isn't a next record to prove it with. Any
// other leftover (a nonempty `after` shorter than a full
// header, or one that fails its own plausibility check)
// still does NOT confirm -- that's a genuinely torn/corrupt
// tail, correctly left unconfirmed so tail.rs's finalize
// check reports it as such.
let trailing_and_trusted = trust_trailing_record && after.is_empty();
if !next_is_proven && !trailing_and_trusted {
break;
}
}
self.complete_len += record_total;
}
}
fn drain_complete(&mut self) -> (ChunkKind, Vec<u8>) {
let chunk: Vec<u8> = self.pending.drain(..self.complete_len).collect();
self.emitted_len += self.complete_len as u64;
self.complete_len = 0;
(ChunkKind::Data, chunk)
}
}
impl Chunker for RecordAlignedChunker {
fn feed(&mut self, bytes: &[u8]) -> Result<()> {
if bytes.is_empty() {
return Ok(());
}
let mut rest = bytes;
if !self.header_done {
let need = GLOBAL_HEADER_LEN - self.header_buf.len();
let take = need.min(rest.len());
self.header_buf.extend_from_slice(&rest[..take]);
rest = &rest[take..];
if self.header_buf.len() == GLOBAL_HEADER_LEN {
self.validate_header()?;
if !self.header_done {
// Partial source, all-zero header -- not written yet.
return Ok(());
}
} else {
// Still accumulating the header; nothing else to do.
return Ok(());
}
}
if !rest.is_empty() {
self.pending.extend_from_slice(rest);
self.rescan(false);
self.last_growth = Instant::now();
}
Ok(())
}
fn ready_chunk(&mut self) -> Option<(ChunkKind, Vec<u8>)> {
if self.header_ready {
self.header_ready = false;
return Some((ChunkKind::Header, std::mem::take(&mut self.header_buf)));
}
if self.complete_len >= self.target {
return Some(self.drain_complete());
}
None
}
fn should_flush(&self, now: Instant) -> bool {
self.complete_len > 0 && now.duration_since(self.last_growth) >= self.flush_after
}
fn flush(&mut self) -> Option<(ChunkKind, Vec<u8>)> {
if self.complete_len > 0 {
Some(self.drain_complete())
} else {
None
}
}
fn finish(&mut self) -> Result<Option<(ChunkKind, Vec<u8>)>> {
if !self.header_done {
bail!("source ended before a complete pcap global header was read");
}
if self.complete_len > 0 {
Ok(Some(self.drain_complete()))
} else {
Ok(None)
}
}
fn read_from(&self) -> u64 {
if !self.header_done {
self.header_buf.len() as u64
} else {
GLOBAL_HEADER_LEN as u64 + self.emitted_len + self.pending.len() as u64
}
}
fn confirmed_len(&self) -> u64 {
if !self.header_done {
// While not yet validated, nothing is confirmed -- even if
// header_buf is "full" but stuck on an all-zero (partial-only)
// header. This must be 0, not header_buf.len(), so tail.rs's
// partial-branch read anchor re-reads offset 0 instead of
// treating the stuck zero bytes as already consumed.
0
} else {
GLOBAL_HEADER_LEN as u64 + self.emitted_len + self.complete_len as u64
}
}
fn refresh_tail(&mut self, offset: u64, bytes: &[u8], trust_trailing_record: bool) -> Result<()> {
if offset != self.confirmed_len() {
bail!(
"chunker refresh_tail offset mismatch: got {offset}, expected {}",
self.confirmed_len()
);
}
if !self.header_done {
if bytes.len() < GLOBAL_HEADER_LEN {
self.header_buf = bytes.to_vec();
return Ok(());
}
self.header_buf = bytes[..GLOBAL_HEADER_LEN].to_vec();
self.validate_header()?;
if !self.header_done {
return Ok(());
}
// header_done just flipped true, so complete_len is still 0 here
// (nothing beyond the header has been considered yet) --
// pending is simply the fresh post-header bytes.
self.pending = bytes[GLOBAL_HEADER_LEN..].to_vec();
self.rescan(trust_trailing_record);
self.last_growth = Instant::now();
return Ok(());
}
// `pending[..complete_len]` is CONFIRMED content that just hasn't
// been drained (emitted) yet -- e.g. because it's still under
// `target`. `bytes` starts exactly at `confirmed_len()` (checked
// above), i.e. exactly where that confirmed prefix ends, so it must
// be appended after it, never used to replace the whole buffer --
// doing that would silently discard already-confirmed, already
// real, not-yet-emitted content on every single re-peek, which is
// catastrophic for a long-running growing transfer (each poll would
// re-derive from scratch instead of building on prior confirmed
// progress, and once the fresh read window is too small to
// re-establish the same progress alone -- e.g. capped by
// READ_WINDOW -- confirmed_len can never recover).
self.pending.truncate(self.complete_len);
self.pending.extend_from_slice(bytes);
self.rescan(trust_trailing_record);
self.last_growth = Instant::now();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn header_bytes(magic_le: u32) -> Vec<u8> {
let mut h = vec![0u8; GLOBAL_HEADER_LEN];
h[0..4].copy_from_slice(&magic_le.to_le_bytes());
h
}
fn record_bytes(payload: &[u8], big_endian: bool) -> Vec<u8> {
record_bytes_with_ts(payload, big_endian, 0)
}
fn record_bytes_with_ts(payload: &[u8], big_endian: bool, ts_sec: u32) -> Vec<u8> {
let mut r = Vec::with_capacity(RECORD_HEADER_LEN + payload.len());
if big_endian {
r.extend_from_slice(&ts_sec.to_be_bytes());
} else {
r.extend_from_slice(&ts_sec.to_le_bytes());
}
r.extend_from_slice(&[0u8; 4]); // ts_usec
let incl_len = payload.len() as u32;
if big_endian {
r.extend_from_slice(&incl_len.to_be_bytes());
} else {
r.extend_from_slice(&incl_len.to_le_bytes());
}
r.extend_from_slice(&incl_len.to_le_bytes()); // orig_len, value doesn't matter
r.extend_from_slice(payload);
r
}
#[test]
fn raw_chunker_passthrough() {
let mut c = RawChunker::new();
c.feed(b"abc").unwrap();
c.feed(b"def").unwrap();
let (kind, data) = c.ready_chunk().unwrap();
assert_eq!(kind, ChunkKind::Data);
assert_eq!(data, b"abcdef");
assert!(c.ready_chunk().is_none());
}
#[test]
fn header_emitted_as_its_own_chunk() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, false);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
let (kind, data) = c.ready_chunk().unwrap();
assert_eq!(kind, ChunkKind::Header);
assert_eq!(data.len(), GLOBAL_HEADER_LEN);
assert!(c.ready_chunk().is_none());
}
#[test]
fn insufficient_header_bytes_is_not_an_error() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, false);
// Only 10 bytes so far -- not enough to validate the magic.
c.feed(&[0u8; 10]).unwrap();
assert!(c.ready_chunk().is_none());
}
#[test]
fn bad_magic_is_an_error() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, false);
let mut bad = vec![0xFFu8; GLOBAL_HEADER_LEN];
bad[0..4].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
assert!(c.feed(&bad).is_err());
}
#[test]
fn chunks_always_end_on_record_boundary_when_fed_in_small_increments() {
let mut c = RecordAlignedChunker::new(50, DEFAULT_FLUSH, false); // tiny target to force cuts
let mut stream = header_bytes(0xA1B2C3D4);
let mut expected_records: Vec<Vec<u8>> = Vec::new();
for i in 0..20u8 {
let rec = record_bytes(&[i; 10], false);
expected_records.push(rec.clone());
stream.extend_from_slice(&rec);
}
// Feed in arbitrary tiny increments (1-3 bytes at a time).
let mut collected: Vec<u8> = Vec::new();
let mut i = 0;
let mut step = 1usize;
while i < stream.len() {
let n = step.min(stream.len() - i);
c.feed(&stream[i..i + n]).unwrap();
i += n;
step = (step % 3) + 1;
while let Some((_, data)) = c.ready_chunk() {
collected.extend_from_slice(&data);
}
}
if let Some((_, data)) = c.finish().unwrap() {
collected.extend_from_slice(&data);
}
// Reassembled bytes must equal the original stream, and every
// intermediate chunk boundary must have landed on a whole record --
// verified implicitly since decoding never errored and the
// concatenation is byte-exact.
assert_eq!(collected, stream);
}
#[test]
fn target_is_a_floor_not_a_ceiling_cuts_at_next_boundary() {
let mut c = RecordAlignedChunker::new(15, DEFAULT_FLUSH, false);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
c.ready_chunk(); // drain header
// A single record whose payload alone overshoots the tiny target.
let rec = record_bytes(&[1u8; 40], false);
c.feed(&rec).unwrap();
let (kind, data) = c.ready_chunk().unwrap();
assert_eq!(kind, ChunkKind::Data);
assert_eq!(data, rec); // whole record, even though it overshoots target=15
}
#[test]
fn flush_predicate_and_flush_are_time_independent_of_sleep() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, Duration::from_millis(100), false);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
c.ready_chunk();
let rec = record_bytes(&[9u8; 5], false);
c.feed(&rec).unwrap();
let just_now = Instant::now();
assert!(!c.should_flush(just_now));
let later = just_now + Duration::from_millis(150);
assert!(c.should_flush(later));
let (kind, data) = c.flush().unwrap();
assert_eq!(kind, ChunkKind::Data);
assert_eq!(data, rec);
assert!(c.flush().is_none());
}
#[test]
fn torn_trailing_record_is_left_unconfirmed_not_an_error() {
// finish()'s job narrowed to "was a header ever read" -- detecting a
// genuinely unresolvable torn tail at true EOF is tail.rs's job now
// (it has the file-length/rename context needed to tell "still
// resolving" from "will never resolve"). See tail.rs's
// `non_partial_torn_trailing_record_is_an_error` and
// `torn_trailing_record_after_rename_is_an_error_not_a_hang`.
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, false);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
c.ready_chunk();
let rec = record_bytes(&[1u8; 20], false);
// Feed everything except the last 3 bytes of the record.
c.feed(&rec[..rec.len() - 3]).unwrap();
assert_eq!(c.finish().unwrap(), None);
}
#[test]
fn big_endian_magic_reads_incl_len_as_big_endian() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, false);
c.feed(&header_bytes(0xD4C3B2A1)).unwrap();
c.ready_chunk();
let rec = record_bytes(&[1u8; 30], true);
c.feed(&rec).unwrap();
let (_, data) = c.finish().unwrap().unwrap();
assert_eq!(data, rec);
}
#[test]
fn finish_before_any_header_bytes_is_an_error() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, false);
assert!(c.finish().is_err());
}
#[test]
fn all_zero_ts_sec_is_treated_as_not_yet_written() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
c.ready_chunk();
let rec1 = record_bytes_with_ts(&[1u8; 10], false, 1_700_000_000);
let rec2 = record_bytes_with_ts(&[2u8; 10], false, 1_700_000_001);
c.feed(&rec1).unwrap();
c.feed(&rec2).unwrap();
// rec1 confirms (proven by rec2's real header); rec2 itself is held
// back (nothing proves it yet).
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64 + rec1.len() as u64);
// Now simulate trailing preallocated padding: a large all-zero
// block. confirmed_len must not advance into it.
let padding = vec![0u8; 4096];
c.feed(&padding).unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64 + rec1.len() as u64);
}
#[test]
fn refresh_tail_reexamines_previously_zero_region_once_it_fills_in() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.refresh_tail(0, &header_bytes(0xA1B2C3D4), false).unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64);
let zero_block = vec![0u8; 64];
c.refresh_tail(c.confirmed_len(), &zero_block, false).unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64);
// Writer has since filled this offset in with two real records.
let rec1 = record_bytes_with_ts(&[1u8; 10], false, 1_700_000_000);
let rec2 = record_bytes_with_ts(&[2u8; 10], false, 1_700_000_001);
let mut real = rec1.clone();
real.extend_from_slice(&rec2);
c.refresh_tail(c.confirmed_len(), &real, false).unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64 + rec1.len() as u64);
}
#[test]
fn all_zero_global_header_is_treated_as_not_yet_written() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
let zero_header = vec![0u8; GLOBAL_HEADER_LEN];
c.refresh_tail(0, &zero_header, false).unwrap();
assert_eq!(c.confirmed_len(), 0);
c.refresh_tail(0, &header_bytes(0xA1B2C3D4), false).unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64);
}
#[test]
fn nonzero_bad_magic_is_still_a_hard_error_even_when_partial() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
let mut bad = vec![0xFFu8; GLOBAL_HEADER_LEN];
bad[0..4].copy_from_slice(&0xDEADBEEFu32.to_le_bytes());
assert!(c.feed(&bad).is_err());
}
#[test]
fn incl_len_exceeding_bound_is_rejected() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
c.ready_chunk();
// incl_len far exceeds MAX_INCL_LEN, but we only need the header's
// 16 bytes physically present for the check to fire.
let mut rec = vec![0u8; RECORD_HEADER_LEN];
rec[0..4].copy_from_slice(&1_700_000_000u32.to_le_bytes());
rec[8..12].copy_from_slice(&(MAX_INCL_LEN as u32 + 1).to_le_bytes());
c.feed(&rec).unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64);
}
#[test]
fn record_not_confirmed_without_proof_of_next_record() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
c.ready_chunk();
let rec = record_bytes_with_ts(&[1u8; 10], false, 1_700_000_000);
c.feed(&rec).unwrap();
// A single fully-present, individually-plausible record with
// nothing after it: ordinary feed() (trust=false) must not confirm
// it -- no proof yet that the writer has moved past it.
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64);
}
#[test]
fn record_confirmed_once_next_record_header_is_real() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
c.ready_chunk();
let rec1 = record_bytes_with_ts(&[1u8; 10], false, 1_700_000_000);
let rec2 = record_bytes_with_ts(&[2u8; 10], false, 1_700_000_001);
c.feed(&rec1).unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64);
c.feed(&rec2).unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64 + rec1.len() as u64);
}
#[test]
fn torn_payload_followed_by_zero_padding_is_not_confirmed() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.feed(&header_bytes(0xA1B2C3D4)).unwrap();
c.ready_chunk();
// A record whose own header looks plausible (real ts_sec, sane
// incl_len) but whose declared payload reaches into all-zero bytes.
let rec = record_bytes_with_ts(&[0u8; 10], false, 1_700_000_000);
c.feed(&rec).unwrap();
c.feed(&[0u8; 64]).unwrap(); // more zero padding after it
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64);
// Real content lands there instead.
let rec2 = record_bytes_with_ts(&[1u8; 10], false, 1_700_000_001);
// Replace: simulate the writer having overwritten what followed rec
// by feeding a fresh, corrected view via refresh_tail.
c.refresh_tail(c.confirmed_len(), &[&rec[..], &rec2[..]].concat(), false)
.unwrap();
assert_eq!(c.confirmed_len(), GLOBAL_HEADER_LEN as u64 + rec.len() as u64);
}
#[test]
fn trailing_record_confirmed_only_when_trust_granted_and_truly_last() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.refresh_tail(0, &header_bytes(0xA1B2C3D4), false).unwrap();
let rec = record_bytes_with_ts(&[1u8; 10], false, 1_700_000_000);
let offset = c.confirmed_len();
c.refresh_tail(offset, &rec, false).unwrap();
assert_eq!(c.confirmed_len(), offset, "must not confirm without trust");
c.refresh_tail(offset, &rec, true).unwrap();
assert_eq!(c.confirmed_len(), offset + rec.len() as u64, "must confirm once trusted");
}
#[test]
fn trust_does_not_confirm_a_genuinely_torn_tail() {
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.refresh_tail(0, &header_bytes(0xA1B2C3D4), false).unwrap();
let rec = record_bytes_with_ts(&[1u8; 10], false, 1_700_000_000);
let mut bytes = rec.clone();
// Nonempty garbage after the record -- not a full header, not empty.
bytes.extend_from_slice(&[0xAB, 0xCD, 0xEF]);
let offset = c.confirmed_len();
c.refresh_tail(offset, &bytes, true).unwrap();
assert_eq!(c.confirmed_len(), offset, "trust must not rescue a nonempty torn fragment");
}
#[test]
fn refresh_tail_never_discards_already_confirmed_undrained_content() {
// Regression test for a real production incident: refresh_tail used
// to unconditionally replace `pending` with the fresh read, which
// silently discarded already-confirmed-but-not-yet-drained content
// (anything below `target`, i.e. never emitted via ready_chunk) any
// time a LATER refresh_tail call's fresh window started past it and
// didn't happen to re-derive the same progress on its own. This
// caused confirmed_len to oscillate or, worse, get permanently
// stuck once the fresh window was too small (e.g. capped by
// READ_WINDOW in tail.rs) to ever re-establish the lost progress.
//
// Use a target far bigger than the data so nothing ever drains via
// ready_chunk -- everything must survive purely via the confirmed
// prefix being preserved across refresh_tail calls.
let mut c = RecordAlignedChunker::new(DEFAULT_TARGET_BYTES, DEFAULT_FLUSH, true);
c.refresh_tail(0, &header_bytes(0xA1B2C3D4), false).unwrap();
let rec1 = record_bytes_with_ts(&[1u8; 10], false, 1_700_000_001);
let rec2 = record_bytes_with_ts(&[2u8; 10], false, 1_700_000_002);
let rec3 = record_bytes_with_ts(&[3u8; 10], false, 1_700_000_003);
// First call: feed rec1+rec2 together. rec1 confirms (proven by
// rec2's header); rec2 itself is held back (nothing proves it yet).
let offset = c.confirmed_len();
let mut first_batch = rec1.clone();
first_batch.extend_from_slice(&rec2);
c.refresh_tail(offset, &first_batch, false).unwrap();
assert_eq!(
c.confirmed_len(),
offset + rec1.len() as u64,
"rec1 should confirm, proven by rec2's header"
);
let after_first = c.confirmed_len();
// Second call: a FRESH read starting exactly at confirmed_len()
// (per the trait contract) -- this does NOT include rec1, which is
// already confirmed but still sitting undrained in `pending`. Only
// rec2's own bytes (already seen) plus rec3 (new) are read this
// time, simulating a subsequent, narrower re-peek.
let mut second_batch = rec2.clone();
second_batch.extend_from_slice(&rec3);
c.refresh_tail(after_first, &second_batch, false).unwrap();
// rec1's confirmation must NOT have been discarded, and rec2 should
// now also confirm (proven by rec3's header).
assert_eq!(
c.confirmed_len(),
offset + rec1.len() as u64 + rec2.len() as u64,
"confirmed_len must never regress below what was already established"
);
}
}