Audit round 4-6: disc parsing, extents, codecs and drive faults
Squashed from 12 commits. Every fix was proven red-before-green and killed by a mutation; the reasoning for each is in the private audit record. UDF and extents Honour ICB types rather than assuming a Short AD, so an AD-type-3 directory is no longer decoded from FID bytes into a silently empty listing. Carry the ECMA-167 recorded flag through to the resolvers: an allocated-but-never- written extent used to reach the read plan as ordinary content and splice undefined sectors into the rip. file_extents now refuses such a file, and only when the hole actually occupies byte space — a zero-length one displaces nothing, and refusing on it dropped whole titles off discs that ripped correctly. Type-2 sparse extents are kept alongside type-1; they were falling into a catch-all that exited the descriptor loop and returned a truncated list as complete. merge_ranges no longer claims a sector neither input covered. A short skip or an over-long AD chain errors instead of truncating. HD-DVD and Blu-ray scanning Bound the XPL nesting depth, title count, clips and chapters per title, and memoize the clip-name fallback probe — four separate amplification axes, each of which alone left the worst case unbounded. The clip and title caps are 512, ~10x any retail disc, and a test pins the product of cap and probe budget. The scan is cancellable: it returned Ok with titles carrying no streams when halted, presenting a cancelled scan as a successful one. A clip dropped for an unrecorded extent now says so. Codecs and muxing Resume a held E-AC-3 access unit rather than rescanning from its first frame, and drop it on a discontinuity — a stale hold indexed past the end of the new buffer. Map every ISO 639-1 code instead of collapsing fifteen languages to und. Correct the DVD palette order. Detect a skip past EOF. Drive and I/O Classify dead-bus faults so the wedged-drive path can see them; a catch-all arm had been flattening the variants before the classifier ran. A prefetch producer that dies now reports SourceTerminated instead of Ok(0), which the reader legitimately read as a short read and zero-filled — a whole title could be fabricated and the pass reported complete. Also: charge Ok(0) reads to the CSS crack budget, drop the unreachable soft re-crack, and send disc-derived strings to logs through the debug formatter so a crafted label cannot paint an operator's terminal.
This commit is contained in:
+241
-7
@@ -91,6 +91,18 @@ pub struct Ac3Parser {
|
||||
/// across the PES boundary because it may be the core of an AC-3-core +
|
||||
/// E-AC-3-dependent frame set whose remaining substreams are in the next PES.
|
||||
saw_extension: bool,
|
||||
/// The access unit held open across the last PES boundary, ALREADY
|
||||
/// scanned. The carry-over begins at its first byte, so without this the
|
||||
/// next call re-scans and re-CRCs every syncframe of it from byte 0 — and
|
||||
/// an access unit that keeps gaining substreams grows to [`MAX_AC3_BUF`]
|
||||
/// (1 MiB) before the resync guard drops it, which on a ~2 KiB DVD PES is
|
||||
/// three orders of magnitude of repeated work per packet.
|
||||
held: Option<HeldAu>,
|
||||
/// Test-only: syncframes examined (sized + CRC-gated) by
|
||||
/// `scan_access_units`. Pins the resume above — the property it exists for
|
||||
/// is a WORK bound, which no frame-level assertion can observe.
|
||||
#[cfg(test)]
|
||||
frames_scanned: u64,
|
||||
}
|
||||
|
||||
impl Default for Ac3Parser {
|
||||
@@ -107,6 +119,9 @@ impl Ac3Parser {
|
||||
flush_pts_ns: 0,
|
||||
tally: super::dropgate::DropTally::new("ac3"),
|
||||
saw_extension: false,
|
||||
held: None,
|
||||
#[cfg(test)]
|
||||
frames_scanned: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,20 +164,56 @@ impl Ac3Parser {
|
||||
anchor: Option<PtsAnchor>,
|
||||
at_eos: bool,
|
||||
marks: &[(usize, super::pesbuf::PesFacts)],
|
||||
) -> (Vec<Frame>, usize, i64) {
|
||||
held: Option<HeldAu>,
|
||||
) -> ScanOut {
|
||||
let mut frames = Vec::new();
|
||||
let mut pos = 0usize;
|
||||
// Running PTS for the next access unit to emit in this call.
|
||||
let mut frame_pts_ns = base_pts_ns;
|
||||
let mut anchor = anchor;
|
||||
let mut pending: Option<PendingAu> = None;
|
||||
// How far this call has proved there is no further syncframe to
|
||||
// process; carried over so the held access unit's own bytes (and the
|
||||
// junk after them) are not searched again next call.
|
||||
let mut scanned_to = 0usize;
|
||||
|
||||
// Resume a held access unit instead of re-deriving it. `keep_from` was
|
||||
// its first byte, so it starts at 0 of this buffer, and every frame in
|
||||
// it was sized and CRC-gated on the call that built it.
|
||||
if let Some(h) = held {
|
||||
let mut drop_reason = h.drop_reason;
|
||||
// The one verdict that can have changed since: the track may have
|
||||
// become poisoned while this access unit was held, and a re-scan
|
||||
// would have picked that up.
|
||||
if drop_reason.is_none() && self.tally.is_poisoned() {
|
||||
drop_reason = Some("track-poisoned");
|
||||
}
|
||||
pending = Some(PendingAu {
|
||||
start: 0,
|
||||
end: h.end,
|
||||
pts_ns: base_pts_ns,
|
||||
duration_ns: h.duration_ns,
|
||||
drop_reason,
|
||||
bsid: h.bsid,
|
||||
});
|
||||
frame_pts_ns = base_pts_ns + h.duration_ns as i64;
|
||||
pos = h.scanned_to;
|
||||
scanned_to = h.scanned_to;
|
||||
}
|
||||
|
||||
while pos < data.len() {
|
||||
let sync = find_ac3_sync(&data[pos..]);
|
||||
let start = match sync {
|
||||
Some(offset) => pos + offset,
|
||||
None => break,
|
||||
None => {
|
||||
// No syncword in `data[pos..]` at all: every byte but the
|
||||
// last is proved sync-free (a syncword is two bytes and the
|
||||
// second may still arrive).
|
||||
scanned_to = data.len().saturating_sub(1).max(pos);
|
||||
break;
|
||||
}
|
||||
};
|
||||
scanned_to = start;
|
||||
|
||||
let remaining = &data[start..];
|
||||
|
||||
@@ -182,6 +233,7 @@ impl Ac3Parser {
|
||||
// Invalid/sub-header frame size (e.g. an E-AC-3 frmsiz of 0/1
|
||||
// sizing to a 2/4-byte fragment) — skip this sync word.
|
||||
pos = start + 2;
|
||||
scanned_to = pos;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -191,6 +243,10 @@ impl Ac3Parser {
|
||||
}
|
||||
|
||||
let frame = &data[start..start + frame_size];
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.frames_scanned += 1;
|
||||
}
|
||||
// Decodability gate: a syncframe with an out-of-range bsid (> 16) or
|
||||
// a failed native CRC (payload corruption) poisons the access unit it
|
||||
// belongs to — a dependent substream is useless without its parent and
|
||||
@@ -258,6 +314,7 @@ impl Ac3Parser {
|
||||
}
|
||||
|
||||
pos = start + frame_size;
|
||||
scanned_to = pos;
|
||||
}
|
||||
|
||||
// Close or HOLD the trailing access unit. The rest of its frame set — its
|
||||
@@ -271,10 +328,22 @@ impl Ac3Parser {
|
||||
// substream that extends an access unit — a plain AC-3 track keeps
|
||||
// emitting every frame in-call.
|
||||
let mut hold_from = None;
|
||||
let mut held_out = None;
|
||||
if let Some(au) = pending {
|
||||
if !at_eos && (au.bsid >= 11 || self.saw_extension) {
|
||||
frame_pts_ns = au.pts_ns;
|
||||
hold_from = Some(au.start);
|
||||
// Everything below `scanned_to` has been searched already, and
|
||||
// the access unit's own frames have been sized and CRC-gated;
|
||||
// record both, rebased onto the carry-over (which starts at
|
||||
// `au.start`), so the next call resumes instead of redoing it.
|
||||
held_out = Some(HeldAu {
|
||||
end: au.end - au.start,
|
||||
scanned_to: scanned_to.max(au.end) - au.start,
|
||||
duration_ns: au.duration_ns,
|
||||
drop_reason: au.drop_reason,
|
||||
bsid: au.bsid,
|
||||
});
|
||||
} else {
|
||||
close_access_unit(&mut self.tally, data, &au, marks, &mut frames);
|
||||
}
|
||||
@@ -306,7 +375,12 @@ impl Ac3Parser {
|
||||
None => data.len(),
|
||||
};
|
||||
|
||||
(frames, keep_from, frame_pts_ns)
|
||||
ScanOut {
|
||||
frames,
|
||||
keep_from,
|
||||
frame_pts_ns,
|
||||
held: held_out,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +393,36 @@ struct PtsAnchor {
|
||||
pts_ns: i64,
|
||||
}
|
||||
|
||||
/// What one `scan_access_units` pass produced: the access units it emitted,
|
||||
/// the offset in the scanned buffer from which bytes must be carried over to
|
||||
/// the next call, the PTS to stamp on the access unit that begins that
|
||||
/// carry-over, and — when the trailing access unit was HELD — the state that
|
||||
/// lets the next call resume rather than re-derive it.
|
||||
struct ScanOut {
|
||||
frames: Vec<Frame>,
|
||||
keep_from: usize,
|
||||
frame_pts_ns: i64,
|
||||
held: Option<HeldAu>,
|
||||
}
|
||||
|
||||
/// A trailing access unit held across the PES boundary, already scanned.
|
||||
/// Offsets are relative to the carry-over, which begins at the access unit's
|
||||
/// first byte — so the access unit occupies `0..end`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct HeldAu {
|
||||
/// End of the access unit's bytes.
|
||||
end: usize,
|
||||
/// How far the scan that built it had searched (`>= end`). Bytes below it
|
||||
/// hold no further syncframe to process.
|
||||
scanned_to: usize,
|
||||
/// Duration contributed by the access unit's `substreamid`-0 substream.
|
||||
duration_ns: u64,
|
||||
/// Decodability verdict reached for it so far.
|
||||
drop_reason: Option<&'static str>,
|
||||
/// bsid of the substream that opened it.
|
||||
bsid: u8,
|
||||
}
|
||||
|
||||
/// An access unit (frame set) under construction: `data[start..end]` is the
|
||||
/// `substreamid`-0 independent substream frame plus every substream appended to it
|
||||
/// so far — its dependents, and any additional independent substreams 1..7 with
|
||||
@@ -477,6 +581,8 @@ impl CodecParser for Ac3Parser {
|
||||
// non-empty PES today; this is defensive for any future caller).
|
||||
if pes.discontinuity {
|
||||
self.acc.clear();
|
||||
// The held access unit's bytes went with it.
|
||||
self.held = None;
|
||||
}
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -516,8 +622,13 @@ impl CodecParser for Ac3Parser {
|
||||
buf.extend_from_slice(self.acc.as_slice());
|
||||
let marks = self.acc.marks_snapshot();
|
||||
let data = &buf;
|
||||
let (frames, keep_from, frame_pts_ns) =
|
||||
self.scan_access_units(data, self.flush_pts_ns, anchor, false, &marks);
|
||||
let held = self.held.take();
|
||||
let ScanOut {
|
||||
frames,
|
||||
keep_from,
|
||||
frame_pts_ns,
|
||||
held: still_held,
|
||||
} = self.scan_access_units(data, self.flush_pts_ns, anchor, false, &marks, held);
|
||||
|
||||
if keep_from < data.len() {
|
||||
let tail = &data[keep_from..];
|
||||
@@ -531,6 +642,7 @@ impl CodecParser for Ac3Parser {
|
||||
MAX_AC3_BUF
|
||||
);
|
||||
self.acc.clear();
|
||||
self.held = None;
|
||||
// Advance the cadence, as both sibling branches below do, so the
|
||||
// three paths out of this block cannot disagree. Defensive: no
|
||||
// input reaching this parser was found that both parses frames and
|
||||
@@ -539,6 +651,7 @@ impl CodecParser for Ac3Parser {
|
||||
self.flush_pts_ns = frame_pts_ns;
|
||||
} else {
|
||||
self.acc.drain(keep_from);
|
||||
self.held = still_held;
|
||||
// The carried bytes, when later completed and emitted (next call
|
||||
// or by flush() at EOS), are timed at the PTS the scanner reached
|
||||
// here: the PTS of the next access unit in presentation order, or
|
||||
@@ -548,6 +661,7 @@ impl CodecParser for Ac3Parser {
|
||||
}
|
||||
} else {
|
||||
self.acc.clear();
|
||||
self.held = None;
|
||||
// Nothing carried, but keep the cadence so a following PES with no
|
||||
// PTS (no anchor) continues the timeline instead of reusing a stale
|
||||
// value.
|
||||
@@ -569,9 +683,10 @@ impl CodecParser for Ac3Parser {
|
||||
let buf = self.acc.as_slice().to_vec();
|
||||
let marks = self.acc.marks_snapshot();
|
||||
self.acc.clear();
|
||||
let held = self.held.take();
|
||||
let out = self
|
||||
.scan_access_units(&buf, self.flush_pts_ns, None, true, &marks)
|
||||
.0;
|
||||
.scan_access_units(&buf, self.flush_pts_ns, None, true, &marks, held)
|
||||
.frames;
|
||||
// Aggregate drop report at end-of-stream (warn-level, always visible).
|
||||
self.tally.log_summary();
|
||||
out
|
||||
@@ -2199,6 +2314,125 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A 256-byte E-AC-3 syncframe with a valid CRC. `strmtyp`/`substreamid`
|
||||
/// go into byte 2 (A/52 Annex E), which is what `substream_role` reads:
|
||||
/// (0, 0) OPENS an access unit, (1, 0) is a dependent substream that
|
||||
/// EXTENDS the open one.
|
||||
fn eac3_substream_frame(strmtyp: u8, substreamid: u8) -> Vec<u8> {
|
||||
const SIZE: usize = 256;
|
||||
let frmsiz = SIZE / 2 - 1; // (frmsiz + 1) * 2 == SIZE
|
||||
let mut f = vec![0u8; SIZE];
|
||||
f[0] = 0x0B;
|
||||
f[1] = 0x77;
|
||||
f[2] = (strmtyp << 6) | (substreamid << 3) | ((frmsiz >> 8) as u8 & 0x07);
|
||||
f[3] = (frmsiz & 0xFF) as u8;
|
||||
f[5] = 16 << 3; // bsid 16 → E-AC-3
|
||||
finalize_ac3_crc(&mut f);
|
||||
f
|
||||
}
|
||||
|
||||
/// An access unit closes only at the next `substreamid`-0 independent
|
||||
/// substream, so one that keeps gaining dependent substreams stays OPEN
|
||||
/// across PES boundaries and its bytes stay in the carry-over. The
|
||||
/// carry-over must not be re-scanned — and re-CRCed — from the access
|
||||
/// unit's first byte on every packet: the buffer only stops growing at
|
||||
/// MAX_AC3_BUF (1 MiB), and a PES on a DVD is about 2 KiB, so re-deriving
|
||||
/// the held access unit costs work quadratic in the packets fed.
|
||||
///
|
||||
/// Measured directly, because a work bound has no frame-level shadow:
|
||||
/// `frames_scanned` counts the syncframes the scanner sizes and CRC-gates.
|
||||
/// Re-scanning from byte 0 examines 1 + 2 + ... + (N+1) frames.
|
||||
///
|
||||
/// Mutation: pass `None` for `held` in `parse` (or drop the `if let
|
||||
/// Some(h) = held` resume) — the count returns to the quadratic figure.
|
||||
#[test]
|
||||
fn a_held_access_unit_is_not_rescanned_from_its_first_frame_every_packet() {
|
||||
const DEPENDENTS: usize = 200;
|
||||
|
||||
let mut parser = Ac3Parser::new();
|
||||
// Opens the access unit.
|
||||
let emitted = parser.parse(&make_eac3_pes(eac3_substream_frame(0, 0)));
|
||||
assert!(
|
||||
emitted.is_empty(),
|
||||
"the access unit is held open, not emitted"
|
||||
);
|
||||
for _ in 0..DEPENDENTS {
|
||||
let f = parser.parse(&make_eac3_pes(eac3_substream_frame(1, 0)));
|
||||
assert!(f.is_empty(), "a dependent substream extends the open unit");
|
||||
}
|
||||
|
||||
let fed = (DEPENDENTS + 1) as u64;
|
||||
assert!(
|
||||
parser.frames_scanned <= 2 * fed,
|
||||
"the scanner examined {} syncframes for {fed} fed — a held access \
|
||||
unit must be resumed, not re-derived",
|
||||
parser.frames_scanned
|
||||
);
|
||||
|
||||
// ...and the resume must not have cost correctness: the whole frame
|
||||
// set is still one access unit, emitted intact at EOS.
|
||||
let out = parser.flush();
|
||||
assert_eq!(out.len(), 1, "the frame set is a single access unit");
|
||||
assert_eq!(
|
||||
out[0].data.len(),
|
||||
256 * (DEPENDENTS + 1),
|
||||
"every substream of the frame set belongs to it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A concealed gap must drop the HELD access unit, not just the byte
|
||||
/// buffer.
|
||||
///
|
||||
/// `parse` clears `self.acc` on a discontinuity because the buffered bytes
|
||||
/// are a truncated frame. The held access unit is described by OFFSETS into
|
||||
/// exactly those bytes, so it has to go with them. Without
|
||||
/// `self.held = None`, the next packet resumes a HeldAu whose `start`/`end`
|
||||
/// were computed against the pre-gap buffer but are applied to the
|
||||
/// unrelated post-gap bytes — splicing audio across the gap at best, and
|
||||
/// indexing past the end of the new, shorter buffer at worst.
|
||||
///
|
||||
/// The two existing discontinuity tests use plain AC-3 (bsid < 11), which
|
||||
/// never holds an access unit open, so neither of them reaches this reset.
|
||||
#[test]
|
||||
fn a_discontinuity_drops_the_held_access_unit_with_its_bytes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
// Open an access unit and extend it, so a HeldAu exists describing
|
||||
// offsets into a large buffer.
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_eac3_pes(eac3_substream_frame(0, 0)))
|
||||
.is_empty(),
|
||||
"the access unit is held open, not emitted"
|
||||
);
|
||||
for _ in 0..8 {
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_eac3_pes(eac3_substream_frame(1, 0)))
|
||||
.is_empty(),
|
||||
"a dependent substream extends the open unit"
|
||||
);
|
||||
}
|
||||
|
||||
// The gap. Its post-gap payload is deliberately far SHORTER than the
|
||||
// held unit's bytes, so a stale HeldAu indexes past its end.
|
||||
let mut gap = make_eac3_pes(eac3_substream_frame(0, 0));
|
||||
gap.discontinuity = true;
|
||||
let _ = parser.parse(&gap);
|
||||
|
||||
// Whatever comes out, nothing may carry pre-gap bytes: the truncated
|
||||
// unit was dropped, so the only access unit that can be emitted is the
|
||||
// one opened after the gap.
|
||||
let out = parser.flush();
|
||||
let total: usize = out.iter().map(|f| f.data.len()).sum();
|
||||
assert!(
|
||||
total <= 256,
|
||||
"a post-gap access unit must not be spliced onto the 9 frames held \
|
||||
before the gap; got {total} bytes across {} frame(s)",
|
||||
out.len()
|
||||
);
|
||||
}
|
||||
|
||||
// helper: PES with a generic pts for E-AC-3 tests
|
||||
fn make_eac3_pes(data: Vec<u8>) -> PesPacket {
|
||||
PesPacket {
|
||||
|
||||
+18
-6
@@ -29,10 +29,17 @@ pub(crate) fn crc16_ansi(data: &[u8]) -> u16 {
|
||||
}
|
||||
|
||||
/// CRC-16 with polynomial 0x002D, init 0, MSB-first, used by the MLP / Dolby
|
||||
/// TrueHD major-sync header checksum. NOTE: MLP's checksum is the "reversed"
|
||||
/// scheme — the stored trailer word is the little-endian-read CRC, so this
|
||||
/// standard CRC must be compared against the stored bytes read big-endian.
|
||||
/// The caller handles that comparison (see `truehd::mlp_major_sync_ok`).
|
||||
/// TrueHD major-sync header checksum.
|
||||
///
|
||||
/// NOTE: MLP's checksum is the "reversed" scheme. This function emits its two
|
||||
/// bytes in the OPPOSITE order to a standard little-endian CRC readout, so the
|
||||
/// caller swaps them back and compares against the stored trailer word read
|
||||
/// LITTLE-endian — see `truehd::mlp_major_sync_crc_ok`, which is authoritative.
|
||||
///
|
||||
/// Comparing big-endian instead is precisely the bug that function was fixed
|
||||
/// for: it could never validate a real extended major sync, so whole TrueHD
|
||||
/// tracks were dropped silently. This comment used to prescribe exactly that,
|
||||
/// and to point at a `truehd::mlp_major_sync_ok` that does not exist.
|
||||
/// Verified against real MLP/TrueHD bitstreams (225/225 major-sync AUs).
|
||||
pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
@@ -103,8 +110,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn crc16_mlp_residue_property_holds() {
|
||||
// Appending the big-endian CRC zeroes the residue over message+crc — the
|
||||
// scheme `truehd::mlp_major_sync_ok` relies on.
|
||||
// Appending the big-endian CRC zeroes the residue over message+crc.
|
||||
// This is a property of the CRC itself, pinned here so a change to the
|
||||
// polynomial or the bit order is caught. It is NOT how the TrueHD
|
||||
// caller validates a major sync: `truehd::mlp_major_sync_crc_ok` does a
|
||||
// swap-and-XOR compare against the little-endian trailer word. (This
|
||||
// comment used to claim the caller relied on the residue, and named a
|
||||
// `truehd::mlp_major_sync_ok` that does not exist.)
|
||||
let msg = [0xF8u8, 0x72, 0x6F, 0xBA];
|
||||
let c = crc16_mlp(&msg);
|
||||
let mut framed = msg.to_vec();
|
||||
|
||||
+60
-11
@@ -159,7 +159,13 @@ impl CodecParser for DvdSubParser {
|
||||
|
||||
/// Convert a single YCbCr color to RGB, clamping to [0, 255].
|
||||
///
|
||||
/// Input: `[padding, Y, Cb, Cr]` (as stored in DVD IFO PGC data).
|
||||
/// Input: `[padding, Y, Cr, Cb]` (as stored in DVD IFO PGC data). Note the
|
||||
/// chroma order: the on-disc PGC CLUT is **Cr before Cb** — byte 2 is Cr and
|
||||
/// byte 3 is Cb. Reading byte 2 as Cb swaps red and blue on every chromatic
|
||||
/// entry, and is invisible on the achromatic (white/black/grey, Cb = Cr = 128)
|
||||
/// entries that dominate real palettes, which is how it survives casual
|
||||
/// inspection. The order is fixed by the DVD-Video PGC format, not by us.
|
||||
///
|
||||
/// Returns `[R, G, B]`.
|
||||
///
|
||||
/// Range convention (deliberate): this uses the **full-range (JFIF) BT.601**
|
||||
@@ -175,8 +181,8 @@ impl CodecParser for DvdSubParser {
|
||||
/// side in lockstep.
|
||||
pub fn ycbcr_to_rgb(color: &[u8; 4]) -> [u8; 3] {
|
||||
let y = color[1] as f64;
|
||||
let cb = color[2] as f64;
|
||||
let cr = color[3] as f64;
|
||||
let cr = color[2] as f64;
|
||||
let cb = color[3] as f64;
|
||||
|
||||
let r = y + 1.402 * (cr - 128.0);
|
||||
let g = y - 0.344 * (cb - 128.0) - 0.714 * (cr - 128.0);
|
||||
@@ -198,7 +204,7 @@ fn clamp_u8(v: f64) -> u8 {
|
||||
/// Format a 16-color YCbCr palette as a VobSub `.idx` header for S_VOBSUB
|
||||
/// CodecPrivate.
|
||||
///
|
||||
/// Each entry is `[padding, Y, Cb, Cr]`. Output is a UTF-8 text block carrying
|
||||
/// Each entry is `[padding, Y, Cr, Cb]`. Output is a UTF-8 text block carrying
|
||||
/// the two `.idx` header lines mkvmerge / libvobsub expect:
|
||||
///
|
||||
/// ```text
|
||||
@@ -432,24 +438,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ycbcr_to_rgb_clamps_overflow() {
|
||||
// Y=255, Cr=255 → R would be 255 + 1.402*127 = ~433, should clamp to 255
|
||||
let color = [0x00, 255, 128, 255];
|
||||
// Y=255, Cr=255 → R would be 255 + 1.402*127 = ~433, should clamp to 255.
|
||||
// Cr is byte 2 in the on-disc [pad, Y, Cr, Cb] layout.
|
||||
let color = [0x00, 255, 255, 128];
|
||||
let [r, _g, _b] = ycbcr_to_rgb(&color);
|
||||
assert_eq!(r, 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ycbcr_to_rgb_clamps_underflow() {
|
||||
// Y=0, Cr=0 → R = 0 + 1.402*(0-128) = -179, should clamp to 0
|
||||
let color = [0x00, 0, 128, 0];
|
||||
// Y=0, Cr=0 → R = 0 + 1.402*(0-128) = -179, should clamp to 0.
|
||||
// Cr is byte 2 in the on-disc [pad, Y, Cr, Cb] layout.
|
||||
let color = [0x00, 0, 0, 128];
|
||||
let [r, _g, _b] = ycbcr_to_rgb(&color);
|
||||
assert_eq!(r, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ycbcr_to_rgb_red() {
|
||||
// Approximate red: Y=82, Cb=90, Cr=240
|
||||
let color = [0x00, 82, 90, 240];
|
||||
// Approximate red: Y=82, Cr=240, Cb=90 — on disc as [pad, Y, Cr, Cb].
|
||||
let color = [0x00, 82, 240, 90];
|
||||
let [r, g, b] = ycbcr_to_rgb(&color);
|
||||
// R = 82 + 1.402*(240-128) = 82 + 156.9 ≈ 239
|
||||
// G = 82 - 0.344*(90-128) - 0.714*(240-128) = 82 + 13.1 - 79.97 ≈ 15
|
||||
@@ -459,6 +467,46 @@ mod tests {
|
||||
assert!(b < 30, "B should be low for red, got {}", b);
|
||||
}
|
||||
|
||||
/// On-disc DVD PGC CLUT byte order is `[0, Y, Cr, Cb]` — byte 2 is **Cr**
|
||||
/// and byte 3 is **Cb**, per the DVD-Video PGC format.
|
||||
///
|
||||
/// This fixture uses a real on-disc red entry, so it fails if the two
|
||||
/// chroma bytes are ever exchanged again. It is deliberately NOT built
|
||||
/// from this crate's own doc comments: those described the order wrongly
|
||||
/// for a long time, and the previous version of this test inherited the
|
||||
/// error from them and therefore could not detect it.
|
||||
///
|
||||
/// A saturated RED entry therefore appears on disc as Y=76, Cr=255, Cb=85
|
||||
/// (full-range BT.601 encoding of RGB #FF0000), i.e. bytes
|
||||
/// `[0x00, 76, 255, 85]`. Reading byte 2 as Cb and byte 3 as Cr instead
|
||||
/// turns this entry BLUE, which is the exact user-visible symptom.
|
||||
///
|
||||
/// The pre-existing `_white` / `_black` tests cannot catch this: they use
|
||||
/// Cb = Cr = 128, so exchanging two equal bytes is a literal no-op.
|
||||
#[test]
|
||||
fn ycbcr_to_rgb_reads_byte2_as_cr_and_byte3_as_cb() {
|
||||
// On-disc [pad, Y, Cr, Cb] for saturated red.
|
||||
let on_disc_red = [0x00u8, 76, 255, 85];
|
||||
let [r, g, b] = ycbcr_to_rgb(&on_disc_red);
|
||||
|
||||
assert!(
|
||||
r > 200 && b < 60,
|
||||
"on-disc red [0,Y=76,Cr=255,Cb=85] must render red-dominant, \
|
||||
got R={r} G={g} B={b} (R and B swapped => byte 2/3 are transposed)"
|
||||
);
|
||||
assert_eq!([r, g, b], [254, 0, 0], "exact full-range BT.601 red");
|
||||
|
||||
// And the converse: a saturated BLUE on-disc entry (Y=29, Cr=107, Cb=255)
|
||||
// must not come out red.
|
||||
let on_disc_blue = [0x00u8, 29, 107, 255];
|
||||
let [r2, g2, b2] = ycbcr_to_rgb(&on_disc_blue);
|
||||
assert!(
|
||||
b2 > 200 && r2 < 60,
|
||||
"on-disc blue [0,Y=29,Cr=107,Cb=255] must render blue-dominant, \
|
||||
got R={r2} G={g2} B={b2}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Palette formatting tests ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -699,7 +747,8 @@ mod tests {
|
||||
#[test]
|
||||
fn ycbcr_blue_channel_clamps_high() {
|
||||
// B = Y + 1.772*(Cb-128). Y=128, Cb=255 → 128 + 1.772*127 ≈ 353 → clamp 255.
|
||||
let [_r, _g, b] = ycbcr_to_rgb(&[0x00, 128, 255, 128]);
|
||||
// Cb is byte 3 in the on-disc [pad, Y, Cr, Cb] layout.
|
||||
let [_r, _g, b] = ycbcr_to_rgb(&[0x00, 128, 128, 255]);
|
||||
assert_eq!(b, 255, "blue clamps at 255");
|
||||
}
|
||||
|
||||
|
||||
+148
@@ -566,6 +566,20 @@ impl DiscStream {
|
||||
.into());
|
||||
}
|
||||
|
||||
// The read SOURCE is gone (a prefetch producer thread that
|
||||
// terminated), not one range of media. Shrinking and retrying at
|
||||
// the same LBA asks a dead source for data it can never produce,
|
||||
// and the `skip_errors` branch below would then zero-fill and
|
||||
// advance over every remaining sector of the title and still
|
||||
// return success. Abort with the terminal error itself — a
|
||||
// fabricated SCSI status would be a lie, so this is deliberately
|
||||
// NOT folded into the transport-failure arm above.
|
||||
if let Some(e) = res.as_ref().err()
|
||||
&& e.is_source_terminated()
|
||||
{
|
||||
return Err(crate::error::Error::SourceTerminated.into());
|
||||
}
|
||||
|
||||
if (sectors as u32) <= align {
|
||||
// Bottomed out at one unit (AACS) / one sector (CSS) / the
|
||||
// extent tail. This is single-pass disc→MKV, which has NO Pass N
|
||||
@@ -621,6 +635,16 @@ impl DiscStream {
|
||||
.into());
|
||||
}
|
||||
|
||||
// Same rule as after the first-attempt read: the 60s recovery
|
||||
// read goes through the same source, so it can be the call
|
||||
// that discovers the source is dead. Skipping the unit would
|
||||
// zero-fill the rest of the title as fabricated content.
|
||||
if let Some(e) = rec.as_ref().err()
|
||||
&& e.is_source_terminated()
|
||||
{
|
||||
return Err(crate::error::Error::SourceTerminated.into());
|
||||
}
|
||||
|
||||
// Recovery read also failed. Skip the WHOLE failed unit or bail.
|
||||
// Zero-filling and advancing by the full unit keeps
|
||||
// current_offset unit-aligned, so the next read still begins on a
|
||||
@@ -1859,6 +1883,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (round-4 audit): an ordinary MEDIUM ERROR bad sector must
|
||||
/// keep its identity when it crosses the prefetch producer channel — the
|
||||
/// same `DiscRead` with its SCSI status, NOT a transport failure.
|
||||
///
|
||||
/// `PrefetchedSectorSource::read_sectors` re-wrapped every error that
|
||||
/// crossed the channel as `Error::IoError`, and `is_scsi_transport_failure`
|
||||
/// matches `IoError` (the wedged-USB-bridge arm). So a bad sector reached
|
||||
/// `fill_extents` looking like a dead bus and aborted the pass with a
|
||||
/// fabricated status 0xFF — the exact inverse of what that short-circuit
|
||||
/// exists for, and it told the user to power-cycle a healthy drive.
|
||||
///
|
||||
/// Asserted on the source, not on a `fill_extents` skip: the producer
|
||||
/// thread exits for good after sending an error, so nothing downstream of
|
||||
/// it can genuinely recover the rest of the title (see
|
||||
/// `dead_prefetch_producer_does_not_silently_zero_fill_the_title`). An
|
||||
/// assertion that the pass continues could only ever have been satisfied
|
||||
/// by fabricated zeros.
|
||||
#[test]
|
||||
fn bad_sector_keeps_its_identity_across_the_prefetch_channel() {
|
||||
const COUNT: u32 = 9;
|
||||
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let reader = RecordingReader {
|
||||
capacity: COUNT,
|
||||
bad_sector: 4,
|
||||
log: log.clone(),
|
||||
};
|
||||
let mut prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
|
||||
reader,
|
||||
vec![crate::disc::Extent {
|
||||
start_lba: 0,
|
||||
sector_count: COUNT,
|
||||
}],
|
||||
8,
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("spawn producer");
|
||||
|
||||
let mut buf = vec![0u8; 8 * 2048];
|
||||
let err = crate::sector::SectorSource::read_sectors(&mut prefetched, 0, 8, &mut buf, false)
|
||||
.expect_err("the batch covering the bad sector must fail");
|
||||
assert!(
|
||||
!err.is_scsi_transport_failure(),
|
||||
"a MEDIUM ERROR bad sector is not a dead bus; got {err:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
crate::error::Error::DiscRead {
|
||||
sector: 4,
|
||||
status: Some(0x02),
|
||||
..
|
||||
}
|
||||
),
|
||||
"the producer's typed error must survive the channel intact; got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The prefetch producer thread terminates PERMANENTLY on its first read
|
||||
/// error, so once one bad sector has crossed the channel the source can
|
||||
/// never deliver another byte. Driving `fill_extents` to exhaustion after
|
||||
/// that must NOT look like a completed pass: every remaining sector would
|
||||
/// be fabricated zeros, and DATA LOSS MUST NEVER LOOK LIKE SUCCESS.
|
||||
///
|
||||
/// The expectation is the product rule, not the code: a source that is
|
||||
/// permanently out of data must report that, not answer `Ok(0)` forever —
|
||||
/// which `commit_read` legitimately reads as an ordinary short read and
|
||||
/// zero-fills.
|
||||
#[test]
|
||||
fn dead_prefetch_producer_does_not_silently_zero_fill_the_title() {
|
||||
const COUNT: u32 = 30;
|
||||
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let reader = RecordingReader {
|
||||
capacity: COUNT,
|
||||
bad_sector: 4,
|
||||
log: log.clone(),
|
||||
};
|
||||
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
|
||||
reader,
|
||||
vec![crate::disc::Extent {
|
||||
start_lba: 0,
|
||||
sector_count: COUNT,
|
||||
}],
|
||||
8,
|
||||
1,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("spawn producer");
|
||||
let mut stream = DiscStream::new(
|
||||
Box::new(prefetched),
|
||||
synthetic_title(COUNT),
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
stream.skip_errors = true;
|
||||
|
||||
// Drive the whole title. Bounded so a regression cannot hang the suite.
|
||||
let mut completed_clean = false;
|
||||
for _ in 0..(COUNT as usize * 4) {
|
||||
match stream.fill_extents() {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {
|
||||
completed_clean = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!completed_clean,
|
||||
"the producer died at sector 4, so sectors 4..{COUNT} were never \
|
||||
read — reporting the pass as complete zero-fills {} of {} bytes \
|
||||
and calls it success",
|
||||
stream.lost_bytes,
|
||||
COUNT as u64 * 2048
|
||||
);
|
||||
}
|
||||
|
||||
/// AACS unit-alignment skip (the #1 coverage gap). With `unit_align=3`
|
||||
/// (DecryptKeys::Aacs) and `skip_errors=true`, a single bad mid-extent
|
||||
/// sector must NOT desync the rest of the title: every `read_sectors`
|
||||
|
||||
+297
-2
@@ -359,6 +359,22 @@ fn write_hdr10<W: Write + Seek>(w: &mut W, h: &crate::mux::codec::Hdr10Metadata)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Matroska `Language` element the muxer writes for a stream whose source
|
||||
/// reported `lang`. RFC 9559 §12 defines the element as an ISO 639-2 code and
|
||||
/// gives no meaning to an empty one; the code for "no language stated" is
|
||||
/// `und`, and that is what a source with no language table (the HD-DVD EVO
|
||||
/// stream probe, a Blu-ray STN slot with no language bytes) has to emit. The
|
||||
/// element is written unconditionally by `MkvMuxer::new`, so this is the one
|
||||
/// place that decides it — a source-side default would have to be repeated in
|
||||
/// every scanner and would still leave the muxer able to ship an invalid file.
|
||||
fn language_or_und(lang: &str) -> String {
|
||||
if lang.is_empty() {
|
||||
"und".to_string()
|
||||
} else {
|
||||
lang.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl MkvTrack {
|
||||
/// Build a video track from a [`VideoStream`]. Language defaults to `"und"`;
|
||||
/// colour metadata is derived from the stream's colour space and HDR format
|
||||
@@ -530,7 +546,7 @@ impl MkvTrack {
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||||
codec_id,
|
||||
language: a.language.clone(),
|
||||
language: language_or_und(&a.language),
|
||||
name,
|
||||
codec_private: None,
|
||||
is_default: !a.secondary,
|
||||
@@ -587,7 +603,7 @@ impl MkvTrack {
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_SUBTITLE,
|
||||
codec_id,
|
||||
language: s.language.clone(),
|
||||
language: language_or_und(&s.language),
|
||||
name: String::new(),
|
||||
codec_private: s.codec_data.clone(),
|
||||
is_default: false,
|
||||
@@ -3307,6 +3323,285 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Read back the value of the FIRST `Language` element in `data` as a
|
||||
/// UTF-8 string. `LANGUAGE` (0x22B59C) is a 3-byte EBML ID; its size is
|
||||
/// always a 1-byte VINT for the short strings this writer emits.
|
||||
fn first_language_value(data: &[u8]) -> &str {
|
||||
let pos = find_id(data, ebml::LANGUAGE).expect("Language element must be present");
|
||||
let len = (data[pos + 3] & 0x7F) as usize;
|
||||
std::str::from_utf8(&data[pos + 4..pos + 4 + len]).unwrap()
|
||||
}
|
||||
|
||||
/// RFC 9559 §12 / the Matroska `Language` element spec restrict the
|
||||
/// legacy `Language` element to the Matroska language form (ISO 639-2,
|
||||
/// three lowercase letters), never ISO 639-1 (two letters). The DVD
|
||||
/// IFO audio-attribute block itself carries a raw ISO 639-1 code (e.g.
|
||||
/// "en") on disc — `ifo::parse_audio_attr` converts it to ISO 639-2
|
||||
/// before returning, via `ifo::dvd_lang_to_iso639_2`. This mimics the
|
||||
/// real DVD pipeline (`disc/dvd.rs`'s `Stream::Audio` construction) end
|
||||
/// to end: real on-disc IFO bytes -> `ifo::parse_audio_attr` ->
|
||||
/// `disc::AudioStream` -> `MkvTrack::audio` -> the muxer -> the emitted
|
||||
/// `Language` element.
|
||||
#[test]
|
||||
fn dvd_two_letter_language_becomes_iso_639_2_in_language_element() {
|
||||
// AC-3 (coding_mode=0), 48 kHz, 6 channels, on-disc language "en" —
|
||||
// the exact byte layout `ifo::audio_attr_parsing` pins.
|
||||
let mut attr_bytes = vec![0u8; 8];
|
||||
attr_bytes[0] = 0x00;
|
||||
attr_bytes[1] = 0x05;
|
||||
attr_bytes[2] = b'e';
|
||||
attr_bytes[3] = b'n';
|
||||
let attr = crate::ifo::parse_audio_attr(&attr_bytes, 0).unwrap();
|
||||
assert_eq!(
|
||||
attr.language, "eng",
|
||||
"parse_audio_attr must already return the ISO 639-2 form"
|
||||
);
|
||||
|
||||
let audio_stream = crate::disc::AudioStream {
|
||||
pid: 0xBD80,
|
||||
codec: attr.codec,
|
||||
channels: crate::disc::AudioChannels::from_count(attr.channels),
|
||||
language: attr.language,
|
||||
sample_rate: crate::disc::SampleRate::from_hz(attr.sample_rate),
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
};
|
||||
let track = MkvTrack::audio(&audio_stream);
|
||||
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [track];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
assert_eq!(
|
||||
first_language_value(&data),
|
||||
"eng",
|
||||
"a DVD-sourced ISO 639-1 code must be written as its ISO 639-2 \
|
||||
equivalent in the Matroska Language element, per RFC 9559 §12"
|
||||
);
|
||||
}
|
||||
|
||||
/// A source that knows no language at all leaves `language` EMPTY, and the
|
||||
/// muxer writes the `Language` element unconditionally — so an empty
|
||||
/// string becomes a zero-length `Language` in the shipped file, which is
|
||||
/// not a Matroska language form (RFC 9559 §12 wants three ISO 639-2
|
||||
/// letters) and is not the ISO 639-2 code for "unknown" either.
|
||||
///
|
||||
/// This is the state every HD-DVD rip is in: `disc::hddvd`'s EVO stream
|
||||
/// probe has no language table to read and sets `language: String::new()`
|
||||
/// on every audio stream it finds. The DVD path normalises to "und" in
|
||||
/// `ifo::parse_audio_attr`; the guard has to exist at the muxer too, which
|
||||
/// is the one place every source funnels through.
|
||||
#[test]
|
||||
fn a_source_with_no_language_emits_und_not_an_empty_language_element() {
|
||||
// Exactly what `disc::hddvd::probe_evo_streams` builds.
|
||||
let audio_stream = crate::disc::AudioStream {
|
||||
pid: 0xBD80,
|
||||
codec: Codec::Ac3Plus,
|
||||
channels: crate::disc::AudioChannels::Surround51,
|
||||
language: String::new(),
|
||||
sample_rate: crate::disc::SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
};
|
||||
let subtitle_stream = crate::disc::SubtitleStream {
|
||||
pid: 0x1200,
|
||||
codec: Codec::Pgs,
|
||||
language: String::new(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
};
|
||||
|
||||
for track in [
|
||||
MkvTrack::audio(&audio_stream),
|
||||
MkvTrack::subtitle(&subtitle_stream),
|
||||
] {
|
||||
let track_type = track.track_type;
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [track];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert_eq!(
|
||||
first_language_value(&data),
|
||||
"und",
|
||||
"track type {track_type}: a stream with no known language must \
|
||||
emit the ISO 639-2 'undetermined' code, never a zero-length \
|
||||
Language element"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An unmapped or absent DVD language code (bytes 0x00 0x00 in the IFO
|
||||
/// attribute block) must degrade to the valid Matroska "undetermined"
|
||||
/// code `und`, never to an empty string or a raw 2-letter code — both of
|
||||
/// which violate the Matroska language form.
|
||||
#[test]
|
||||
fn dvd_unmapped_or_empty_language_becomes_und_in_language_element() {
|
||||
// Empty IFO language bytes (0x00 0x00).
|
||||
let mut empty_bytes = vec![0u8; 8];
|
||||
empty_bytes[0] = 0x00; // AC-3, 48k
|
||||
empty_bytes[1] = 0x05; // 6ch
|
||||
let empty_attr = crate::ifo::parse_audio_attr(&empty_bytes, 0).unwrap();
|
||||
assert_eq!(
|
||||
empty_attr.language, "und",
|
||||
"IFO zero bytes (unspecified) must resolve to 'und', not empty"
|
||||
);
|
||||
|
||||
// An IFO code with no known ISO 639-1 -> 639-2 mapping (e.g. a
|
||||
// fictitious "zz").
|
||||
let mut unmapped_bytes = vec![0u8; 8];
|
||||
unmapped_bytes[0] = 0x00;
|
||||
unmapped_bytes[1] = 0x05;
|
||||
unmapped_bytes[2] = b'z';
|
||||
unmapped_bytes[3] = b'z';
|
||||
let unmapped_attr = crate::ifo::parse_audio_attr(&unmapped_bytes, 0).unwrap();
|
||||
assert_eq!(
|
||||
unmapped_attr.language, "und",
|
||||
"an on-disc code with no known ISO 639-1 -> 639-2 mapping must \
|
||||
degrade to 'und', never pass through raw"
|
||||
);
|
||||
|
||||
for attr in [empty_attr, unmapped_attr] {
|
||||
let audio_stream = crate::disc::AudioStream {
|
||||
pid: 0xBD80,
|
||||
codec: attr.codec,
|
||||
channels: crate::disc::AudioChannels::from_count(attr.channels),
|
||||
language: attr.language,
|
||||
sample_rate: crate::disc::SampleRate::from_hz(attr.sample_rate),
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
};
|
||||
let track = MkvTrack::audio(&audio_stream);
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [track];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert_eq!(
|
||||
first_language_value(&data),
|
||||
"und",
|
||||
"an empty or unmapped DVD language code must degrade to 'und', \
|
||||
never an empty string or an invalid ISO 639-1 code"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an 8-byte DVD IFO audio-attribute block (AC-3, 48 kHz, 6ch)
|
||||
/// carrying `code` in the language bytes, run it through the real DVD
|
||||
/// pipeline (`ifo::parse_audio_attr` -> `disc::AudioStream` ->
|
||||
/// `MkvTrack::audio` -> the muxer) and return the value that actually
|
||||
/// lands in the emitted Matroska `Language` element.
|
||||
fn emitted_language_for_dvd_code(code: &[u8; 2]) -> String {
|
||||
let mut attr_bytes = vec![0u8; 8];
|
||||
attr_bytes[0] = 0x00; // AC-3, 48 kHz
|
||||
attr_bytes[1] = 0x05; // 6 channels
|
||||
attr_bytes[2] = code[0];
|
||||
attr_bytes[3] = code[1];
|
||||
let attr = crate::ifo::parse_audio_attr(&attr_bytes, 0).unwrap();
|
||||
|
||||
let audio_stream = crate::disc::AudioStream {
|
||||
pid: 0xBD80,
|
||||
codec: attr.codec,
|
||||
channels: crate::disc::AudioChannels::from_count(attr.channels),
|
||||
language: attr.language,
|
||||
sample_rate: crate::disc::SampleRate::from_hz(attr.sample_rate),
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
};
|
||||
let track = MkvTrack::audio(&audio_stream);
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [track];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
first_language_value(&data).to_string()
|
||||
}
|
||||
|
||||
/// The ISO 639-1 -> ISO 639-2 conversion must cover the WHOLE of ISO
|
||||
/// 639-1, not just the handful of languages that happen to appear in
|
||||
/// Blu-ray menu-graphic filenames. A Region-2 disc routinely carries
|
||||
/// Romanian, Bulgarian, Croatian, Serbian, Slovak, Slovenian, Hebrew,
|
||||
/// Estonian, Latvian, Lithuanian, Icelandic and so on; if those all
|
||||
/// collapse to `und`, every one of a disc's subtitle tracks emits the
|
||||
/// same `Language` value and nothing else tells them apart (DVD streams
|
||||
/// carry an empty `label`). A valid-but-identical code is worse for the
|
||||
/// user than the invalid one it replaced, so each of these must reach the
|
||||
/// emitted `Language` element as its own correct three-letter code.
|
||||
#[test]
|
||||
fn dvd_language_outside_the_menu_vocabulary_is_still_mapped() {
|
||||
assert_eq!(
|
||||
emitted_language_for_dvd_code(b"ro"),
|
||||
"ron",
|
||||
"Romanian ('ro'), common on Region-2 discs, must reach the \
|
||||
Matroska Language element as 'ron' — not 'und'"
|
||||
);
|
||||
// The rest of the set the menu-label table never knew, one per
|
||||
// language so a single missing table row fails loudly.
|
||||
for (code, expected) in [
|
||||
(b"bg", "bul"),
|
||||
(b"hr", "hrv"),
|
||||
(b"sr", "srp"),
|
||||
(b"sk", "slk"),
|
||||
(b"sl", "slv"),
|
||||
(b"he", "heb"),
|
||||
(b"et", "est"),
|
||||
(b"lv", "lav"),
|
||||
(b"lt", "lit"),
|
||||
(b"is", "isl"),
|
||||
(b"id", "ind"),
|
||||
(b"vi", "vie"),
|
||||
(b"fa", "fas"),
|
||||
] {
|
||||
assert_eq!(
|
||||
emitted_language_for_dvd_code(code),
|
||||
expected,
|
||||
"DVD language {:?} must map to {expected:?} in the emitted \
|
||||
Language element",
|
||||
std::str::from_utf8(code).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DVD-Video froze its language list on the 1988 edition of ISO 639-1,
|
||||
/// which spelled Hebrew `iw`, Indonesian `in` and Yiddish `ji`. Real
|
||||
/// discs authored to that list carry those bytes, so they must map to the
|
||||
/// same ISO 639-2 codes as the modern `he` / `id` / `yi` spellings rather
|
||||
/// than degrading to `und`.
|
||||
#[test]
|
||||
fn dvd_era_language_aliases_map_to_the_modern_code() {
|
||||
assert_eq!(
|
||||
emitted_language_for_dvd_code(b"iw"),
|
||||
"heb",
|
||||
"the DVD-era spelling of Hebrew ('iw') must emit 'heb'"
|
||||
);
|
||||
assert_eq!(emitted_language_for_dvd_code(b"in"), "ind");
|
||||
assert_eq!(emitted_language_for_dvd_code(b"ji"), "yid");
|
||||
// ...and agree with the modern spellings.
|
||||
assert_eq!(emitted_language_for_dvd_code(b"he"), "heb");
|
||||
assert_eq!(emitted_language_for_dvd_code(b"id"), "ind");
|
||||
assert_eq!(emitted_language_for_dvd_code(b"yi"), "yid");
|
||||
}
|
||||
|
||||
/// Widening the table must not weaken the degradation guarantee: a code
|
||||
/// that is not ISO 639-1 at all still has to yield exactly `und`, a valid
|
||||
/// Matroska language value, and never a passed-through two-letter code,
|
||||
/// an empty string, or a guess.
|
||||
#[test]
|
||||
fn unknown_dvd_language_still_yields_exactly_und() {
|
||||
for code in [b"zz", b"qq", b"xx"] {
|
||||
assert_eq!(
|
||||
emitted_language_for_dvd_code(code),
|
||||
"und",
|
||||
"a code outside ISO 639-1 must degrade to exactly 'und'"
|
||||
);
|
||||
}
|
||||
// Empty language bytes (0x00 0x00) likewise.
|
||||
assert_eq!(emitted_language_for_dvd_code(&[0x00, 0x00]), "und");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_forced_flag_on_forced_subtitle() {
|
||||
use crate::disc::SubtitleStream;
|
||||
|
||||
+84
-1
@@ -12,8 +12,19 @@ use super::{WriteSeek, ebml};
|
||||
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>, i64, TrackTable)>;
|
||||
|
||||
/// Skip `n` bytes on a forward-only reader (no Seek required).
|
||||
///
|
||||
/// A skip that runs out of input before `n` bytes is a TRUNCATED element, and is
|
||||
/// reported the same way `ebml::read_binary_val` reports a truncated body: as
|
||||
/// `MkvSourceInvalid`. Discarding `io::copy`'s byte count instead made a skip
|
||||
/// that hit EOF look like a success, so one corrupt size field mid-Clusters
|
||||
/// drained the rest of the file, the next element header raised
|
||||
/// `UnexpectedEof`, and `Stream::read` mapped that to `Ok(None)` — half the
|
||||
/// title missing, `errors = 0`, `completed = true`.
|
||||
fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> {
|
||||
io::copy(&mut r.take(n), &mut io::sink())?;
|
||||
let skipped = io::copy(&mut r.take(n), &mut io::sink())?;
|
||||
if skipped != n {
|
||||
return Err(crate::error::Error::MkvSourceInvalid.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3193,6 +3204,78 @@ mod tests {
|
||||
assert!(stream.read().unwrap().is_none(), "clean EOF → None");
|
||||
}
|
||||
|
||||
/// A skipped element whose declared size runs PAST the end of the file is a
|
||||
/// truncated element, exactly like a truncated `read_binary_val` body — and
|
||||
/// must be reported the same way, as `MkvSourceInvalid`.
|
||||
///
|
||||
/// `skip_bytes` used to discard `io::copy`'s returned count, so the skip
|
||||
/// "succeeded" having drained the rest of the file. The next element header
|
||||
/// then hit `UnexpectedEof`, which `read()` maps to `Ok(None)` — a clean end
|
||||
/// of stream. One corrupt size field mid-Clusters therefore threw away every
|
||||
/// remaining frame of the title and reported `errors = 0`, `complete = true`.
|
||||
#[test]
|
||||
fn a_skip_past_eof_is_an_error_not_a_clean_end_of_stream() {
|
||||
let mut cluster = Vec::new();
|
||||
ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap();
|
||||
ebml::write_unknown_size(&mut cluster).unwrap();
|
||||
// Frame 1 — read normally.
|
||||
let block = [0x81u8, 0x00, 0x00, 0x80, 0xAA];
|
||||
ebml::write_id(&mut cluster, ebml::SIMPLE_BLOCK).unwrap();
|
||||
ebml::write_size(&mut cluster, block.len() as u64).unwrap();
|
||||
cluster.extend_from_slice(&block);
|
||||
// A VOID whose size field is corrupt: it claims 1 MiB, and the file
|
||||
// holds only the handful of bytes below. This is the "corrupt size
|
||||
// field mid-Clusters" case.
|
||||
ebml::write_id(&mut cluster, ebml::VOID).unwrap();
|
||||
ebml::write_size(&mut cluster, 1024 * 1024).unwrap();
|
||||
// Frame 2 — the rest of the title, swallowed by the bad skip.
|
||||
let block2 = [0x81u8, 0x00, 0x01, 0x80, 0xBB];
|
||||
ebml::write_id(&mut cluster, ebml::SIMPLE_BLOCK).unwrap();
|
||||
ebml::write_size(&mut cluster, block2.len() as u64).unwrap();
|
||||
cluster.extend_from_slice(&block2);
|
||||
|
||||
let bytes = mkv_with_track_and_cluster(1, 1, &cluster);
|
||||
let mut stream = MkvStream::open(Cursor::new(bytes)).unwrap();
|
||||
assert!(stream.read().unwrap().is_some(), "first frame reads");
|
||||
let e = match stream.read() {
|
||||
Err(e) => e,
|
||||
Ok(None) => panic!(
|
||||
"a skip that hit EOF was reported as a CLEAN END OF STREAM: the \
|
||||
rest of the title is gone and the caller sees errors = 0, \
|
||||
complete = true"
|
||||
),
|
||||
Ok(Some(_)) => panic!("the truncated skip must not yield a frame"),
|
||||
};
|
||||
assert!(is_mkv_source_invalid(&e), "{e:?}");
|
||||
}
|
||||
|
||||
/// The honest path this fix must not break: a skipped element whose declared
|
||||
/// size is exactly satisfied by the bytes present is still skipped cleanly,
|
||||
/// and the genuine EOF that follows is still `Ok(None)`.
|
||||
#[test]
|
||||
fn a_fully_satisfied_skip_still_ends_at_a_clean_eof() {
|
||||
let mut cluster = Vec::new();
|
||||
ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap();
|
||||
ebml::write_unknown_size(&mut cluster).unwrap();
|
||||
// A VOID that is fully present.
|
||||
ebml::write_id(&mut cluster, ebml::VOID).unwrap();
|
||||
ebml::write_size(&mut cluster, 8).unwrap();
|
||||
cluster.extend_from_slice(&[0u8; 8]);
|
||||
let block = [0x81u8, 0x00, 0x00, 0x80, 0xAA];
|
||||
ebml::write_id(&mut cluster, ebml::SIMPLE_BLOCK).unwrap();
|
||||
ebml::write_size(&mut cluster, block.len() as u64).unwrap();
|
||||
cluster.extend_from_slice(&block);
|
||||
|
||||
let bytes = mkv_with_track_and_cluster(1, 1, &cluster);
|
||||
let mut stream = MkvStream::open(Cursor::new(bytes)).unwrap();
|
||||
let f = stream.read().unwrap().expect("the frame after the VOID");
|
||||
assert_eq!(f.data, vec![0xAA]);
|
||||
assert!(
|
||||
stream.read().unwrap().is_none(),
|
||||
"a genuine EOF at a record boundary is still a clean end"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Block LACING (RFC 9559 §10.3) and TrackNumber→stream routing
|
||||
// (RFC 9559 §5.1.4.1.1).
|
||||
|
||||
+144
-9
@@ -169,6 +169,24 @@ pub struct PsDemuxer {
|
||||
/// stay byte-identical.
|
||||
buffer_base: u64,
|
||||
has_base: bool,
|
||||
/// Boundary-scan cursor for an unbounded (length-0) PES still waiting for
|
||||
/// its terminating PS-layer unit: `(buffer offset of the PES start code,
|
||||
/// buffer offset up to which the search has already proved there is no
|
||||
/// boundary)`. Both are buffer-relative and are rebased when the buffer
|
||||
/// drains.
|
||||
///
|
||||
/// Without it, every `feed` re-searches the WHOLE accumulated payload from
|
||||
/// the PES header: the buffer only stops growing at [`MAX_PS_BUFFER`], so a
|
||||
/// stream that declares an unbounded PES and then never emits a PS-layer
|
||||
/// start code (a corrupt or crafted VOB) makes the demuxer scan up to 4 MiB
|
||||
/// per call, quadratic in the bytes fed. Cleared whenever the PES is
|
||||
/// emitted, so it can never outlive the packet it describes.
|
||||
pending_scan: Option<(usize, usize)>,
|
||||
/// Test-only: total bytes examined by `find_ps_boundary`. Pins the cursor
|
||||
/// above — the property it exists for is a WORK bound, which no
|
||||
/// packet-level assertion can observe.
|
||||
#[cfg(test)]
|
||||
boundary_bytes_scanned: u64,
|
||||
}
|
||||
|
||||
impl Default for PsDemuxer {
|
||||
@@ -184,6 +202,9 @@ impl PsDemuxer {
|
||||
buffer: Vec::with_capacity(64 * 1024),
|
||||
buffer_base: 0,
|
||||
has_base: false,
|
||||
pending_scan: None,
|
||||
#[cfg(test)]
|
||||
boundary_bytes_scanned: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +241,8 @@ impl PsDemuxer {
|
||||
// discarded.
|
||||
let packets = self.extract_packets(true);
|
||||
self.buffer.clear();
|
||||
// The buffer the cursor indexes into is gone.
|
||||
self.pending_scan = None;
|
||||
packets
|
||||
}
|
||||
|
||||
@@ -288,11 +311,29 @@ impl PsDemuxer {
|
||||
// start code — the video ES payload is itself full of
|
||||
// 00 00 01 xx codes that would otherwise cut the PES short.
|
||||
let end = if pes_packet_len == 0 {
|
||||
match find_ps_boundary(&self.buffer, sc + 4) {
|
||||
Some(next) => next,
|
||||
// Resume where the last call stopped searching for
|
||||
// THIS PES's terminating unit; anything before that is
|
||||
// already proved boundary-free.
|
||||
let from = match self.pending_scan {
|
||||
Some((pes_at, searched_to)) if pes_at == sc => searched_to,
|
||||
_ => sc + 4,
|
||||
};
|
||||
let (found, searched_to) = find_ps_boundary(&self.buffer, from);
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.boundary_bytes_scanned += searched_to.saturating_sub(from) as u64;
|
||||
}
|
||||
match found {
|
||||
Some(next) => {
|
||||
self.pending_scan = None;
|
||||
next
|
||||
}
|
||||
// At EOF the rest of the buffer is this PES's
|
||||
// payload — emit it.
|
||||
None if flushing => self.buffer.len(),
|
||||
None if flushing => {
|
||||
self.pending_scan = None;
|
||||
self.buffer.len()
|
||||
}
|
||||
None => {
|
||||
// No boundary buffered yet. Normally wait for
|
||||
// more data, but a corrupt stream could declare
|
||||
@@ -301,8 +342,10 @@ impl PsDemuxer {
|
||||
// stops untrusted input forcing unbounded
|
||||
// allocation. Past the cap, flush what we have.
|
||||
if self.buffer.len() - sc > MAX_PS_BUFFER {
|
||||
self.pending_scan = None;
|
||||
self.buffer.len()
|
||||
} else {
|
||||
self.pending_scan = Some((sc, searched_to));
|
||||
break; // wait for more data
|
||||
}
|
||||
}
|
||||
@@ -338,6 +381,13 @@ impl PsDemuxer {
|
||||
if self.has_base {
|
||||
self.buffer_base += pos as u64;
|
||||
}
|
||||
// The cursor is a BUFFER offset, so it moves with the drain. A
|
||||
// pending PES always starts at or after `pos` (the loop broke on
|
||||
// it, having already consumed everything before it), so neither
|
||||
// component can underflow.
|
||||
self.pending_scan = self
|
||||
.pending_scan
|
||||
.map(|(pes_at, searched_to)| (pes_at - pos, searched_to - pos));
|
||||
}
|
||||
|
||||
// Trim a start-code-free tail. Every other exit from the loop above
|
||||
@@ -359,6 +409,10 @@ impl PsDemuxer {
|
||||
if self.has_base {
|
||||
self.buffer_base += drop as u64;
|
||||
}
|
||||
// A pending PES implies a start code IS in the buffer, so this
|
||||
// branch cannot run while one is open; drop the cursor anyway
|
||||
// rather than leave a stale offset behind this drain.
|
||||
self.pending_scan = None;
|
||||
}
|
||||
|
||||
packets
|
||||
@@ -380,11 +434,19 @@ const START_CODE_PREFIX_KEEP: usize = 2;
|
||||
/// PES inside its own payload and re-scan the discarded video bytes as bogus PS
|
||||
/// units. Restricting the search to PS-layer IDs (>= 0xB9, excluding the video
|
||||
/// ES codes below it) frames the unbounded PES at the right boundary.
|
||||
fn find_ps_boundary(data: &[u8], from: usize) -> Option<usize> {
|
||||
/// Returns `(boundary, searched_to)`. `searched_to` is the offset up to which
|
||||
/// every byte has been PROVED not to begin a PS-layer boundary start code, so
|
||||
/// a later call over the same buffer (grown at the tail) may resume there
|
||||
/// instead of re-scanning the payload from the PES header. When the scan runs
|
||||
/// off the end, the last two bytes are NOT proved: a `00 00 01` prefix can
|
||||
/// straddle the next feed's boundary by up to two bytes.
|
||||
fn find_ps_boundary(data: &[u8], from: usize) -> (Option<usize>, usize) {
|
||||
let mut pos = from;
|
||||
while let Some(sc) = find_start_code(data, pos) {
|
||||
if sc + 3 >= data.len() {
|
||||
return None;
|
||||
// A start code whose ID byte has not arrived yet: undecided, so
|
||||
// the next scan must look at it again.
|
||||
return (None, sc);
|
||||
}
|
||||
let id = data[sc + 3];
|
||||
if id == PACK_HEADER_ID
|
||||
@@ -392,11 +454,11 @@ fn find_ps_boundary(data: &[u8], from: usize) -> Option<usize> {
|
||||
|| id == PROGRAM_END_ID
|
||||
|| is_pes_stream_id(id)
|
||||
{
|
||||
return Some(sc);
|
||||
return (Some(sc), sc);
|
||||
}
|
||||
pos = sc + 4;
|
||||
}
|
||||
None
|
||||
(None, data.len().saturating_sub(2).max(from))
|
||||
}
|
||||
|
||||
/// Check whether a start code byte is a valid PES stream ID that carries payload.
|
||||
@@ -1752,7 +1814,80 @@ mod tests {
|
||||
/// after it — exactly the tail a real feed can end on.
|
||||
#[test]
|
||||
fn find_ps_boundary_handles_a_bare_start_code_at_the_buffer_head() {
|
||||
assert_eq!(find_ps_boundary(&[0x00, 0x00, 0x01], 0), None);
|
||||
assert_eq!(
|
||||
find_ps_boundary(&[0x00, 0x00, 0x01], 0),
|
||||
(None, 0),
|
||||
"an undecided trailing start code is not proved boundary-free"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unbounded (length-0) PES is terminated by the next PS-LAYER unit, and
|
||||
/// until one arrives the payload accumulates in the buffer. The search for
|
||||
/// that unit must not restart at the PES header on every feed: the buffer
|
||||
/// only stops growing at `MAX_PS_BUFFER` (4 MiB), and a feed is one read
|
||||
/// batch (at most 510 sectors ≈ 1 MiB, 60 sectors ≈ 120 KiB on an optical
|
||||
/// drive), so re-scanning from byte 0 costs work quadratic in the bytes
|
||||
/// fed — up to 4 MiB of scanning per call, for as long as a corrupt or
|
||||
/// crafted VOB withholds the boundary. A conformant DVD ends every pack
|
||||
/// within 2048 bytes and never reaches this state.
|
||||
///
|
||||
/// Measured directly, because a work bound has no packet-level shadow:
|
||||
/// `boundary_bytes_scanned` counts the bytes `find_ps_boundary` examines.
|
||||
/// 256 chunks x 4 KiB of boundary-free payload is 1 MiB of input;
|
||||
/// re-scanning from the header on every call examines
|
||||
/// 4 KiB * 256*257/2 = ~128 MiB.
|
||||
///
|
||||
/// Mutation: drop the `Some((pes_at, searched_to)) if pes_at == sc` arm so
|
||||
/// `from` is always `sc + 4`.
|
||||
#[test]
|
||||
fn an_unterminated_pes_is_not_rescanned_from_its_header_every_feed() {
|
||||
const CHUNKS: usize = 256;
|
||||
const CHUNK: usize = 4096;
|
||||
|
||||
let mut demuxer = PsDemuxer::new();
|
||||
// Unbounded PES header (length 0), then payload that carries no start
|
||||
// code at all, so no PS-layer boundary is ever found.
|
||||
demuxer.feed(&[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00]);
|
||||
for _ in 0..CHUNKS {
|
||||
assert!(
|
||||
demuxer.feed(&[0xFFu8; CHUNK]).is_empty(),
|
||||
"no boundary yet, so no PES can be emitted"
|
||||
);
|
||||
}
|
||||
|
||||
let fed = (CHUNKS * CHUNK) as u64;
|
||||
assert!(
|
||||
demuxer.boundary_bytes_scanned <= 2 * fed,
|
||||
"boundary search examined {} bytes over {fed} bytes of payload — \
|
||||
the scan must advance with the buffer, not restart at the PES header",
|
||||
demuxer.boundary_bytes_scanned
|
||||
);
|
||||
|
||||
// ...and the cursor must not have cost correctness: the PES still ends
|
||||
// at the pack header that finally arrives, with its whole payload.
|
||||
let pack = [
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
PACK_HEADER_ID,
|
||||
0x44,
|
||||
0x00,
|
||||
0x04,
|
||||
0x00,
|
||||
0x04,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x03,
|
||||
0xF8,
|
||||
];
|
||||
let packets = demuxer.feed(&pack);
|
||||
assert_eq!(packets.len(), 1, "the pack header terminates the PES");
|
||||
assert_eq!(
|
||||
packets[0].data.len(),
|
||||
CHUNKS * CHUNK,
|
||||
"the whole accumulated payload belongs to the PES"
|
||||
);
|
||||
}
|
||||
|
||||
/// The boundary-ID check is a 4-way `||`; a mutant that turns the FIRST
|
||||
@@ -1763,7 +1898,7 @@ mod tests {
|
||||
let data = [0x00, 0x00, 0x01, PACK_HEADER_ID, 0xAA];
|
||||
assert_eq!(
|
||||
find_ps_boundary(&data, 0),
|
||||
Some(0),
|
||||
(Some(0), 0),
|
||||
"a pack header start code alone must register as a PS-layer boundary"
|
||||
);
|
||||
}
|
||||
|
||||
+5
-2
@@ -1521,8 +1521,11 @@ fn forensic_clip_extents(
|
||||
tracing::warn!(target: "freemkv::keysource", "fmts: more than one forensic clip on the disc — segment byte space is ambiguous");
|
||||
return Ok(None);
|
||||
}
|
||||
// Addressing variant: these extents are a byte-space map for the forensic
|
||||
// segment table (`clip_byte_to_lba`), not a read plan — an unrecorded
|
||||
// extent must stay in place here or every later segment offset shifts.
|
||||
let exts: Vec<crate::disc::Extent> = udf
|
||||
.file_extents(reader, &format!("/BDMV/STREAM/{name}"))
|
||||
.file_extents_addressing(reader, &format!("/BDMV/STREAM/{name}"))
|
||||
.map_err(io::Error::from)?
|
||||
.into_iter()
|
||||
.filter(|&(lba, sectors)| lba > 0 && sectors > 0)
|
||||
@@ -4440,7 +4443,7 @@ mod tests {
|
||||
"00001.fmts",
|
||||
20,
|
||||
FMTS_CONTENT_LBA - PART_START,
|
||||
FMTS_CONTENT_SECTORS * 2048,
|
||||
u64::from(FMTS_CONTENT_SECTORS) * 2048,
|
||||
true,
|
||||
)],
|
||||
subdirs: Vec::new(),
|
||||
|
||||
Reference in New Issue
Block a user