Round 5: reject a degenerate fixed lace, bound the pending buffer by bytes

Five fixes. Three are real defects with regression tests; two are bounds
that were expressible but not expressed.

A fixed-size lace (RFC 9559 §10.3.4) whose body is empty declared n
frames and carried none. The divisibility check passed, because 0 % n is
0, and `chunks` yields nothing on an empty slice whatever width it is
given — so the clamp that existed to avoid chunks(0) returned zero frames
where the Lacing Head said n. The whole lace vanished with no error
raised and the caller saw a clean short block. A zero-size frame cannot
be a valid frame, so it is now malformed.

A disc read failure while fetching a directory entry's ICB became a file
size of zero rather than an error. Zero is indistinguishable from a
genuinely empty file, so an unreadable ICB on a damaged disc silently
changed which titles a caller saw as present — read_directory already
fails hard on its entry-budget guard, so propagating is also what the
surrounding code does. read_file_size still returns Ok(0) for an ICB
whose tag is neither File Entry nor Extended File Entry, which is a real
zero and not a failure.

The pending-frame buffer was capped at 4096 frames, which does not bound
memory: frames are arbitrarily large and a UHD video frame runs to a few
hundred KB, so the existing cap permitted over a gigabyte. Now bounded by
bytes as well, at 64 MiB.

round_up_grain overflowed for inputs within one grain of u64::MAX —
div_ceil then multiply — and the wrapped product is small, turning the
largest possible estimate into a negligible reserve. It saturates, and
the reserve is clamped to what a `free` box's 32-bit size field can
actually hold, since writing a larger one truncated the size and left
mdat beyond a box claiming to be far shorter. No real title comes close;
a 90 GB UHD title estimates a few MiB.

The AC-3 resync guard now advances the PTS cadence like both of its
sibling branches, so the three paths out of that block cannot disagree.
This one is defensive and has NO test: reaching it needs input that both
parses frames and leaves a megabyte of residue, and the parser's own
carry rules drop pre-sync junk and cap a partial frame at 8192 bytes, so
no such input was found. Stated here rather than covered by a test that
would pass either way.

Two findings from this round were rejected on inspection. A reported
panic in the .mpls suffix check does not exist: the `.get(..)` on the
line above returns None off a char boundary and `filter` never runs its
closure, so the byte index is unreachable. A test written for it passed
against the unfixed code, which is what surfaced the error.
This commit is contained in:
Matthew Jackson
2026-07-29 22:28:43 -07:00
parent f5e169efb3
commit 5c6a6d0785
4 changed files with 165 additions and 8 deletions
+28 -2
View File
@@ -59,9 +59,15 @@ const RESERVE_BUFFER: u64 = 4 << 20; // 4 MiB
const RESERVE_FLOOR: u64 = 8 << 20; // 8 MiB
/// Rounding granularity for the reserve.
const RESERVE_GRAIN: u64 = 4 << 20; // 4 MiB
/// Largest reserve expressible in a `free` box's 32-bit size field, rounded down
/// to a whole grain.
const RESERVE_CAP: u64 = (u32::MAX as u64 / RESERVE_GRAIN) * RESERVE_GRAIN;
/// Round up to `RESERVE_GRAIN`, saturating rather than wrapping. `div_ceil` then
/// multiply overflows for inputs within one grain of `u64::MAX`, which would turn
/// an enormous estimate into a tiny reserve — the opposite of the intent.
fn round_up_grain(x: u64) -> u64 {
x.div_ceil(RESERVE_GRAIN) * RESERVE_GRAIN
x.div_ceil(RESERVE_GRAIN).saturating_mul(RESERVE_GRAIN)
}
/// Estimate the faststart hole: `round_up_4MB(bytes_per_sample × est_samples)`
@@ -98,7 +104,16 @@ fn estimate_reserve(title: &DiscTitle, included: &[usize]) -> u64 {
}
}
let est = (est_samples as u64).saturating_mul(BYTES_PER_SAMPLE);
round_up_grain(est).max(RESERVE_FLOOR) + RESERVE_BUFFER
let reserve = round_up_grain(est)
.max(RESERVE_FLOOR)
.saturating_add(RESERVE_BUFFER);
// The hole is a `free` box with a 32-bit size field, so a reserve at or above
// u32::MAX cannot be expressed: writing it truncated the size and left mdat
// beyond a box that claimed to be far shorter. Clamp to the largest
// grain-aligned value the field can hold. No real title comes near this —
// a 90 GB UHD title estimates a few MiB — but truncating silently produces an
// unreadable file, so it is bounded rather than trusted.
reserve.min(RESERVE_CAP)
}
/// One accumulated sample's bookkeeping (the mdat bytes are already on disk).
@@ -1420,6 +1435,17 @@ mod tests {
#[test]
fn reserve_rounds_to_4mb_plus_buffer() {
// round_up_4MB(x) + 4 MiB, floored at 8 MiB.
// Saturates rather than wrapping. div_ceil(GRAIN) * GRAIN overflows within
// one grain of u64::MAX, and the wrapped product is SMALL — which would
// turn the largest possible estimate into a negligible reserve, the exact
// opposite of the intent. Only the no-wrap property matters here.
assert!(
round_up_grain(u64::MAX) >= u64::MAX - (4 << 20),
"round_up_grain must saturate near u64::MAX, not wrap to a small value"
);
// The reserve the writer emits must fit the `free` box's 32-bit size field.
assert!(RESERVE_CAP <= u32::MAX as u64);
assert_eq!(RESERVE_CAP % RESERVE_GRAIN, 0);
assert_eq!(round_up_grain(1), 4 << 20);
assert_eq!(round_up_grain(4 << 20), 4 << 20);
assert_eq!(round_up_grain((4 << 20) + 1), 8 << 20);