diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 5eec9e4..b5013d4 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1050,3 +1050,121 @@ fn format_samplerate(audio_rate: u8) -> String { _ => String::new(), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: build a DiscTitle with a single video stream at the given resolution. + fn title_with_video(codec: Codec, resolution: &str) -> 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: resolution.into(), + frame_rate: "23.976".into(), + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + secondary: false, + label: String::new(), + })], + extents: Vec::new(), + content_format: ContentFormat::BdTs, + } + } + + #[test] + fn detect_format_uhd() { + let titles = vec![title_with_video(Codec::Hevc, "2160p")]; + assert_eq!(Disc::detect_format(&titles), DiscFormat::Uhd); + } + + #[test] + fn detect_format_bluray() { + let titles = vec![title_with_video(Codec::H264, "1080p")]; + assert_eq!(Disc::detect_format(&titles), DiscFormat::BluRay); + } + + #[test] + fn detect_format_dvd() { + let titles = vec![title_with_video(Codec::Mpeg2, "480i")]; + assert_eq!(Disc::detect_format(&titles), DiscFormat::Dvd); + } + + #[test] + fn detect_format_empty() { + let titles: Vec = Vec::new(); + assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown); + } + + #[test] + fn content_format_default_bdts() { + let t = title_with_video(Codec::H264, "1080p"); + assert_eq!(t.content_format, ContentFormat::BdTs); + } + + #[test] + fn content_format_dvd_mpegps() { + let t = DiscTitle { + content_format: ContentFormat::MpegPs, + ..title_with_video(Codec::Mpeg2, "480i") + }; + assert_eq!(t.content_format, ContentFormat::MpegPs); + } + + #[test] + fn disc_capacity_gb() { + // Single-layer BD-25: ~12,219,392 sectors + let disc = Disc { + volume_id: String::new(), + meta_title: None, + format: DiscFormat::BluRay, + capacity_sectors: 12_219_392, + capacity_bytes: 12_219_392u64 * 2048, + layers: 1, + titles: Vec::new(), + region: DiscRegion::Free, + aacs: None, + css: None, + encrypted: false, + content_format: ContentFormat::BdTs, + }; + let gb = disc.capacity_gb(); + // 12,219,392 * 2048 / 1073741824 = ~23.3 GB + assert!((gb - 23.3).abs() < 0.1, "expected ~23.3 GB, got {}", gb); + + // Zero sectors + let disc_zero = Disc { + capacity_sectors: 0, + capacity_bytes: 0, + ..disc + }; + assert_eq!(disc_zero.capacity_gb(), 0.0); + } + + #[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 + t.duration_secs = 24.0 * 3600.0; + assert_eq!(t.duration_display(), "24h 00m"); + } +} diff --git a/src/mux/isowriter.rs b/src/mux/isowriter.rs index 2377714..f774def 100644 --- a/src/mux/isowriter.rs +++ b/src/mux/isowriter.rs @@ -469,3 +469,155 @@ fn write_fid(buf: &mut [u8], icb_lba: u32, name: &str, is_parent: bool) -> usize padded } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + /// Read a little-endian u16 from a byte slice at the given offset. + fn le_u16(data: &[u8], off: usize) -> u16 { + u16::from_le_bytes([data[off], data[off + 1]]) + } + + /// Read a little-endian u32 from a byte slice at the given offset. + fn le_u32(data: &[u8], off: usize) -> u32 { + u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]]) + } + + /// Read a little-endian u64 from a byte slice at the given offset. + fn le_u64(data: &[u8], off: usize) -> u64 { + u64::from_le_bytes([ + data[off], + data[off + 1], + data[off + 2], + data[off + 3], + data[off + 4], + data[off + 5], + data[off + 6], + data[off + 7], + ]) + } + + /// Get the sector at a given sector number from the output data. + fn sector(data: &[u8], num: u32) -> &[u8] { + let start = num as usize * SECTOR_SIZE as usize; + &data[start..start + SECTOR_SIZE as usize] + } + + #[test] + fn isowriter_creates_valid_udf() { + let buf = Cursor::new(Vec::new()); + let mut w = IsoWriter::new(buf, "TEST_VOL", "00001.m2ts"); + w.start().unwrap(); + w.write_data(&[0xAA; 4096]).unwrap(); + w.finish().unwrap(); + let data = w.writer.into_inner(); + + // AVDP at sector 256 should have tag ID = 2 + let avdp = sector(&data, AVDP_SECTOR); + assert_eq!(le_u16(avdp, 0), 2, "AVDP tag ID should be 2"); + + // VRS at sector 16 should contain "BEA01" + let vrs = sector(&data, VRS_START); + assert_eq!( + &vrs[1..6], + b"BEA01", + "VRS sector 16 should contain BEA01" + ); + + // FSD at metadata sector should have tag ID = 256 + let fsd = sector(&data, FSD_SECTOR); + assert_eq!(le_u16(fsd, 0), 256, "FSD tag ID should be 256"); + } + + #[test] + fn isowriter_updates_file_size() { + let buf = Cursor::new(Vec::new()); + let mut w = IsoWriter::new(buf, "SIZE_TEST", "00001.m2ts"); + w.start().unwrap(); + + let test_data = vec![0x42u8; 8192]; // exactly 4 sectors + let written = w.write_data(&test_data).unwrap(); + assert_eq!(written, 8192); + + w.finish().unwrap(); + let data = w.writer.into_inner(); + + // Read m2ts ICB at M2TS_ICB_SECTOR and check information length at offset 56 + let icb = sector(&data, M2TS_ICB_SECTOR); + let file_size = le_u64(icb, 56); + assert_eq!( + file_size, 8192, + "m2ts ICB file size should match bytes written (8192), got {}", + file_size + ); + } + + #[test] + fn isowriter_with_names() { + let buf = Cursor::new(Vec::new()); + let mut w = IsoWriter::new(buf, "MY_DISC", "00042.m2ts"); + w.start().unwrap(); + w.write_data(&[0x00; 2048]).unwrap(); + w.finish().unwrap(); + let data = w.writer.into_inner(); + + // Check PVD (sector 32) volume_id at offset 24 as d-string + let pvd = sector(&data, VDS_START); + // d-string: byte 0 = compression ID (8), then ASCII chars + assert_eq!(pvd[24], 8, "PVD volume_id compression ID should be 8"); + assert_eq!( + &pvd[25..32], + b"MY_DISC", + "PVD should contain volume_id 'MY_DISC'" + ); + + // Check STREAM directory (sector 266) for m2ts filename in FID + let stream_dir = sector(&data, STREAM_DIR_SECTOR); + // The FID for the m2ts file should contain the filename after the parent entry. + // Search for "00042.m2ts" in the sector data + let name = b"00042.m2ts"; + let found = stream_dir + .windows(name.len()) + .any(|w| w == name); + assert!( + found, + "STREAM directory should contain m2ts filename '00042.m2ts'" + ); + } + + #[test] + fn isowriter_empty_content() { + let buf = Cursor::new(Vec::new()); + let mut w = IsoWriter::new(buf, "EMPTY", "00001.m2ts"); + w.start().unwrap(); + // No data written + w.finish().unwrap(); + let data = w.writer.into_inner(); + + // Should still have valid UDF structure + // AVDP at sector 256 + let avdp = sector(&data, AVDP_SECTOR); + assert_eq!(le_u16(avdp, 0), 2, "AVDP tag should be present even with no data"); + + // VRS + let vrs = sector(&data, VRS_START); + assert_eq!(&vrs[1..6], b"BEA01"); + + // FSD + let fsd = sector(&data, FSD_SECTOR); + assert_eq!(le_u16(fsd, 0), 256); + + // m2ts ICB should show 0 file size + let icb = sector(&data, M2TS_ICB_SECTOR); + let file_size = le_u64(icb, 56); + assert_eq!(file_size, 0, "empty content should have 0 file size"); + + // Output should be at least DATA_START sectors (the header structure) + assert!( + data.len() >= DATA_START as usize * SECTOR_SIZE as usize, + "output too small for valid UDF structure" + ); + } +} diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index d3db7f5..b8a1d71 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -423,3 +423,227 @@ fn parse_channels(s: &str) -> u8 { 6 } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + /// Helper: search for a 4-byte big-endian EBML ID in a byte slice. + fn find_id(data: &[u8], id: u32) -> Option { + let bytes = id.to_be_bytes(); + // Determine how many leading zero bytes to skip + let start = if bytes[0] != 0 { + 0 + } else if bytes[1] != 0 { + 1 + } else if bytes[2] != 0 { + 2 + } else { + 3 + }; + let needle = &bytes[start..]; + data.windows(needle.len()).position(|w| w == needle) + } + + fn make_video_track() -> MkvTrack { + MkvTrack { + track_type: ebml::TRACK_TYPE_VIDEO, + codec_id: "V_MPEG4/ISO/AVC", + language: "und".into(), + name: String::new(), + codec_private: Some(vec![0x00, 0x01, 0x02, 0x03]), + is_default: true, + is_forced: false, + pixel_width: 1920, + pixel_height: 1080, + sample_rate: 0.0, + channels: 0, + bit_depth: 0, + } + } + + fn make_audio_track() -> MkvTrack { + MkvTrack { + track_type: ebml::TRACK_TYPE_AUDIO, + codec_id: "A_AC3", + language: "eng".into(), + name: "English".into(), + codec_private: None, + is_default: true, + is_forced: false, + pixel_width: 0, + pixel_height: 0, + sample_rate: 48000.0, + channels: 6, + bit_depth: 0, + } + } + + #[test] + fn mkv_writes_ebml_header() { + let buf = Cursor::new(Vec::new()); + let tracks = [make_video_track()]; + let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0).unwrap(); + let data = muxer.writer.into_inner(); + // EBML header element ID: 0x1A45DFA3 + assert!(data.len() >= 4); + assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]); + } + + #[test] + fn mkv_writes_segment() { + let buf = Cursor::new(Vec::new()); + let tracks = [make_video_track()]; + let muxer = MkvMuxer::new(buf, &tracks, None, 0.0).unwrap(); + let data = muxer.writer.into_inner(); + // Segment element ID: 0x18538067 + assert!( + find_id(&data, ebml::SEGMENT).is_some(), + "Segment element not found in output" + ); + } + + #[test] + fn mkv_write_frame_creates_cluster() { + let buf = Cursor::new(Vec::new()); + let tracks = [make_video_track()]; + let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap(); + muxer + .write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF]) + .unwrap(); + let data = muxer.writer.into_inner(); + assert!( + find_id(&data, ebml::CLUSTER).is_some(), + "Cluster element not found after write_frame" + ); + } + + #[test] + fn mkv_finish_writes_cues_element() { + // Use a Vec wrapped in Cursor, then check after finish + use std::sync::{Arc, Mutex}; + + // We'll write to a Cursor, but finish() consumes self. + // The trick: Cursor> - we can get data back via into_inner chain. + // But MkvMuxer::finish consumes self and flushes writer. + // We need a way to inspect the output. Let's use a wrapper. + + struct SharedWriter(Arc>>>); + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().write(buf) + } + fn flush(&mut self) -> io::Result<()> { + self.0.lock().unwrap().flush() + } + } + impl Seek for SharedWriter { + fn seek(&mut self, pos: io::SeekFrom) -> io::Result { + self.0.lock().unwrap().seek(pos) + } + } + + let shared = Arc::new(Mutex::new(Cursor::new(Vec::new()))); + let writer = SharedWriter(shared.clone()); + let tracks = [make_video_track()]; + let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0).unwrap(); + muxer + .write_frame(0, 0, true, &[0x01, 0x02, 0x03]) + .unwrap(); + muxer.finish().unwrap(); + + let data = shared.lock().unwrap().clone().into_inner(); + assert!( + find_id(&data, ebml::CUES).is_some(), + "Cues element (0x1C53BB6B) not found after finish()" + ); + } + + #[test] + fn mkv_multiple_tracks() { + let buf = Cursor::new(Vec::new()); + let tracks = [make_video_track(), make_audio_track()]; + let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0).unwrap(); + // Write frames to both tracks + muxer + .write_frame(0, 0, true, &[0x00, 0x00, 0x01]) + .unwrap(); + muxer + .write_frame(1, 0, false, &[0x0B, 0x77, 0x00]) + .unwrap(); + muxer + .write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01]) + .unwrap(); + muxer + .write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01]) + .unwrap(); + // Should not panic + let data = muxer.writer.into_inner(); + assert!(data.len() > 100, "output too small for multi-track MKV"); + } + + #[test] + fn mkv_keyframe_flag() { + let buf = Cursor::new(Vec::new()); + let tracks = [make_video_track()]; + let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0).unwrap(); + + // Record position before first frame + let pos_before_kf = muxer.writer.position(); + muxer + .write_frame(0, 0, true, &[0xAA]) + .unwrap(); + let pos_after_kf = muxer.writer.position(); + + muxer + .write_frame(0, 1_000_000, false, &[0xBB]) + .unwrap(); + let pos_after_nkf = muxer.writer.position(); + + let data = muxer.writer.into_inner(); + + // Extract the SimpleBlock regions + let kf_region = &data[pos_before_kf as usize..pos_after_kf as usize]; + let nkf_region = &data[pos_after_kf as usize..pos_after_nkf as usize]; + + // In a SimpleBlock, after ID + size + track_vint + 2-byte timestamp, + // the next byte is flags. Keyframe flag = 0x80, non-keyframe = 0x00. + // Find the flags byte in each region: it's the byte after the 2-byte timestamp. + // SimpleBlock ID is 0xA3. Find it and walk past ID + size + vint + ts. + fn extract_flags(region: &[u8]) -> u8 { + // Find 0xA3 (SimpleBlock ID) + let sb_pos = region.iter().position(|&b| b == 0xA3).unwrap(); + // After ID: size (variable), track vint (1 byte for track<128), ts (2 bytes), flags (1 byte) + // Size is 1 byte for small blocks (< 127 bytes) + let after_id = sb_pos + 1; + // Read VINT size: first byte has high bit set for 1-byte sizes + let size_byte = region[after_id]; + let size_len = if size_byte & 0x80 != 0 { 1 } else { 2 }; + // Track VINT: 1 byte (track 1 = 0x81) + let track_vint_pos = after_id + size_len; + let track_vint_len = 1; // track 1 encoded as 0x81 + // 2-byte relative timestamp + let ts_pos = track_vint_pos + track_vint_len; + // flags byte + let flags_pos = ts_pos + 2; + region[flags_pos] + } + + let kf_flags = extract_flags(kf_region); + let nkf_flags = extract_flags(nkf_region); + + assert_eq!( + kf_flags & 0x80, + 0x80, + "keyframe flag should be set (0x80), got 0x{:02X}", + kf_flags + ); + assert_eq!( + nkf_flags & 0x80, + 0x00, + "non-keyframe flag should be clear, got 0x{:02X}", + nkf_flags + ); + } +} diff --git a/src/mux/network.rs b/src/mux/network.rs index 9a2e598..9877b8e 100644 --- a/src/mux/network.rs +++ b/src/mux/network.rs @@ -140,3 +140,135 @@ impl Read for NetworkStream { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::disc::{ + AudioStream, Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream, + }; + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// Build a DiscTitle with streams for metadata tests. + fn sample_title() -> DiscTitle { + DiscTitle { + playlist: "NetworkTest".into(), + playlist_id: 1, + duration_secs: 3600.0, + size_bytes: 0, + clips: Vec::new(), + streams: vec![ + Stream::Video(VideoStream { + pid: 0x1011, + codec: Codec::Hevc, + resolution: "2160p".into(), + frame_rate: "23.976".into(), + hdr: HdrFormat::Hdr10, + color_space: ColorSpace::Bt2020, + secondary: false, + label: "Main".into(), + }), + Stream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::TrueHd, + channels: "7.1".into(), + language: "eng".into(), + sample_rate: "48kHz".into(), + secondary: false, + label: "English".into(), + }), + ], + extents: Vec::new(), + content_format: ContentFormat::BdTs, + } + } + + #[test] + #[ignore] // Requires TCP; may be flaky in CI environments + fn network_listen_connect_roundtrip() { + // Bind to OS-assigned port + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); // Release so NetworkStream::listen can bind + + let addr = format!("127.0.0.1:{}", port); + let addr_clone = addr.clone(); + + // Spawn listener in a thread + let handle = std::thread::spawn(move || { + let mut ns = NetworkStream::listen(&addr_clone).unwrap(); + let mut buf = vec![0u8; 4096]; + let mut received = Vec::new(); + loop { + match ns.read(&mut buf) { + Ok(0) => break, + Ok(n) => received.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + received + }); + + // Small delay to let the listener thread bind + std::thread::sleep(std::time::Duration::from_millis(50)); + + // Connect and write data + let dt = sample_title(); + let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt); + let payload = b"Hello from the write side of the network stream!"; + writer.write_all(payload).unwrap(); + writer.finish().unwrap(); + + let received = handle.join().unwrap(); + // The received data should end with our payload (after the FMKV header) + assert!( + received.windows(payload.len()).any(|w| w == payload), + "payload not found in received data (got {} bytes)", + received.len() + ); + } + + #[test] + #[ignore] // Requires TCP; may be flaky in CI environments + fn network_metadata_flows() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let addr = format!("127.0.0.1:{}", port); + let addr_clone = addr.clone(); + + let handle = std::thread::spawn(move || { + let ns = NetworkStream::listen(&addr_clone).unwrap(); + let info = ns.info().clone(); + info + }); + + std::thread::sleep(std::time::Duration::from_millis(50)); + + let dt = sample_title(); + let mut writer = NetworkStream::connect(&addr).unwrap().meta(&dt); + // Must write at least one byte to trigger header send + writer.write_all(&[0u8; 192]).unwrap(); + writer.finish().unwrap(); + + let info = handle.join().unwrap(); + assert_eq!(info.playlist, "NetworkTest"); + assert_eq!(info.duration_secs, 3600.0); + assert_eq!(info.streams.len(), 2); + } + + #[test] + fn network_empty_addr_errors() { + let result = NetworkStream::connect(""); + assert!(result.is_err(), "empty address should fail"); + } + + #[test] + fn network_no_port_errors() { + // Connecting to an address without a port should fail + let result = NetworkStream::connect("127.0.0.1"); + assert!(result.is_err(), "address without port should fail"); + } +} diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 408c5ce..ea8c786 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -140,3 +140,239 @@ fn scan_options_with_keydb_pathbuf() { let opts = ScanOptions::with_keydb(path.clone()); assert_eq!(opts.keydb_path.unwrap(), path); } + +// ── detect_format integration tests ─────────────────────────────────────── + +use libfreemkv::{Codec, ColorSpace, ContentFormat, HdrFormat, Stream, VideoStream}; + +fn title_with_video(codec: Codec, resolution: &str, 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: resolution.into(), + frame_rate: "23.976".into(), + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + secondary: false, + label: String::new(), + })], + extents: Vec::new(), + content_format, + } +} + +#[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, "1080p", ContentFormat::BdTs); + assert_eq!(t.content_format, ContentFormat::BdTs); +} + +#[test] +fn content_format_dvd_mpegps() { + let t = title_with_video(Codec::Mpeg2, "480i", 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 + 0 + 0 + 3) & !3) as usize; + 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 + 0 + l_fi as usize + 3) & !3) as usize; + 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"); +} + +#[test] +fn resolve_encryption_no_keydb() { + // A UDF image with /AACS directory but no keydb path -> aacs is None + let mut reader = MockSectorReader::new(); + build_udf_with_aacs_dir(&mut reader); + + // No keydb configured and no standard keydb on the system + let opts = ScanOptions::with_keydb("/nonexistent/path/KEYDB.cfg"); + let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap(); + + // The disc detects encryption but can't resolve keys without a keydb + assert!( + disc.aacs.is_none(), + "aacs should be None when keydb is unavailable" + ); +} diff --git a/tests/streams.rs b/tests/streams.rs index f8cbe43..b7d658b 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -616,3 +616,142 @@ fn mkvstream_meta_sets_title() { assert_eq!(info.duration_secs, 7200.0); assert_eq!(info.streams.len(), 4); } + +// ── MkvStream additional tests ────────────────────────────── + +#[test] +fn mkvstream_roundtrip_bdts() { + // Write BD-TS packets through MkvStream and verify the pipeline works. + // Without real codec headers (SPS/PPS), the muxer stays in scanning phase. + // With a title that has no video streams (audio-only), codec scanning is + // skipped and the muxer enters streaming mode immediately, producing EBML output. + + let dt = DiscTitle { + playlist: "Audio Only".into(), + playlist_id: 0, + duration_secs: 60.0, + size_bytes: 0, + clips: Vec::new(), + streams: vec![Stream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::Ac3, + channels: "5.1".into(), + language: "eng".into(), + sample_rate: "48kHz".into(), + secondary: false, + label: "English".into(), + })], + extents: Vec::new(), + content_format: ContentFormat::BdTs, + }; + + let output = Cursor::new(Vec::new()); + let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024); + + // Write BD-TS packets targeting the audio PID 0x1100 + for i in 0..10u8 { + let mut pkt = [0u8; 192]; + pkt[4] = 0x47; + // PID 0x1100 + pkt[5] = 0x11; + pkt[6] = 0x00; + pkt[7] = 0x10; + pkt[8] = i; + stream.write_all(&pkt).unwrap(); + } + + stream.finish().unwrap(); + + // Verify the info is correct + let info = stream.info(); + assert_eq!(info.streams.len(), 1); + assert_eq!(info.playlist, "Audio Only"); +} + +#[test] +fn mkvstream_meta_preserves_all_streams() { + let dt = DiscTitle { + playlist: "Stream Test".into(), + playlist_id: 0, + duration_secs: 3600.0, + size_bytes: 0, + clips: Vec::new(), + streams: vec![ + Stream::Video(VideoStream { + pid: 0x1011, + codec: Codec::H264, + resolution: "1080p".into(), + frame_rate: "23.976".into(), + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + secondary: false, + label: "Main Video".into(), + }), + Stream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::Ac3, + channels: "5.1".into(), + language: "eng".into(), + sample_rate: "48kHz".into(), + secondary: false, + label: "English".into(), + }), + Stream::Audio(AudioStream { + pid: 0x1101, + codec: Codec::DtsHdMa, + channels: "7.1".into(), + language: "fra".into(), + sample_rate: "48kHz".into(), + secondary: false, + label: "French".into(), + }), + Stream::Subtitle(SubtitleStream { + pid: 0x1200, + codec: Codec::Pgs, + language: "eng".into(), + forced: false, + }), + Stream::Subtitle(SubtitleStream { + pid: 0x1201, + codec: Codec::Pgs, + language: "fra".into(), + forced: true, + }), + ], + extents: Vec::new(), + content_format: ContentFormat::BdTs, + }; + + let output = Cursor::new(Vec::new()); + let stream = MkvStream::new(output).meta(&dt); + + let info = stream.info(); + assert_eq!(info.streams.len(), 5, "all 5 streams should be preserved"); + assert_eq!(info.playlist, "Stream Test"); + assert_eq!(info.duration_secs, 3600.0); + + // Verify stream types preserved in order + assert!(matches!(&info.streams[0], Stream::Video(_))); + assert!(matches!(&info.streams[1], Stream::Audio(_))); + assert!(matches!(&info.streams[2], Stream::Audio(_))); + assert!(matches!(&info.streams[3], Stream::Subtitle(_))); + assert!(matches!(&info.streams[4], Stream::Subtitle(_))); + + // Check specific attributes + if let Stream::Video(v) = &info.streams[0] { + assert_eq!(v.codec, Codec::H264); + } + if let Stream::Audio(a) = &info.streams[1] { + assert_eq!(a.language, "eng"); + } + if let Stream::Audio(a) = &info.streams[2] { + assert_eq!(a.codec, Codec::DtsHdMa); + assert_eq!(a.language, "fra"); + } + if let Stream::Subtitle(s) = &info.streams[3] { + assert!(!s.forced); + } + if let Stream::Subtitle(s) = &info.streams[4] { + assert!(s.forced); + } +}