libfreemkv 0.31.2: comprehensive spec-grounded test suite (~950 tests)
Test-hardening release, no runtime changes. Adds spec-grounded unit tests across the silent-corruption surfaces — UDF/MPLS/CLPI/IFO parsing, BD/DVD title + extent assembly, AACS/CSS key handling, TS/PS demux + codec parsers, MKV/EBML container output, the mux pipeline, sector prefetch + decrypt decorator, drive/SCSI sense decoding, label extraction, and core I/O. Each test is grounded in the format spec or real on-disc behavior and verified to fail under a targeted source mutation. No behavior changed.
This commit is contained in:
+1285
File diff suppressed because it is too large
Load Diff
+595
@@ -148,3 +148,598 @@ impl Disc {
|
||||
titles
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc + minimal UDF image (single physical partition,
|
||||
// metadata_start == partition_start). Offsets cited against
|
||||
// udf.rs::read_filesystem / ECMA-167.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const PART_START: u32 = 3000;
|
||||
|
||||
struct MemDisc {
|
||||
sectors: HashMap<u32, [u8; 2048]>,
|
||||
}
|
||||
impl MemDisc {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sectors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||||
self.sectors.insert(lba, data);
|
||||
}
|
||||
fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
|
||||
for (i, chunk) in bytes.chunks(2048).enumerate() {
|
||||
let mut s = [0u8; 2048];
|
||||
s[..chunk.len()].copy_from_slice(chunk);
|
||||
self.put(lba + i as u32, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectorSource for MemDisc {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let need = count as usize * 2048;
|
||||
for i in 0..count as u32 {
|
||||
let off = i as usize * 2048;
|
||||
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
|
||||
buf[off..off + 2048].copy_from_slice(&s);
|
||||
}
|
||||
Ok(need)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extended File Entry ICB (tag 266) with one Short AD. info_length@56,
|
||||
/// l_ea@208, l_ad@212, AD len(4)@216 | lba(4)@220.
|
||||
fn build_file_icb(size: u32, data_lba: u32) -> [u8; 2048] {
|
||||
let mut s = [0u8; 2048];
|
||||
s[0..2].copy_from_slice(&266u16.to_le_bytes());
|
||||
s[56..64].copy_from_slice(&(size as u64).to_le_bytes());
|
||||
s[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
s[212..216].copy_from_slice(&8u32.to_le_bytes());
|
||||
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
|
||||
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// One FID (tag 257). file_chars@18, l_fi@19, ICB LBA@24, l_iu@36,
|
||||
/// name@(38). Name compression-id 8 (ASCII).
|
||||
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||
let start = buf.len();
|
||||
let name_field: Vec<u8> = if is_parent {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut v = vec![0x08u8];
|
||||
v.extend_from_slice(name.as_bytes());
|
||||
v
|
||||
};
|
||||
let mut fid = vec![0u8; 38];
|
||||
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
|
||||
let mut fc = 0u8;
|
||||
if is_dir {
|
||||
fc |= 0x02;
|
||||
}
|
||||
if is_parent {
|
||||
fc |= 0x08;
|
||||
}
|
||||
fid[18] = fc;
|
||||
fid[19] = name_field.len() as u8;
|
||||
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
|
||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
|
||||
buf.extend_from_slice(&fid);
|
||||
buf.extend_from_slice(&name_field);
|
||||
let used = buf.len() - start;
|
||||
buf.resize(start + ((used + 3) & !3), 0);
|
||||
}
|
||||
|
||||
struct FileSpec {
|
||||
name: String,
|
||||
icb_lba: u32,
|
||||
data_lba: u32,
|
||||
contents: Vec<u8>,
|
||||
}
|
||||
|
||||
fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
|
||||
let mut avdp = [0u8; 2048];
|
||||
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||
disc.put(256, avdp);
|
||||
let mut pd = [0u8; 2048];
|
||||
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
|
||||
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
|
||||
disc.put(32, pd);
|
||||
let mut lvd = [0u8; 2048];
|
||||
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
|
||||
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
|
||||
disc.put(33, lvd);
|
||||
let mut td = [0u8; 2048];
|
||||
td[0..2].copy_from_slice(&8u16.to_le_bytes());
|
||||
disc.put(34, td);
|
||||
let mut fsd = [0u8; 2048];
|
||||
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
|
||||
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
|
||||
disc.put(PART_START, fsd);
|
||||
}
|
||||
|
||||
/// Build a UDF tree with a single VIDEO_TS directory holding the given
|
||||
/// files, and return the navigable UdfFs over `disc`.
|
||||
fn build_video_ts_fs(disc: &mut MemDisc, files: &[FileSpec]) -> crate::udf::UdfFs {
|
||||
let mut fids = Vec::new();
|
||||
push_fid(&mut fids, "", 50, true, true);
|
||||
for f in files {
|
||||
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
|
||||
disc.put(
|
||||
PART_START + f.icb_lba,
|
||||
build_file_icb(f.contents.len() as u32, f.data_lba),
|
||||
);
|
||||
disc.put_bytes(PART_START + f.data_lba, &f.contents);
|
||||
}
|
||||
// VIDEO_TS dir ICB + data.
|
||||
disc.put(PART_START + 50, build_file_icb(fids.len() as u32, 51));
|
||||
disc.put_bytes(PART_START + 51, &fids);
|
||||
// Root dir referencing VIDEO_TS.
|
||||
let mut root_fids = Vec::new();
|
||||
push_fid(&mut root_fids, "", 10, true, true);
|
||||
push_fid(&mut root_fids, "VIDEO_TS", 50, true, false);
|
||||
disc.put(PART_START + 10, build_file_icb(root_fids.len() as u32, 11));
|
||||
disc.put_bytes(PART_START + 11, &root_fids);
|
||||
build_udf_skeleton(disc, 10);
|
||||
crate::udf::read_filesystem(disc).expect("fs")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// IFO builders (DVD-Video spec). Offsets cited against ifo.rs.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// VMG (VIDEO_TS.IFO): magic "DVDVIDEO-VMG"@0, TT_SRPT sector ptr@0xC4.
|
||||
/// TT_SRPT lives at tt_srpt_sector*2048: num_titles(u16)@0, then 12-byte
|
||||
/// entries from +8. Each entry: num_chapters(u16)@+2, vts_number@+6,
|
||||
/// vts_title_num@+7.
|
||||
fn build_vmg(
|
||||
titles: &[(
|
||||
u16, /*chapters*/
|
||||
u8, /*vts*/
|
||||
u8, /*vts_title*/
|
||||
)],
|
||||
) -> Vec<u8> {
|
||||
// Put TT_SRPT at sector 1 (offset 2048).
|
||||
let tt_srpt_sector = 1u32;
|
||||
let mut d = vec![0u8; 2 * 2048];
|
||||
d[0..12].copy_from_slice(b"DVDVIDEO-VMG");
|
||||
d[0xC4..0xC8].copy_from_slice(&tt_srpt_sector.to_be_bytes());
|
||||
let base = tt_srpt_sector as usize * 2048;
|
||||
d[base..base + 2].copy_from_slice(&(titles.len() as u16).to_be_bytes());
|
||||
for (i, (chapters, vts, vts_title)) in titles.iter().enumerate() {
|
||||
let e = base + 8 + i * 12;
|
||||
d[e + 2..e + 4].copy_from_slice(&chapters.to_be_bytes());
|
||||
d[e + 6] = *vts;
|
||||
d[e + 7] = *vts_title;
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Cell playback info entry (24 bytes): BCD time@4..8 (unused here),
|
||||
/// first_sector(u32 BE)@8, last_sector(u32 BE)@20.
|
||||
fn write_cell(buf: &mut [u8], off: usize, first_sector: u32, last_sector: u32) {
|
||||
buf[off + 8..off + 12].copy_from_slice(&first_sector.to_be_bytes());
|
||||
buf[off + 20..off + 24].copy_from_slice(&last_sector.to_be_bytes());
|
||||
}
|
||||
|
||||
/// Build a VTS_XX_0.IFO. Layout per ifo.rs:
|
||||
/// magic "DVDVIDEO-VTS"@0
|
||||
/// vob_start_sector(u32 BE)@0xC0
|
||||
/// VTS_PGCIT sector ptr(u32 BE)@0xCC
|
||||
/// video attr byte@0x200
|
||||
/// num_audio(u16 BE)@0x202, audio blocks (8B) @0x204
|
||||
/// num_subs(u16 BE)@0x254, subtitle blocks (6B) @0x256
|
||||
/// PGCIT (at pgcit_sector*2048): num_pgcs(u16)@0, PGC info entries (8B)
|
||||
/// from +8 with PGC byte offset(u32 BE)@+4.
|
||||
/// PGC: nr_programs@0x02, nr_cells@0x03, BCD time@0x04, pgm_map ptr@0xE6,
|
||||
/// cell_playback ptr@0xE8 (both u16 BE rel to PGC start).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_vts(
|
||||
vob_start: u32,
|
||||
video_b0: u8,
|
||||
audio: &[(
|
||||
u8, /*b0 coding/sr*/
|
||||
u8, /*b1 channels*/
|
||||
[u8; 2], /*lang*/
|
||||
)],
|
||||
subs: &[[u8; 2]],
|
||||
cells: &[(u32, u32)],
|
||||
palette_nonzero: bool,
|
||||
) -> Vec<u8> {
|
||||
// Total file: header sector(s) + PGCIT at sector 2.
|
||||
let pgcit_sector = 2u32;
|
||||
let mut d = vec![0u8; 4 * 2048];
|
||||
d[0..12].copy_from_slice(b"DVDVIDEO-VTS");
|
||||
d[0xC0..0xC4].copy_from_slice(&vob_start.to_be_bytes());
|
||||
d[0xCC..0xD0].copy_from_slice(&pgcit_sector.to_be_bytes());
|
||||
d[0x200] = video_b0;
|
||||
d[0x202..0x204].copy_from_slice(&(audio.len() as u16).to_be_bytes());
|
||||
for (i, (b0, b1, lang)) in audio.iter().enumerate() {
|
||||
let a = 0x204 + i * 8;
|
||||
d[a] = *b0;
|
||||
d[a + 1] = *b1;
|
||||
d[a + 2] = lang[0];
|
||||
d[a + 3] = lang[1];
|
||||
}
|
||||
d[0x254..0x256].copy_from_slice(&(subs.len() as u16).to_be_bytes());
|
||||
for (i, lang) in subs.iter().enumerate() {
|
||||
let s = 0x256 + i * 6;
|
||||
d[s + 2] = lang[0];
|
||||
d[s + 3] = lang[1];
|
||||
}
|
||||
|
||||
// PGCIT: one PGC.
|
||||
let pg = pgcit_sector as usize * 2048;
|
||||
d[pg..pg + 2].copy_from_slice(&1u16.to_be_bytes()); // num_pgcs = 1
|
||||
// PGC info entry 0 at pg+8; PGC byte offset (rel to PGCIT) at +4.
|
||||
let pgc_rel: u32 = 0x100; // PGC body 256 bytes into the PGCIT
|
||||
d[pg + 8 + 4..pg + 8 + 8].copy_from_slice(&pgc_rel.to_be_bytes());
|
||||
let pgc = pg + pgc_rel as usize;
|
||||
// Ensure room for PGC (needs >= 0xEA past pgc, plus cell table).
|
||||
d[pgc + 0x02] = 1; // nr_of_programs
|
||||
d[pgc + 0x03] = cells.len() as u8; // nr_of_cells
|
||||
// BCD playback time 00:00:30:00 → 30 s, frame-rate bits 0b01 (25fps)
|
||||
// not needed; keep simple 30s. BCD: hh,mm,ss,frame|rate.
|
||||
d[pgc + 0x04] = 0x00;
|
||||
d[pgc + 0x05] = 0x00;
|
||||
d[pgc + 0x06] = 0x30; // 30 seconds BCD
|
||||
d[pgc + 0x07] = 0b0100_0000; // rate bits = 01 (25fps); 0 frames
|
||||
// pgm map ptr @0xE6, cell playback ptr @0xE8 (rel to PGC start).
|
||||
let cell_tbl_rel: u16 = 0xF0;
|
||||
let pgm_map_rel: u16 = 0xEC;
|
||||
d[pgc + 0xE6..pgc + 0xE8].copy_from_slice(&pgm_map_rel.to_be_bytes());
|
||||
d[pgc + 0xE8..pgc + 0xEA].copy_from_slice(&cell_tbl_rel.to_be_bytes());
|
||||
// Program map: program 0 → first cell 1.
|
||||
d[pgc + pgm_map_rel as usize] = 1;
|
||||
// Cell playback table.
|
||||
let cell_base = pgc + cell_tbl_rel as usize;
|
||||
for (i, (first, last)) in cells.iter().enumerate() {
|
||||
write_cell(&mut d, cell_base + i * 24, *first, *last);
|
||||
}
|
||||
// Palette at PGC+0xA4: 16 × [pad,Y,Cb,Cr]. Non-zero if requested.
|
||||
if palette_nonzero {
|
||||
d[pgc + 0xA4 + 1] = 0x40; // Y of color 0
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// scan_dvd_titles returns empty when VIDEO_TS.IFO can't be parsed
|
||||
/// (dvd.rs: `parse_vmg(...) Err → return Vec::new()`). Never panics.
|
||||
#[test]
|
||||
fn scan_dvd_titles_no_ifo_is_empty() {
|
||||
let mut disc = MemDisc::new();
|
||||
// VIDEO_TS exists but VIDEO_TS.IFO is missing.
|
||||
let udf = build_video_ts_fs(&mut disc, &[]);
|
||||
assert!(Disc::scan_dvd_titles(&mut disc, &udf).is_empty());
|
||||
}
|
||||
|
||||
/// Single VTS, single title, one cell. Extent absolute LBA =
|
||||
/// vob_start + cell.first_sector (dvd.rs); sector_count = last - first
|
||||
/// + 1 (inclusive range); size_bytes = sectors * 2048 (DVD sector).
|
||||
#[test]
|
||||
fn scan_dvd_titles_single_cell_extent_math() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]); // 1 chapter, VTS 1, title 1
|
||||
// vob_start 1000; one cell sectors [10..=109] → 100 sectors.
|
||||
let vts = build_vts(
|
||||
1000,
|
||||
0x00, // NTSC, 4:3
|
||||
&[],
|
||||
&[],
|
||||
&[(10, 109)],
|
||||
false,
|
||||
);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
assert_eq!(titles.len(), 1);
|
||||
let t = &titles[0];
|
||||
assert_eq!(t.extents.len(), 1);
|
||||
// absolute start = vob_start(1000) + first_sector(10) = 1010.
|
||||
assert_eq!(t.extents[0].start_lba, 1010);
|
||||
// inclusive: 109 - 10 + 1 = 100 sectors.
|
||||
assert_eq!(t.extents[0].sector_count, 100);
|
||||
// DVD sector = 2048 bytes.
|
||||
assert_eq!(t.size_bytes, 100 * 2048);
|
||||
// playlist field format VTS_XX_title.VOB; title_number is 1.
|
||||
assert_eq!(t.playlist, "VTS_01_1.VOB");
|
||||
assert_eq!(t.playlist_id, 1);
|
||||
assert_eq!(t.content_format, ContentFormat::MpegPs);
|
||||
}
|
||||
|
||||
/// Multi-cell title: extents preserve cell order and each maps to its
|
||||
/// own (vob_start + first .. last) range. mux reads cells in order.
|
||||
#[test]
|
||||
fn scan_dvd_titles_multi_cell_extents_in_order() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(2, 1, 1)]);
|
||||
let vts = build_vts(
|
||||
500,
|
||||
0x00,
|
||||
&[],
|
||||
&[],
|
||||
&[(0, 99), (200, 299)], // two cells
|
||||
false,
|
||||
);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
assert_eq!(t.extents.len(), 2);
|
||||
assert_eq!(t.extents[0].start_lba, 500); // 500 + 0
|
||||
assert_eq!(t.extents[0].sector_count, 100);
|
||||
assert_eq!(t.extents[1].start_lba, 700); // 500 + 200
|
||||
assert_eq!(t.extents[1].sector_count, 100);
|
||||
assert_eq!(t.size_bytes, 200 * 2048);
|
||||
}
|
||||
|
||||
/// PAL video standard (b0 low bits == 1) sets FrameRate::F25; NTSC sets
|
||||
/// F29_97 (dvd.rs match on ts.video.standard). The video PID is the
|
||||
/// fixed DVD MPEG-PS video stream id 0xE0.
|
||||
#[test]
|
||||
fn scan_dvd_titles_pal_frame_rate_and_video_pid() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
// video b0 low 2 bits = 1 → PAL.
|
||||
let vts = build_vts(0, 0x01, &[], &[], &[(0, 9)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Stream::Video(v) => Some(v),
|
||||
_ => None,
|
||||
})
|
||||
.expect("video stream");
|
||||
assert_eq!(v.pid, 0xE0, "DVD video PID is fixed 0xE0");
|
||||
assert_eq!(v.frame_rate, FrameRate::F25, "PAL → 25 fps");
|
||||
assert_eq!(v.resolution, Resolution::R576i, "PAL → 576i");
|
||||
}
|
||||
|
||||
/// AC-3 audio gets sub_stream_id 0x80 → PID routed via dvd_audio_pid
|
||||
/// (dvd.rs uses `a.sub_stream_id.and_then(dvd_audio_pid)`). A mixed
|
||||
/// AC-3 + DTS title must NOT collide: AC-3 → 0x80 base, DTS → 0x88 base.
|
||||
#[test]
|
||||
fn scan_dvd_titles_mixed_audio_codecs_distinct_pids() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
// audio b0: coding_mode is (b0 >> 5) & 7. AC-3 = 0 → b0=0x00.
|
||||
// DTS = 6 → b0 = 6<<5 = 0xC0. b1 channels nibble high.
|
||||
let vts = build_vts(
|
||||
0,
|
||||
0x00,
|
||||
&[(0x00, 0x10, *b"en"), (0xC0, 0x50, *b"fr")], // AC-3 eng, DTS fra
|
||||
&[],
|
||||
&[(0, 9)],
|
||||
false,
|
||||
);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Audio(a) => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(audios.len(), 2);
|
||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||
assert_eq!(audios[0].language, "en");
|
||||
assert_eq!(audios[1].codec, Codec::Dts);
|
||||
// PIDs must differ (no 0xBD00 collision).
|
||||
assert_ne!(
|
||||
audios[0].pid, audios[1].pid,
|
||||
"mixed-codec audio must route to distinct PIDs"
|
||||
);
|
||||
}
|
||||
|
||||
/// Subtitle streams map to Codec::DvdSub with palette codec_data when a
|
||||
/// non-zero palette is present (dvd.rs builds codec_data from
|
||||
/// dvd_title.palette). VobSub sub-id 0x20+i.
|
||||
#[test]
|
||||
fn scan_dvd_titles_subtitle_palette_codec_data() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
let vts = build_vts(
|
||||
0,
|
||||
0x00,
|
||||
&[],
|
||||
&[*b"en"],
|
||||
&[(0, 9)],
|
||||
true, // non-zero palette
|
||||
);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
let sub = t
|
||||
.streams
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Stream::Subtitle(s) => Some(s),
|
||||
_ => None,
|
||||
})
|
||||
.expect("subtitle stream");
|
||||
assert_eq!(sub.codec, Codec::DvdSub);
|
||||
assert_eq!(sub.language, "en");
|
||||
assert!(
|
||||
sub.codec_data.is_some(),
|
||||
"non-zero palette must yield codec_data"
|
||||
);
|
||||
}
|
||||
|
||||
/// Multiple titles in one VTS each become their own DiscTitle with a
|
||||
/// monotonically increasing title_number / playlist_id (dvd.rs
|
||||
/// `title_number += 1` per dvd_title). Both share the VTS streams.
|
||||
#[test]
|
||||
fn scan_dvd_titles_numbering_increments_per_title() {
|
||||
let mut disc = MemDisc::new();
|
||||
// Two titles in VTS 1 (title nums 1 and 2). num_pgcs must cover
|
||||
// pgc_index = vts_title - 1, so we need >=2 PGC entries; our
|
||||
// build_vts only emits 1 PGC. So the second title's PGC index (1)
|
||||
// exceeds num_pgcs (1) and is skipped. To exercise numbering we use
|
||||
// two separate VTS sets instead.
|
||||
let vmg = build_vmg(&[(1, 1, 1), (1, 2, 1)]);
|
||||
let vts1 = build_vts(100, 0x00, &[], &[], &[(0, 9)], false);
|
||||
let vts2 = build_vts(200, 0x00, &[], &[], &[(0, 19)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts1,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_02_0.IFO".into(),
|
||||
icb_lba: 64,
|
||||
data_lba: 7000,
|
||||
contents: vts2,
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
assert_eq!(titles.len(), 2);
|
||||
// title_number is a running counter across all title sets.
|
||||
assert_eq!(titles[0].playlist_id, 1);
|
||||
assert_eq!(titles[1].playlist_id, 2);
|
||||
assert_eq!(titles[0].playlist, "VTS_01_1.VOB");
|
||||
assert_eq!(titles[1].playlist, "VTS_02_2.VOB");
|
||||
// Distinct vob_start → distinct extents.
|
||||
assert_eq!(titles[0].extents[0].start_lba, 100);
|
||||
assert_eq!(titles[1].extents[0].start_lba, 200);
|
||||
}
|
||||
|
||||
/// chapter_times from the IFO become Chapter entries with ordinal
|
||||
/// names (dvd.rs maps chapter_times → Chapter{time_secs, chapter_name}).
|
||||
#[test]
|
||||
fn scan_dvd_titles_chapters_present() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
let vts = build_vts(0, 0x00, &[], &[], &[(0, 9)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
|
||||
// One program in the program map → one chapter time (0.0 for the
|
||||
// first program). Name is the ordinal from chapter_name(0).
|
||||
assert_eq!(t.chapters.len(), 1);
|
||||
assert_eq!(t.chapters[0].name, chapter_name(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,3 +382,442 @@ impl Disc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs;
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc + minimal UDF image with a single physical
|
||||
// partition (metadata_start == partition_start). Offsets cited
|
||||
// against udf.rs::read_filesystem / ECMA-167.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const PART_START: u32 = 4000;
|
||||
|
||||
struct MemDisc {
|
||||
sectors: HashMap<u32, [u8; 2048]>,
|
||||
}
|
||||
impl MemDisc {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
sectors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
fn put(&mut self, lba: u32, data: [u8; 2048]) {
|
||||
self.sectors.insert(lba, data);
|
||||
}
|
||||
fn put_bytes(&mut self, lba: u32, bytes: &[u8]) {
|
||||
for (i, chunk) in bytes.chunks(2048).enumerate() {
|
||||
let mut s = [0u8; 2048];
|
||||
s[..chunk.len()].copy_from_slice(chunk);
|
||||
self.put(lba + i as u32, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SectorSource for MemDisc {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let need = count as usize * 2048;
|
||||
for i in 0..count as u32 {
|
||||
let off = i as usize * 2048;
|
||||
let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]);
|
||||
buf[off..off + 2048].copy_from_slice(&s);
|
||||
}
|
||||
Ok(need)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extended File Entry ICB (tag 266) with one Short AD.
|
||||
fn build_file_icb(size: u32, data_lba: u32) -> [u8; 2048] {
|
||||
let mut s = [0u8; 2048];
|
||||
s[0..2].copy_from_slice(&266u16.to_le_bytes());
|
||||
s[56..64].copy_from_slice(&(size as u64).to_le_bytes());
|
||||
s[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||
s[212..216].copy_from_slice(&8u32.to_le_bytes());
|
||||
s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes());
|
||||
s[220..224].copy_from_slice(&data_lba.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
fn push_fid(buf: &mut Vec<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||
let start = buf.len();
|
||||
let name_field: Vec<u8> = if is_parent {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut v = vec![0x08u8];
|
||||
v.extend_from_slice(name.as_bytes());
|
||||
v
|
||||
};
|
||||
let mut fid = vec![0u8; 38];
|
||||
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
|
||||
let mut fc = 0u8;
|
||||
if is_dir {
|
||||
fc |= 0x02;
|
||||
}
|
||||
if is_parent {
|
||||
fc |= 0x08;
|
||||
}
|
||||
fid[18] = fc;
|
||||
fid[19] = name_field.len() as u8;
|
||||
fid[24..28].copy_from_slice(&icb_lba.to_le_bytes());
|
||||
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
|
||||
buf.extend_from_slice(&fid);
|
||||
buf.extend_from_slice(&name_field);
|
||||
let used = buf.len() - start;
|
||||
buf.resize(start + ((used + 3) & !3), 0);
|
||||
}
|
||||
|
||||
struct AacsFile {
|
||||
name: &'static str,
|
||||
icb_lba: u32,
|
||||
data_lba: u32,
|
||||
contents: Vec<u8>,
|
||||
}
|
||||
|
||||
fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) {
|
||||
let mut avdp = [0u8; 2048];
|
||||
avdp[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||
disc.put(256, avdp);
|
||||
let mut pd = [0u8; 2048];
|
||||
pd[0..2].copy_from_slice(&5u16.to_le_bytes());
|
||||
pd[188..192].copy_from_slice(&PART_START.to_le_bytes());
|
||||
disc.put(32, pd);
|
||||
let mut lvd = [0u8; 2048];
|
||||
lvd[0..2].copy_from_slice(&6u16.to_le_bytes());
|
||||
lvd[268..272].copy_from_slice(&1u32.to_le_bytes());
|
||||
disc.put(33, lvd);
|
||||
let mut td = [0u8; 2048];
|
||||
td[0..2].copy_from_slice(&8u16.to_le_bytes());
|
||||
disc.put(34, td);
|
||||
let mut fsd = [0u8; 2048];
|
||||
fsd[0..2].copy_from_slice(&256u16.to_le_bytes());
|
||||
fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes());
|
||||
disc.put(PART_START, fsd);
|
||||
}
|
||||
|
||||
/// Build a UDF tree with a single /AACS directory holding the given
|
||||
/// files. Returns the navigable UdfFs over `disc`.
|
||||
fn build_aacs_fs(disc: &mut MemDisc, files: &[AacsFile]) -> udf::UdfFs {
|
||||
let mut aacs_fids = Vec::new();
|
||||
push_fid(&mut aacs_fids, "", 50, true, true);
|
||||
for f in files {
|
||||
push_fid(&mut aacs_fids, f.name, f.icb_lba, false, false);
|
||||
disc.put(
|
||||
PART_START + f.icb_lba,
|
||||
build_file_icb(f.contents.len() as u32, f.data_lba),
|
||||
);
|
||||
disc.put_bytes(PART_START + f.data_lba, &f.contents);
|
||||
}
|
||||
disc.put(PART_START + 50, build_file_icb(aacs_fids.len() as u32, 51));
|
||||
disc.put_bytes(PART_START + 51, &aacs_fids);
|
||||
// Root referencing AACS.
|
||||
let mut root_fids = Vec::new();
|
||||
push_fid(&mut root_fids, "", 10, true, true);
|
||||
push_fid(&mut root_fids, "AACS", 50, true, false);
|
||||
disc.put(PART_START + 10, build_file_icb(root_fids.len() as u32, 11));
|
||||
disc.put_bytes(PART_START + 11, &root_fids);
|
||||
build_udf_skeleton(disc, 10);
|
||||
udf::read_filesystem(disc).expect("fs")
|
||||
}
|
||||
|
||||
/// A content certificate: type byte@0 (0x00 = V10, else V20),
|
||||
/// bus_encryption bit0@1, cc_id@2..8 (aacs/keys.rs parse_content_cert).
|
||||
fn build_content_cert(cert_type: u8, bus_encryption: bool) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 8];
|
||||
v[0] = cert_type;
|
||||
v[1] = if bus_encryption { 0x01 } else { 0x00 };
|
||||
v
|
||||
}
|
||||
|
||||
/// An MKB with one Type-and-Version record (type 0x10) carrying the
|
||||
/// version as BE u32 at record offset 8, followed by a recorded EOF
|
||||
/// record then trailing zero padding. mkb_content_len walks records
|
||||
/// and stops at the first padding (type 0) byte (aacs/keys.rs).
|
||||
fn build_mkb(version: u32, pad_to: usize) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
// Type 0x10 record, length 16 (>= 12 so version is read).
|
||||
v.push(0x10);
|
||||
v.extend_from_slice(&[0x00, 0x00, 0x10]); // rec_len = 16 (3-byte BE)
|
||||
v.extend_from_slice(&[0u8; 4]); // bytes 4..8 reserved
|
||||
v.extend_from_slice(&version.to_be_bytes()); // version @ rec+8
|
||||
v.extend_from_slice(&[0u8; 4]); // pad record body to 16
|
||||
debug_assert_eq!(v.len(), 16);
|
||||
// Trailing zero padding (the "fixed-region" allocation).
|
||||
v.resize(pad_to, 0);
|
||||
v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Tests: resolve_vid_only
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// Missing Unit_Key_RO.inf (and its DUPLICATE) → Error::AacsNoKeys
|
||||
/// (encrypt.rs `.map_err(|_| Error::AacsNoKeys)`). Never panics.
|
||||
#[test]
|
||||
fn resolve_vid_only_missing_unit_key_ro_errors() {
|
||||
let mut disc = MemDisc::new();
|
||||
// AACS dir exists but has no Unit_Key_RO.inf.
|
||||
let udf = build_aacs_fs(&mut disc, &[]);
|
||||
let err = Disc::resolve_vid_only(&udf, &mut disc, None)
|
||||
.expect_err("missing Unit_Key_RO must error");
|
||||
assert!(matches!(err, Error::AacsNoKeys));
|
||||
}
|
||||
|
||||
/// A V10 content cert (type 0x00, bus_encryption off) → version 1,
|
||||
/// bus_encryption false (encrypt.rs version match: Some(V10) → 1).
|
||||
#[test]
|
||||
fn resolve_vid_only_v10_cert_sets_version_1() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
AacsFile {
|
||||
name: "Unit_Key_RO.inf",
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vec![0xAB; 32],
|
||||
},
|
||||
AacsFile {
|
||||
name: "Content000.cer",
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: build_content_cert(0x00, false),
|
||||
},
|
||||
],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert_eq!(st.version, 1, "V10 cert → AACS version 1");
|
||||
assert!(!st.bus_encryption);
|
||||
assert_eq!(st.key_source, KeyOrigin::ExternalUk);
|
||||
assert!(st.unit_keys.is_empty(), "vid-only resolves no keys");
|
||||
assert!(st.vuk.is_none());
|
||||
}
|
||||
|
||||
/// A V20 content cert (type != 0x00) → version 2 (encrypt.rs Some(_) → 2).
|
||||
#[test]
|
||||
fn resolve_vid_only_v20_cert_sets_version_2() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
AacsFile {
|
||||
name: "Unit_Key_RO.inf",
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vec![0xAB; 32],
|
||||
},
|
||||
AacsFile {
|
||||
name: "Content000.cer",
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: build_content_cert(0x01, true),
|
||||
},
|
||||
],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert_eq!(st.version, 2, "V20 cert → AACS version 2");
|
||||
assert!(st.bus_encryption, "cert bus_encryption bit must propagate");
|
||||
}
|
||||
|
||||
/// No content cert at all but bus_encryption can't be read → version
|
||||
/// defaults to 1 (encrypt.rs: `None => 1`). bus_encryption false.
|
||||
#[test]
|
||||
fn resolve_vid_only_no_cert_defaults_version_1() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
&[AacsFile {
|
||||
name: "Unit_Key_RO.inf",
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vec![0xAB; 32],
|
||||
}],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert_eq!(st.version, 1, "no cert → default version 1");
|
||||
assert!(!st.bus_encryption);
|
||||
}
|
||||
|
||||
/// disc_hash is SHA1 of the Unit_Key_RO.inf bytes, hex with 0x prefix
|
||||
/// and uppercase (aacs::disc_hash + disc_hash_hex). The state's
|
||||
/// disc_hash must match independently computing it over the same bytes.
|
||||
#[test]
|
||||
fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() {
|
||||
let mut disc = MemDisc::new();
|
||||
let uk = vec![0x42u8; 100];
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
&[AacsFile {
|
||||
name: "Unit_Key_RO.inf",
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: uk.clone(),
|
||||
}],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
let expected = aacs::disc_hash_hex(&aacs::disc_hash(&uk));
|
||||
assert_eq!(st.disc_hash, expected);
|
||||
assert!(st.disc_hash.starts_with("0x"));
|
||||
// uk_ro must be stashed verbatim for the external resolver.
|
||||
assert_eq!(st.uk_ro, uk);
|
||||
}
|
||||
|
||||
/// The MKB is trimmed to its real record length, NOT left as the full
|
||||
/// fixed-region zero-pad (encrypt.rs `mkb_bytes.truncate(mkb_content_len)`).
|
||||
/// A 16-byte record + 5000 bytes of padding must trim to 16.
|
||||
#[test]
|
||||
fn resolve_vid_only_trims_mkb_padding() {
|
||||
let mut disc = MemDisc::new();
|
||||
let mkb = build_mkb(77, 5000); // record + 4984 pad bytes
|
||||
assert_eq!(mkb.len(), 5000);
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
AacsFile {
|
||||
name: "Unit_Key_RO.inf",
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vec![0xAB; 32],
|
||||
},
|
||||
AacsFile {
|
||||
name: "MKB_RO.inf",
|
||||
icb_lba: 62,
|
||||
data_lba: 7000,
|
||||
contents: mkb.clone(),
|
||||
},
|
||||
],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
// Real record stream is the single 16-byte type-0x10 record.
|
||||
assert_eq!(
|
||||
st.mkb.len(),
|
||||
aacs::mkb_content_len(&mkb),
|
||||
"MKB must be trimmed to record-stream length, not the zero-pad"
|
||||
);
|
||||
assert_eq!(st.mkb.len(), 16);
|
||||
// Version comes from the type-0x10 record body @ offset 8.
|
||||
assert_eq!(st.mkb_version, Some(77));
|
||||
}
|
||||
|
||||
/// With no MKB file present, mkb is empty and mkb_version is None
|
||||
/// (encrypt.rs `.unwrap_or_default()` → empty Vec; mkb_version(&[]) None).
|
||||
#[test]
|
||||
fn resolve_vid_only_no_mkb_is_empty() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
&[AacsFile {
|
||||
name: "Unit_Key_RO.inf",
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vec![0xAB; 32],
|
||||
}],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert!(st.mkb.is_empty());
|
||||
assert_eq!(st.mkb_version, None);
|
||||
}
|
||||
|
||||
/// A supplied handshake's volume_id and read_data_key propagate onto the
|
||||
/// AacsState (encrypt.rs `handshake.map(|h| h.volume_id)` /
|
||||
/// `handshake.and_then(|h| h.read_data_key)`).
|
||||
#[test]
|
||||
fn resolve_vid_only_propagates_handshake_vid_and_rdk() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
&[AacsFile {
|
||||
name: "Unit_Key_RO.inf",
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vec![0xAB; 32],
|
||||
}],
|
||||
);
|
||||
let vid = [0x11u8; 16];
|
||||
let rdk = [0x22u8; 16];
|
||||
let hs = HandshakeResult {
|
||||
volume_id: vid,
|
||||
read_data_key: Some(rdk),
|
||||
};
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
|
||||
assert_eq!(st.volume_id, vid);
|
||||
assert_eq!(st.read_data_key, Some(rdk));
|
||||
}
|
||||
|
||||
/// With NO handshake, volume_id defaults to all-zero (encrypt.rs
|
||||
/// `.unwrap_or([0u8; 16])`) and read_data_key is None.
|
||||
#[test]
|
||||
fn resolve_vid_only_no_handshake_zero_vid() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = build_aacs_fs(
|
||||
&mut disc,
|
||||
&[AacsFile {
|
||||
name: "Unit_Key_RO.inf",
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vec![0xAB; 32],
|
||||
}],
|
||||
);
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
|
||||
assert_eq!(st.volume_id, [0u8; 16]);
|
||||
assert_eq!(st.read_data_key, None);
|
||||
}
|
||||
|
||||
/// Unit_Key_RO.inf is read from /AACS/DUPLICATE when the primary copy
|
||||
/// is absent (encrypt.rs `.or_else(|_| read_file(DUPLICATE/...))`).
|
||||
/// This is the damaged-primary recovery path real discs rely on.
|
||||
#[test]
|
||||
fn resolve_vid_only_falls_back_to_duplicate_unit_key_ro() {
|
||||
let mut disc = MemDisc::new();
|
||||
// Build AACS dir with a DUPLICATE subdir holding Unit_Key_RO.inf.
|
||||
let uk = vec![0x55u8; 48];
|
||||
let mut dup_fids = Vec::new();
|
||||
push_fid(&mut dup_fids, "", 70, true, true);
|
||||
push_fid(&mut dup_fids, "Unit_Key_RO.inf", 72, false, false);
|
||||
disc.put(PART_START + 72, build_file_icb(uk.len() as u32, 9000));
|
||||
disc.put_bytes(PART_START + 9000, &uk);
|
||||
disc.put(PART_START + 70, build_file_icb(dup_fids.len() as u32, 71));
|
||||
disc.put_bytes(PART_START + 71, &dup_fids);
|
||||
// AACS dir: only a DUPLICATE subdir (no primary Unit_Key_RO.inf).
|
||||
let mut aacs_fids = Vec::new();
|
||||
push_fid(&mut aacs_fids, "", 50, true, true);
|
||||
push_fid(&mut aacs_fids, "DUPLICATE", 70, true, false);
|
||||
disc.put(PART_START + 50, build_file_icb(aacs_fids.len() as u32, 51));
|
||||
disc.put_bytes(PART_START + 51, &aacs_fids);
|
||||
let mut root_fids = Vec::new();
|
||||
push_fid(&mut root_fids, "", 10, true, true);
|
||||
push_fid(&mut root_fids, "AACS", 50, true, false);
|
||||
disc.put(PART_START + 10, build_file_icb(root_fids.len() as u32, 11));
|
||||
disc.put_bytes(PART_START + 11, &root_fids);
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
let udf = udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("DUPLICATE fallback");
|
||||
// disc_hash must be computed over the DUPLICATE bytes.
|
||||
assert_eq!(
|
||||
st.disc_hash,
|
||||
aacs::disc_hash_hex(&aacs::disc_hash(&uk)),
|
||||
"fallback must hash the DUPLICATE Unit_Key_RO.inf"
|
||||
);
|
||||
assert_eq!(st.uk_ro, uk);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Tests: read_vid_oem (response parsing). The OEM path issues a
|
||||
// READ_BUFFER CDB and parses a 36-byte response; we can't easily
|
||||
// fixture a real Drive, but the response-shape contract (3-byte
|
||||
// signature 00 22 00, VID at [4..20]) is documented and worth a
|
||||
// direct guard via a fake transport. Skipped here because Drive
|
||||
// construction requires a live transport; the parsing branches are
|
||||
// exercised through `read_vid_oem`'s callers in integration.
|
||||
// ---------------------------------------------------------------
|
||||
}
|
||||
|
||||
@@ -1039,6 +1039,427 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
// ── status char round-trip (ddrescue alphabet ?*/-+) ──────────
|
||||
|
||||
/// Every SectorStatus must round-trip through to_char/from_char, and
|
||||
/// the chars must be the exact ddrescue alphabet (header doc: `?` `*`
|
||||
/// `/` `-` `+`). A swapped mapping would silently misclassify resume
|
||||
/// state (e.g. a good sector read back as unreadable).
|
||||
#[test]
|
||||
fn status_char_round_trip_is_ddrescue_alphabet() {
|
||||
let pairs = [
|
||||
(SectorStatus::NonTried, '?'),
|
||||
(SectorStatus::NonTrimmed, '*'),
|
||||
(SectorStatus::NonScraped, '/'),
|
||||
(SectorStatus::Unreadable, '-'),
|
||||
(SectorStatus::Finished, '+'),
|
||||
];
|
||||
for (st, ch) in pairs {
|
||||
assert_eq!(st.to_char(), ch, "{st:?} must map to '{ch}'");
|
||||
assert_eq!(SectorStatus::from_char(ch), Some(st));
|
||||
}
|
||||
// Any char outside the alphabet is rejected.
|
||||
for bad in ['x', ' ', '0', '#', '?'.to_ascii_uppercase()] {
|
||||
if "?*/-+".contains(bad) {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
SectorStatus::from_char(bad),
|
||||
None,
|
||||
"'{bad}' is not a status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── parse_hex / parse_uk_line / parse_vid_hex error paths ─────
|
||||
|
||||
/// parse_hex accepts both `0x`-prefixed and bare hex (ddrescue writes
|
||||
/// `0x`-prefixed). A non-hex field is a MapfileInvalid{kind:"hex"}.
|
||||
#[test]
|
||||
fn parse_hex_accepts_prefixed_and_bare_rejects_garbage() {
|
||||
assert_eq!(parse_hex("0x10").unwrap(), 16);
|
||||
assert_eq!(parse_hex("10").unwrap(), 16);
|
||||
assert_eq!(parse_hex("0xffffffff").unwrap(), 0xffff_ffff);
|
||||
let err = parse_hex("0xzz").unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
/// A `# freemkv-uk:` line missing the `cps:hex` shape, with a bad cps,
|
||||
/// or a wrong-length key, must parse to None (best-effort, never fatal).
|
||||
#[test]
|
||||
fn parse_uk_line_rejects_malformed() {
|
||||
assert_eq!(parse_uk_line("no-colon"), None);
|
||||
assert_eq!(
|
||||
parse_uk_line("notanumber:11111111111111111111111111111111"),
|
||||
None
|
||||
);
|
||||
// 30 hex chars (15 bytes) — wrong length.
|
||||
assert_eq!(parse_uk_line("0:1111111111111111111111111111"), None);
|
||||
// Valid.
|
||||
assert_eq!(
|
||||
parse_uk_line("3:000102030405060708090a0b0c0d0e0f"),
|
||||
Some((3u32, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]))
|
||||
);
|
||||
}
|
||||
|
||||
/// parse_vid_hex tolerates an optional `0x` prefix and uppercase hex,
|
||||
/// but a 31- or 33-char string (not 32) is rejected — a VID is exactly
|
||||
/// 16 bytes = 32 hex chars.
|
||||
#[test]
|
||||
fn parse_vid_hex_length_and_case() {
|
||||
assert_eq!(
|
||||
parse_vid_hex("0xAABBCCDDEEFF00112233445566778899"),
|
||||
Some([
|
||||
0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
||||
0x88, 0x99
|
||||
])
|
||||
);
|
||||
assert_eq!(parse_vid_hex(&"a".repeat(31)), None);
|
||||
assert_eq!(parse_vid_hex(&"a".repeat(33)), None);
|
||||
}
|
||||
|
||||
// ── next_with / ranges_with semantics ─────────────────────────
|
||||
|
||||
/// next_with returns the first matching range AT OR AFTER `from`,
|
||||
/// clipping the returned start to `from` when `from` lands inside a
|
||||
/// matching range (the patch loop relies on resuming mid-range).
|
||||
#[test]
|
||||
fn next_with_clips_start_to_from() {
|
||||
let p = tmpfile("next_with_clips");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(200, 300, SectorStatus::NonTrimmed).unwrap();
|
||||
// from inside the NonTrimmed range [200,500): start clips to 350,
|
||||
// size is 500-350 = 150.
|
||||
assert_eq!(
|
||||
mf.next_with(350, SectorStatus::NonTrimmed),
|
||||
Some((350, 150))
|
||||
);
|
||||
// from before the range: returns the whole range from its pos.
|
||||
assert_eq!(mf.next_with(0, SectorStatus::NonTrimmed), Some((200, 300)));
|
||||
// from at/after the range end: no match.
|
||||
assert_eq!(mf.next_with(500, SectorStatus::NonTrimmed), None);
|
||||
// status with no entries: None.
|
||||
assert_eq!(mf.next_with(0, SectorStatus::Unreadable), None);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// ranges_with matches ANY of the supplied statuses, preserving
|
||||
/// position order. Used to build the Pass-N retry queue (NonTrimmed +
|
||||
/// NonScraped together).
|
||||
#[test]
|
||||
fn ranges_with_multiple_statuses_in_order() {
|
||||
let p = tmpfile("ranges_with_multi");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 100, SectorStatus::NonTrimmed).unwrap();
|
||||
mf.record(300, 100, SectorStatus::NonScraped).unwrap();
|
||||
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
let retry = mf.ranges_with(&[SectorStatus::NonTrimmed, SectorStatus::NonScraped]);
|
||||
assert_eq!(retry, vec![(100, 100), (300, 100)]);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
// ── record edge cases ─────────────────────────────────────────
|
||||
|
||||
/// A zero-size record is a no-op (record() early-returns on size==0):
|
||||
/// entries and stats are unchanged.
|
||||
#[test]
|
||||
fn record_zero_size_is_noop() {
|
||||
let p = tmpfile("record_zero");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
let before = mf.entries().to_vec();
|
||||
mf.record(500, 0, SectorStatus::Finished).unwrap();
|
||||
assert_eq!(mf.entries(), before.as_slice());
|
||||
assert_eq!(mf.stats().bytes_good, 0);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// Recording the FULL disc with one status collapses to a single
|
||||
/// coalesced entry (record splits then merges adjacent same-status).
|
||||
#[test]
|
||||
fn record_full_span_coalesces_to_one_entry() {
|
||||
let p = tmpfile("record_full_span");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 500, SectorStatus::Finished).unwrap();
|
||||
mf.record(500, 500, SectorStatus::Finished).unwrap();
|
||||
let es = mf.entries();
|
||||
assert_eq!(es.len(), 1, "two adjacent Finished must coalesce");
|
||||
assert_eq!((es[0].pos, es[0].size), (0, 1000));
|
||||
assert_eq!(mf.stats().bytes_good, 1000);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// A record that exactly overwrites the whole previous entry leaves the
|
||||
/// partition disjoint and total coverage invariant. bytes_total stays
|
||||
/// constant; good+pending+unreadable always sums to total.
|
||||
#[test]
|
||||
fn record_partition_invariant_total_coverage() {
|
||||
let p = tmpfile("record_invariant");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 250, SectorStatus::Finished).unwrap();
|
||||
mf.record(250, 250, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(500, 250, SectorStatus::NonTrimmed).unwrap();
|
||||
// NonTried (500..750? no) leftover is [750,1000).
|
||||
let s = mf.stats();
|
||||
assert_eq!(
|
||||
s.bytes_good + s.bytes_unreadable + s.bytes_pending,
|
||||
s.bytes_total,
|
||||
"coverage must partition the disc exactly"
|
||||
);
|
||||
// Entries must be disjoint and sorted.
|
||||
let es = mf.entries();
|
||||
for w in es.windows(2) {
|
||||
assert!(
|
||||
w[0].pos + w[0].size <= w[1].pos,
|
||||
"entries must stay disjoint and sorted"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
// ── load() current-line heuristic ─────────────────────────────
|
||||
|
||||
/// load() skips the ddrescue "current pos" status line (2nd field is a
|
||||
/// status char, not a 0x size) and parses the data lines that follow.
|
||||
/// The header doc shows `0x000000000 ? 1 0` as the status line.
|
||||
#[test]
|
||||
fn load_skips_current_status_line() {
|
||||
let p = tmpfile("load_skips_current");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(
|
||||
&p,
|
||||
"# Rescue Logfile. Created by test\n\
|
||||
0x000000000 ? 1 0\n\
|
||||
0x000000000 0x00000100 +\n\
|
||||
0x000000100 0x00000100 -\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mf = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(mf.entries().len(), 2);
|
||||
assert_eq!(mf.entries()[0].status, SectorStatus::Finished);
|
||||
assert_eq!(mf.entries()[1].status, SectorStatus::Unreadable);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// A mapfile written WITHOUT a current-line (first non-comment line is
|
||||
/// already a data entry: 2nd field starts `0x`) must still parse that
|
||||
/// first line as an entry — the heuristic detects it and falls through.
|
||||
#[test]
|
||||
fn load_treats_leading_data_line_as_entry() {
|
||||
let p = tmpfile("load_leading_entry");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(
|
||||
&p,
|
||||
"# Rescue Logfile. Created by test\n\
|
||||
0x000000000 0x00000200 +\n\
|
||||
0x000000200 0x00000100 ?\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mf = Mapfile::load(&p).unwrap();
|
||||
// First line is NOT a status line; both lines are entries.
|
||||
assert_eq!(mf.entries().len(), 2);
|
||||
assert_eq!(mf.entries()[0].size, 0x200);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// load() parses the version from the `# Rescue Logfile. Created by`
|
||||
/// header and exposes it (round-trips through write_to_disk).
|
||||
#[test]
|
||||
fn load_parses_version_header() {
|
||||
let p = tmpfile("load_version");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(
|
||||
&p,
|
||||
"# Rescue Logfile. Created by libfreemkv v9.9.9\n\
|
||||
0x000000000 ? 1 0\n\
|
||||
0x000000000 0x00000100 +\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mf = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(mf.version, "libfreemkv v9.9.9");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// load() rejects an entry with a non-hex pos/size field
|
||||
/// (MapfileInvalid{kind:"hex"}) rather than silently skipping it —
|
||||
/// a corrupt data line must not be dropped, masking missing coverage.
|
||||
#[test]
|
||||
fn load_rejects_non_hex_field() {
|
||||
let p = tmpfile("load_nonhex");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(
|
||||
&p,
|
||||
"# Rescue Logfile. Created by test\n\
|
||||
0x000000000 ? 1 0\n\
|
||||
0xZZZ 0x100 +\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(Mapfile::load(&p).is_err());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// load() rejects an unknown status char (MapfileInvalid{kind:
|
||||
/// "status_char"}). A `~` is not in the ddrescue alphabet.
|
||||
#[test]
|
||||
fn load_rejects_unknown_status_char() {
|
||||
let p = tmpfile("load_badstatus");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(
|
||||
&p,
|
||||
"# Rescue Logfile. Created by test\n\
|
||||
0x000000000 ? 1 0\n\
|
||||
0x000000000 0x100 ~\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = match Mapfile::load(&p) {
|
||||
Ok(_) => panic!("unknown status char must be rejected"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// An empty mapfile (only comments / blank lines) loads with zero
|
||||
/// entries and total_size 0 — never panics on the `entries.last()` None.
|
||||
#[test]
|
||||
fn load_empty_mapfile_is_zero_total() {
|
||||
let p = tmpfile("load_empty");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(&p, "# Rescue Logfile. Created by test\n\n \n").unwrap();
|
||||
let mf = Mapfile::load(&p).unwrap();
|
||||
assert!(mf.entries().is_empty());
|
||||
assert_eq!(mf.total_size(), 0);
|
||||
assert_eq!(mf.stats().bytes_total, 0);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// load() sorts entries by pos even when the file lists them out of
|
||||
/// order, and total_size derives from the highest end (entries are
|
||||
/// sorted then last().pos+size).
|
||||
#[test]
|
||||
fn load_sorts_out_of_order_entries() {
|
||||
let p = tmpfile("load_sort");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(
|
||||
&p,
|
||||
"# Rescue Logfile. Created by test\n\
|
||||
0x000000000 ? 1 0\n\
|
||||
0x000000200 0x00000100 -\n\
|
||||
0x000000000 0x00000200 +\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mf = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(mf.entries()[0].pos, 0);
|
||||
assert_eq!(mf.entries()[1].pos, 0x200);
|
||||
assert_eq!(mf.total_size(), 0x300);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
// ── write_to_disk format ──────────────────────────────────────
|
||||
|
||||
/// write_to_disk emits each entry as `0x{pos:09x} 0x{size:09x} {char}`
|
||||
/// and a load() recovers identical entries (the canonical resume path).
|
||||
/// Also verifies the fixed header block (Created by / Current pos /
|
||||
/// column header) is present so external ddrescue tools parse it.
|
||||
#[test]
|
||||
fn write_to_disk_format_round_trips_and_has_headers() {
|
||||
let p = tmpfile("write_format");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 0x1000, "vTEST").unwrap();
|
||||
mf.record(0x100, 0x200, SectorStatus::Finished).unwrap();
|
||||
mf.record(0x500, 0x100, SectorStatus::Unreadable).unwrap();
|
||||
mf.flush().unwrap();
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(text.contains("# Rescue Logfile. Created by vTEST"));
|
||||
assert!(text.contains("# Current pos / status / pass / pass_time"));
|
||||
assert!(text.contains("0x000000100 0x000000200 +"));
|
||||
assert!(text.contains("0x000000500 0x000000100 -"));
|
||||
let reloaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(reloaded.entries(), mf.entries());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// create() persists immediately so a resume sees the fresh mapfile
|
||||
/// even if record() is never called (load right after create matches).
|
||||
#[test]
|
||||
fn create_persists_eagerly() {
|
||||
let p = tmpfile("create_eager");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mf = Mapfile::create(&p, 4096, "test").unwrap();
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
assert_eq!(loaded.total_size(), 4096);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// open_or_create returns a fresh NonTried mapfile when the path does
|
||||
/// not exist (NotFound → create), not an error.
|
||||
#[test]
|
||||
fn open_or_create_creates_when_absent() {
|
||||
let p = tmpfile("open_or_create_absent");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mf = Mapfile::open_or_create(&p, 2048, "test").unwrap();
|
||||
assert_eq!(mf.entries().len(), 1);
|
||||
assert_eq!(mf.entries()[0].status, SectorStatus::NonTried);
|
||||
assert_eq!(mf.total_size(), 2048);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// open_or_create loads an existing file (and does NOT reset it to
|
||||
/// NonTried) even when the supplied total_size differs from the loaded
|
||||
/// coverage — the warn path must still return the loaded state.
|
||||
#[test]
|
||||
fn open_or_create_loads_existing_despite_size_mismatch() {
|
||||
let p = tmpfile("open_or_create_mismatch");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 500, SectorStatus::Finished).unwrap();
|
||||
mf.flush().unwrap();
|
||||
// Supply a DIFFERENT total; must still load the existing entries.
|
||||
let reopened = Mapfile::open_or_create(&p, 999_999, "test").unwrap();
|
||||
assert_eq!(reopened.stats().bytes_good, 500);
|
||||
// Loaded total reflects the file, not the supplied arg.
|
||||
assert_eq!(reopened.total_size(), 1000);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// set_unit_keys with an EMPTY slice must NOT clear an existing VID —
|
||||
/// the keys-XOR-vid invariant only flips when keys are actually present
|
||||
/// (mapfile.rs: `if !self.unit_keys.is_empty() { self.vid = None }`).
|
||||
#[test]
|
||||
fn set_unit_keys_empty_preserves_vid() {
|
||||
let p = tmpfile("uk_empty_preserves_vid");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.set_vid([0x7Au8; 16]);
|
||||
mf.set_unit_keys(&[]); // empty — must not clear vid
|
||||
assert_eq!(mf.vid(), Some([0x7Au8; 16]));
|
||||
assert!(mf.unit_keys().is_empty());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
/// Drop flushes pending in-memory state (a sweep that returns early
|
||||
/// must not lose records). After dropping a dirty Mapfile, a fresh
|
||||
/// load() sees the last record.
|
||||
#[test]
|
||||
fn drop_flushes_pending_state() {
|
||||
let p = tmpfile("drop_flush");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
{
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
// record may or may not flush (time-batched); ensure dirty.
|
||||
mf.record(0, 400, SectorStatus::Finished).unwrap();
|
||||
// Drop here flushes.
|
||||
}
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.stats().bytes_good, 400);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_consistent_after_split_record() {
|
||||
let p = tmpfile("stats_consistent_after_split");
|
||||
|
||||
Reference in New Issue
Block a user