From 761e1ac2122379e0c2cf83079b09b3156f32825b Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:36:10 -0700 Subject: [PATCH 1/8] udf: combine surrogate pairs, sanitise CS0 names, map directory extents through the metadata partition, fail short file reads --- Cargo.lock | 16 ++++++++ src/udf.rs | 115 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 118 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf3055a8..f2bdc533 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -854,3 +854,19 @@ dependencies = [ "log", "simd-adler32", ] + +[[patch.unused]] +name = "freemkv-engine" +version = "1.7.7" + +[[patch.unused]] +name = "freemkv-i18n" +version = "1.7.7" + +[[patch.unused]] +name = "freemkv-keysources" +version = "1.7.7" + +[[patch.unused]] +name = "libfreemkv" +version = "1.7.7" diff --git a/src/udf.rs b/src/udf.rs index 0a3e21a9..eda5691c 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -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> { + 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 and '/' becomes '_'. 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, and '/' would split a path. + s.chars() + .filter(|c| !c.is_control()) + .map(|c| if c == '/' { '_' } else { c }) + .collect::() + .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,57 @@ mod audit_tests { ); } + #[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_or_slashes() { + assert_eq!( + parse_udf_name(&[8, b'a', 0x1B, b'[', b'2', b'J', b'/', b'b']), + "a[2J_b" + ); + } + + #[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"); From 6ddadda5ddc77bd5c765856d0536bb1f4f5cd986 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:37:08 -0700 Subject: [PATCH 2/8] udf: tests for non-stream ranges and FUA bypass --- src/udf.rs | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/udf.rs b/src/udf.rs index eda5691c..a9eb2549 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -6605,6 +6605,88 @@ mod audit_tests { ); } + // Marks sector data by which entry point served it; forwards the trait's extras. + struct Probe { + unmapped: Vec, + } + + impl SectorSource for Probe { + fn read_sectors(&mut self, _: u32, count: u16, buf: &mut [u8], _: bool) -> Result { + 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 { + 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. From 1a3e56f8c73e4cba1148e37811527cbc449dde76 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:38:10 -0700 Subject: [PATCH 3/8] bdnav: GOTO/BREAK/PLAY/SETSYSTEM tests; doc fixes in pes, consts, golden helper --- src/bdnav/vm.rs | 36 ++++++++++++++++++++++++++++++++++++ src/consts.rs | 4 ++-- src/pes.rs | 10 +++++----- src/test_util_golden.rs | 2 +- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/bdnav/vm.rs b/src/bdnav/vm.rs index c335ac11..b73faa2d 100644 --- a/src/bdnav/vm.rs +++ b/src/bdnav/vm.rs @@ -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 diff --git a/src/consts.rs b/src/consts.rs index c134e8b4..90392782 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -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: diff --git a/src/pes.rs b/src/pes.rs index a0252c9a..0e503650 100644 --- a/src/pes.rs +++ b/src/pes.rs @@ -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 diff --git a/src/test_util_golden.rs b/src/test_util_golden.rs index 5ed59be7..b3b1bb53 100644 --- a/src/test_util_golden.rs +++ b/src/test_util_golden.rs @@ -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) From 69329b29ca7570df6650f6dae8a943474ba40ec9 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:39:04 -0700 Subject: [PATCH 4/8] clpi, bdjo: tests for short sci and NUL-padded app strings --- src/bdnav/bdjo.rs | 14 ++++++++++++++ src/clpi.rs | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/bdnav/bdjo.rs b/src/bdnav/bdjo.rs index 92f03504..8dc0a383 100644 --- a/src/bdnav/bdjo.rs +++ b/src/bdnav/bdjo.rs @@ -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 { diff --git a/src/clpi.rs b/src/clpi.rs index 3a185782..45755092 100644 --- a/src/clpi.rs +++ b/src/clpi.rs @@ -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. From 18060529a9dea07f5f65f7ad29415a0502248541 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:42:50 -0700 Subject: [PATCH 5/8] error: escape disc-derived names in Display; use code constants in the kind map --- src/error.rs | 62 ++++++++++++++++++++++++++++++++++++++++------------ src/udf.rs | 12 +++++----- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/error.rs b/src/error.rs index bfab7c94..ece6ccfc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 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 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 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 = [ diff --git a/src/udf.rs b/src/udf.rs index a9eb2549..a97d9d64 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1563,7 +1563,7 @@ 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, surrogate pairs combined). Control characters (NUL included) -// are dropped and '/' becomes '_'. +// are dropped. fn decode_cs0(data: &[u8]) -> String { let Some((&comp, rest)) = data.split_first() else { return String::new(); @@ -1580,10 +1580,10 @@ fn decode_cs0(data: &[u8]) -> String { 8 => rest.iter().map(|&b| b as char).collect(), _ => String::from_utf8_lossy(rest).into_owned(), }; - // Names reach error text and logs: no control characters, and '/' would split a path. + // 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()) - .map(|c| if c == '/' { '_' } else { c }) .collect::() .trim() .to_string() @@ -6697,10 +6697,10 @@ mod audit_tests { } #[test] - fn cs0_names_carry_no_control_characters_or_slashes() { + fn cs0_names_carry_no_control_characters() { assert_eq!( - parse_udf_name(&[8, b'a', 0x1B, b'[', b'2', b'J', b'/', b'b']), - "a[2J_b" + parse_udf_name(&[8, b'a', 0x1B, b'[', b'2', b'J', b'b']), + "a[2Jb" ); } From 58c1191dd1832a79ac55ecb4c31180d1f96087c1 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:44:21 -0700 Subject: [PATCH 6/8] dvdnav: tests for counter-mode, rnd/swap taint and link-code range --- src/dvdnav/nav.rs | 45 ++++++++++++++++++++++++++++++++++++++++++++- src/dvdnav/vmcmd.rs | 9 +++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/dvdnav/nav.rs b/src/dvdnav/nav.rs index 164bb70b..f411891f 100644 --- a/src/dvdnav/nav.rs +++ b/src/dvdnav/nav.rs @@ -345,7 +345,7 @@ fn run_first_play(cmds: &[[u8; 8]]) -> Option { } => { 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. diff --git a/src/dvdnav/vmcmd.rs b/src/dvdnav/vmcmd.rs index b7a9dec3..1f9a9b96 100644 --- a/src/dvdnav/vmcmd.rs +++ b/src/dvdnav/vmcmd.rs @@ -337,6 +337,15 @@ 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() { From 9f913b7db87b99c52aecf8fd610c9aa8692201d7 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:48:16 -0700 Subject: [PATCH 7/8] ifo: fall back to the BUP when the IFO keeps fewer titles, bound chapter cells, tests --- src/dvdnav/vmcmd.rs | 9 ++- src/ifo.rs | 143 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 142 insertions(+), 10 deletions(-) diff --git a/src/dvdnav/vmcmd.rs b/src/dvdnav/vmcmd.rs index 1f9a9b96..167902cb 100644 --- a/src/dvdnav/vmcmd.rs +++ b/src/dvdnav/vmcmd.rs @@ -340,7 +340,14 @@ mod tests { // 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)] { + 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}"); } diff --git a/src/ifo.rs b/src/ifo.rs index 0d4d28dc..c1373fbb 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -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 { 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, 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, Vec) { + 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 { + 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); From 9cb0ad73d3cbd7240f4efd9fd4f57aaa140f4715 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:50:27 -0700 Subject: [PATCH 8/8] mpls: warn on dropped play items and STN entries; spec_guards, lint_stop tests --- src/mpls.rs | 28 +++++++++++++++++++++++++--- tests/lint_stop.rs | 28 +++++++++++++++++++--------- tests/spec_guards.rs | 20 ++++++++++++++++++++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/mpls.rs b/src/mpls.rs index 1165e5bc..ea7eb80a 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -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 { 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 { streams.push(entry); spos = next; } else { + stn_entry_unreadable("primary video"); break; } } @@ -193,6 +200,7 @@ pub fn parse(data: &[u8]) -> Result { streams.push(entry); spos = next; } else { + stn_entry_unreadable("primary audio"); break; } } @@ -206,6 +214,7 @@ pub fn parse(data: &[u8]) -> Result { streams.push(entry); spos = next; } else { + stn_entry_unreadable("PG subtitle"); break; } } @@ -214,6 +223,7 @@ pub fn parse(data: &[u8]) -> Result { 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 { spos = next; } } else { + stn_entry_unreadable("secondary audio"); break; } } @@ -260,6 +271,7 @@ pub fn parse(data: &[u8]) -> Result { spos = next; } } else { + stn_entry_unreadable("secondary video"); break; } } @@ -273,6 +285,7 @@ pub fn parse(data: &[u8]) -> Result { streams.push(entry); spos = next; } else { + stn_entry_unreadable("Dolby Vision enhancement layer"); break; } } @@ -336,6 +349,15 @@ pub fn parse(data: &[u8]) -> Result { }) } +// 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, diff --git a/tests/lint_stop.rs b/tests/lint_stop.rs index a6bae9a3..1deaa4ff 100644 --- a/tests/lint_stop.rs +++ b/tests/lint_stop.rs @@ -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 { + 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] diff --git a/tests/spec_guards.rs b/tests/spec_guards.rs index a878c663..b425d488 100644 --- a/tests/spec_guards.rs +++ b/tests/spec_guards.rs @@ -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]