mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Merge branch 'audit-lib-G8' into audit-fix-lib
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user