diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a263387..9f16219 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +9,13 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - run: cargo test check-macos: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - run: cargo check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d817606..f46ea54 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Verify Cargo.toml version matches tag run: | CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')" @@ -26,7 +26,7 @@ jobs: needs: verify runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - run: cargo test @@ -34,7 +34,7 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable - name: Publish to crates.io run: cargo publish @@ -45,7 +45,7 @@ jobs: needs: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/.github/workflows/update-readme.yml b/.github/workflows/update-readme.yml index 1c7f496..27ff724 100644 --- a/.github/workflows/update-readme.yml +++ b/.github/workflows/update-readme.yml @@ -11,7 +11,7 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: main token: ${{ secrets.ORG_DISPATCH_TOKEN }} diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..6b953b2 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,30 @@ +# libfreemkv — Feature List + +## v0.7.1 (current) + +### Done +- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject +- [x] 206 bundled drive profiles (MediaTek MT1959 A + B variants) +- [x] SCSI transport: Linux SG_IO, macOS IOKit +- [x] UDF 2.50 filesystem parser (metadata partitions, Blu-ray profile) +- [x] MPLS playlist parser (play items, STN table, secondary streams) +- [x] CLPI clip info parser (EP map, sector extents) +- [x] AACS 1.0 decryption (4 VUK paths: KEYDB, media key, processing key, device key) +- [x] AACS 2.0 SCSI handshake (P-256/SHA-256 ECDH, bus decryption, read data key) +- [x] KEYDB.cfg download, verify, save (raw TCP, zero HTTP deps) +- [x] Content reading with adaptive batch sizing, error recovery, 12+ MB/s +- [x] Stream labels: 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe) +- [x] MKV muxer: 8 codec parsers (H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1, LPCM) +- [x] SectorReader trait: decouples disc scanning from SCSI +- [x] 7 stream types: Disc, ISO, MKV, M2TS, Network, Stdio, Null +- [x] IOStream trait with URL-based resolver (scheme://path) +- [x] FMKV metadata header for M2TS and network streams +- [x] Numeric error codes only (no English text in library) +- [x] Event system for progress callbacks + +### Planned +- [ ] Windows SCSI transport (SPTI) +- [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C) +- [ ] DVD CSS decryption +- [ ] TranscodeStream (ffmpeg integration) +- [ ] ISO write with BD-compliant UDF structure diff --git a/src/clpi.rs b/src/clpi.rs index 8dac016..4d044b8 100644 --- a/src/clpi.rs +++ b/src/clpi.rs @@ -268,3 +268,250 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec, Vec)> { Ok((ep_coarse, ep_fine)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal CLPI binary. + /// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length). + fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec { + // We need at least 60 bytes for the header area. + // Offsets: + // 0..4: "HDMV" + // 4..8: "0200" + // 8..12: seq_info_start (unused, set to 0) + // 12..16: prog_info_start (unused, set to 0) + // 16..20: cpi_start + // 20..40: reserved/padding + // 40..56: ClipInfo section area (length + stuff before source_packet_count) + // 56..60: source_packet_count + + let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 }; + + let mut buf = vec![0u8; 60]; + // Magic + version + buf[0..4].copy_from_slice(b"HDMV"); + buf[4..8].copy_from_slice(b"0200"); + // seq_info_start = 0 + // prog_info_start = 0 + // cpi_start + buf[16..20].copy_from_slice(&cpi_start.to_be_bytes()); + // source_packet_count at offset 56 + buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes()); + + if let Some(cpi) = cpi_data { + buf.extend_from_slice(cpi); + } + + buf + } + + /// Build a CPI section with one stream's EP map. + /// coarse_entries: Vec<(ref_to_fine_id, pts_coarse, spn_coarse)> + /// fine_entries: Vec<(pts_fine, spn_fine)> + fn build_cpi( + stream_pid: u16, + coarse_entries: &[(u32, u32, u32)], + fine_entries: &[(u32, u32)], + ) -> Vec { + // CPI section layout: + // [0..4] cpi_length (u32 BE) + // [4..6] reserved/type (2 bytes) + // [6..] EP map + // + // EP map layout (relative to byte 6 of CPI): + // [0] reserved + // [1] num_streams (1) + // [2..4] stream_PID (u16 BE) + // [4..14] 80 bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32) + // [14..] (next stream entry, if any) + // + // Stream EP map (at EP_map_start relative to EP map start): + // [0..4] fine_start (relative to stream EP map start) + // [4..] coarse entries, 8 bytes each + // [fine_start..] fine entries, 4 bytes each + + let num_coarse = coarse_entries.len() as u32; + let num_fine = fine_entries.len() as u32; + + // EP_map_start: offset from ep_map start where the stream EP data begins. + // ep_map has: reserved(1) + num_streams(1) + stream_header(12) = 14 bytes + // So EP_map_start = 14 (first stream data right after the header) + let ep_map_start: u32 = 14; + + // Build the 80-bit stream PID entry (10 bytes: ep_map[4..14]) + // Bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32) + // Total: 80 bits = 10 bytes + // + // Pack into a u128 for convenience then extract 10 bytes + let ep_stream_type: u32 = 1; // video + let packed: u128 = ((0u128) << 70) // reserved: 10 bits + | ((ep_stream_type as u128) << 66) // EP_stream_type: 4 bits + | ((num_coarse as u128) << 50) // num_coarse: 16 bits + | ((num_fine as u128) << 32) // num_fine: 18 bits + | (ep_map_start as u128); // EP_map_start: 32 bits + let packed_bytes = packed.to_be_bytes(); // 16 bytes, we want the last 10 + let stream_header_bits = &packed_bytes[6..16]; + + // Build stream EP data + // fine_start = 4 (header) + num_coarse * 8 + let fine_start: u32 = 4 + num_coarse * 8; + let mut stream_ep = Vec::new(); + stream_ep.extend_from_slice(&fine_start.to_be_bytes()); + + // Coarse entries: 8 bytes each + // dword0 = (ref_to_fine_id << 14) | (pts_coarse & 0x3FFF) + // dword1 = spn_coarse + for &(ref_id, pts_c, spn_c) in coarse_entries { + let dword0 = (ref_id << 14) | (pts_c & 0x3FFF); + stream_ep.extend_from_slice(&dword0.to_be_bytes()); + stream_ep.extend_from_slice(&spn_c.to_be_bytes()); + } + + // Fine entries: 4 bytes each + // dword = (is_angle(1) + i_end_offset(3) + pts_fine(11) + spn_fine(17)) + for &(pts_f, spn_f) in fine_entries { + let dword: u32 = ((pts_f & 0x7FF) << 17) | (spn_f & 0x1FFFF); + stream_ep.extend_from_slice(&dword.to_be_bytes()); + } + + // Assemble EP map + let mut ep_map = Vec::new(); + ep_map.push(0); // reserved + ep_map.push(1); // num_streams = 1 + ep_map.extend_from_slice(&stream_pid.to_be_bytes()); + ep_map.extend_from_slice(stream_header_bits); + ep_map.extend_from_slice(&stream_ep); + + // Assemble CPI section + let mut cpi = Vec::new(); + let cpi_length = (2 + ep_map.len()) as u32; // reserved/type(2) + ep_map + cpi.extend_from_slice(&cpi_length.to_be_bytes()); + cpi.extend_from_slice(&[0u8; 2]); // reserved/type + cpi.extend_from_slice(&ep_map); + + cpi + } + + #[test] + fn parse_valid_clpi() { + let cpi = build_cpi( + 0x1011, + &[(0, 100, 0x00020000)], // 1 coarse + &[(50, 1024)], // 1 fine + ); + let data = build_clpi(500_000, Some(&cpi)); + + let clip = parse(&data).expect("should parse valid CLPI"); + assert_eq!(clip.version, "0200"); + assert_eq!(clip.source_packet_count, 500_000); + assert_eq!(clip.ep_coarse.len(), 1); + assert_eq!(clip.ep_fine.len(), 1); + } + + #[test] + fn parse_ep_map() { + let cpi = build_cpi( + 0x1011, + &[ + (0, 100, 0x00020000), // coarse 0: fine starts at 0, pts_coarse=100, spn_coarse=0x20000 + (2, 200, 0x00040000), // coarse 1: fine starts at 2, pts_coarse=200, spn_coarse=0x40000 + ], + &[ + (50, 1024), // fine 0 + (100, 2048), // fine 1 + (25, 512), // fine 2 + (75, 1536), // fine 3 + ], + ); + let data = build_clpi(1_000_000, Some(&cpi)); + + let clip = parse(&data).expect("should parse EP map"); + assert_eq!(clip.ep_coarse.len(), 2); + assert_eq!(clip.ep_fine.len(), 4); + + // Verify coarse entries + assert_eq!(clip.ep_coarse[0].ref_to_fine_id, 0); + assert_eq!(clip.ep_coarse[0].pts_coarse, 100); + assert_eq!(clip.ep_coarse[0].spn_coarse, 0x00020000); + assert_eq!(clip.ep_coarse[1].ref_to_fine_id, 2); + assert_eq!(clip.ep_coarse[1].pts_coarse, 200); + assert_eq!(clip.ep_coarse[1].spn_coarse, 0x00040000); + + // Verify fine entries + assert_eq!(clip.ep_fine[0].pts_fine, 50); + assert_eq!(clip.ep_fine[0].spn_fine, 1024); + assert_eq!(clip.ep_fine[1].pts_fine, 100); + assert_eq!(clip.ep_fine[1].spn_fine, 2048); + assert_eq!(clip.ep_fine[2].pts_fine, 25); + assert_eq!(clip.ep_fine[2].spn_fine, 512); + assert_eq!(clip.ep_fine[3].pts_fine, 75); + assert_eq!(clip.ep_fine[3].spn_fine, 1536); + + // Verify resolved EP map assigns fine entries to coarse correctly + let resolved = clip.resolved_ep_map(); + assert_eq!(resolved.len(), 4); + // First two fines belong to coarse 0, last two to coarse 1 + } + + #[test] + fn full_pts_calculation() { + let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 100, spn_coarse: 0 }; + let fine = EpFine { pts_fine: 50, spn_fine: 0 }; + // full_pts = (100 << 19) + (50 << 8) = 52_428_800 + 12_800 = 52_441_600 + let pts = ClipInfo::full_pts(&coarse, &fine); + assert_eq!(pts, (100 << 19) + (50 << 8)); + assert_eq!(pts, 52_441_600); + } + + #[test] + fn full_spn_calculation() { + let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FE0000 }; + let fine = EpFine { pts_fine: 0, spn_fine: 0x1234 }; + // full_spn = (0x00FE0000 & 0xFFFE0000) + 0x1234 = 0x00FE0000 + 0x1234 = 0x00FE1234 + let spn = ClipInfo::full_spn(&coarse, &fine); + assert_eq!(spn, 0x00FE0000 + 0x1234); + assert_eq!(spn, 0x00FE1234); + + // Test that the low bit of spn_coarse is masked out + let coarse2 = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FF0000 }; + let spn2 = ClipInfo::full_spn(&coarse2, &fine); + // 0x00FF0000 & 0xFFFE0000 = 0x00FE0000, so low 17 bits of coarse are zeroed + assert_eq!(spn2, 0x00FE0000 + 0x1234); + } + + #[test] + fn parse_invalid_magic() { + let mut data = build_clpi(1000, None); + data[0] = b'X'; + data[1] = b'X'; + data[2] = b'X'; + data[3] = b'X'; + assert!(parse(&data).is_err()); + } + + #[test] + fn parse_empty_ep_map() { + // cpi_start = 0 means no CPI section + let data = build_clpi(100_000, None); + let clip = parse(&data).expect("should parse with no EP map"); + assert_eq!(clip.source_packet_count, 100_000); + assert!(clip.ep_coarse.is_empty()); + assert!(clip.ep_fine.is_empty()); + + // Also test: CPI section present but with zero streams + let mut cpi = Vec::new(); + let cpi_length: u32 = 6; // reserved/type(2) + ep_map(reserved(1) + num_streams=0(1) + 2 padding) + cpi.extend_from_slice(&cpi_length.to_be_bytes()); + cpi.extend_from_slice(&[0u8; 2]); // reserved/type + cpi.push(0); // reserved + cpi.push(0); // num_streams = 0 + cpi.extend_from_slice(&[0u8; 4]); // padding + + let data2 = build_clpi(100_000, Some(&cpi)); + let clip2 = parse(&data2).expect("should parse with zero-stream EP map"); + assert!(clip2.ep_coarse.is_empty()); + assert!(clip2.ep_fine.is_empty()); + } +} diff --git a/src/mpls.rs b/src/mpls.rs index 8d6f26c..4ae278d 100644 --- a/src/mpls.rs +++ b/src/mpls.rs @@ -321,3 +321,308 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea secondary: false, }, sa_end)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal MPLS binary with given play items and STN streams on the first item. + /// STN counts: (n_video, n_audio, n_pg, n_ig, n_sec_audio, n_sec_video, n_pip_pg, n_dv) + fn build_mpls( + play_items_data: &[(/*clip_id*/&[u8;5], /*conn*/u8, /*in_time*/u32, /*out_time*/u32)], + stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8), + stream_entries: &[Vec], // raw stream entry + attributes bytes for each stream + ) -> Vec { + let playlist_start: u32 = 40; // right after the 40-byte header + let mut buf = Vec::new(); + + // File header: "MPLS" + version + playlist_start + 3 more offsets (unused) + buf.extend_from_slice(b"MPLS0200"); + buf.extend_from_slice(&playlist_start.to_be_bytes()); + // mark_start, extension_start (unused by parser), padding to 40 bytes + buf.extend_from_slice(&[0u8; 28]); + + // PlayList section starts here (offset 40) + // PlayList: length(4) + reserved(2) + num_play_items(2) + num_sub_paths(2) = 10 header bytes + let pl_start = buf.len(); + buf.extend_from_slice(&[0u8; 4]); // length placeholder + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&(play_items_data.len() as u16).to_be_bytes()); + buf.extend_from_slice(&[0u8; 2]); // num_sub_paths + + for (idx, (clip_id, conn, in_time, out_time)) in play_items_data.iter().enumerate() { + // Build play item content + let mut item = Vec::new(); + // [0..5] clip_id + item.extend_from_slice(*clip_id); + // [5..9] codec_id ("M2TS") + item.extend_from_slice(b"M2TS"); + // [9] connection_condition in low nibble + item.push(*conn & 0x0F); + // [10..12] reserved + item.extend_from_slice(&[0u8; 2]); + // [12..16] in_time + item.extend_from_slice(&in_time.to_be_bytes()); + // [16..20] out_time + item.extend_from_slice(&out_time.to_be_bytes()); + // [20..28] UO_mask_table + item.extend_from_slice(&[0u8; 8]); + // [28] misc flags + item.push(0); + // [29] still_mode + item.push(0); + // [30..32] still_time + item.extend_from_slice(&[0u8; 2]); + + // STN table (only for the first play item) + if idx == 0 { + // STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes + let stn_header_start = item.len(); + item.extend_from_slice(&[0u8; 2]); // STN length placeholder + item.extend_from_slice(&[0u8; 2]); // reserved + item.push(stn_counts.0); // n_video + item.push(stn_counts.1); // n_audio + item.push(stn_counts.2); // n_pg + item.push(stn_counts.3); // n_ig + item.push(stn_counts.4); // n_sec_audio + item.push(stn_counts.5); // n_sec_video + item.push(stn_counts.6); // n_pip_pg + item.push(stn_counts.7); // n_dv + item.extend_from_slice(&[0u8; 4]); // reserved + + // Stream entries + for se in stream_entries { + item.extend_from_slice(se); + } + + // Patch STN length + let stn_len = (item.len() - stn_header_start - 2) as u16; + let stn_len_bytes = stn_len.to_be_bytes(); + item[stn_header_start] = stn_len_bytes[0]; + item[stn_header_start + 1] = stn_len_bytes[1]; + } + + // Write item_length(2) + item + let item_length = item.len() as u16; + buf.extend_from_slice(&item_length.to_be_bytes()); + buf.extend_from_slice(&item); + } + + // Patch PlayList length + let pl_len = (buf.len() - pl_start - 4) as u32; + let pl_len_bytes = pl_len.to_be_bytes(); + buf[pl_start] = pl_len_bytes[0]; + buf[pl_start + 1] = pl_len_bytes[1]; + buf[pl_start + 2] = pl_len_bytes[2]; + buf[pl_start + 3] = pl_len_bytes[3]; + + buf + } + + /// Build a stream entry (stream_entry part + stream_attributes part). + /// stream_entry: type=0x01 (PlayItem stream), PID given. + /// For video: attrs = coding_type(1) + format_rate(1) [+ hdr_byte if HEVC] + /// For audio: attrs = coding_type(1) + format_rate(1) + language(3) + /// For PG: attrs = coding_type(1) + language(3) + fn build_stream_entry_video(pid: u16, coding_type: u8, format: u8, rate: u8, hdr: Option) -> Vec { + let mut out = Vec::new(); + // Stream entry: length(1) + sub_path_type(1) + pid(2) + out.push(3); // se_len = 3 bytes (type + pid_hi + pid_lo) + out.push(0x01); // type: PlayItem stream + out.extend_from_slice(&pid.to_be_bytes()); + // Stream attributes + let mut attrs = vec![coding_type, (format << 4) | rate]; + if let Some(h) = hdr { + attrs.push(h); + } + out.push(attrs.len() as u8); // sa_len + out.extend_from_slice(&attrs); + out + } + + fn build_stream_entry_audio(pid: u16, coding_type: u8, ch_layout: u8, sample_rate: u8, lang: &[u8; 3]) -> Vec { + let mut out = Vec::new(); + out.push(3); + out.push(0x01); + out.extend_from_slice(&pid.to_be_bytes()); + // attrs: coding_type(1) + format_rate(1) + language(3) + let attrs = vec![coding_type, (ch_layout << 4) | sample_rate, lang[0], lang[1], lang[2]]; + out.push(attrs.len() as u8); + out.extend_from_slice(&attrs); + out + } + + fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec { + let mut out = Vec::new(); + out.push(3); + out.push(0x01); + out.extend_from_slice(&pid.to_be_bytes()); + // attrs: coding_type(1) + language(3) + let attrs = vec![coding_type, lang[0], lang[1], lang[2]]; + out.push(attrs.len() as u8); + out.extend_from_slice(&attrs); + out + } + + #[test] + fn parse_valid_mpls() { + let in_time: u32 = 90000; // 2 seconds at 45kHz + let out_time: u32 = 4500000; // 100 seconds + + let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); // H264, 1080p, 23.976 + let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"); // TrueHD, 5.1, 48kHz + let pg = build_stream_entry_pg(0x1200, 0x90, b"eng"); // PGS subtitle + + let data = build_mpls( + &[(b"00001", 1, in_time, out_time)], + (1, 1, 1, 0, 0, 0, 0, 0), + &[video, audio, pg], + ); + + let playlist = parse(&data).expect("should parse valid MPLS"); + assert_eq!(playlist.version, "0200"); + assert_eq!(playlist.play_items.len(), 1); + assert_eq!(playlist.play_items[0].clip_id, "00001"); + assert_eq!(playlist.play_items[0].in_time, in_time); + assert_eq!(playlist.play_items[0].out_time, out_time); + assert_eq!(playlist.play_items[0].connection_condition, 1); + } + + #[test] + fn parse_streams() { + let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12)); // HEVC, 2160p, 23.976, HDR10+BT.2020 + let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"); + let pg = build_stream_entry_pg(0x1200, 0x90, b"fra"); + + let data = build_mpls( + &[(b"00001", 1, 0, 9000000)], + (1, 1, 1, 0, 0, 0, 0, 0), + &[video, audio, pg], + ); + + let playlist = parse(&data).expect("should parse"); + assert_eq!(playlist.streams.len(), 3); + + // Video stream + let v = &playlist.streams[0]; + assert_eq!(v.stream_type, 1); + assert_eq!(v.pid, 0x1011); + assert_eq!(v.coding_type, 0x24); // HEVC + assert_eq!(v.video_format, 8); // 2160p + assert_eq!(v.video_rate, 1); // 23.976 + assert_eq!(v.dynamic_range, 1); // HDR10 + assert_eq!(v.color_space, 2); // BT.2020 + assert!(!v.secondary); + + // Audio stream + let a = &playlist.streams[1]; + assert_eq!(a.stream_type, 2); + assert_eq!(a.pid, 0x1100); + assert_eq!(a.coding_type, 0x83); // TrueHD + assert_eq!(a.audio_format, 6); // 5.1 + assert_eq!(a.audio_rate, 1); // 48kHz + assert_eq!(a.language, "eng"); + assert!(!a.secondary); + + // PG subtitle stream + let s = &playlist.streams[2]; + assert_eq!(s.stream_type, 3); + assert_eq!(s.pid, 0x1200); + assert_eq!(s.coding_type, 0x90); // PGS + assert_eq!(s.language, "fra"); + assert!(!s.secondary); + } + + #[test] + fn parse_invalid_magic() { + let mut data = build_mpls( + &[(b"00001", 1, 0, 9000000)], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + ); + data[0] = b'X'; + data[1] = b'X'; + data[2] = b'X'; + data[3] = b'X'; + assert!(parse(&data).is_err()); + } + + #[test] + fn parse_truncated() { + // Less than 40 bytes + assert!(parse(&[0u8; 10]).is_err()); + assert!(parse(b"MPLS0200").is_err()); + assert!(parse(&[0u8; 39]).is_err()); + } + + #[test] + fn parse_multiple_play_items() { + let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); + + let data = build_mpls( + &[ + (b"00001", 1, 90000, 4500000), + (b"00002", 5, 4500000, 9000000), + (b"00003", 6, 9000000, 13500000), + ], + (1, 0, 0, 0, 0, 0, 0, 0), + &[video], + ); + + let playlist = parse(&data).expect("should parse multiple play items"); + assert_eq!(playlist.play_items.len(), 3); + assert_eq!(playlist.play_items[0].clip_id, "00001"); + assert_eq!(playlist.play_items[0].connection_condition, 1); + assert_eq!(playlist.play_items[1].clip_id, "00002"); + assert_eq!(playlist.play_items[1].connection_condition, 5); + assert_eq!(playlist.play_items[1].in_time, 4500000); + assert_eq!(playlist.play_items[2].clip_id, "00003"); + assert_eq!(playlist.play_items[2].connection_condition, 6); + assert_eq!(playlist.play_items[2].out_time, 13500000); + } + + #[test] + fn parse_secondary_streams() { + // Primary video + let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); + // Secondary audio (stream_type 5): build as audio, parser overrides type to 5 + let sec_audio_se = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"eng"); + // Need ref bytes after secondary audio: num_refs(1) + reserved(1) = 2 bytes min + let mut sec_audio_with_refs = sec_audio_se; + sec_audio_with_refs.push(0); // num_refs = 0 + sec_audio_with_refs.push(0); // reserved + + // Secondary video (stream_type 6): build as video, parser overrides type to 6 + let sec_video_se = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None); + // Need ref bytes: n_arefs(1) + reserved(1) + n_prefs(1) + reserved(1) = 4 bytes + let mut sec_video_with_refs = sec_video_se; + sec_video_with_refs.push(0); // n_arefs = 0 + sec_video_with_refs.push(0); // reserved + sec_video_with_refs.push(0); // n_prefs = 0 + sec_video_with_refs.push(0); // reserved + + let data = build_mpls( + &[(b"00001", 1, 0, 9000000)], + (1, 0, 0, 0, 1, 1, 0, 0), // 1 video, 0 audio, 0 pg, 0 ig, 1 sec_audio, 1 sec_video + &[video, sec_audio_with_refs, sec_video_with_refs], + ); + + let playlist = parse(&data).expect("should parse secondary streams"); + // Should have 3 streams: primary video, secondary audio, secondary video + assert_eq!(playlist.streams.len(), 3); + + // Primary video + assert_eq!(playlist.streams[0].stream_type, 1); + assert!(!playlist.streams[0].secondary); + + // Secondary audio + assert_eq!(playlist.streams[1].stream_type, 5); + assert!(playlist.streams[1].secondary); + assert_eq!(playlist.streams[1].pid, 0x1A00); + + // Secondary video + assert_eq!(playlist.streams[2].stream_type, 6); + assert!(playlist.streams[2].secondary); + assert_eq!(playlist.streams[2].pid, 0x1B00); + } +} diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index bbea0cf..914bd6b 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -47,3 +47,137 @@ fn find_ac3_sync(data: &[u8]) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec, pts: Option) -> PesPacket { + PesPacket { pid: 0x1100, pts, dts: None, data } + } + + // --- syncword detection --- + + #[test] + fn find_ac3_sync_at_start() { + let data = [0x0B, 0x77, 0x01, 0x02, 0x03]; + assert_eq!(find_ac3_sync(&data), Some(0)); + } + + #[test] + fn find_ac3_sync_with_garbage_prefix() { + let data = [0xFF, 0xFE, 0x0B, 0x77, 0x01, 0x02]; + assert_eq!(find_ac3_sync(&data), Some(2)); + } + + #[test] + fn find_ac3_sync_none() { + let data = [0x0B, 0x78, 0x00, 0x00]; + assert_eq!(find_ac3_sync(&data), None); + } + + #[test] + fn find_ac3_sync_empty() { + let data: [u8; 0] = []; + assert_eq!(find_ac3_sync(&data), None); + } + + // --- parse syncword → frame extracted --- + + #[test] + fn parse_syncword() { + let mut parser = Ac3Parser::new(); + + // AC3 frame starting with syncword + let data = vec![0x0B, 0x77, 0x44, 0x55, 0x66, 0x77, 0x88]; + let pes = make_pes(data.clone(), Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data, data); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + + #[test] + fn parse_syncword_with_garbage_prefix() { + let mut parser = Ac3Parser::new(); + + // Garbage bytes before syncword + let data = vec![0xFF, 0xFE, 0x0B, 0x77, 0x44, 0x55]; + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + // Data should start from the syncword + assert_eq!(frames[0].data[0], 0x0B); + assert_eq!(frames[0].data[1], 0x77); + assert_eq!(frames[0].data.len(), 4); // syncword + 2 payload bytes + } + + // --- all frames are keyframes --- + + #[test] + fn all_keyframes() { + let mut parser = Ac3Parser::new(); + + for i in 0..5 { + let data = vec![0x0B, 0x77, 0x00, i]; + let pes = make_pes(data, Some(90000 * i as i64)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "AC3 frame {} should be a keyframe", i); + } + } + + // --- codec_private is None --- + + #[test] + fn codec_private_none() { + let parser = Ac3Parser::new(); + assert!(parser.codec_private().is_none()); + } + + // --- empty / too-short PES --- + + #[test] + fn parse_empty_pes() { + let mut parser = Ac3Parser::new(); + let pes = make_pes(Vec::new(), Some(0)); + let frames = parser.parse(&pes); + assert!(frames.is_empty()); + } + + #[test] + fn parse_single_byte_pes() { + let mut parser = Ac3Parser::new(); + let pes = make_pes(vec![0x0B], Some(0)); + let frames = parser.parse(&pes); + assert!(frames.is_empty()); + } + + // --- PTS conversion --- + + #[test] + fn pts_conversion() { + let mut parser = Ac3Parser::new(); + let data = vec![0x0B, 0x77, 0x00, 0x01]; + // 45000 ticks = 0.5 seconds → 500_000_000 ns + let pes = make_pes(data, Some(45000)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].pts_ns, 500_000_000); + } + + // --- None PTS --- + + #[test] + fn no_pts() { + let mut parser = Ac3Parser::new(); + let data = vec![0x0B, 0x77, 0x00, 0x01]; + let pes = make_pes(data, None); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].pts_ns, 0); + } +} diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index 3b4e40b..e5469a2 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -24,3 +24,60 @@ impl CodecParser for DtsParser { fn codec_private(&self) -> Option> { None } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec, pts: Option) -> PesPacket { + PesPacket { pid: 0x1100, pts, dts: None, data } + } + + #[test] + fn parse_basic_frame() { + let mut parser = DtsParser::new(); + // DTS core syncword: 7F FE 80 01 + payload + let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC]; + let pes = make_pes(data.clone(), Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data, data); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + + #[test] + fn all_keyframes() { + let mut parser = DtsParser::new(); + for i in 0..3 { + let data = vec![0x7F, 0xFE, 0x80, 0x01, i]; + let pes = make_pes(data, Some(90000 * i as i64)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "DTS frame should always be keyframe"); + } + } + + #[test] + fn codec_private_none() { + let parser = DtsParser::new(); + assert!(parser.codec_private().is_none()); + } + + #[test] + fn parse_empty_pes() { + let mut parser = DtsParser::new(); + let pes = make_pes(Vec::new(), Some(0)); + assert!(parser.parse(&pes).is_empty()); + } + + #[test] + fn no_pts() { + let mut parser = DtsParser::new(); + let pes = make_pes(vec![0x7F, 0xFE, 0x80, 0x01], None); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].pts_ns, 0); + } +} diff --git a/src/mux/codec/h264.rs b/src/mux/codec/h264.rs index 3a916de..afb23f3 100644 --- a/src/mux/codec/h264.rs +++ b/src/mux/codec/h264.rs @@ -189,3 +189,253 @@ pub fn skip_start_code(data: &[u8], pos: usize) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec, pts: Option) -> PesPacket { + PesPacket { pid: 0x1011, pts, dts: None, data } + } + + // --- find_start_code tests --- + + #[test] + fn find_start_code_3byte() { + let data = [0x00, 0x00, 0x01, 0x65]; + assert_eq!(find_start_code(&data, 0), Some(0)); + } + + #[test] + fn find_start_code_4byte() { + let data = [0x00, 0x00, 0x00, 0x01, 0x65]; + // find_start_code looks for 00 00 01 pattern, which starts at offset 1 in a 4-byte start code + assert_eq!(find_start_code(&data, 0), Some(1)); + } + + #[test] + fn find_start_code_offset() { + let data = [0xFF, 0xFF, 0x00, 0x00, 0x01, 0x09]; + assert_eq!(find_start_code(&data, 0), Some(2)); + } + + #[test] + fn find_start_code_none() { + let data = [0x00, 0x00, 0x00, 0x00]; + assert_eq!(find_start_code(&data, 0), None); + } + + #[test] + fn find_start_code_too_short() { + let data = [0x00, 0x00]; + assert_eq!(find_start_code(&data, 0), None); + } + + // --- parse SPS+PPS → codec_private --- + + #[test] + fn parse_sps_pps() { + let mut parser = H264Parser::new(); + + // Build PES with SPS (type 7) + PPS (type 8) + IDR slice (type 5) + // SPS NAL: 0x67 = 0_11_00111 (nal_type = 7), followed by profile/compat/level + payload + // PPS NAL: 0x68 = 0_11_01000 (nal_type = 8) + let mut data = Vec::new(); + // SPS: 00 00 01 [67 42 00 1E ] + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x67); // SPS + data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB, 0xCD]); // profile=0x42, compat=0x00, level=0x1E + // PPS: 00 00 01 [68 ] + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x68); // PPS + data.extend_from_slice(&[0xCE, 0x01]); + // IDR slice: 00 00 01 [65 ] + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x65); // IDR + data.extend_from_slice(&[0x88, 0x00, 0x10]); + + let pes = make_pes(data, Some(90000)); + let frames = parser.parse(&pes); + + // codec_private should now be available + let cp = parser.codec_private(); + assert!(cp.is_some(), "codec_private should be Some after seeing SPS+PPS"); + let cp = cp.unwrap(); + + // AVCDecoderConfigurationRecord checks + assert_eq!(cp[0], 1, "configurationVersion"); + assert_eq!(cp[1], 0x42, "profile from SPS[1]"); + assert_eq!(cp[2], 0x00, "compatibility from SPS[2]"); + assert_eq!(cp[3], 0x1E, "level from SPS[3]"); + assert_eq!(cp[4], 0xFF, "reserved + lengthSizeMinusOne=3"); + assert_eq!(cp[5], 0xE1, "reserved + numSPS=1"); + + // Frames should have been produced + assert_eq!(frames.len(), 1); + } + + #[test] + fn codec_private_none_before_sps_pps() { + let parser = H264Parser::new(); + assert!(parser.codec_private().is_none()); + } + + // --- IDR keyframe detection --- + + #[test] + fn parse_idr_keyframe() { + let mut parser = H264Parser::new(); + + // PES with IDR NAL (type 5 = 0x65) + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x65); // IDR slice (nal_type = 5) + data.extend_from_slice(&[0x88, 0x00, 0x10, 0x20]); + + let pes = make_pes(data, Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "IDR slice should be detected as keyframe"); + } + + // --- non-IDR → not keyframe --- + + #[test] + fn parse_non_idr() { + let mut parser = H264Parser::new(); + + // PES with non-IDR slice (type 1 = 0x61 or 0x41) + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x41); // non-IDR coded slice (nal_type = 1) + data.extend_from_slice(&[0x9A, 0x00, 0x10]); + + let pes = make_pes(data, Some(180000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert!(!frames[0].keyframe, "non-IDR slice should not be keyframe"); + } + + // --- length prefix conversion --- + + #[test] + fn length_prefix_conversion() { + let mut parser = H264Parser::new(); + + // PES with a single non-IDR NAL + let nal_payload = [0x41, 0xAA, 0xBB, 0xCC, 0xDD]; // type 1, 5 bytes + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&nal_payload); + + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + let frame_data = &frames[0].data; + + // Should start with 4-byte big-endian length prefix + assert!(frame_data.len() >= 4, "frame data should have length prefix"); + let length = u32::from_be_bytes([frame_data[0], frame_data[1], frame_data[2], frame_data[3]]); + assert_eq!(length as usize, nal_payload.len(), "length prefix should match NAL size"); + + // Followed by the NAL data itself + assert_eq!(&frame_data[4..], &nal_payload); + + // No start code (00 00 01) should appear in the output + for i in 0..frame_data.len().saturating_sub(2) { + let is_sc = frame_data[i] == 0x00 && frame_data[i + 1] == 0x00 && frame_data[i + 2] == 0x01; + assert!(!is_sc, "output should not contain Annex B start codes"); + } + } + + // --- SPS/PPS/AUD are stripped from frame data --- + + #[test] + fn sps_pps_aud_stripped_from_frame_data() { + let mut parser = H264Parser::new(); + + let mut data = Vec::new(); + // AUD (type 9) + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x09); + data.push(0xF0); + // SPS (type 7) + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x67); + data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB]); + // PPS (type 8) + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x68); + data.extend_from_slice(&[0xCE, 0x01]); + // IDR (type 5) - only this should appear in frame data + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x65); + data.extend_from_slice(&[0x88, 0x00]); + + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + + // Frame data should only contain the IDR NAL (length-prefixed) + let fd = &frames[0].data; + let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]); + // IDR NAL is 0x65, 0x88 (trailing 0x00 is stripped as potential start code prefix) + assert_eq!(length, 2); + assert_eq!(fd[4], 0x65); // IDR NAL type byte + } + + // --- PTS conversion --- + + #[test] + fn pts_conversion() { + let mut parser = H264Parser::new(); + + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x41); + data.extend_from_slice(&[0x00, 0x10]); + + // PTS = 90000 (1 second at 90kHz) → 1_000_000_000 ns + let pes = make_pes(data, Some(90000)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + + // --- empty PES --- + + #[test] + fn parse_empty_pes() { + let mut parser = H264Parser::new(); + let pes = make_pes(Vec::new(), Some(0)); + let frames = parser.parse(&pes); + assert!(frames.is_empty()); + } + + // --- DTS preferred over PTS when present --- + + #[test] + fn dts_preferred_over_pts() { + let mut parser = H264Parser::new(); + + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.push(0x41); + data.extend_from_slice(&[0x00, 0x10]); + + let pes = PesPacket { + pid: 0x1011, + pts: Some(180000), // 2 seconds + dts: Some(90000), // 1 second + data, + }; + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + // DTS should be used, not PTS + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } +} diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index 971312c..da061c0 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -169,3 +169,259 @@ impl CodecParser for HevcParser { Some(record) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec, pts: Option) -> PesPacket { + PesPacket { pid: 0x1011, pts, dts: None, data } + } + + /// Build an HEVC NAL header (2 bytes). Type is bits 1-6 of first byte. + /// Format: forbidden(1) | type(6) | layer_id_high(1) || layer_id_low(5) | tid(3) + fn hevc_nal_header(nal_type: u8) -> [u8; 2] { + [(nal_type & 0x3F) << 1, 0x01] // tid=1 + } + + // --- VPS+SPS+PPS → codec_private --- + + #[test] + fn parse_vps_sps_pps() { + let mut parser = HevcParser::new(); + + let mut data = Vec::new(); + // VPS (type 32) + data.extend_from_slice(&[0x00, 0x00, 0x01]); + let vps_hdr = hevc_nal_header(32); + data.extend_from_slice(&vps_hdr); + data.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // VPS payload + + // SPS (type 33) + data.extend_from_slice(&[0x00, 0x00, 0x01]); + let sps_hdr = hevc_nal_header(33); + data.extend_from_slice(&sps_hdr); + data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D]); // SPS payload (>12 bytes for level) + + // PPS (type 34) + data.extend_from_slice(&[0x00, 0x00, 0x01]); + let pps_hdr = hevc_nal_header(34); + data.extend_from_slice(&pps_hdr); + data.extend_from_slice(&[0xDD, 0xEE]); // PPS payload + + // IRAP slice (type 19 = IDR_W_RADL) so a frame is emitted + data.extend_from_slice(&[0x00, 0x00, 0x01]); + let idr_hdr = hevc_nal_header(19); + data.extend_from_slice(&idr_hdr); + data.extend_from_slice(&[0x10, 0x20, 0x30]); + + let pes = make_pes(data, Some(90000)); + let _frames = parser.parse(&pes); + + let cp = parser.codec_private(); + assert!(cp.is_some(), "codec_private should be Some after VPS+SPS+PPS"); + + let cp = cp.unwrap(); + // configurationVersion = 1 + assert_eq!(cp[0], 1); + // numOfArrays = 3 (VPS, SPS, PPS) + assert_eq!(cp[22], 3); + // Should be longer than the minimal header (23 bytes) + array entries + assert!(cp.len() > 23, "codec_private should contain VPS+SPS+PPS data"); + } + + #[test] + fn codec_private_none_before_params() { + let parser = HevcParser::new(); + assert!(parser.codec_private().is_none()); + } + + #[test] + fn codec_private_none_missing_pps() { + let mut parser = HevcParser::new(); + + // Only VPS + SPS, no PPS + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(32)); + data.extend_from_slice(&[0xAA, 0xBB]); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(33)); + data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]); + // Add a slice so parse doesn't return empty + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R + data.extend_from_slice(&[0x10, 0x20]); + + let pes = make_pes(data, Some(0)); + parser.parse(&pes); + assert!(parser.codec_private().is_none(), "should be None without PPS"); + } + + // --- IRAP keyframe detection --- + + #[test] + fn parse_irap_keyframe_idr_w_radl() { + let mut parser = HevcParser::new(); + + let mut data = Vec::new(); + // IDR_W_RADL = type 19 + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(19)); + data.extend_from_slice(&[0x10, 0x20, 0x30]); + + let pes = make_pes(data, Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "IDR_W_RADL (type 19) should be keyframe"); + } + + #[test] + fn parse_irap_keyframe_bla() { + let mut parser = HevcParser::new(); + + // BLA_W_LP = type 16 + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(16)); + data.extend_from_slice(&[0x10, 0x20]); + + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "BLA_W_LP (type 16) should be keyframe"); + } + + #[test] + fn parse_irap_keyframe_cra() { + let mut parser = HevcParser::new(); + + // CRA_NUT = type 21 + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(21)); + data.extend_from_slice(&[0x10, 0x20]); + + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "CRA (type 21) should be keyframe"); + } + + #[test] + fn parse_irap_type_23() { + let mut parser = HevcParser::new(); + + // RSV_IRAP_VCL23 = type 23 (upper boundary) + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(23)); + data.extend_from_slice(&[0x10, 0x20]); + + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "type 23 should be keyframe"); + } + + // --- non-IRAP (trailing) → not keyframe --- + + #[test] + fn parse_trailing_not_keyframe() { + let mut parser = HevcParser::new(); + + // TRAIL_R = type 1 + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(1)); + data.extend_from_slice(&[0x10, 0x20, 0x30]); + + let pes = make_pes(data, Some(180000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert!(!frames[0].keyframe, "TRAIL_R (type 1) should not be keyframe"); + } + + #[test] + fn parse_tsa_not_keyframe() { + let mut parser = HevcParser::new(); + + // TSA_N = type 2 + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(2)); + data.extend_from_slice(&[0x10, 0x20]); + + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(!frames[0].keyframe, "TSA_N (type 2) should not be keyframe"); + } + + // --- VPS/SPS/PPS stripped from frame data --- + + #[test] + fn param_sets_stripped_from_frame() { + let mut parser = HevcParser::new(); + + let mut data = Vec::new(); + // VPS + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(32)); + data.extend_from_slice(&[0xAA]); + // SPS + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(33)); + data.extend_from_slice(&[0xBB]); + // PPS + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(34)); + data.extend_from_slice(&[0xCC]); + // IDR slice + data.extend_from_slice(&[0x00, 0x00, 0x01]); + let idr_hdr = hevc_nal_header(19); + data.extend_from_slice(&idr_hdr); + data.extend_from_slice(&[0x10, 0x20]); + + let pes = make_pes(data, Some(0)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + + // Frame data should only have the IDR NAL (length-prefixed) + let fd = &frames[0].data; + let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]); + // IDR NAL = 2 bytes header + 2 bytes payload = 4 bytes + assert_eq!(length as usize + 4, fd.len(), "frame should contain exactly one length-prefixed NAL"); + } + + // --- empty PES --- + + #[test] + fn parse_empty_pes() { + let mut parser = HevcParser::new(); + let pes = make_pes(Vec::new(), Some(0)); + let frames = parser.parse(&pes); + assert!(frames.is_empty()); + } + + // --- PTS conversion --- + + #[test] + fn pts_conversion() { + let mut parser = HevcParser::new(); + + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(1)); + data.extend_from_slice(&[0x10, 0x20]); + + let pes = make_pes(data, Some(90000)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } +} diff --git a/src/mux/codec/pgs.rs b/src/mux/codec/pgs.rs index 9da6b7a..3f46b66 100644 --- a/src/mux/codec/pgs.rs +++ b/src/mux/codec/pgs.rs @@ -23,3 +23,51 @@ impl CodecParser for PgsParser { fn codec_private(&self) -> Option> { None } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec, pts: Option) -> PesPacket { + PesPacket { pid: 0x1200, pts, dts: None, data } + } + + #[test] + fn parse_basic_segment() { + let mut parser = PgsParser::new(); + // PGS segment data (PCS = presentation composition segment) + let data = vec![0x16, 0x00, 0x00, 0x11, 0x01, 0x02, 0x03]; + let pes = make_pes(data.clone(), Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data, data); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + + #[test] + fn all_keyframes() { + let mut parser = PgsParser::new(); + for i in 0..3 { + let data = vec![0x16, 0x00, i]; + let pes = make_pes(data, Some(90000 * i as i64)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "PGS segment should always be keyframe"); + } + } + + #[test] + fn codec_private_none() { + let parser = PgsParser::new(); + assert!(parser.codec_private().is_none()); + } + + #[test] + fn parse_empty_pes() { + let mut parser = PgsParser::new(); + let pes = make_pes(Vec::new(), Some(0)); + assert!(parser.parse(&pes).is_empty()); + } +} diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index fe34443..b5b3d67 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -25,3 +25,51 @@ impl CodecParser for TrueHdParser { fn codec_private(&self) -> Option> { None } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec, pts: Option) -> PesPacket { + PesPacket { pid: 0x1100, pts, dts: None, data } + } + + #[test] + fn parse_basic_frame() { + let mut parser = TrueHdParser::new(); + // TrueHD major sync: F8 72 6F BA (at 4-byte aligned position) + payload + let data = vec![0xF8, 0x72, 0x6F, 0xBA, 0x01, 0x02, 0x03, 0x04]; + let pes = make_pes(data.clone(), Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].data, data); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + + #[test] + fn all_keyframes() { + let mut parser = TrueHdParser::new(); + for i in 0..3 { + let data = vec![0xF8, 0x72, 0x6F, 0xBA, i]; + let pes = make_pes(data, Some(90000 * i as i64)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert!(frames[0].keyframe, "TrueHD frame should always be keyframe"); + } + } + + #[test] + fn codec_private_none() { + let parser = TrueHdParser::new(); + assert!(parser.codec_private().is_none()); + } + + #[test] + fn parse_empty_pes() { + let mut parser = TrueHdParser::new(); + let pes = make_pes(Vec::new(), Some(0)); + assert!(parser.parse(&pes).is_empty()); + } +} diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs index 984542c..e76f77f 100644 --- a/src/mux/codec/vc1.rs +++ b/src/mux/codec/vc1.rs @@ -120,3 +120,225 @@ fn find_next_sc(data: &[u8], from: usize) -> Option { } None } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mux::ts::PesPacket; + + fn make_pes(data: Vec, pts: Option) -> PesPacket { + PesPacket { pid: 0x1011, pts, dts: None, data } + } + + /// Build a VC-1 PES with sequence header + entry point + frame start code. + fn build_vc1_iframe_pes() -> Vec { + let mut data = Vec::new(); + // Sequence header: 00 00 01 0F + payload + data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]); + data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + // Entry point: 00 00 01 0E + payload + data.extend_from_slice(&[0x00, 0x00, 0x01, SC_ENTRY_POINT]); + data.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]); + // Frame: 00 00 01 0D + payload + data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]); + data.extend_from_slice(&[0x55, 0x66, 0x77, 0x88, 0x99]); + data + } + + // --- sequence header detection --- + + #[test] + fn parse_sequence_header() { + let mut parser = Vc1Parser::new(); + + let data = build_vc1_iframe_pes(); + let pes = make_pes(data, Some(90000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + // Sequence header present → keyframe + assert!(frames[0].keyframe, "PES with sequence header should be keyframe"); + // seq_header should be stored internally + assert!(parser.seq_header.is_some()); + } + + #[test] + fn parse_entry_point() { + let mut parser = Vc1Parser::new(); + + let data = build_vc1_iframe_pes(); + let pes = make_pes(data, Some(0)); + parser.parse(&pes); + + assert!(parser.entry_point.is_some()); + } + + // --- codec_private is BITMAPINFOHEADER (40+ bytes) --- + + #[test] + fn codec_private_bitmapinfoheader() { + let mut parser = Vc1Parser::new(); + + let data = build_vc1_iframe_pes(); + let pes = make_pes(data, Some(0)); + parser.parse(&pes); + + let cp = parser.codec_private(); + assert!(cp.is_some(), "codec_private should be Some after seq header + entry point"); + + let cp = cp.unwrap(); + // BITMAPINFOHEADER is 40 bytes + extra data + assert!(cp.len() >= 40, "codec_private should be at least 40 bytes (BITMAPINFOHEADER)"); + + // biSize (first 4 bytes, little-endian) should equal total length + let bi_size = u32::from_le_bytes([cp[0], cp[1], cp[2], cp[3]]); + assert_eq!(bi_size as usize, cp.len(), "biSize should match total codec_private length"); + + // biCompression = "WVC1" at offset 16 + assert_eq!(&cp[16..20], b"WVC1", "FOURCC should be WVC1"); + + // biWidth at offset 4 (little-endian u32) = 1920 + let width = u32::from_le_bytes([cp[4], cp[5], cp[6], cp[7]]); + assert_eq!(width, 1920); + + // biHeight at offset 8 (little-endian u32) = 1080 + let height = u32::from_le_bytes([cp[8], cp[9], cp[10], cp[11]]); + assert_eq!(height, 1080); + } + + #[test] + fn codec_private_none_before_data() { + let parser = Vc1Parser::new(); + assert!(parser.codec_private().is_none()); + } + + #[test] + fn codec_private_none_missing_entry_point() { + let mut parser = Vc1Parser::new(); + + // Only sequence header, no entry point + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]); + data.extend_from_slice(&[0xAA, 0xBB, 0xCC]); + data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]); + data.extend_from_slice(&[0x55, 0x66]); + + let pes = make_pes(data, Some(0)); + parser.parse(&pes); + + assert!(parser.codec_private().is_none(), "should be None without entry point"); + } + + // --- frame without sequence header → not keyframe --- + + #[test] + fn parse_non_keyframe() { + let mut parser = Vc1Parser::new(); + + // PES with only a frame start code (no sequence header) + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]); + data.extend_from_slice(&[0x55, 0x66, 0x77]); + + let pes = make_pes(data, Some(180000)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + assert!(!frames[0].keyframe, "frame without sequence header should not be keyframe"); + } + + // --- frame data starts from frame start code --- + + #[test] + fn frame_data_starts_at_frame_sc() { + let mut parser = Vc1Parser::new(); + + let data = build_vc1_iframe_pes(); + let pes = make_pes(data.clone(), Some(0)); + let frames = parser.parse(&pes); + + assert_eq!(frames.len(), 1); + // Frame data should start with the frame start code (00 00 01 0D) + assert!(frames[0].data.len() >= 4); + assert_eq!(&frames[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]); + } + + // --- empty PES --- + + #[test] + fn parse_empty_pes() { + let mut parser = Vc1Parser::new(); + let pes = make_pes(Vec::new(), Some(0)); + let frames = parser.parse(&pes); + assert!(frames.is_empty()); + } + + // --- PTS conversion --- + + #[test] + fn pts_conversion() { + let mut parser = Vc1Parser::new(); + + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]); + data.extend_from_slice(&[0x55, 0x66]); + + let pes = make_pes(data, Some(90000)); + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + + // --- DTS preferred over PTS --- + + #[test] + fn dts_preferred_over_pts() { + let mut parser = Vc1Parser::new(); + + let mut data = Vec::new(); + data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]); + data.extend_from_slice(&[0x55, 0x66]); + + let pes = PesPacket { + pid: 0x1011, + pts: Some(180000), + dts: Some(90000), + data, + }; + let frames = parser.parse(&pes); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].pts_ns, 1_000_000_000); + } + + // --- find_next_sc utility --- + + #[test] + fn find_next_sc_basic() { + let data = [0xAA, 0x00, 0x00, 0x01, 0x0D, 0xBB]; + assert_eq!(find_next_sc(&data, 0), Some(1)); + } + + #[test] + fn find_next_sc_none() { + let data = [0xAA, 0xBB, 0xCC]; + assert_eq!(find_next_sc(&data, 0), None); + } + + // --- codec_private extra data contains seq header + entry point --- + + #[test] + fn codec_private_contains_extra_data() { + let mut parser = Vc1Parser::new(); + + let data = build_vc1_iframe_pes(); + let pes = make_pes(data, Some(0)); + parser.parse(&pes); + + let cp = parser.codec_private().unwrap(); + // After the 40-byte BITMAPINFOHEADER, we should have seq_header + entry_point data + let extra = &cp[40..]; + assert!(!extra.is_empty(), "extra data after BITMAPINFOHEADER should not be empty"); + // Extra data should start with the sequence header start code + assert_eq!(&extra[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]); + } +} diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index d567918..e050a2b 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -413,4 +413,125 @@ mod tests { // EBML header: 1A 45 DF A3, then 8-byte size, then content assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]); } + + #[test] + fn write_read_id_roundtrip() { + // 1-byte IDs have high bit set (0x80..=0xFF) + for &id in &[0x80u32, 0xA3, 0xFF] { + let mut buf = Vec::new(); + write_id(&mut buf, id).unwrap(); + assert_eq!(buf.len(), 1); + let mut cursor = Cursor::new(&buf); + let (read_back, consumed) = read_id(&mut cursor).unwrap(); + assert_eq!(read_back, id, "1-byte ID roundtrip failed for 0x{:X}", id); + assert_eq!(consumed, 1); + } + // 2-byte IDs (0x4000..=0x7FFF) + for &id in &[0x4286u32, 0x4282, 0x7FFF] { + let mut buf = Vec::new(); + write_id(&mut buf, id).unwrap(); + assert_eq!(buf.len(), 2); + let mut cursor = Cursor::new(&buf); + let (read_back, consumed) = read_id(&mut cursor).unwrap(); + assert_eq!(read_back, id, "2-byte ID roundtrip failed for 0x{:X}", id); + assert_eq!(consumed, 2); + } + // 3-byte IDs (0x200000..=0x3FFFFF) + for &id in &[0x22B59Cu32, 0x23E383] { + let mut buf = Vec::new(); + write_id(&mut buf, id).unwrap(); + assert_eq!(buf.len(), 3); + let mut cursor = Cursor::new(&buf); + let (read_back, consumed) = read_id(&mut cursor).unwrap(); + assert_eq!(read_back, id, "3-byte ID roundtrip failed for 0x{:X}", id); + assert_eq!(consumed, 3); + } + // 4-byte IDs (0x10000000..=0x1FFFFFFF) + for &id in &[EBML, SEGMENT, TRACKS, CLUSTER] { + let mut buf = Vec::new(); + write_id(&mut buf, id).unwrap(); + assert_eq!(buf.len(), 4); + let mut cursor = Cursor::new(&buf); + let (read_back, consumed) = read_id(&mut cursor).unwrap(); + assert_eq!(read_back, id, "4-byte ID roundtrip failed for 0x{:X}", id); + assert_eq!(consumed, 4); + } + } + + #[test] + fn write_read_size_roundtrip() { + let test_sizes: &[u64] = &[0, 1, 0x7E, 127, 128, 0x3FFE, 16383, 16384, 0x1FFFFE, 0x0FFFFFFE, 0x1_0000_0000]; + for &size in test_sizes { + let mut buf = Vec::new(); + write_size(&mut buf, size).unwrap(); + let mut cursor = Cursor::new(&buf); + let (read_back, _consumed) = read_size(&mut cursor).unwrap(); + assert_eq!(read_back, size, "size roundtrip failed for {}", size); + } + } + + #[test] + fn write_read_uint_roundtrip() { + let test_vals: &[u64] = &[0, 1, 127, 255, 256, 0xFFFF, 0xFF_FFFF, 0xFFFF_FFFF, 1_000_000_000_000]; + let test_id = EBML_VERSION; + for &val in test_vals { + let mut buf = Vec::new(); + write_uint(&mut buf, test_id, val).unwrap(); + let mut cursor = Cursor::new(&buf); + let (id, id_len) = read_id(&mut cursor).unwrap(); + assert_eq!(id, test_id); + let (size, _) = read_size(&mut cursor).unwrap(); + let read_val = read_uint_val(&mut cursor, size as usize).unwrap(); + assert_eq!(read_val, val, "uint roundtrip failed for {}", val); + } + } + + #[test] + fn write_read_string_roundtrip() { + let test_strings = &["", "matroska", "freemkv", "Hello, World!", "unicode: \u{1F600}"]; + let test_id = EBML_DOC_TYPE; + for &s in test_strings { + let mut buf = Vec::new(); + write_string(&mut buf, test_id, s).unwrap(); + let mut cursor = Cursor::new(&buf); + let (id, _) = read_id(&mut cursor).unwrap(); + assert_eq!(id, test_id); + let (size, _) = read_size(&mut cursor).unwrap(); + let read_s = read_string_val(&mut cursor, size as usize).unwrap(); + assert_eq!(read_s, s, "string roundtrip failed for {:?}", s); + } + } + + #[test] + fn write_read_float_roundtrip() { + let test_vals: &[f64] = &[0.0, 1.0, -1.0, 3.14159265358979, 48000.0, 7200000.0, f64::MIN, f64::MAX]; + let test_id = DURATION; + for &val in test_vals { + let mut buf = Vec::new(); + write_float(&mut buf, test_id, val).unwrap(); + let mut cursor = Cursor::new(&buf); + let (id, _) = read_id(&mut cursor).unwrap(); + assert_eq!(id, test_id); + let (size, _) = read_size(&mut cursor).unwrap(); + assert_eq!(size, 8); + let read_val = read_float_val(&mut cursor, size as usize).unwrap(); + assert_eq!(read_val.to_bits(), val.to_bits(), "float roundtrip failed for {}", val); + } + } + + #[test] + fn unknown_size() { + let mut buf = Vec::new(); + write_unknown_size(&mut buf).unwrap(); + assert_eq!(buf.len(), 8); + assert_eq!(buf[0], 0x01); + for &b in &buf[1..] { + assert_eq!(b, 0xFF, "unknown size bytes should all be 0xFF after first byte"); + } + // Reading it back should yield u64::MAX + let mut cursor = Cursor::new(&buf); + let (size, consumed) = read_size(&mut cursor).unwrap(); + assert_eq!(size, u64::MAX); + assert_eq!(consumed, 8); + } } diff --git a/src/mux/iso.rs b/src/mux/iso.rs index e53974b..d245e13 100644 --- a/src/mux/iso.rs +++ b/src/mux/iso.rs @@ -198,6 +198,81 @@ impl Read for IsoStream { } } +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use crate::sector::SectorReader; + + #[test] + fn iso_reader_read_sectors() { + // Create a temp file with known sector data + let dir = std::env::temp_dir(); + let path = dir.join("libfreemkv_test_iso_sectors.iso"); + let path_str = path.to_str().unwrap(); + + // Write 4 sectors of known data + { + let mut f = File::create(&path).unwrap(); + for sector_idx in 0u8..4 { + let mut sector = [sector_idx; SECTOR_SIZE as usize]; + sector[0] = sector_idx; + sector[2047] = sector_idx.wrapping_mul(0x37); + f.write_all(§or).unwrap(); + } + f.flush().unwrap(); + } + + let mut reader = IsoSectorReader::open(path_str).unwrap(); + assert_eq!(reader.capacity(), 4); + + // Read sector 0 + let mut buf = [0u8; SECTOR_SIZE as usize]; + let n = reader.read_sectors(0, 1, &mut buf).unwrap(); + assert_eq!(n, SECTOR_SIZE as usize); + assert_eq!(buf[0], 0); + assert_eq!(buf[2047], 0u8.wrapping_mul(0x37)); + + // Read sector 2 + let n = reader.read_sectors(2, 1, &mut buf).unwrap(); + assert_eq!(n, SECTOR_SIZE as usize); + assert_eq!(buf[0], 2); + assert_eq!(buf[1], 2); // filled with sector_idx + assert_eq!(buf[2047], 2u8.wrapping_mul(0x37)); + + // Read 2 sectors at once (sectors 1 and 2) + let mut buf2 = [0u8; SECTOR_SIZE as usize * 2]; + let n = reader.read_sectors(1, 2, &mut buf2).unwrap(); + assert_eq!(n, SECTOR_SIZE as usize * 2); + assert_eq!(buf2[0], 1); // sector 1 first byte + assert_eq!(buf2[SECTOR_SIZE as usize], 2); // sector 2 first byte + + // Clean up + let _ = std::fs::remove_file(&path); + } + + #[test] + fn iso_reader_capacity() { + let dir = std::env::temp_dir(); + let path = dir.join("libfreemkv_test_iso_capacity.iso"); + let path_str = path.to_str().unwrap(); + + // Write exactly 10 sectors + { + let mut f = File::create(&path).unwrap(); + let data = vec![0u8; SECTOR_SIZE as usize * 10]; + f.write_all(&data).unwrap(); + f.flush().unwrap(); + } + + let reader = IsoSectorReader::open(path_str).unwrap(); + assert_eq!(reader.capacity(), 10); + + // Clean up + let _ = std::fs::remove_file(&path); + } +} + impl Write for IsoStream { fn write(&mut self, buf: &[u8]) -> io::Result { match self.writer.as_mut() { diff --git a/src/mux/null.rs b/src/mux/null.rs index 7623f52..fae4ce9 100644 --- a/src/mux/null.rs +++ b/src/mux/null.rs @@ -41,3 +41,48 @@ impl Read for NullStream { Err(io::Error::new(io::ErrorKind::Unsupported, "null stream is write-only")) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn null_counts_bytes() { + let mut ns = NullStream::new(); + assert_eq!(ns.bytes_written(), 0); + ns.write_all(&[0u8; 100]).unwrap(); + assert_eq!(ns.bytes_written(), 100); + ns.write_all(&[1u8; 50]).unwrap(); + assert_eq!(ns.bytes_written(), 150); + // Single write returns correct count + let n = ns.write(&[0u8; 200]).unwrap(); + assert_eq!(n, 200); + assert_eq!(ns.bytes_written(), 350); + } + + #[test] + fn null_read_errors() { + let mut ns = NullStream::new(); + let mut buf = [0u8; 10]; + let err = ns.read(&mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::Unsupported); + } + + #[test] + fn null_finish_ok() { + let mut ns = NullStream::new(); + ns.write_all(&[0u8; 1000]).unwrap(); + ns.finish().unwrap(); + } + + #[test] + fn null_implements_iostream() { + let ns = NullStream::new(); + let mut boxed: Box = Box::new(ns); + boxed.write_all(&[0u8; 50]).unwrap(); + let info = boxed.info(); + assert_eq!(info.streams.len(), 0); + boxed.finish().unwrap(); + } +} diff --git a/src/mux/stdio.rs b/src/mux/stdio.rs index 5576d01..1f69d6e 100644 --- a/src/mux/stdio.rs +++ b/src/mux/stdio.rs @@ -75,3 +75,26 @@ impl Write for StdioStream { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + + #[test] + fn stdio_output_write_errors_on_read() { + let mut stream = StdioStream::output(); + let mut buf = [0u8; 10]; + let err = stream.read(&mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::Unsupported); + assert!(err.to_string().contains("cannot read"), "got: {}", err); + } + + #[test] + fn stdio_input_read_errors_on_write() { + let mut stream = StdioStream::input(); + let err = stream.write(&[0u8; 10]).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::Unsupported); + assert!(err.to_string().contains("cannot write"), "got: {}", err); + } +} diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs new file mode 100644 index 0000000..0b35adf --- /dev/null +++ b/tests/disc_tests.rs @@ -0,0 +1,131 @@ +//! Disc scanning pipeline tests. + +use std::collections::HashMap; +use libfreemkv::error::Result; +use libfreemkv::sector::SectorReader; +use libfreemkv::{Disc, DiscTitle, ScanOptions}; + +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 SectorReader for MockSectorReader { + fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> 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() { + let opts = ScanOptions::default(); + assert!(opts.keydb_path.is_none()); +} + +#[test] +fn scan_options_with_keydb() { + let opts = ScanOptions::with_keydb("/tmp/KEYDB.cfg"); + assert_eq!( + opts.keydb_path.as_ref().unwrap().to_str().unwrap(), + "/tmp/KEYDB.cfg" + ); +} + +#[test] +fn scan_options_with_keydb_pathbuf() { + let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg"); + let opts = ScanOptions::with_keydb(path.clone()); + assert_eq!(opts.keydb_path.unwrap(), path); +} diff --git a/tests/streams.rs b/tests/streams.rs index 7107fcb..01579fc 100644 --- a/tests/streams.rs +++ b/tests/streams.rs @@ -347,3 +347,185 @@ fn disc_title_empty() { assert_eq!(dt.duration_secs, 0.0); assert!(dt.playlist.is_empty()); } + +// ── Meta codec roundtrip ───────────────────────────────────── + +#[test] +fn meta_codec_roundtrip() { + // Test that all codec types survive from_title -> to_title + let codecs_video = &[Codec::Hevc, Codec::H264, Codec::Vc1, Codec::Mpeg2]; + let codecs_audio = &[Codec::Ac3, Codec::Ac3Plus, Codec::TrueHd, Codec::DtsHdMa, Codec::DtsHdHr, Codec::Dts, Codec::Lpcm]; + let codecs_sub = &[Codec::Pgs]; + + let mut streams = Vec::new(); + for (i, &codec) in codecs_video.iter().enumerate() { + streams.push(Stream::Video(VideoStream { + pid: (0x1011 + i) as u16, codec, + resolution: "1080p".into(), frame_rate: "23.976".into(), + hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, + secondary: false, label: String::new(), + })); + } + for (i, &codec) in codecs_audio.iter().enumerate() { + streams.push(Stream::Audio(AudioStream { + pid: (0x1100 + i) as u16, codec, + channels: "5.1".into(), language: "eng".into(), + sample_rate: "48kHz".into(), secondary: false, + label: String::new(), + })); + } + for (i, &codec) in codecs_sub.iter().enumerate() { + streams.push(Stream::Subtitle(SubtitleStream { + pid: (0x1200 + i) as u16, codec, + language: "eng".into(), forced: false, + })); + } + + let dt = DiscTitle { + playlist: "Codec Test".into(), + playlist_id: 0, + duration_secs: 100.0, + size_bytes: 0, + clips: Vec::new(), + streams, + extents: Vec::new(), + }; + + let meta = M2tsMeta::from_title(&dt); + let restored = meta.to_title(); + + assert_eq!(restored.streams.len(), dt.streams.len()); + for (orig, rest) in dt.streams.iter().zip(restored.streams.iter()) { + match (orig, rest) { + (Stream::Video(o), Stream::Video(r)) => assert_eq!(o.codec, r.codec, "video codec mismatch"), + (Stream::Audio(o), Stream::Audio(r)) => assert_eq!(o.codec, r.codec, "audio codec mismatch"), + (Stream::Subtitle(o), Stream::Subtitle(r)) => assert_eq!(o.codec, r.codec, "subtitle codec mismatch"), + _ => panic!("stream type mismatch"), + } + } +} + +#[test] +fn meta_empty_streams() { + let dt = DiscTitle { + playlist: "Empty".into(), + playlist_id: 0, + duration_secs: 0.0, + size_bytes: 0, + clips: Vec::new(), + streams: Vec::new(), + extents: Vec::new(), + }; + + let meta = M2tsMeta::from_title(&dt); + assert_eq!(meta.streams.len(), 0); + let restored = meta.to_title(); + assert_eq!(restored.streams.len(), 0); + assert_eq!(restored.playlist, "Empty"); +} + +#[test] +fn meta_all_stream_types() { + let dt = DiscTitle { + playlist: "Full".into(), + playlist_id: 0, + 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::Bt709, + secondary: false, label: "Primary".into(), + }), + Stream::Audio(AudioStream { + pid: 0x1100, codec: Codec::TrueHd, + channels: "7.1".into(), language: "eng".into(), + sample_rate: "48kHz".into(), secondary: false, + label: "Primary Audio".into(), + }), + Stream::Subtitle(SubtitleStream { + pid: 0x1200, codec: Codec::Pgs, + language: "fra".into(), forced: true, + }), + Stream::Audio(AudioStream { + pid: 0x1110, codec: Codec::Ac3, + channels: "stereo".into(), language: "eng".into(), + sample_rate: "48kHz".into(), secondary: true, + label: "Commentary".into(), + }), + ], + extents: Vec::new(), + }; + + let meta = M2tsMeta::from_title(&dt); + let restored = meta.to_title(); + + assert_eq!(restored.streams.len(), 4); + + // Video preserved + if let Stream::Video(v) = &restored.streams[0] { + assert_eq!(v.codec, Codec::Hevc); + assert_eq!(v.resolution, "2160p"); + assert_eq!(v.label, "Primary"); + assert!(!v.secondary); + } else { panic!("expected video"); } + + // Primary audio preserved + if let Stream::Audio(a) = &restored.streams[1] { + assert_eq!(a.codec, Codec::TrueHd); + assert_eq!(a.channels, "7.1"); + assert!(!a.secondary); + } else { panic!("expected audio"); } + + // Subtitle preserved (forced flag) + if let Stream::Subtitle(s) = &restored.streams[2] { + assert_eq!(s.language, "fra"); + assert!(s.forced); + } else { panic!("expected subtitle"); } + + // Secondary audio preserved + if let Stream::Audio(a) = &restored.streams[3] { + assert_eq!(a.codec, Codec::Ac3); + assert!(a.secondary); + assert_eq!(a.label, "Commentary"); + } else { panic!("expected secondary audio"); } +} + +// ── MkvStream tests ────────────────────────────────────────── + +#[test] +fn mkvstream_write_finish() { + let output = Cursor::new(Vec::new()); + let dt = sample_disc_title(); + let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024); + + // Write some fake BD-TS packets (they won't produce valid MKV frames + // since there is no real codec data, but it should not panic) + for i in 0..20u8 { + let mut pkt = [0u8; 192]; + pkt[4] = 0x47; + // PID 0x1011 (video) + pkt[5] = 0x10; + pkt[6] = 0x11; + pkt[7] = 0x10; + pkt[8] = i; + stream.write_all(&pkt).unwrap(); + } + + // finish should not panic even without valid codec data + stream.finish().unwrap(); +} + +#[test] +fn mkvstream_meta_sets_title() { + let output = Cursor::new(Vec::new()); + let dt = sample_disc_title(); + let stream = MkvStream::new(output).meta(&dt); + + let info = stream.info(); + assert_eq!(info.playlist, "Test Movie"); + assert_eq!(info.duration_secs, 7200.0); + assert_eq!(info.streams.len(), 4); +} diff --git a/tests/udf_tests.rs b/tests/udf_tests.rs new file mode 100644 index 0000000..e0d2862 --- /dev/null +++ b/tests/udf_tests.rs @@ -0,0 +1,458 @@ +//! UDF parser tests using a MockSectorReader. + +use std::collections::HashMap; +use libfreemkv::error::Result; +use libfreemkv::sector::SectorReader; +use libfreemkv::udf; + +const SECTOR_SIZE: usize = 2048; + +/// In-memory sector reader backed by a HashMap. +/// Any LBA not in the map returns zeroed sectors. +struct MockSectorReader { + sectors: HashMap>, +} + +impl MockSectorReader { + fn new() -> Self { + Self { sectors: HashMap::new() } + } + + /// Write a full 2048-byte sector at the given LBA. + fn set_sector(&mut self, lba: u32, data: Vec) { + assert_eq!(data.len(), SECTOR_SIZE, "sector data must be exactly 2048 bytes"); + self.sectors.insert(lba, data); + } + + /// Write partial data into a sector (rest is zeroed). + fn set_sector_partial(&mut self, lba: u32, data: &[u8]) { + let mut sector = vec![0u8; SECTOR_SIZE]; + let len = data.len().min(SECTOR_SIZE); + sector[..len].copy_from_slice(&data[..len]); + self.sectors.insert(lba, sector); + } +} + +impl SectorReader for MockSectorReader { + fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result { + let total = count as usize * SECTOR_SIZE; + assert!(buf.len() >= total, "buffer too small"); + 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 { + // Return zeros for unmapped sectors + buf[offset..offset + SECTOR_SIZE].fill(0); + } + } + Ok(total) + } +} + +// ── Helper: build raw sector data ────────────────────────────────────────── + +/// 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]; + // Tag ID = 2 (AVDP) at bytes [0..2] + s[0..2].copy_from_slice(&2u16.to_le_bytes()); + // Main VDS extent location at bytes [16..20] + s[16..20].copy_from_slice(&vds_lba.to_le_bytes()); + // Main VDS extent length at bytes [20..24] (arbitrary, say 6 sectors) + s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes()); + s +} + +/// Build a Primary Volume Descriptor (tag_id=1) with the given volume ID. +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()); + // Volume ID at offset 24, d-string format: + // compression_id(1 byte) + ASCII chars + length byte at position 55 + if !volume_id.is_empty() { + let id_bytes = volume_id.as_bytes(); + s[24] = 8; // compression ID = ASCII + let copy_len = id_bytes.len().min(30); + s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]); + // d-string length byte at end of 32-byte field (offset 55) + s[55] = (1 + copy_len) as u8; // compression byte + chars + } + s +} + +/// Build a Partition Descriptor (tag_id=5) with partition_start. +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()); + // Partition start at bytes [188..192] + s[188..192].copy_from_slice(&partition_start.to_le_bytes()); + s +} + +/// Build a Logical Volume Descriptor (tag_id=6) with a single partition map +/// (no metadata partition -- simplest case). +fn make_lvd_sector_simple() -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&6u16.to_le_bytes()); + // num_partition_maps at bytes [268..272] = 1 (no metadata partition) + s[268..272].copy_from_slice(&1u32.to_le_bytes()); + s +} + +/// Build a Terminating Descriptor (tag_id=8). +fn make_terminator() -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&8u16.to_le_bytes()); + s +} + +/// Build a File Set Descriptor (tag_id=256) with root ICB at the given meta LBA. +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()); + // Root directory ICB long_ad at offset 400: + // [400..404] = extent_length (one sector = 2048) + s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); + // [404..408] = extent_location (LBA within metadata partition) + s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes()); + s +} + +/// Build an Extended File Entry (tag_id=266) for a directory with the given +/// allocation extent pointing to directory data. +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()); + // info_length at offset 56 (u64) + s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes()); + // L_EA at offset 208 = 0 (no extended attributes) + s[208..212].copy_from_slice(&0u32.to_le_bytes()); + // L_AD at offset 212 = 8 (one short allocation descriptor) + s[212..216].copy_from_slice(&8u32.to_le_bytes()); + // Short allocation descriptor at offset 216: + // [216..220] = extent_length (type=0 in top 2 bits, length in low 30 bits) + s[216..220].copy_from_slice(&data_len.to_le_bytes()); + // [220..224] = extent_position (LBA within metadata partition) + s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes()); + s +} + +/// Build a File Identifier Descriptor (tag_id=257) for a named entry. +/// Returns raw bytes (not padded to full sector). +fn make_fid(name: &str, icb_meta_lba: u32, is_dir: bool) -> Vec { + // FID name: compression_id(1) + ASCII bytes + let mut name_bytes = vec![8u8]; // compression ID = 8 (ASCII) + 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 }; + + // Fixed header = 38 bytes, L_IU = 0 + let fid_len = ((38 + 0 + l_fi as usize + 3) & !3) as usize; // 4-byte aligned + let mut fid = vec![0u8; fid_len]; + // tag_id = 257 + fid[0..2].copy_from_slice(&257u16.to_le_bytes()); + // file_characteristics at offset 18 + fid[18] = file_chars; + // L_FI at offset 19 + fid[19] = l_fi; + // ICB long_ad at offset 20: + // [20..24] = extent_length (2048) + fid[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes()); + // [24..28] = extent_location (LBA) + fid[24..28].copy_from_slice(&icb_meta_lba.to_le_bytes()); + // L_IU at offset 36 = 0 + fid[36..38].copy_from_slice(&0u16.to_le_bytes()); + // Name starts at offset 38 + fid[38..38 + name_bytes.len()].copy_from_slice(&name_bytes); + fid +} + +/// Build a parent FID (file_chars = 0x08, no name). +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; // parent + fid[19] = 0; // L_FI = 0 + fid +} + +/// Build a File Entry ICB (tag_id=261) for a file, with the given extent. +fn make_file_icb(data_lba: u32, data_len: u32, file_size: u64) -> Vec { + let mut s = vec![0u8; SECTOR_SIZE]; + s[0..2].copy_from_slice(&261u16.to_le_bytes()); + // info_length at offset 56 (u64) + s[56..64].copy_from_slice(&file_size.to_le_bytes()); + // L_EA at offset 168 = 0 + s[168..172].copy_from_slice(&0u32.to_le_bytes()); + // L_AD at offset 172 = 8 + s[172..176].copy_from_slice(&8u32.to_le_bytes()); + // Short allocation descriptor at offset 176: + s[176..180].copy_from_slice(&data_len.to_le_bytes()); + s[180..184].copy_from_slice(&data_lba.to_le_bytes()); + s +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[test] +fn mock_sector_reader_roundtrip() { + let mut reader = MockSectorReader::new(); + + // Write a recognizable pattern to sector 100 + let mut data = vec![0u8; SECTOR_SIZE]; + data[0] = 0xAB; + data[1] = 0xCD; + data[2047] = 0xFF; + reader.set_sector(100, data.clone()); + + // Read it back + let mut buf = vec![0u8; SECTOR_SIZE]; + let n = reader.read_sectors(100, 1, &mut buf).unwrap(); + assert_eq!(n, SECTOR_SIZE); + assert_eq!(buf[0], 0xAB); + assert_eq!(buf[1], 0xCD); + assert_eq!(buf[2047], 0xFF); + + // Reading an unmapped sector returns zeros + let mut buf2 = vec![0xFFu8; SECTOR_SIZE]; + let n2 = reader.read_sectors(999, 1, &mut buf2).unwrap(); + assert_eq!(n2, SECTOR_SIZE); + assert_eq!(buf2[0], 0); + assert_eq!(buf2[2047], 0); +} + +#[test] +fn mock_sector_reader_multi_sector() { + let mut reader = MockSectorReader::new(); + + let mut s10 = vec![0u8; SECTOR_SIZE]; + s10[0] = 10; + reader.set_sector(10, s10); + + let mut s11 = vec![0u8; SECTOR_SIZE]; + s11[0] = 11; + reader.set_sector(11, s11); + + // Read 2 consecutive sectors + let mut buf = vec![0u8; SECTOR_SIZE * 2]; + let n = reader.read_sectors(10, 2, &mut buf).unwrap(); + assert_eq!(n, SECTOR_SIZE * 2); + assert_eq!(buf[0], 10); + assert_eq!(buf[SECTOR_SIZE], 11); +} + +#[test] +fn read_filesystem_no_avdp() { + // Empty reader — sector 256 is all zeros, tag_id=0 != 2 + let mut reader = MockSectorReader::new(); + let result = udf::read_filesystem(&mut reader); + assert!(result.is_err(), "should fail when no AVDP at sector 256"); +} + +#[test] +fn read_filesystem_bad_avdp_tag() { + // Put a sector at 256 with wrong tag_id + let mut reader = MockSectorReader::new(); + let mut bad = vec![0u8; SECTOR_SIZE]; + bad[0..2].copy_from_slice(&99u16.to_le_bytes()); // tag_id=99, not 2 + reader.set_sector(256, bad); + + let result = udf::read_filesystem(&mut reader); + assert!(result.is_err(), "should fail when AVDP tag_id is not 2"); +} + +#[test] +fn read_filesystem_no_partition_descriptor() { + // Valid AVDP but VDS has no Partition Descriptor (tag 5) + let mut reader = MockSectorReader::new(); + reader.set_sector(256, make_avdp_sector(32)); + // Put a terminator immediately at sector 32 + reader.set_sector(32, make_terminator()); + + let result = udf::read_filesystem(&mut reader); + assert!(result.is_err(), "should fail when no partition descriptor in VDS"); +} + +#[test] +fn read_filesystem_bad_fsd_tag() { + // Valid AVDP + VDS with partition desc + LVD, but FSD at metadata_start has wrong tag + let mut reader = MockSectorReader::new(); + let partition_start = 512; + + reader.set_sector(256, make_avdp_sector(32)); + reader.set_sector(32, make_pvd_sector("TEST_DISC")); + reader.set_sector(33, make_partition_desc(partition_start)); + reader.set_sector(34, make_lvd_sector_simple()); + reader.set_sector(35, make_terminator()); + // With 1 partition map, metadata_start = partition_start. + // FSD should be at sector partition_start but we leave it as zeros (tag_id=0 != 256). + + let result = udf::read_filesystem(&mut reader); + assert!(result.is_err(), "should fail when FSD tag_id is not 256"); +} + +#[test] +fn read_filesystem_minimal_valid() { + // Build a minimal valid UDF image: AVDP -> VDS -> FSD -> empty root dir + let mut reader = MockSectorReader::new(); + let partition_start: u32 = 512; + let root_icb_meta_lba: u32 = 1; // relative to metadata_start + let root_data_meta_lba: u32 = 2; + + // Sector 256: AVDP + reader.set_sector(256, make_avdp_sector(32)); + + // VDS at sectors 32..35 + reader.set_sector(32, make_pvd_sector("MY_DISC")); + reader.set_sector(33, make_partition_desc(partition_start)); + reader.set_sector(34, make_lvd_sector_simple()); + reader.set_sector(35, make_terminator()); + + // FSD at partition_start (since single partition map, metadata_start = partition_start) + reader.set_sector(partition_start, make_fsd_sector(root_icb_meta_lba)); + + // Root directory ICB at metadata_start + root_icb_meta_lba + // Points to directory data at root_data_meta_lba, length = 40 (one parent FID) + let parent_fid = make_parent_fid(); + let dir_data_len = parent_fid.len() as u32; + reader.set_sector( + partition_start + root_icb_meta_lba, + make_dir_icb(root_data_meta_lba, dir_data_len), + ); + + // Root directory data: just a parent FID (empty directory) + reader.set_sector_partial(partition_start + root_data_meta_lba, &parent_fid); + + let fs = udf::read_filesystem(&mut reader).expect("should parse minimal UDF"); + assert_eq!(fs.volume_id, "MY_DISC"); + assert!(fs.root.is_dir); + assert!(fs.root.entries.is_empty(), "root should have no children"); +} + +#[test] +fn read_filesystem_with_subdirectory() { + // Build a UDF image with root -> BDMV (dir) -> test.mpls (file) + let mut reader = MockSectorReader::new(); + let partition_start: u32 = 512; + + // Layout (all relative to partition_start which equals metadata_start): + // meta LBA 0 = FSD + // meta LBA 1 = root ICB + // meta LBA 2 = root dir data + // meta LBA 3 = BDMV ICB + // meta LBA 4 = BDMV dir data + // meta LBA 5 = test.mpls file ICB + // meta LBA 10 = test.mpls file data (partition-relative) + + reader.set_sector(256, make_avdp_sector(32)); + reader.set_sector(32, make_pvd_sector("DISC_WITH_BDMV")); + reader.set_sector(33, make_partition_desc(partition_start)); + reader.set_sector(34, make_lvd_sector_simple()); + reader.set_sector(35, make_terminator()); + + // FSD -> root ICB at meta LBA 1 + reader.set_sector(partition_start + 0, make_fsd_sector(1)); + + // Root dir: parent FID + BDMV dir FID + let parent_fid = make_parent_fid(); + let bdmv_fid = make_fid("BDMV", 3, true); + let mut root_data = Vec::new(); + root_data.extend_from_slice(&parent_fid); + root_data.extend_from_slice(&bdmv_fid); + let root_data_len = root_data.len() as u32; + + reader.set_sector(partition_start + 1, make_dir_icb(2, root_data_len)); + reader.set_sector_partial(partition_start + 2, &root_data); + + // BDMV dir: parent FID + test.mpls file FID + let bdmv_parent = make_parent_fid(); + let mpls_fid = make_fid("test.mpls", 5, false); + let mut bdmv_data = Vec::new(); + bdmv_data.extend_from_slice(&bdmv_parent); + bdmv_data.extend_from_slice(&mpls_fid); + let bdmv_data_len = bdmv_data.len() as u32; + + reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data_len)); + reader.set_sector_partial(partition_start + 4, &bdmv_data); + + // test.mpls file ICB (File Entry tag 261) + reader.set_sector(partition_start + 5, make_file_icb(10, 1024, 1024)); + + let fs = udf::read_filesystem(&mut reader).expect("should parse UDF with subdir"); + assert_eq!(fs.volume_id, "DISC_WITH_BDMV"); + + // Root should have one child: BDMV + assert_eq!(fs.root.entries.len(), 1); + let bdmv = &fs.root.entries[0]; + assert_eq!(bdmv.name, "BDMV"); + assert!(bdmv.is_dir); + + // BDMV should have one child: test.mpls + assert_eq!(bdmv.entries.len(), 1); + let mpls = &bdmv.entries[0]; + assert_eq!(mpls.name, "test.mpls"); + assert!(!mpls.is_dir); + assert_eq!(mpls.size, 1024); +} + +#[test] +fn find_dir_case_insensitive() { + // Build a UDF image with BDMV/PLAYLIST directories, then search with various cases + let mut reader = MockSectorReader::new(); + let partition_start: u32 = 512; + + reader.set_sector(256, make_avdp_sector(32)); + reader.set_sector(32, make_pvd_sector("CASE_TEST")); + reader.set_sector(33, make_partition_desc(partition_start)); + reader.set_sector(34, make_lvd_sector_simple()); + reader.set_sector(35, make_terminator()); + + // FSD + reader.set_sector(partition_start + 0, make_fsd_sector(1)); + + // Root -> BDMV + let root_data = [make_parent_fid(), make_fid("BDMV", 3, true)].concat(); + reader.set_sector(partition_start + 1, make_dir_icb(2, root_data.len() as u32)); + reader.set_sector_partial(partition_start + 2, &root_data); + + // BDMV -> PLAYLIST + let bdmv_data = [make_parent_fid(), make_fid("PLAYLIST", 5, true)].concat(); + reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data.len() as u32)); + reader.set_sector_partial(partition_start + 4, &bdmv_data); + + // PLAYLIST (empty) + let playlist_data = make_parent_fid(); + reader.set_sector(partition_start + 5, make_dir_icb(6, playlist_data.len() as u32)); + reader.set_sector_partial(partition_start + 6, &playlist_data); + + let fs = udf::read_filesystem(&mut reader).expect("should parse"); + + // Exact case + assert!(fs.find_dir("BDMV/PLAYLIST").is_some()); + // Lower case + assert!(fs.find_dir("bdmv/playlist").is_some()); + // Mixed case + assert!(fs.find_dir("Bdmv/Playlist").is_some()); + assert!(fs.find_dir("BdMv/PlayList").is_some()); + // Leading/trailing slashes + assert!(fs.find_dir("/BDMV/PLAYLIST/").is_some()); + // Nonexistent + assert!(fs.find_dir("BDMV/STREAM").is_none()); + assert!(fs.find_dir("NONEXISTENT").is_none()); +} + +#[test] +fn sector_reader_is_object_safe() { + // Verify SectorReader can be used as a trait object + let mut reader = MockSectorReader::new(); + reader.set_sector(0, vec![42u8; SECTOR_SIZE]); + + let dyn_reader: &mut dyn SectorReader = &mut reader; + let mut buf = vec![0u8; SECTOR_SIZE]; + let n = dyn_reader.read_sectors(0, 1, &mut buf).unwrap(); + assert_eq!(n, SECTOR_SIZE); + assert_eq!(buf[0], 42); +}