Audit round 7: account for every clip that cannot be resolved
Ten lenses over v1.6.4..HEAD, every claim read against the code before it was believed. Seven confirmed; six are here, one is recorded for the next round. All of these are the same family — a failure wearing the shape of success — which is the family that once shipped 9 MB of ciphertext inside a main-movie m2ts at rc=0. A clip whose extents cannot be resolved is now accounted for, in both disc readers. Only `UdfUnrecordedExtent` used to count: every other way `file_extents` can fail — a scratched sector under the clip's ICB (DiscRead), an allocation-descriptor chain that never terminated, a file whose data is embedded rather than extent-mapped — fell through to the ordinary "file absent" path. On Blu-ray that yielded a title advertising its full runtime with a clip's bytes silently missing, because the size and the play-item timing had already counted it. On HD-DVD it was worse: the clip was never added to `unusable`, so a split feature still composed from FEATURE_1 alone and offered half a movie as the whole thing. Neither emitted a single log line. Absence is still benign — a 2D disc has no .ssif and the extension fallback exists for exactly that. `Halted` is excluded deliberately, and that exclusion is the whole reason the first version of this fix was wrong. Cancellation makes EVERY drive command return `Halted`; classifying it as a disc defect would have dropped each remaining playlist in turn and handed back a truncated title list at success — the same defect, wearing a cancel. `parse_playlist` returns Option and has no channel to propagate a halt, so the existing behaviour is preserved rather than made worse. Propagating it properly is next round's work. Both log sites now emit the error's OWN code instead of a hardcoded 6017. Accounting a scratched disc (E6000) as an authoring hole would send anyone triaging it looking for the wrong thing entirely. AD type 3 is embedded data, not a descriptor list (ECMA-167 4/14.6.8). `read_icb_extents` lumped it in with the reserved values and decoded the file's own CONTENT as (length, LBA) pairs, manufacturing extents out of arbitrary bytes and pointing the reader at unrelated sectors. This same release already taught `read_directory` to honour type 3; this is the file half of that decision. It is an error rather than an empty list, because an empty list reaches the caller as a clip that contributed nothing while its declared duration still counts it — the silent loss pointed the other way. A legally zero-length embedded file still returns an empty list. New code E6018: reusing DiscRead would have mislabelled a deterministic structural property as transient I/O and fed the retry and NonTrimmed machinery a byte that will never change. `file_extents_addressing`, `extents_abs_at` and `AbsExtent` drop to `pub(crate)`. The first hands back unrecorded extents UNFLAGGED, in a shape identical to the safe call's return; its doc says callers must use `file_extents` instead, but a doc comment is not a guard. No dependent crate references any of the three. Three tests close gaps the audit found, each proven red before green: a held AC-3 access unit must not resume as a normal frame after its track poisons; the PS resume cursor must survive a drain that rebases it (three separate mutants caught); and AD type 3 must be refused rather than decoded. The first attempt at the HD-DVD test passed with the fix reverted, which made it worthless — it needed a VTI fixture before the composition path ran at all. Also: four error codes were missing from the uniqueness test that claims to cover every published code, so a new variant reusing 6014, 6016 or 6017 would have passed it.
This commit is contained in:
@@ -2482,4 +2482,88 @@ mod tests {
|
||||
"the unit belongs to the packet its FIRST byte came from"
|
||||
);
|
||||
}
|
||||
/// A track that becomes POISONED while an access unit is held open across a
|
||||
/// PES boundary must not emit that access unit when it resumes.
|
||||
///
|
||||
/// Mutation this catches: deleting (or inverting) the resume-path re-check
|
||||
/// `if drop_reason.is_none() && self.tally.is_poisoned()` at the top of
|
||||
/// `scan_access_units`. The verdict on a held access unit is frozen at the
|
||||
/// moment it was OPENED, and `ac3_drop_reason` reads the tally BEFORE the
|
||||
/// previous access unit is closed — so the very drop that crosses the
|
||||
/// poison threshold lands after the next unit's verdict was already taken
|
||||
/// as `None`. Without the re-check that unit is emitted as an ordinary
|
||||
/// frame after the track has been judged too damaged to mux: corrupt audio
|
||||
/// passed through as success, and worse, it is the ONE frame that escapes
|
||||
/// a whole-track fallback whose entire point is that nothing after the
|
||||
/// verdict ships.
|
||||
///
|
||||
/// Neither existing held-AU test reaches this: both keep a pristine tally.
|
||||
///
|
||||
/// The fixture drives the exact interleaving above. `DropTally` poisons
|
||||
/// once `verified_dropped * 2 > kept + dropped` past the 200-AU gate, so
|
||||
/// one PES carries 200 CRC-failing `substreamid`-0 syncframes followed by a
|
||||
/// clean one. Closing corrupt unit #200 (which happens only when the clean
|
||||
/// frame is reached) latches the poison — after that clean frame's own
|
||||
/// verdict was computed. Being an E-AC-3 unit that can still gain
|
||||
/// substreams, it is then HELD across the boundary with `drop_reason:
|
||||
/// None`, which is precisely the state the re-check exists for.
|
||||
#[test]
|
||||
fn a_held_access_unit_is_dropped_when_the_track_poisons_before_it_resumes() {
|
||||
// One more than the verdict gate: the Nth close is what poisons.
|
||||
const CORRUPT_AUS: usize = 200;
|
||||
|
||||
let mut parser = Ac3Parser::new();
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..CORRUPT_AUS {
|
||||
let mut f = eac3_substream_frame(0, 0);
|
||||
// Corrupt a payload byte AFTER the CRC was finalized: the header
|
||||
// (sizing, strmtyp/substreamid, bsid) stays intact so the frame is
|
||||
// still parsed as a whole access unit and fails only the CRC —
|
||||
// a VERIFIED drop, the only kind that feeds the poison verdict.
|
||||
f[100] ^= 0xFF;
|
||||
assert!(!frame_crc_ok(&f), "the fixture frame must fail its CRC");
|
||||
data.extend_from_slice(&f);
|
||||
}
|
||||
// The clean access unit. Last in the PES, so it is held open.
|
||||
let clean = eac3_substream_frame(0, 0);
|
||||
assert!(
|
||||
frame_crc_ok(&clean),
|
||||
"the held unit is individually decodable"
|
||||
);
|
||||
data.extend_from_slice(&clean);
|
||||
|
||||
let emitted = parser.parse(&make_eac3_pes(data));
|
||||
assert!(
|
||||
emitted.is_empty(),
|
||||
"every corrupt unit is dropped and the clean one is held; got {} frame(s)",
|
||||
emitted.len()
|
||||
);
|
||||
// The state the re-check depends on: the track IS poisoned, and the
|
||||
// held unit was opened before that verdict existed.
|
||||
assert!(
|
||||
parser.tally.is_poisoned(),
|
||||
"fixture must actually cross the whole-track poison threshold"
|
||||
);
|
||||
|
||||
// Resume. The next PES opens a new unit, which closes the held one.
|
||||
let out = parser.parse(&make_eac3_pes(eac3_substream_frame(0, 0)));
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"an access unit held across the boundary must not be emitted once \
|
||||
the track is poisoned; got {} frame(s) totalling {} bytes",
|
||||
out.len(),
|
||||
out.iter().map(|f| f.data.len()).sum::<usize>()
|
||||
);
|
||||
// ...and nothing may leak at end of stream either.
|
||||
let tail = parser.flush();
|
||||
assert!(
|
||||
tail.is_empty(),
|
||||
"a poisoned track emits nothing at EOS; got {} frame(s)",
|
||||
tail.len()
|
||||
);
|
||||
assert!(
|
||||
parser.dropped_frames() > CORRUPT_AUS as u64,
|
||||
"the held unit must be ACCOUNTED as a drop, not silently discarded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user