//! Disc scanning pipeline tests. use libfreemkv::SectorSource; use libfreemkv::error::Result; use libfreemkv::{Disc, DiscTitle, ScanOptions}; use std::collections::HashMap; const SECTOR_SIZE: usize = 2048; /// Minimal mock sector reader for disc scan tests. struct MockSectorReader { sectors: HashMap>, } impl MockSectorReader { fn new() -> Self { Self { sectors: HashMap::new(), } } } impl SectorSource for MockSectorReader { fn read_sectors( &mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool, ) -> Result { let total = count as usize * SECTOR_SIZE; for i in 0..count as u32 { let offset = i as usize * SECTOR_SIZE; if let Some(data) = self.sectors.get(&(lba + i)) { buf[offset..offset + SECTOR_SIZE].copy_from_slice(data); } else { buf[offset..offset + SECTOR_SIZE].fill(0); } } Ok(total) } } // ── scan_image tests ─────────────────────────────────────────────────────── #[test] fn scan_image_empty_reader() { // An empty reader has no AVDP at sector 256 -> UDF parse fails let mut reader = MockSectorReader::new(); let opts = ScanOptions::default(); let result = Disc::scan_image(&mut reader, 0, &opts); assert!( result.is_err(), "scan_image should fail with empty reader (no AVDP)" ); } // ── DiscTitle tests ──────────────────────────────────────────────────────── #[test] fn disc_title_empty() { let t = DiscTitle::empty(); assert_eq!(t.playlist, ""); assert_eq!(t.playlist_id, 0); assert_eq!(t.duration_secs, 0.0); assert_eq!(t.size_bytes, 0); assert!(t.clips.is_empty()); assert!(t.streams.is_empty()); assert!(t.extents.is_empty()); } #[test] fn disc_title_duration_display() { let mut t = DiscTitle::empty(); // 2 hours 15 minutes = 8100 seconds t.duration_secs = 8100.0; assert_eq!(t.duration_display(), "2h 15m"); // 0 hours 5 minutes = 300 seconds t.duration_secs = 300.0; assert_eq!(t.duration_display(), "0h 05m"); // Exact hour boundary t.duration_secs = 3600.0; assert_eq!(t.duration_display(), "1h 00m"); // Large value: 10 hours 30 minutes t.duration_secs = 37800.0; assert_eq!(t.duration_display(), "10h 30m"); } #[test] fn disc_title_size_gb() { let mut t = DiscTitle::empty(); // Exactly 1 GiB t.size_bytes = 1024 * 1024 * 1024; assert!((t.size_gb() - 1.0).abs() < 0.001); // 50 GiB (typical BD) t.size_bytes = 50 * 1024 * 1024 * 1024; assert!((t.size_gb() - 50.0).abs() < 0.001); // Zero t.size_bytes = 0; assert_eq!(t.size_gb(), 0.0); } #[test] fn disc_title_total_sectors() { let mut t = DiscTitle::empty(); assert_eq!(t.total_sectors(), 0); t.extents.push(libfreemkv::Extent { start_lba: 0, sector_count: 100, }); t.extents.push(libfreemkv::Extent { start_lba: 200, sector_count: 50, }); assert_eq!(t.total_sectors(), 150); } // ── ScanOptions tests ────────────────────────────────────────────────────── #[test] fn scan_options_default_has_no_credentials() { // Lookup-free: the only scan input is the optional live-drive credentials. let opts = ScanOptions::default(); assert!(opts.credentials.is_none()); } #[test] fn scan_options_with_credentials() { let opts = ScanOptions { credentials: Some(libfreemkv::DriveCredentials::default()), ..Default::default() }; assert!(opts.credentials.is_some()); assert!(opts.credentials.unwrap().host_certs.is_empty()); } // ── detect_format integration tests ─────────────────────────────────────── use libfreemkv::{ Codec, ColorSpace, ContentFormat, FrameRate, HdrFormat, Resolution, Stream, VideoStream, }; fn title_with_video( codec: Codec, resolution: Resolution, content_format: ContentFormat, ) -> DiscTitle { DiscTitle { playlist: "00800.mpls".into(), playlist_id: 800, duration_secs: 7200.0, size_bytes: 0, clips: Vec::new(), streams: vec![Stream::Video(VideoStream { pid: 0x1011, codec, resolution, frame_rate: FrameRate::F23_976, hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, display_aspect: None, secondary: false, label: String::new(), measured_cicp: None, })], chapters: Vec::new(), extents: Vec::new(), content_format, codec_privates: Vec::new(), } } #[test] fn disc_title_duration_display_edge_cases() { let mut t = DiscTitle::empty(); // 0 seconds t.duration_secs = 0.0; assert_eq!(t.duration_display(), "0h 00m"); // 1 second t.duration_secs = 1.0; assert_eq!(t.duration_display(), "0h 00m"); // 59 minutes t.duration_secs = 59.0 * 60.0; assert_eq!(t.duration_display(), "0h 59m"); // 24 hours exactly t.duration_secs = 24.0 * 3600.0; assert_eq!(t.duration_display(), "24h 00m"); } #[test] fn content_format_default_bdts() { let t = title_with_video(Codec::H264, Resolution::R1080p, ContentFormat::BdTs); assert_eq!(t.content_format, ContentFormat::BdTs); } #[test] fn content_format_dvd_mpegps() { let t = title_with_video(Codec::Mpeg2, Resolution::R480i, ContentFormat::MpegPs); assert_eq!(t.content_format, ContentFormat::MpegPs); } // ── UDF helpers for encryption resolution tests ─────────────────────────── /// Build an AVDP sector (tag_id=2) pointing to VDS at the given LBA. fn make_avdp_sector(vds_lba: u32) -> Vec { let mut s = vec![0u8; SECTOR_SIZE]; s[0..2].copy_from_slice(&2u16.to_le_bytes()); s[16..20].copy_from_slice(&vds_lba.to_le_bytes()); s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes()); s } fn make_pvd_sector(volume_id: &str) -> Vec { let mut s = vec![0u8; SECTOR_SIZE]; s[0..2].copy_from_slice(&1u16.to_le_bytes()); if !volume_id.is_empty() { let id_bytes = volume_id.as_bytes(); s[24] = 8; let copy_len = id_bytes.len().min(30); s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]); s[55] = (1 + copy_len) as u8; } s } fn make_partition_desc(partition_start: u32) -> Vec { let mut s = vec![0u8; SECTOR_SIZE]; s[0..2].copy_from_slice(&5u16.to_le_bytes()); s[188..192].copy_from_slice(&partition_start.to_le_bytes()); s } fn make_lvd_sector_simple() -> Vec { let mut s = vec![0u8; SECTOR_SIZE]; s[0..2].copy_from_slice(&6u16.to_le_bytes()); s[268..272].copy_from_slice(&1u32.to_le_bytes()); s } fn make_terminator() -> Vec { let mut s = vec![0u8; SECTOR_SIZE]; s[0..2].copy_from_slice(&8u16.to_le_bytes()); s } fn make_fsd_sector(root_meta_lba: u32) -> Vec { let mut s = vec![0u8; SECTOR_SIZE]; s[0..2].copy_from_slice(&256u16.to_le_bytes()); s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes()); s } fn make_dir_icb(data_meta_lba: u32, data_len: u32) -> Vec { let mut s = vec![0u8; SECTOR_SIZE]; s[0..2].copy_from_slice(&266u16.to_le_bytes()); s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes()); s[208..212].copy_from_slice(&0u32.to_le_bytes()); s[212..216].copy_from_slice(&8u32.to_le_bytes()); s[216..220].copy_from_slice(&data_len.to_le_bytes()); s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes()); s } fn make_parent_fid() -> Vec { let fid_len = (38 + 3) & !3; let mut fid = vec![0u8; fid_len]; fid[0..2].copy_from_slice(&257u16.to_le_bytes()); fid[18] = 0x08; fid[19] = 0; fid } fn make_fid(name: &str, icb_meta_lba: u32, is_dir: bool) -> Vec { let mut name_bytes = vec![8u8]; name_bytes.extend_from_slice(name.as_bytes()); let l_fi = name_bytes.len() as u8; let file_chars: u8 = if is_dir { 0x02 } else { 0x00 }; let fid_len = (38 + l_fi as usize + 3) & !3; let mut fid = vec![0u8; fid_len]; fid[0..2].copy_from_slice(&257u16.to_le_bytes()); fid[18] = file_chars; fid[19] = l_fi; fid[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); fid[24..28].copy_from_slice(&icb_meta_lba.to_le_bytes()); fid[36..38].copy_from_slice(&0u16.to_le_bytes()); fid[38..38 + name_bytes.len()].copy_from_slice(&name_bytes); fid } /// Build a minimal UDF image with an empty root directory (no /AACS). fn build_minimal_udf(reader: &mut MockSectorReader) { let partition_start: u32 = 512; reader.sectors.insert(256, make_avdp_sector(32)); reader.sectors.insert(32, make_pvd_sector("TEST_DISC")); reader .sectors .insert(33, make_partition_desc(partition_start)); reader.sectors.insert(34, make_lvd_sector_simple()); reader.sectors.insert(35, make_terminator()); reader.sectors.insert(partition_start, make_fsd_sector(1)); let parent_fid = make_parent_fid(); let dir_data_len = parent_fid.len() as u32; reader .sectors .insert(partition_start + 1, make_dir_icb(2, dir_data_len)); let mut sector = vec![0u8; SECTOR_SIZE]; sector[..parent_fid.len()].copy_from_slice(&parent_fid); reader.sectors.insert(partition_start + 2, sector); } /// Build a UDF image with an /AACS directory (empty). fn build_udf_with_aacs_dir(reader: &mut MockSectorReader) { let partition_start: u32 = 512; reader.sectors.insert(256, make_avdp_sector(32)); reader.sectors.insert(32, make_pvd_sector("ENCRYPTED_DISC")); reader .sectors .insert(33, make_partition_desc(partition_start)); reader.sectors.insert(34, make_lvd_sector_simple()); reader.sectors.insert(35, make_terminator()); reader.sectors.insert(partition_start, make_fsd_sector(1)); // Root -> AACS (dir) let parent_fid = make_parent_fid(); let aacs_fid = make_fid("AACS", 3, true); let mut root_data = Vec::new(); root_data.extend_from_slice(&parent_fid); root_data.extend_from_slice(&aacs_fid); let root_data_len = root_data.len() as u32; reader .sectors .insert(partition_start + 1, make_dir_icb(2, root_data_len)); let mut sector = vec![0u8; SECTOR_SIZE]; sector[..root_data.len()].copy_from_slice(&root_data); reader.sectors.insert(partition_start + 2, sector); // AACS dir (empty) let aacs_parent = make_parent_fid(); let aacs_data_len = aacs_parent.len() as u32; reader .sectors .insert(partition_start + 3, make_dir_icb(4, aacs_data_len)); let mut sector2 = vec![0u8; SECTOR_SIZE]; sector2[..aacs_parent.len()].copy_from_slice(&aacs_parent); reader.sectors.insert(partition_start + 4, sector2); } #[test] fn resolve_encryption_no_aacs_dir() { // A UDF image with no /AACS directory should result in no encryption let mut reader = MockSectorReader::new(); build_minimal_udf(&mut reader); let opts = ScanOptions::default(); let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap(); assert!( !disc.encrypted, "disc without /AACS should not be encrypted" ); assert!(disc.aacs.is_none(), "aacs should be None without /AACS dir"); } // ── Batch count arithmetic tests ────────────────────────────────────────── // Regression tests for the u16 truncation bug in the prefetch producer's // per-batch sector count (`src/sector/prefetched.rs`): when // `(remaining as u16).min(batch_sectors)` was used instead of // `remaining.min(batch_sectors as u32) as u16`, any remaining count that is a // multiple of 65536 truncated to 0. // // These tests used to assert against `safe_batch_count`/`buggy_batch_count` // copies defined in THIS file, so the production expression could be reverted // with every one of them staying green. They now drive the real producer // through the public `PrefetchedSectorSource` API and assert on the sector // count of the batch it actually emits. /// Endless zero-filled source: every read succeeds with the full requested /// span, so the producer's batch size is the only thing the returned byte /// count can reflect. struct ZeroSectorSource; impl SectorSource for ZeroSectorSource { fn read_sectors( &mut self, _lba: u32, count: u16, buf: &mut [u8], _recovery: bool, ) -> Result { let bytes = count as usize * SECTOR_SIZE; buf[..bytes].fill(0); Ok(bytes) } } /// Sectors in the FIRST batch the real prefetch producer emits for an extent /// of `sector_count` sectors at the configured `batch_sectors`. This is the /// production expression under test, reached only through public API. fn first_batch_sectors(sector_count: u32, batch_sectors: u16) -> usize { let mut src = libfreemkv::PrefetchedSectorSource::new( ZeroSectorSource, vec![libfreemkv::Extent { start_lba: 0, sector_count, }], batch_sectors, None, ) .expect("prefetch producer spawns"); let mut buf = vec![0u8; batch_sectors as usize * SECTOR_SIZE]; let n = src .read_sectors(0, batch_sectors, &mut buf, false) .expect("first batch"); assert_eq!( n % SECTOR_SIZE, 0, "batch must be a whole number of sectors" ); n / SECTOR_SIZE } #[test] fn batch_count_normal() { // Normal case: remaining > batch_sectors → a full batch. assert_eq!(first_batch_sectors(1000, 60), 60); assert_eq!(first_batch_sectors(47533152, 60), 60); } #[test] fn batch_count_last_batch() { // Only batch: remaining < batch_sectors → the remainder, not the batch. assert_eq!(first_batch_sectors(30, 60), 30); assert_eq!(first_batch_sectors(3, 60), 3); } #[test] fn batch_count_exact_boundary() { // Exact boundary: remaining == batch_sectors. assert_eq!(first_batch_sectors(60, 60), 60); } #[test] fn batch_count_u16_overflow_regression() { // THE BUG: remaining is a multiple of 65536 → `remaining as u16` is 0, so // the batch collapses (the unit-alignment clamp below it then floors the // batch at one 3-sector AACS unit — a 20x throughput cliff on exactly the // disc sizes that hit it, and an outright stall before that clamp existed). let remaining: u32 = 47533152 - 19552; // = 47513600 = 725 * 65536 assert_eq!(remaining, 47513600); assert_eq!( remaining % 65536, 0, "remaining must be a multiple of 65536" ); assert_eq!( first_batch_sectors(remaining, 60), 60, "a remaining count that is a multiple of 65536 must still yield a full batch" ); } #[test] fn batch_count_other_u16_overflow_values() { // Other multiples of 65536 — every one truncates to 0 under the old cast. assert_eq!(first_batch_sectors(65536, 60), 60); assert_eq!(first_batch_sectors(131072, 60), 60); assert_eq!(first_batch_sectors(65536 * 100, 60), 60); } #[test] fn batch_count_near_u16_boundary() { // Just below, at, and just above the 16-bit wrap point. 65535 survives the // bad cast by accident; 65536 truncates to 0 and 65537 to 1 — all three // must produce the same full batch. assert_eq!(first_batch_sectors(65535, 60), 60); assert_eq!(first_batch_sectors(65536, 60), 60); assert_eq!(first_batch_sectors(65537, 60), 60); } #[test] fn batch_count_real_disc_sizes() { let batch: u16 = 60; // DVD-5: ~2,295,104 sectors assert_eq!(first_batch_sectors(2295104, batch), 60); // BD-25: ~12,219,392 sectors assert_eq!(first_batch_sectors(12219392, batch), 60); // BD-50: ~24,438,784 sectors assert_eq!(first_batch_sectors(24438784, batch), 60); // UHD BD-66: ~33,554,432 sectors assert_eq!(first_batch_sectors(33554432, batch), 60); // UHD BD-100: ~47,533,152 sectors assert_eq!(first_batch_sectors(47533152, batch), 60); // Short tails (whole AACS units, as Blu-ray m2ts extents are by spec). assert_eq!(first_batch_sectors(51, batch), 51); assert_eq!(first_batch_sectors(3, batch), 3); } #[test] fn batch_count_zero_remaining() { // A zero-sector extent yields no batch at all: the producer skips it and // the channel closes, which the consumer reads as end-of-stream (Ok(0)). let mut src = libfreemkv::PrefetchedSectorSource::new( ZeroSectorSource, vec![libfreemkv::Extent { start_lba: 0, sector_count: 0, }], 60, None, ) .expect("prefetch producer spawns"); let mut buf = vec![0u8; 60 * SECTOR_SIZE]; assert_eq!(src.read_sectors(0, 60, &mut buf, false).unwrap(), 0); } #[test] fn batch_count_max_batch_sizes() { // Every batch size detect_max_batch_sectors can pick. All are multiples of // the 3-sector AACS unit, so none is reshaped by the alignment trim — the // batch the producer emits is the truncation-prone expression's output. for &batch in &[3u16, 6, 9, 30, 60, 120, 240, 510] { assert_eq!( first_batch_sectors(65536 * 100, batch), batch as usize, "batch {batch}: multiple-of-65536 remaining must still fill the batch" ); assert_eq!( first_batch_sectors(3, batch), 3, "batch {batch}: short tail" ); } } #[test] fn scan_encrypted_resolves_no_keys() { // A UDF image with an /AACS directory: the lookup-free scan detects // encryption and captures inputs, but resolves NO key on its own — a // caller applies one later via Disc::decrypt_with. let mut reader = MockSectorReader::new(); build_udf_with_aacs_dir(&mut reader); let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap(); // No unit keys without an external key (the mock has no Unit_Key_RO.inf to // capture, so the keyless state isn't even built) — either way, no keys. assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None)); } #[test] fn aacs_dir_alone_marks_the_disc_encrypted_and_reports_the_capture_error() { // Encryption detection is an OR over the two on-disc AACS locations: // `/AACS` (Blu-ray / UHD, ECMA-167 root) and `/BDMV/AACS` (the BDMV-nested // variant). This fixture carries ONLY `/AACS`, the standard retail layout, // so a detector that required BOTH would call a genuinely encrypted disc // clear — the worst possible failure here, because a "clear" disc is muxed // straight through and ships ciphertext as if it were video, at exit 0. let mut reader = MockSectorReader::new(); build_udf_with_aacs_dir(&mut reader); let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap(); assert!( disc.encrypted, "a disc carrying /AACS is encrypted even though /BDMV/AACS is absent" ); // Encrypted => the scan attempts the (lookup-free) AACS input capture. This // fixture's /AACS is empty, so that capture fails and the failure must be // PRESERVED on the disc: callers render it, and its absence is what a scan // that never attempted the capture at all would look like. assert!( disc.aacs_error.is_some(), "the failed AACS capture on an encrypted disc must be surfaced, not dropped" ); assert!( disc.aacs.is_none(), "no VID was resolvable from this fixture" ); } /// The scan reports the medium's size on BOTH axes it exposes, derived from the /// one sector count the caller hands in: /// /// * `capacity_bytes` is that sector count times the 2048-byte logical sector /// (ECMA-167 / BD-ROM logical block size). It is what sizes a full-disc image /// read and what the progress percentage divides by, so a wrong scale is a /// wrong ISO length, not a cosmetic number. /// * `layers` distinguishes single- from dual-layer media. The threshold sits /// between the two real capacities: a single-layer BD-25 is 12,219,392 /// sectors (25,025,314,816 bytes / 2048) and a dual-layer BD-50 is 24,438,784 /// sectors, so BD-25 must report 1 layer and BD-50 must report 2. /// /// `scan_image` takes the sector count as a parameter, so this exercises the /// real derivation without a 50 GB fixture. #[test] fn scan_image_reports_capacity_in_bytes_and_the_layer_count() { let opts = ScanOptions::default(); let mut reader = MockSectorReader::new(); build_minimal_udf(&mut reader); let disc = Disc::scan_image(&mut reader, 1_000, &opts).unwrap(); assert_eq!(disc.capacity_sectors, 1_000); assert_eq!( disc.capacity_bytes, 2_048_000, "capacity_bytes is the sector count scaled by the 2048-byte logical sector" ); // BD-25: single layer. let mut reader = MockSectorReader::new(); build_minimal_udf(&mut reader); let bd25 = Disc::scan_image(&mut reader, 12_219_392, &opts).unwrap(); assert_eq!(bd25.capacity_bytes, 25_025_314_816); assert_eq!(bd25.layers, 1, "a BD-25 is single-layer"); // BD-50: dual layer. let mut reader = MockSectorReader::new(); build_minimal_udf(&mut reader); let bd50 = Disc::scan_image(&mut reader, 24_438_784, &opts).unwrap(); assert_eq!(bd50.capacity_bytes, 50_050_629_632); assert_eq!(bd50.layers, 2, "a BD-50 is dual-layer"); }