A slice block's lying base can no longer ship as a complete frame #398
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user