Add 113 tests, update CI to checkout@v5, add FEATURES.md
Test suite: 64 → 177 tests - MPLS parser: 6 tests (synthetic binary, streams, errors) - CLPI parser: 6 tests (EP map, PTS/SPN math, errors) - H.264: 12 tests (NAL parsing, SPS/PPS, keyframes) - HEVC: 13 tests (VPS/SPS/PPS, IRAP range, codec private) - AC3: 12 tests (syncword, frame extraction) - VC1: 15 tests (BITMAPINFOHEADER, start codes) - DTS: 5, TrueHD: 4, PGS: 4 tests - EBML: 6 tests (size/ID/string/float roundtrips) - UDF: 10 tests (MockSectorReader, filesystem parsing, error paths) - Disc: 8 tests (scan_image, DiscTitle helpers) - Streams: 5 new (meta roundtrip, MkvStream) - NullStream: 4, StdioStream: 2, IsoSectorReader: 2 CI: actions/checkout@v4 → v5 (all workflows) FEATURES.md: created for v0.7.1
This commit is contained in:
@@ -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<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<LBA, sector_data>.
|
||||
/// Any LBA not in the map returns zeroed sectors.
|
||||
struct MockSectorReader {
|
||||
sectors: HashMap<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
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<u8>) {
|
||||
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<usize> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
// 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<u8> {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user