diff --git a/crates/punktfunk-core/src/packet/reassemble.rs b/crates/punktfunk-core/src/packet/reassemble.rs index 260ae987..37eefa2c 100644 --- a/crates/punktfunk-core/src/packet/reassemble.rs +++ b/crates/punktfunk-core/src/packet/reassemble.rs @@ -194,7 +194,7 @@ struct ReassemblyWindow { /// partially-arrived frames of ACTUAL size (≪ max); without this cap, [`HARD_LOSS_WINDOW`] /// max-sized declarations from one header-sized packet each could commit gigabytes — an /// amplification the old sparse per-shard allocation didn't have. -const IN_FLIGHT_BUF_FACTOR: usize = 4; +pub(super) const IN_FLIGHT_BUF_FACTOR: usize = 4; /// Recovery-shard buffer pool ceiling (shard-sized buffers): enough for several max-recovery /// blocks in flight, small enough (~720 KB at a 1408-byte shard) to keep after a loss burst. @@ -208,7 +208,12 @@ const RECOVERY_POOL_MAX: usize = 512; /// can mint thousands of distinct-index blocks while its `FrameBuf::buf` stays pinned near zero — /// they must be metered exactly like the buffer, or the firewall meters only half the allocation /// (security-review 2026-08-15 finding 11). -fn block_state_bytes(data_shards: usize, recovery_shards: usize) -> usize { +/// +/// `pub(super)` so the budget tests can locate the refusal boundary from the cost model itself +/// rather than from a baked-in frame count — [`BlockState`] gaining a field moves that boundary, +/// and a test that hard-codes it answers such a change with an arithmetic puzzle instead of the +/// question actually worth asking. +pub(super) fn block_state_bytes(data_shards: usize, recovery_shards: usize) -> usize { std::mem::size_of::() + data_shards // have_data: Vec + recovery_shards * std::mem::size_of::>>() // recovery slot table diff --git a/crates/punktfunk-core/src/packet/tests.rs b/crates/punktfunk-core/src/packet/tests.rs index e31a9942..e23e4254 100644 --- a/crates/punktfunk-core/src/packet/tests.rs +++ b/crates/punktfunk-core/src/packet/tests.rs @@ -520,13 +520,19 @@ fn e2e_unrecoverable_loss_ages_out() { /// gigabytes (the eager whole-frame buffer's amplification defense). #[test] fn in_flight_buffer_budget_bounds_allocation() { - let lim = limits(); // max_frame_bytes 4096, shards 16 B, ≤8 data shards × ≤4 blocks + // limits(): max_frame_bytes 4096, shards 16 B, ≤8 data shards × ≤4 blocks → budget 16384 B. + let lim = limits(); + let budget = IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes; + // What ONE such frame commits: the largest geometry-consistent buffer (4 blocks × 8 shards + // × 16 B = 512 B) plus the state of the single block this first shard opens. Both are sized + // from header fields, so the firewall meters both — counting only the buffer is precisely + // the hole security-review 2026-08-15 #11 closed, and the boundary moved when it did. + let per_frame = 512 + block_state_bytes(8, 0); + let fits = budget / per_frame; let mut r = Reassembler::new(lim); let coder = coder_for(FecScheme::Gf8); let stats = StatsCounters::default(); - // Largest geometry-consistent frame: 4 blocks × 8 shards × 16 B = 512 B per buffer. - // Budget = 4 × 4096 = 16384 B → exactly 32 such frames fit; the 33rd must be refused. - for i in 0..33u32 { + for i in 0..=fits as u32 { let mut h = base_header(); h.frame_index = i; h.frame_bytes = 512; @@ -539,6 +545,14 @@ fn in_flight_buffer_budget_bounds_allocation() { 1, "the frame past the budget is dropped, everything under it accepted" ); + // The point of the whole exercise: whatever the geometry, the commitment stays under the + // ceiling. Asserted on the live figure, so a release site that forgets half the cost (the + // 0.23.0 accounting-drift lesson on `in_flight`) fails here and not in the field. + assert!( + r.in_flight() <= budget, + "in-flight commitment {} must never exceed the {budget} B budget", + r.in_flight(), + ); } /// A header whose (data_shards, block_count) disagree with the geometry derived from its own @@ -1519,11 +1533,15 @@ fn streamed_open_commits_its_own_extent_and_stays_bounded() { ); // A SLICE sentinel whose wire base sits just under the ceiling really does commit a - // max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B) — four fit the budget, the - // fifth must be refused. - let mut r = Reassembler::new(limits()); + // max-sized frame (base 3968 B + K 8 = 256 shards = 4096 B), plus the state of the block it + // opens — so the budget takes fewer of these than the buffer alone would suggest, and the + // first one past it must be refused. + let lim = limits(); + let budget = IN_FLIGHT_BUF_FACTOR * lim.max_frame_bytes; + let fits = budget / (4096 + block_state_bytes(8, 0)); + let mut r = Reassembler::new(lim); let stats = StatsCounters::default(); - for fi in 0..5u32 { + for fi in 0..=fits as u32 { let mut h = base_header(); h.user_flags = USER_FLAG_SLICE_STREAM; h.block_count = 0; @@ -1537,10 +1555,15 @@ fn streamed_open_commits_its_own_extent_and_stays_bounded() { .unwrap() .is_none()); } + assert!( + r.in_flight() <= budget, + "in-flight commitment {} must never exceed the {budget} B budget", + r.in_flight(), + ); assert_eq!( stats.snapshot().packets_dropped, 1, - "the fifth ceiling-claiming open must be refused by the in-flight budget" + "the first ceiling-claiming open past the budget must be refused" ); }