Merge branch 'audit-lib-G8' into audit-fix-lib

This commit is contained in:
Matthew Jackson
2026-10-01 15:12:46 -07:00
14 changed files with 562 additions and 57 deletions
+14
View File
@@ -397,6 +397,20 @@ mod tests {
}
}
#[test]
fn app_strings_drop_trailing_nul_padding() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "0000\0",
classpath_extension: "",
initial_class: "a.B\0\0",
}]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps[0].base_directory, "0000");
assert_eq!(apps[0].initial_class, "a.B");
assert_eq!(apps[0].jar_ids(), vec!["0000"]);
}
#[test]
fn parses_autostart_app_fqcn_and_jar_ids() {
let bytes = build_bdjo(&[AppSpec {
+36
View File
@@ -522,6 +522,42 @@ mod tests {
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn goto_lands_on_its_target_and_break_terminates() {
// op_cnt=1, grp=BRANCH(0), sub_grp=GOTO(0), imm dst; branch_opt GOTO(1) / BREAK(2).
let goto = |line: u32| cmd(1 << 5, 0x81, 0, 0, line, 0);
let brk = cmd(1 << 5, 0x82, 0, 0, 0, 0);
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
// GOTO line 2 jumps over the logo PlayPL 99 (a candidate) to the feature PlayPL 1.
let d = build(&[&[goto(2), play_pl(99), play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
assert_eq!(resolve(&index, &mobjs, &|_| true), Some(1));
let d = build(&[&[brk, play_pl(1)]]);
let mobjs = mobj::parse(&d).unwrap();
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn only_the_play_pl_family_emits_a_playlist() {
// sub_grp=PLAY(2); branch_opt 3 is Terminate/Link, which never plays dst.
let d = build(&[&[cmd((1 << 5) | 2, 0x83, 0, 0, 11, 0)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
}
#[test]
fn setsystem_does_not_execute_as_a_gpr_set() {
// grp=SET(2), sub_grp=SETSYSTEM(1), set_opt=MOVE(1): must not write GPR5.
let setsys = cmd((2 << 5) | (2 << 3) | 1, 0x40, 0, 0x01, 5, 77);
let d = build(&[&[setsys]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
let mut vm = Vm::new(&mobjs, &index);
assert_eq!(run(&mut vm, 0, &|_| false), None);
assert_eq!(vm.gpr[5], 0);
}
#[test]
fn swap_immediate_operand_is_not_written_back() {
// SWAP dst=GPR0 with src=IMMEDIATE 5 (imm_op1=0, imm_op2=1) must refuse
+14
View File
@@ -293,6 +293,20 @@ mod tests {
assert_eq!(clip.streams[0].language, "eng");
}
/// An audio or TextST sci shorter than 5 bytes has no language field: the stream is
/// kept, the language stays empty, and nothing indexes past the sci.
#[test]
fn program_info_short_audio_and_textst_sci_keep_the_stream_without_a_language() {
let pi = build_program_info(&[
(0x1100, vec![0x83u8, 0x61, b'e', b'n']),
(0x1200, vec![0x92u8, 0x01, b'e', b'n']),
]);
let data = build_clpi_with_proginfo(100, &pi, None);
let clip = parse(&data).expect("should parse");
assert_eq!(clip.streams.len(), 2);
assert!(clip.streams.iter().all(|s| s.language.is_empty()));
}
/// ProgramInfo PG (0x90)/IG (0x91): layout is coding_type(1)+lang(3),
/// so language is at sci[1..4] (NOT sci[2..5] like audio). Verify the
/// PG arm reads from the right offset.
+2 -2
View File
@@ -105,7 +105,7 @@ pub mod coding_type {
/// 13818-1 Table 2-22). Shared by the program-stream demuxer and the TS/M2TS
/// muxers, so defined here once. Each is `u8` (matches the byte on the wire).
pub mod pes_stream_id {
/// Video stream (`110x xxxx`; freemkv emits the base id `0xE0`).
/// Video stream (`1110 xxxx`; freemkv emits the base id `0xE0`).
pub const VIDEO: u8 = 0xE0;
/// MPEG audio stream (`110x xxxx`; 11172-3/13818-3/13818-7, freemkv emits `0xC0`).
pub const MPEG_AUDIO: u8 = 0xC0;
@@ -122,7 +122,7 @@ pub mod pes_stream_id {
/// private_stream_2 — DVD navigation (PCI/DSI); carries no muxable ES.
pub const PRIVATE_STREAM_2: u8 = 0xBF;
/// Highest video stream_id — the `110x xxxx` video range tops out at 0xEF.
/// Highest video stream_id — the `1110 xxxx` video range tops out at 0xEF.
pub const VIDEO_MAX: u8 = 0xEF;
/// Inclusive range of every PES `stream_id` that carries demuxable payload:
+44 -1
View File
@@ -345,7 +345,7 @@ fn run_first_play(cmds: &[[u8; 8]]) -> Option<u8> {
} => {
if tainted {
vm.taint_gprm(reg);
if op == 2 {
if op == SET_SWAP {
vm.taint_gprm(src); // swap also writes its source
}
} else {
@@ -611,6 +611,49 @@ mod tests {
assert_eq!(resolve_from_vmg(&vmgi), None);
}
// SetGPRMMD counter mode makes a GPRM time-dependent: a compare on it must abstain,
// including when the mode was set by a line whose own predicate was false.
#[test]
fn counter_mode_gprm_makes_a_later_compare_abstain() {
// 53 .. imm 5, b5 = 0x83: counter bit + g3. Then `if g3 == g3 -> JumpTT 1`.
let cmp = h("3022000000010303");
for set in ["5300000500830000", "5330000500830000"] {
let vmgi = build_vmgi(&[h(set), cmp], 1, &[(2, 1)]);
assert_eq!(resolve_from_vmg(&vmgi), None, "{set}");
}
// Counter bit clear: the compare is decidable.
let vmgi = build_vmgi(&[h("5300000500030000"), cmp], 1, &[(2, 1)]);
assert!(resolve_from_vmg(&vmgi).is_some());
}
// SetGPRMMD stores like a mov: the immediate (bytes 2-3) and source register (byte 3)
// land in the register named by byte 5.
#[test]
fn setgprmmd_stores_its_immediate_and_source_register() {
// g1 = 9; SetGPRMMD g2 = imm 9 / g2 = g1; then `if g2 == g1 -> JumpTT 1`.
let jump = h("3022000000010201");
for md in ["5300000900020000", "4300000100020000"] {
let vmgi = build_vmgi(&[h("7100000100090000"), h(md), jump], 1, &[(2, 1)]);
assert!(resolve_from_vmg(&vmgi).is_some(), "{md}");
}
}
// A random value is never decidable, and a swap under an undecidable guard leaves
// its source register unknown too.
#[test]
fn rnd_and_guarded_swap_taint_their_registers() {
let mut vm = Vm::new();
vm.set(0, SET_RND, true, 1, 0);
assert!(vm.reg_tainted(0));
// line 0: swap g0,g1 guarded by an SPRM compare; line 1: `if g1 == g1 -> JumpTT 1`.
let vmgi = build_vmgi(
&[h("6220800000010000"), h("3022000000010101")],
1,
&[(2, 1)],
);
assert_eq!(resolve_from_vmg(&vmgi), None);
}
// A SetSystem (SPRM write, type 2) in the First-Play list is a no-op FOR
// RESOLUTION: it must keep executing, not abort, so a following JumpTT still
// resolves. `40..` = type 2, zero compare nibble → unconditional.
+16
View File
@@ -337,6 +337,22 @@ mod tests {
assert!(!decode(&h("7101000000010011")).link, "sub-op 0x11 is not");
}
// Every link code from LinkPGCN through LinkCN leaves the pre list; neighbours do not.
#[test]
fn set_link_flag_covers_linkpgcn_through_linkcn() {
for (cmd, link) in [
(3, false),
(4, true),
(5, true),
(6, true),
(7, true),
(8, false),
] {
let c = decode(&h(&format!("710{cmd:x}000000000001")));
assert_eq!(c.link, link, "cmd {cmd}");
}
}
// Special sub-command 3 (SetTmpPML) sets SPRM13 then gotos byte7 when cond holds.
#[test]
fn set_tmp_pml_decodes_as_goto() {
+48 -14
View File
@@ -1253,8 +1253,10 @@ impl std::fmt::Display for Error {
(None, None) => write!(f, "E{}: {}", self.code(), sector),
},
Error::Halted => write!(f, "E{}", self.code()),
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
Error::UdfUnrecordedExtent { path } => write!(f, "E{}: {}", self.code(), path),
// Disc- or backup-folder-derived names: escape control characters.
Error::UdfNotFound { path } | Error::UdfUnrecordedExtent { path } => {
write!(f, "E{}: {}", self.code(), path.escape_debug())
}
Error::SeamPlanDroppedMost { dropped, written } => {
write!(f, "E{} {dropped}/{written}", self.code())
}
@@ -1265,7 +1267,7 @@ impl std::fmt::Display for Error {
| Error::DirImageFileChanged { path }
| Error::DirNameTooLong { path }
| Error::DirImageFanout { path } => {
write!(f, "E{}: {}", self.code(), path)
write!(f, "E{}: {}", self.code(), path.escape_debug())
}
Error::DiscTitleRange { index, count } => {
write!(f, "E{}: {}/{}", self.code(), index, count)
@@ -1331,8 +1333,10 @@ impl std::fmt::Display for Error {
Error::TimedOut { op } | Error::WorkerLost { op } => {
write!(f, "E{}: {op}", self.code())
}
Error::BusStreamUnmapped { files } => write!(f, "E{}: {files}", self.code()),
Error::ImageScoped { path } => write!(f, "E{}: {path}", self.code()),
Error::BusStreamUnmapped { files } => {
write!(f, "E{}: {}", self.code(), files.escape_debug())
}
Error::ImageScoped { path } => write!(f, "E{}: {}", self.code(), path.escape_debug()),
Error::RemuxVerifyFailed { kind, path } => match kind {
RemuxVerifyKind::RuntimeMismatch {
have_secs,
@@ -1409,21 +1413,21 @@ impl From<Error> for std::io::Error {
9000..=9001 => std::io::ErrorKind::Unsupported,
9002..=9008 => std::io::ErrorKind::InvalidInput,
// 9010 HevcParamParse: malformed hvcC payload.
9010 => std::io::ErrorKind::InvalidData,
E_HEVC_PARAM_PARSE => std::io::ErrorKind::InvalidData,
// 9011 MuxTrackRange: caller passed a bad track index.
9011 => std::io::ErrorKind::InvalidInput,
E_MUX_TRACK_RANGE => std::io::ErrorKind::InvalidInput,
// 9012 Fmp4Unimplemented: sink can't emit media yet.
9012 => std::io::ErrorKind::Unsupported,
E_FMP4_UNIMPLEMENTED => std::io::ErrorKind::Unsupported,
// 9014 PipelineJoinTimeout: consumer drain exceeded deadline.
E_PIPELINE_JOIN_TIMEOUT => std::io::ErrorKind::TimedOut,
// 9017 PesTrackTooLarge: out-of-range track index on serialize.
9017 => std::io::ErrorKind::InvalidInput,
E_PES_TRACK_TOO_LARGE => std::io::ErrorKind::InvalidInput,
// 9020 DiscCapacityOverflow: disc reported a capacity sentinel
// we can't represent — treat as bad/invalid device data.
9020 => std::io::ErrorKind::InvalidData,
E_DISC_CAPACITY_OVERFLOW => std::io::ErrorKind::InvalidData,
// 9021 M2tsPacketMalformed: a muxer invariant break produced
// a non-188-byte packet — treat as invalid data.
9021 => std::io::ErrorKind::InvalidData,
E_M2TS_PACKET_MALFORMED => std::io::ErrorKind::InvalidData,
// 9022 NetworkAddrBlocked: the output host resolved only to
// invalid (unspecified/multicast/broadcast/reserved) addresses.
E_NETWORK_ADDR_BLOCKED => std::io::ErrorKind::PermissionDenied,
@@ -1440,7 +1444,7 @@ impl From<Error> for std::io::Error {
// truncated — invalid input data.
E_MKV_SOURCE_INVALID => std::io::ErrorKind::InvalidData,
// 9054 MkvUnencodable: the writer was asked for an element size EBML
// cannot represent. An output-side limit, not bad input.
// cannot represent; the data it was given cannot be encoded.
E_MKV_UNENCODABLE => std::io::ErrorKind::InvalidData,
// mp4:// demux errors: a malformed/truncated source file
// (E_MP4_INVALID), or a source whose tracks the mux can't use — no
@@ -1457,10 +1461,10 @@ impl From<Error> for std::io::Error {
E_SYNC_TIMEOUT | E_SYNC_WORKER_LOST => std::io::ErrorKind::TimedOut,
// 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
// extent was handed to the prefetch producer.
9030 => std::io::ErrorKind::InvalidInput,
E_EXTENT_NOT_UNIT_ALIGNED => std::io::ErrorKind::InvalidInput,
// 9047 DiscCapacityMalformed: the drive returned an unusable
// READ CAPACITY response (short transfer / overflow).
9047 => std::io::ErrorKind::InvalidData,
E_DISC_CAPACITY_MALFORMED => std::io::ErrorKind::InvalidData,
// dir:// usage / footgun gates (9019, 9024–9026, 9028): the caller
// gave an invalid flag/source/name combination — InvalidInput.
E_DIR_RAW_REJECTED
@@ -1787,6 +1791,36 @@ mod tests {
assert!(shown.contains("A\\u{1b}[2JB\\n"), "{shown}");
}
#[test]
fn disc_derived_names_cannot_inject_terminal_escapes_via_display() {
let n = || "A\x1b[2JB".to_string();
let errors = [
Error::UdfNotFound { path: n() },
Error::UdfUnrecordedExtent { path: n() },
Error::DirImagePlacement { path: n() },
Error::DirImageFileChanged { path: n() },
Error::DirNameTooLong { path: n() },
Error::DirImageFanout { path: n() },
Error::BusStreamUnmapped { files: n() },
Error::ImageScoped { path: n() },
];
for e in errors {
let shown = e.to_string();
assert!(!shown.chars().any(char::is_control), "{shown:?}");
assert_eq!(shown, format!("E{}: A\\u{{1b}}[2JB", e.code()));
}
}
#[test]
fn image_ends_before_read_display_is_lba_then_have_slash_want() {
let e = Error::ImageEndsBeforeRead {
lba: 111,
have: 222,
want: 333,
};
assert_eq!(e.to_string(), format!("E{}: 111 222/333", e.code()));
}
#[test]
fn new_variants_have_distinct_codes() {
let codes = [
+134 -9
View File
@@ -519,7 +519,8 @@ pub(crate) fn parse_tt_srpt(
// ── VTS parser ──────────────────────────────────────────────────────────────
/// Parse VTS_XX_0.IFO for one title set, falling back to its VTS_XX_0.BUP copy.
/// Parse VTS_XX_0.IFO for one title set, falling back to its VTS_XX_0.BUP copy when the
/// IFO fails or keeps fewer titles than `titles_info` declares.
///
/// `titles_info` is a list of (chapter_count, vts_title_number) from TT_SRPT.
fn parse_vts(
@@ -529,19 +530,30 @@ fn parse_vts(
titles_info: &[(u16, u8)],
) -> Result<DvdTitleSet> {
let path = format!("/VIDEO_TS/VTS_{vts_number:02}_0.IFO");
let err = match parse_vts_file(reader, udf, &path, &path, vts_number, titles_info) {
Ok(ts) => return Ok(ts),
let mut partial = None;
let mut err = None;
match parse_vts_file(reader, udf, &path, &path, vts_number, titles_info) {
Ok(ts) if ts.titles.len() >= titles_info.len() => return Ok(ts),
Ok(ts) => partial = Some(ts),
Err(Error::Halted) => return Err(Error::Halted),
Err(e) => e,
};
Err(e) => err = Some(e),
}
let bup = format!("/VIDEO_TS/VTS_{vts_number:02}_0.BUP");
match parse_vts_file(reader, udf, &bup, &path, vts_number, titles_info) {
Ok(ts) => {
tracing::warn!(target: "freemkv::scan", vts = vts_number, code = err.code(), "VTS IFO bad; using BUP");
Ok(ts)
if partial
.as_ref()
.is_none_or(|p| ts.titles.len() > p.titles.len()) =>
{
tracing::warn!(target: "freemkv::scan", vts = vts_number, "VTS IFO bad; using BUP");
Ok(ts)
}
Err(Error::Halted) => Err(Error::Halted),
Err(_) => Err(err),
_ => match (partial, err) {
(Some(ts), _) => Ok(ts),
(None, Some(e)) => Err(e),
(None, None) => Err(Error::IfoParse),
},
}
}
@@ -1030,7 +1042,8 @@ pub(crate) fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result
let cell_base = pgc_offset + cell_playback_offset;
for i in 0..num_cells {
let co = cell_base + i * 24;
if co + 8 > data.len() {
// Same bound as the cell loop above: a cell that is not kept adds nothing.
if co + 24 > data.len() {
cell_frames.push((0, 0.0));
continue;
}
@@ -2448,6 +2461,25 @@ mod tests {
assert_eq!(title.chapter_times, vec![0.0, 10.01]);
}
// A cell cut off mid-record is not in `cells`, so it adds no time to a chapter mark.
#[test]
fn pgc_chapter_times_ignore_a_cell_cut_off_mid_record() {
let mut pgc = build_pgc(
bcd_secs(59),
&[
(0x00, bcd_secs(10), 0, 9),
(0x00, bcd_secs(20), 10, 19),
(0x00, bcd_secs(31), 20, 29),
],
&[1, 3],
None,
);
pgc.truncate(0xEA + 2 + 24 + 12);
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert_eq!(title.cells.len(), 1);
assert_eq!(title.chapter_times, vec![0.0, 10.01]);
}
// A program map running past the end of the data must stop at the buffer
// end. Fixture map begins at PGC+0xEA, buffer holds 50 bytes from there,
// so a declared 255 programs must yield exactly 50 chapter times.
@@ -2752,6 +2784,34 @@ mod tests {
d
}
// The title number and its entry offset are both bounded by the table: bytes beyond
// it are never read as a PGCN. The table sits at byte 8 (offset 0 means absent).
#[test]
fn ptt_srpt_pgc_index_stays_inside_the_table() {
let put =
|d: &mut Vec<u8>, at: usize, v: &[u8]| d[8 + at..8 + at + v.len()].copy_from_slice(v);
let mut d = vec![0u8; 8 + 24];
put(&mut d, 0, &1u16.to_be_bytes());
put(&mut d, 4, &23u32.to_be_bytes());
put(&mut d, 8, &16u32.to_be_bytes());
put(&mut d, 12, &20u32.to_be_bytes());
put(&mut d, 16, &3u16.to_be_bytes());
put(&mut d, 20, &9u16.to_be_bytes());
assert_eq!(ptt_srpt_pgc_index(&d, 8, 1), Some(2));
assert_eq!(
ptt_srpt_pgc_index(&d, 8, 2),
None,
"title 2 is past the count"
);
// The declared table ends before the entry.
put(&mut d, 4, &15u32.to_be_bytes());
assert_eq!(
ptt_srpt_pgc_index(&d, 8, 1),
None,
"entry outside the table"
);
}
#[test]
fn ptt_srpt_counts_the_pgcs_a_title_spans() {
// Title 1: one PTT per PGC (1, 2, 3); title 2: two PTTs in PGC 4.
@@ -2959,6 +3019,71 @@ mod tests {
assert_eq!(ts.titles[0].duration_secs, 22.022, "TTN 1 -> PGC 2 via PTT");
}
fn bup_fixture() -> (Vec<u8>, Vec<u8>) {
let two = vec![
build_pgc(bcd_secs(11), &[(0x00, bcd_secs(11), 0, 9)], &[], None),
build_pgc(bcd_secs(22), &[(0x00, bcd_secs(22), 50, 59)], &[], None),
];
let good = build_vts_ifo(0, 0, &two, None);
let mut bad = good.clone();
bad[0] = b'X';
(bad, good)
}
/// A bad IFO falls back to the BUP, but the title VOBS stay anchored on the IFO's LBA.
#[test]
fn parse_vts_falls_back_to_the_bup_and_anchors_on_the_ifo() {
let (bad, good) = bup_fixture();
let (mut disc, udf) = video_ts_disc(vec![("VTS_01_0.IFO", bad), ("VTS_01_0.BUP", good)]);
let ts = parse_vts(&mut disc, &udf, 1, &[(1, 1), (1, 2)]).expect("BUP used");
assert_eq!(ts.titles.len(), 2);
let lba = udf
.file_start_lba(&mut disc, "/VIDEO_TS/VTS_01_0.IFO")
.unwrap();
assert_eq!(ts.vob_start_sector, lba + 7);
}
/// An IFO that parses but drops a damaged PGC defers to a BUP that keeps it.
#[test]
fn parse_vts_prefers_a_bup_that_keeps_more_titles() {
let (_, good) = bup_fixture();
let mut damaged = good.clone();
// Second PGC entry's offset: point it past the end of the table.
damaged[2 * 2048 + 8 + 8 + 4..2 * 2048 + 8 + 8 + 8]
.copy_from_slice(&0x00ff_ffffu32.to_be_bytes());
let (mut disc, udf) = video_ts_disc(vec![
("VTS_01_0.IFO", damaged.clone()),
("VTS_01_0.BUP", good),
]);
let ts = parse_vts(&mut disc, &udf, 1, &[(1, 1), (1, 2)]).expect("vts");
assert_eq!(ts.titles.len(), 2, "BUP kept both titles");
// With no usable BUP the IFO's partial result stands.
let (mut disc, udf) = video_ts_disc(vec![("VTS_01_0.IFO", damaged)]);
let ts = parse_vts(&mut disc, &udf, 1, &[(1, 1), (1, 2)]).expect("vts");
assert_eq!(ts.titles.len(), 1);
}
/// A stop during the BUP attempt is a stop, not a reason to report the IFO's error.
#[test]
fn parse_vts_propagates_a_halt_during_the_bup_attempt() {
struct HaltAt(MemDisc, u32);
impl SectorSource for HaltAt {
fn read_sectors(&mut self, lba: u32, n: u16, buf: &mut [u8], r: bool) -> Result<usize> {
if lba == self.1 {
return Err(Error::Halted);
}
self.0.read_sectors(lba, n, buf, r)
}
}
let (bad, good) = bup_fixture();
let (mut disc, udf) = video_ts_disc(vec![("VTS_01_0.IFO", bad), ("VTS_01_0.BUP", good)]);
let bup_lba = udf
.file_start_lba(&mut disc, "/VIDEO_TS/VTS_01_0.BUP")
.unwrap();
let r = parse_vts(&mut HaltAt(disc, bup_lba), &udf, 1, &[(1, 1)]);
assert!(matches!(r, Err(Error::Halted)), "{r:?}");
}
#[test]
fn parse_vts_rejects_bad_magic_and_short_files() {
let mut bad_magic = build_vts_ifo(0, 0, &one_pgc(), None);
+25 -3
View File
@@ -30,8 +30,8 @@ pub(crate) struct PlaylistMark {
/// Chapter filters should test `== 1`, not `<= 1`.
pub mark_type: u8,
/// Which play item this mark belongs to. Carries the per-PlayItem
/// timebase needed to place a mark in a multi-PlayItem playlist;
/// the chapter builder does not consume it yet.
/// timebase needed to place a mark in a multi-PlayItem playlist; the
/// chapter builder resolves each mark against that PlayItem.
pub play_item_ref: u16,
/// Timestamp in 45kHz PTS ticks
pub timestamp: u32,
@@ -141,6 +141,12 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
let item = &pl[pos + 2..pos + 2 + item_length];
if item.len() < 20 {
tracing::warn!(
target: "freemkv::mpls",
item_idx,
item_length,
"play item too short to read; it is dropped from the playlist"
);
pos += 2 + item_length;
continue;
}
@@ -184,6 +190,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
streams.push(entry);
spos = next;
} else {
stn_entry_unreadable("primary video");
break;
}
}
@@ -193,6 +200,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
streams.push(entry);
spos = next;
} else {
stn_entry_unreadable("primary audio");
break;
}
}
@@ -206,6 +214,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
streams.push(entry);
spos = next;
} else {
stn_entry_unreadable("PG subtitle");
break;
}
}
@@ -214,6 +223,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
if let Some((_, next)) = parse_stream_entry(item, spos, STREAM_CATEGORY_IG) {
spos = next;
} else {
stn_entry_unreadable("IG");
break;
}
}
@@ -233,6 +243,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
spos = next;
}
} else {
stn_entry_unreadable("secondary audio");
break;
}
}
@@ -260,6 +271,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
spos = next;
}
} else {
stn_entry_unreadable("secondary video");
break;
}
}
@@ -273,6 +285,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
streams.push(entry);
spos = next;
} else {
stn_entry_unreadable("Dolby Vision enhancement layer");
break;
}
}
@@ -336,6 +349,15 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
})
}
// An unreadable stream entry ends its STN category and every one after it.
fn stn_entry_unreadable(kind: &str) {
tracing::warn!(
target: "freemkv::mpls",
kind,
"unreadable STN stream entry; this and the later stream entries are dropped"
);
}
impl Playlist {
/// Total play-item running time in 45 kHz ticks (out_time - in_time, summed).
pub fn duration_ticks(&self) -> u64 {
@@ -380,7 +402,7 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
// PID location depends on stream-entry type (BD spec stream_entry()): type 1
// (PlayItem's Clip) → +2; type 2 (SubPath SubClip) → +4; type 3/4 (SubPath
// clip / DV enhancement layer) → +3. Previously only type 1 was handled.
// clip / DV enhancement layer) → +3.
let pid_off = match item[pos + 1] {
STREAM_ENTRY_PLAYITEM_CLIP => 2,
STREAM_ENTRY_SUBPATH_SUBCLIP => 4,
+5 -5
View File
@@ -1,10 +1,10 @@
//! Stream — read PES frames in, write PES frames out.
//! PES frames — the unit that moves between an input and an output.
//!
//! A stream is a stream. You read() from it or write() to it.
//! The stream handles its own format internally.
//! A [`PesSource`] yields frames with `read()`; a [`PesSink`] takes them with `write()`.
//! Each handles its own format internally.
//!
//! disc.read() → PES frame (sectors → decrypt → demux internally)
//! mkv.write(frame) → MKV file (mux internally)
//! source.read() → PES frame (sectors → decrypt → demux internally)
//! sink.write(frame) → output file or wire (mux internally)
// Shared by `serialize`/`deserialize` so the wire format round-trips.
// Oversized frames are rejected on write rather than hard-erroring
+1 -1
View File
@@ -37,7 +37,7 @@ impl Golden {
self
}
/// Record the length and SHA-256 of `bytes` under `key`.
/// Record the length and the first 16 bytes (hex) of the SHA-256 of `bytes` under `key`.
pub fn bytes(&mut self, key: &str, bytes: &[u8]) -> &mut Self {
use sha2::{Digest, Sha256};
let digest: String = Sha256::digest(bytes)
+184 -13
View File
@@ -139,7 +139,8 @@ pub(crate) struct AbsExtent {
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
/// LBA within the metadata partition (add metadata_start for absolute)
/// Block within the metadata partition. Not `metadata_start() + meta_lba` when the
/// Metadata File is fragmented: later extents live elsewhere on disc.
pub meta_lba: u32,
/// File size in bytes (from ICB info_length)
pub size: u64,
@@ -320,9 +321,15 @@ impl UdfFs {
}
// Trim to the real file size, or to the requested prefix — whichever is
// smaller. If extents under-covered the file (e.g. sparse), leave what
// we have rather than over-reporting.
// smaller. Extents covering less than that mean a damaged File Entry.
let trim_to = (entry.size as usize).min(limit);
if data.len() < trim_to {
return Err(Error::DiscRead {
sector: self.partition_start as u64,
status: None,
sense: None,
});
}
if data.len() > trim_to {
data.truncate(trim_to);
}
@@ -397,7 +404,7 @@ impl UdfFs {
if !seen.insert(e.meta_lba) {
continue;
}
match self.extents_abs_at(reader, e.meta_lba) {
match self.data_extents_abs(reader, e) {
Ok(exts) => ranges.extend(
exts.iter()
.filter(|x| x.recorded && x.len > 0)
@@ -414,6 +421,27 @@ impl UdfFs {
Ok(merge_ranges(&ranges))
}
// A directory's FID data is metadata-partition-relative, a file's physical-partition-relative.
fn data_extents_abs(
&self,
reader: &mut dyn SectorSource,
e: &DirEntry,
) -> Result<Vec<AbsExtent>> {
if !e.is_dir {
return self.extents_abs_at(reader, e.meta_lba);
}
self.read_icb_extents(reader, e.meta_lba)?
.into_iter()
.map(|x| {
Ok(AbsExtent {
lba: self.meta_to_abs(x.lba)?,
len: x.len,
recorded: x.recorded,
})
})
.collect()
}
fn collect_file_ranges(
&self,
reader: &mut dyn SectorSource,
@@ -1534,22 +1562,31 @@ fn read_file_size(reader: &mut dyn SectorSource, meta: &MetaMap, meta_lba: u32)
}
// Decode an OSTA CS0 string: first byte is a compression ID (8 = one byte per code point,
// i.e. Latin-1; 16 = UTF-16BE). NULs are dropped.
// i.e. Latin-1; 16 = UTF-16BE, surrogate pairs combined). Control characters (NUL included)
// are dropped.
fn decode_cs0(data: &[u8]) -> String {
let Some((&comp, rest)) = data.split_first() else {
return String::new();
};
let s: String = match comp {
16 => rest
.as_chunks::<2>()
.0
.iter()
.filter_map(|&c| char::from_u32(u16::from_be_bytes(c) as u32))
.collect(),
16 => char::decode_utf16(
rest.as_chunks::<2>()
.0
.iter()
.map(|&c| u16::from_be_bytes(c)),
)
.filter_map(|c| c.ok())
.collect(),
8 => rest.iter().map(|&b| b as char).collect(),
_ => String::from_utf8_lossy(rest).into_owned(),
};
s.replace('\0', "").trim().to_string()
// Names reach error text and logs: no control characters. A '/' is kept so the
// structure-file filter still rejects the name.
s.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.trim()
.to_string()
}
// Parse a UDF filename (an OSTA CS0 string).
@@ -1576,7 +1613,8 @@ fn compact_ranges(ranges: &mut Vec<(u32, u32)>, meta_start: u32) -> Result<()> {
}
/// Merge overlapping or adjacent (start, count) ranges. Caller sorts by start
/// first. Shared range utility — also used to build the disc's encrypted-content
/// first; zero-length ranges are kept (unlike `whole_disc::merge_ranges`, which sorts and
/// drops them). Shared range utility — also used to build the disc's encrypted-content
/// extent map (see `Disc::encrypted_content_ranges`).
pub(crate) fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut result: Vec<(u32, u32)> = Vec::new();
@@ -6567,6 +6605,139 @@ mod audit_tests {
);
}
// Marks sector data by which entry point served it; forwards the trait's extras.
struct Probe {
unmapped: Vec<crate::sector::bus_removal::UnmappedStreamFile>,
}
impl SectorSource for Probe {
fn read_sectors(&mut self, _: u32, count: u16, buf: &mut [u8], _: bool) -> Result<usize> {
buf[..count as usize * 2048].fill(0x11);
Ok(count as usize * 2048)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
if fua {
buf[..count as usize * 2048].fill(0x22);
return Ok(count as usize * 2048);
}
self.read_sectors(lba, count, buf, recovery)
}
fn unmapped_stream_files(&self) -> &[crate::sector::bus_removal::UnmappedStreamFile] {
&self.unmapped
}
fn random_access(&self) -> bool {
false
}
}
#[test]
fn buffered_reader_sends_a_fua_read_to_the_medium_and_forwards_the_source_traits() {
let mut inner = Probe {
unmapped: vec![crate::sector::bus_removal::UnmappedStreamFile::new(
"/BDMV/STREAM/00001.m2ts".into(),
7,
&Error::Halted,
)],
};
let mut br = BufferedSectorReader::new(&mut inner, 8);
let mut buf = [0u8; 2048];
br.read_sectors(100, 1, &mut buf, true).unwrap();
assert_eq!(buf[0], 0x11);
br.read_sectors_fua(100, 1, &mut buf, true, true).unwrap();
assert_eq!(
buf[0], 0x22,
"a FUA read must not be answered from the cache"
);
assert!(!br.random_access());
assert_eq!(br.unmapped_stream_files().len(), 1);
}
#[test]
fn non_stream_ranges_cover_structures_and_non_stream_data_only() {
let mut r = GenReader::new(0);
let mut a = efe(16);
put_ad(&mut a, 0, 2048, 20);
put_ad(&mut a, 1, 2048 | (1 << 30), 700);
r.over.insert(300, a);
let mut s = efe(8);
put_ad(&mut s, 0, 10 * 2048, 9000);
r.over.insert(103, s);
let dir = |name: &str, meta_lba, entries| DirEntry {
name: name.into(),
is_dir: true,
meta_lba,
size: 0,
entries,
};
let stream = dir("STREAM", 2, vec![file("00000.m2ts", 3)]);
let bdmv = dir("BDMV", 1, vec![stream, file("a.bin", 4)]);
let mut fs = fs_of(100, 8, vec![bdmv]);
fs.meta = MetaMap(vec![(100, 4), (300, 4)]);
fs.partition_start = 50;
assert_eq!(
fs.non_stream_ranges(&mut r).expect("ranges"),
vec![(0, 50), (70, 1), (100, 8), (300, 4)]
);
}
#[test]
fn utf16_cs0_combines_surrogate_pairs() {
// U+1F600 is D83D DE00.
assert_eq!(
parse_udf_name(&[16, 0, b'A', 0xD8, 0x3D, 0xDE, 0x00]),
"A\u{1F600}"
);
}
#[test]
fn cs0_names_carry_no_control_characters() {
assert_eq!(
parse_udf_name(&[8, b'a', 0x1B, b'[', b'2', b'J', b'b']),
"a[2Jb"
);
}
#[test]
fn read_file_fails_when_extents_cover_less_than_the_declared_size() {
let mut r = GenReader::new(0);
let mut icb = efe(8);
put_ad(&mut icb, 0, 2048, 10);
r.over.insert(5, icb);
let mut f = file("F", 5);
f.size = 8192;
let fs = fs_of(0, 1, vec![f]);
assert!(matches!(
fs.read_file(&mut r, "/F"),
Err(Error::DiscRead { .. })
));
}
#[test]
fn non_stream_ranges_map_directory_data_through_the_metadata_partition() {
let mut r = GenReader::new(0);
let mut icb = efe(8);
put_ad(&mut icb, 0, 2048, 3);
r.over.insert(105, icb);
let mut dir = file("D", 5);
dir.is_dir = true;
let mut fs = fs_of(100, 1, vec![dir]);
fs.partition_start = 5000;
let ranges = fs.non_stream_ranges(&mut r).expect("ranges");
let covers = |lba: u32| ranges.iter().any(|&(s, n)| (s..s + n).contains(&lba));
assert!(
covers(103),
"directory data sits at metadata block 3: {ranges:?}"
);
assert!(!covers(5003), "not at partition_start + 3: {ranges:?}");
}
#[test]
fn eight_bit_cs0_is_latin1() {
assert_eq!(parse_udf_name(&[8, 0xE9, b'A']), "\u{e9}A");
+19 -9
View File
@@ -398,19 +398,29 @@ fn no_thread_sleep_on_op_paths() {
let mut hits = Vec::new();
for s in srcs.iter().filter(|s| in_dirs(s, OP_DIRS)) {
hits.extend(s.hits("thread::sleep").into_iter().map(|i| (s, i)));
for i in s.hits("thread::{") {
let group = &s.code[i..close_of(s.code.as_bytes(), i + 8)];
if group
.split(|c: char| !is_ident(c as u8))
.any(|w| w == "sleep")
{
hits.push((s, i));
}
}
hits.extend(sleep_imports(&s.code).into_iter().map(|i| (s, i)));
}
check("thread::sleep on an op path", hits, ALLOW);
}
// Offsets of `thread::{...}` import groups that bring `sleep` into scope.
fn sleep_imports(code: &str) -> Vec<usize> {
code.match_indices("thread::{")
.map(|(i, _)| i)
.filter(|&i| {
code[i..close_of(code.as_bytes(), i + 8)]
.split(|c: char| !is_ident(c as u8))
.any(|w| w == "sleep")
})
.collect()
}
#[test]
fn sleep_imports_finds_a_grouped_sleep_only() {
let code = "use std::thread::{self, sleep};\nuse std::thread::{self, spawn};\nuse a::thread::{sleeper};";
assert_eq!(sleep_imports(code), vec![code.find("thread::{").unwrap()]);
}
/// §2.2: "**Every** raw `self.scsi.as_mut().execute` in `drive/mod.rs` and `identity.rs`
/// goes through [`Drive::exec`]"; §5.8 widens the grep to all of `drive/`.
#[test]
+20
View File
@@ -264,6 +264,26 @@ fn v20_stride_64_evidence() {
);
}
/// KS-27: HD DVD `.evo` units carry the flag in PES_scrambling_control (bits 5-4 of byte
/// 20). Literal offsets here, so a drifted constant in the library fails this guard.
#[test]
fn hddvd_flag_is_bits_5_4_of_byte_20() {
let unit_with = |at: usize, b: u8| {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
u[at] = b;
aacs_unit_encrypted(&u, ContentFormat::MpegPs)
};
for b in [0x10, 0x20, 0x30, 0xFF] {
assert!(unit_with(20, b), "byte 20 = {b:#04x}");
}
for b in [0x00, 0x0F, 0x40, 0x80, 0xC0] {
assert!(!unit_with(20, b), "byte 20 = {b:#04x}");
}
for at in [0, 19, 21] {
assert!(!unit_with(at, 0x30), "byte {at}");
}
}
/// per evidence (no public spec); do not change without evidence proving otherwise —
/// KS-27: the HD DVD book was "removed from this AACS website due to inactivity".
#[test]