A slice block's lying base can no longer ship as a complete frame #398

Merged
enricobuehler merged 1 commits from worktree-slice-tiling-guard into main 2026-08-26 14:48:15 +00:00
2 changed files with 86 additions and 3 deletions
+36 -3
View File
@@ -643,9 +643,11 @@ impl Reassembler {
// Slice frames have no uniform shape to demand — the invariant is positional:
// every sentinel block must sit strictly below the final block's base
// (`total_data data_shards`; the firewall already proved the subtraction
// safe) and be a non-final index. Sentinel-vs-sentinel overlap is not policed
// the sender is AEAD-authenticated, so a lying base can only corrupt this
// frame's own pixels, never memory (placement stays in-bounds by these checks).
// safe) and be a non-final index. Sentinel-vs-sentinel overlap is not policed
// HERE — placement stays in-bounds by these checks, so a lying base can only
// corrupt this frame's own bytes, never memory — but the completion tiling
// check below refuses to deliver such a frame (black-band corruption from a
// buggy, AEAD-authenticated sender would otherwise ship as `complete`).
let final_base = total_data - data_shards;
frame.blocks.iter().any(|(&bi, b)| {
let bi = bi as usize;
@@ -881,6 +883,31 @@ impl Reassembler {
reconstructed_shards(&done.blocks, lim.max_data_shards),
);
*in_flight_bytes -= frame_cost(&done); // buffer + block state, before the truncate below
// Slice-streamed frames: every base was bounds-checked on arrival (in range,
// below the final block) but nothing yet proved the blocks TILE the AU. A base
// that lies WITHIN bounds leaves a zero gap and an overlap — wrong bytes in a
// frame stamped `complete`, which the decoder paints as garbage rectangles and
// no loss counter ever moves. Refuse to deliver: the index is already in
// `completed` (stragglers can't resurrect it), so just count the loss — the
// `frames_dropped` climb is what fires the client's recovery request.
if done.user_flags & crate::packet::USER_FLAG_SLICE_STREAM != 0 {
let total_data = done.frame_bytes.div_ceil(done.shard_bytes).max(1);
let mut next = 0usize;
let tiled = (0..block_count).all(|bi| match done.blocks.get(&(bi as u16)) {
Some(b) if b.base_shard == next => {
next += b.data_shards;
true
}
_ => false,
}) && next == total_data;
if !tiled {
if !is_probe {
StatsCounters::add(&stats.frames_dropped, 1);
}
drop(stats);
return Ok(None);
}
}
done.buf.truncate(done.frame_bytes); // trim trailing-shard zero padding
// Slice-progressive consumers already hold the delivered prefix — the completing
// packet hands up only the SUFFIX (with `last`), or the degenerate whole-AU part
@@ -925,6 +952,12 @@ impl Reassembler {
if block_count != 0 && (*next_part_block as usize) + 1 >= block_count {
break;
}
// A prefix is only a prefix if this block starts where the last one ended —
// a slice block whose wire base lies within bounds must not extend it (the
// frame then dies at the completion tiling check above).
if b.base_shard != *delivered_shards {
break;
}
*delivered_shards = b.base_shard + b.data_shards;
*next_part_block += 1;
}
+50
View File
@@ -1186,6 +1186,56 @@ fn slice_streamed_lying_final_kills_frame() {
assert_eq!(stats.snapshot().frames_dropped, 1);
}
/// Completion tiling check: a sentinel base that lies WITHIN every bounds check (in range,
/// below the final block) but breaks the tiling — a gap at the honest base, an overlap at
/// the claimed one — must NOT be delivered as a `complete` frame (the black-band corruption
/// shape: wrong-offset bytes with zeros in the gap and no loss counter moving). The frame
/// is counted lost instead, which is what fires the client's recovery request.
#[test]
fn slice_streamed_lying_base_within_bounds_kills_frame() {
let (pkts, _) = slice_streamed_packets();
let hdr_of = |p: &Vec<u8>| PacketHeader::read_from_bytes(&p[..HEADER_LEN]).unwrap();
// Shift block 1's base from shard 19 (304 B) to shard 20 (320 B) on EVERY packet of the
// block (the base is pinned by the block's first packet, so all must agree). Still
// shard-aligned, still 20 + 26 = 46 ≤ 63 (the final block's base) — every pre-fix
// check passes, and the frame would have completed with a one-shard zero gap at 19
// and block 1's last shard overwriting block 2's first.
let delivery: Vec<Vec<u8>> = pkts
.iter()
.map(|p| {
let mut h = hdr_of(p);
if h.block_count == 0 && h.block_index == 1 {
let mut p = p.clone();
h.frame_bytes = 320;
p[..HEADER_LEN].copy_from_slice(h.as_bytes());
p
} else {
p.clone()
}
})
.collect();
let cfg = slice_config();
let mut r = Reassembler::new(ReassemblerLimits::from_config(&cfg));
let coder = coder_for(FecScheme::Gf16);
let stats = StatsCounters::default();
assert!(
push_all(&mut r, coder.as_ref(), &stats, &delivery).is_none(),
"a mis-tiled frame must never be delivered"
);
assert_eq!(
stats.snapshot().frames_dropped,
1,
"the mis-tiled frame must be counted lost"
);
assert_eq!(r.in_flight(), 0, "the killed frame must release its budget");
// Its packets are stragglers for a terminated index now — no resurrection, no recount.
assert!(push_all(&mut r, coder.as_ref(), &stats, &delivery).is_none());
assert_eq!(stats.snapshot().frames_dropped, 1);
}
/// One slice bigger than a whole FEC block must cut MULTIPLE blocks from a single push (the
/// flush loop) — the final block can never be left oversized.
#[test]