use super::Disc;
use crate::decrypt::DecryptKeys;
use crate::error::{Error, Result};
use crate::sector::{DecryptingSectorSource, SectorSource};
use crate::udf::{self, DirEntry, UdfFs};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
const AACS_UNIT_SECTORS: u32 = 3;
const READ_BATCH_SECTORS: u32 = 1536; const READ_RETRIES: u32 = 3;
#[derive(Default)]
pub struct ExtractOptions<'a> {
pub force: bool,
pub progress: Option<&'a dyn crate::progress::Progress>,
pub halt: Option<crate::halt::Halt>,
}
impl ExtractOptions<'_> {
fn cancelled(&self, progress_continue: bool) -> bool {
!progress_continue || self.halt.as_ref().is_some_and(|h| h.is_cancelled())
}
}
#[derive(Debug, Clone)]
pub struct FileResult {
pub path: PathBuf,
pub bytes_good: u64,
pub bytes_unreadable: u64,
pub bytes_undecryptable: u64,
pub complete: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ExtractResult {
pub files: Vec<FileResult>,
pub bytes_good: u64,
pub bytes_unreadable: u64,
pub bytes_undecryptable: u64,
pub complete: bool,
pub halted: bool,
}
impl ExtractResult {
pub fn bytes_lost(&self) -> u64 {
self.bytes_unreadable + self.bytes_undecryptable
}
}
struct PlannedFile {
host_rel: PathBuf,
disc_name: String,
size: u64,
inline: Option<Vec<u8>>,
extents: Vec<(u32, u32)>,
}
impl Disc {
pub fn extract_tree(
&self,
reader: &mut dyn SectorSource,
dest: &Path,
opts: &ExtractOptions,
) -> Result<ExtractResult> {
std::fs::create_dir_all(dest).map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
if !opts.force && dir_is_non_empty(dest) {
return Err(Error::DirNotEmpty);
}
let fs = udf::read_filesystem(reader)?;
let mut planned: Vec<PlannedFile> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
std::collections::HashMap::new();
plan_tree(
reader,
&fs,
&fs.root,
Path::new(""),
"",
true,
&mut planned,
&mut dirs,
&mut seen_hosts,
)?;
let required: u64 = planned
.iter()
.map(|p| p.size)
.fold(0u64, |a, b| a.saturating_add(b));
if let Some(available) = available_space(dest) {
if available < required {
return Err(Error::DirInsufficientSpace {
required,
available,
});
}
}
for d in &dirs {
let abs = dest.join(d);
std::fs::create_dir_all(&abs).map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
}
let base_keys = self.decrypt_keys();
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
let decrypt_loss = dec.decrypt_loss();
let mut result = ExtractResult::default();
let total_bytes = required;
let mut done_bytes: u64 = 0;
let is_css = matches!(base_keys, DecryptKeys::Css { .. });
let mut vts_keys: std::collections::HashMap<String, DecryptKeys> =
std::collections::HashMap::new();
for pf in &planned {
if opts.cancelled(true) {
result.halted = true;
break;
}
if is_css {
if let Some(vts) = vts_group_of(&pf.disc_name) {
let key = match vts_keys.get(&vts) {
Some(k) => k.clone(),
None => {
let k = self.resolve_vts_key(&vts, &planned, &mut dec, &base_keys);
vts_keys.insert(vts.clone(), k.clone());
k
}
};
dec.set_keys(key);
} else {
dec.set_keys(base_keys.clone());
}
}
let before_loss = decrypt_loss.load(Ordering::Acquire);
let (mut fr, halted) =
extract_one_file(&mut dec, dest, pf, total_bytes, &mut done_bytes, opts)?;
let after_loss = decrypt_loss.load(Ordering::Acquire);
fr.bytes_undecryptable = after_loss.saturating_sub(before_loss);
fr.bytes_good = fr.bytes_good.saturating_sub(fr.bytes_undecryptable);
result.bytes_good = result.bytes_good.saturating_add(fr.bytes_good);
result.bytes_unreadable = result.bytes_unreadable.saturating_add(fr.bytes_unreadable);
result.bytes_undecryptable = result
.bytes_undecryptable
.saturating_add(fr.bytes_undecryptable);
result.files.push(fr);
if halted {
result.halted = true;
break;
}
}
result.complete = !result.halted
&& result.bytes_unreadable == 0
&& result.bytes_undecryptable == 0
&& result.files.iter().all(|f| f.complete);
Ok(result)
}
fn resolve_vts_key<S: SectorSource>(
&self,
vts: &str,
planned: &[PlannedFile],
dec: &mut DecryptingSectorSource<S>,
base_keys: &DecryptKeys,
) -> DecryptKeys {
let mut extents: Vec<crate::disc::Extent> = Vec::new();
for pf in planned {
if vts_group_of(&pf.disc_name).as_deref() == Some(vts) && is_title_vob(&pf.disc_name) {
for &(abs_lba, byte_len) in &pf.extents {
extents.push(crate::disc::Extent {
start_lba: abs_lba,
sector_count: (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32,
});
}
}
}
if extents.is_empty() {
return base_keys.clone();
}
extents.sort_by_key(|e| std::cmp::Reverse(e.sector_count));
match crate::css::crack_key(dec.inner_mut(), &extents, 64) {
Some(state) => DecryptKeys::Css {
title_key: state.title_key,
},
None => base_keys.clone(),
}
}
}
fn is_aacs_clip(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.ends_with(".m2ts") || lower.ends_with(".ssif")
}
pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec<crate::disc::verify::ClipLayout> {
let result = (|| -> Result<Vec<crate::disc::verify::ClipLayout>> {
let fs = udf::read_filesystem(reader)?;
let mut planned: Vec<PlannedFile> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
std::collections::HashMap::new();
plan_tree(
reader,
&fs,
&fs.root,
Path::new(""),
"",
true,
&mut planned,
&mut dirs,
&mut seen_hosts,
)?;
Ok(planned
.into_iter()
.filter(|pf| pf.inline.is_none() && is_aacs_clip(&pf.disc_name))
.map(|pf| crate::disc::verify::ClipLayout {
size: pf.size,
extents: pf.extents,
container: crate::disc::verify::ContainerKind::Ts,
})
.collect())
})();
result.unwrap_or_else(|e| {
tracing::warn!(
target: "freemkv::verify",
error = %e,
"clip enumeration failed; post-read verify disabled for this pass"
);
Vec::new()
})
}
struct Borrowed<'a>(&'a mut dyn SectorSource);
impl SectorSource for Borrowed<'_> {
fn capacity_sectors(&self) -> u32 {
self.0.capacity_sectors()
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
self.0.read_sectors(lba, count, buf, recovery)
}
fn set_speed(&mut self, kbs: u16) {
self.0.set_speed(kbs)
}
fn set_unit_base(&mut self, lba: u32) {
self.0.set_unit_base(lba)
}
}
#[allow(clippy::too_many_arguments)]
fn plan_tree(
reader: &mut dyn SectorSource,
fs: &UdfFs,
dir: &DirEntry,
host_rel: &Path,
disc_path: &str,
is_root: bool,
files: &mut Vec<PlannedFile>,
dirs: &mut Vec<PathBuf>,
seen_hosts: &mut std::collections::HashMap<PathBuf, String>,
) -> Result<()> {
for entry in &dir.entries {
if entry.name.is_empty() {
continue;
}
if is_root
&& (entry.name.eq_ignore_ascii_case("AACS")
|| entry.name.eq_ignore_ascii_case("CERTIFICATE"))
{
continue;
}
let safe = sanitize_component(&entry.name)?;
let child_rel = host_rel.join(&safe);
let child_disc = format!("{disc_path}/{}", entry.name);
if let Some(prev) = seen_hosts.insert(child_rel.clone(), child_disc.clone()) {
if prev != child_disc {
return Err(Error::DirNameCollision {
host: child_rel.to_string_lossy().into_owned(),
});
}
}
if entry.is_dir {
dirs.push(child_rel.clone());
plan_tree(
reader,
fs,
entry,
&child_rel,
&child_disc,
false,
files,
dirs,
seen_hosts,
)?;
} else {
let inline = fs.inline_data_at(reader, entry.meta_lba)?;
let extents = if inline.is_some() {
Vec::new()
} else {
fs.extents_abs_at(reader, entry.meta_lba)?
};
files.push(PlannedFile {
host_rel: child_rel,
disc_name: entry.name.clone(),
size: entry.size,
inline,
extents,
});
}
}
Ok(())
}
fn extract_one_file<S: SectorSource>(
dec: &mut DecryptingSectorSource<S>,
dest: &Path,
pf: &PlannedFile,
total_bytes: u64,
done_bytes: &mut u64,
opts: &ExtractOptions,
) -> Result<(FileResult, bool)> {
let final_path = dest.join(&pf.host_rel);
let partial_path = with_partial_suffix(&final_path);
let file =
crate::io::WritebackFile::create_with_size_hint(&partial_path, pf.size).map_err(|e| {
Error::DirWriteFailed {
errno: e.raw_os_error(),
}
})?;
let mut writer = file;
let mut fr = FileResult {
path: pf.host_rel.clone(),
bytes_good: 0,
bytes_unreadable: 0,
bytes_undecryptable: 0,
complete: false,
};
if let Some(bytes) = &pf.inline {
let n = (pf.size as usize).min(bytes.len());
write_all(&mut writer, &bytes[..n], &partial_path)?;
fr.bytes_good = n as u64;
finalize_file(writer, &partial_path, pf.size, &final_path)?;
fr.complete = true;
*done_bytes = done_bytes.saturating_add(pf.size);
report(opts, *done_bytes, total_bytes);
return Ok((fr, false));
}
let mut written: u64 = 0;
let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_BYTES];
'extents: for &(abs_lba, byte_len) in &pf.extents {
if written >= pf.size {
break;
}
dec.set_unit_base(abs_lba);
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES_U64) as u32;
let mut sector_off: u32 = 0;
while sector_off < sectors {
let batch = whole_unit_batch(sectors - sector_off);
let lba = abs_lba + sector_off;
let want = batch as usize * SECTOR_BYTES;
let read_ok = read_batch(dec, lba, batch, &mut buf[..want]);
let chunk_bytes = want as u64;
let remaining = pf.size.saturating_sub(written);
let usable = chunk_bytes.min(remaining) as usize;
if read_ok {
write_all(&mut writer, &buf[..usable], &partial_path)?;
fr.bytes_good = fr.bytes_good.saturating_add(usable as u64);
} else {
for b in buf[..usable].iter_mut() {
*b = 0;
}
write_all(&mut writer, &buf[..usable], &partial_path)?;
fr.bytes_unreadable = fr.bytes_unreadable.saturating_add(usable as u64);
}
written = written.saturating_add(usable as u64);
*done_bytes = done_bytes.saturating_add(usable as u64);
let cont = report(opts, *done_bytes, total_bytes);
sector_off += batch;
if opts.cancelled(cont) {
return Ok((fr, true));
}
if written >= pf.size {
break 'extents;
}
}
}
finalize_file(writer, &partial_path, pf.size, &final_path)?;
fr.complete = true;
Ok((fr, false))
}
fn whole_unit_batch(remaining: u32) -> u32 {
let mut batch = remaining.min(READ_BATCH_SECTORS);
if batch >= AACS_UNIT_SECTORS && batch < remaining {
batch -= batch % AACS_UNIT_SECTORS;
}
batch
}
fn read_batch<S: SectorSource>(
dec: &mut DecryptingSectorSource<S>,
lba: u32,
count: u32,
buf: &mut [u8],
) -> bool {
for attempt in 0..=READ_RETRIES {
match dec.read_sectors(lba, count as u16, buf, true) {
Ok(_) => return true,
Err(Error::DecryptFailed) => return false,
Err(_) if attempt < READ_RETRIES => continue,
Err(_) => return false,
}
}
false
}
fn write_all(writer: &mut crate::io::WritebackFile, data: &[u8], path: &Path) -> Result<()> {
writer.write_all(data).map_err(|e| {
let _ = std::fs::remove_file(path);
Error::DirWriteFailed {
errno: e.raw_os_error(),
}
})
}
fn finalize_file(
mut writer: crate::io::WritebackFile,
partial: &Path,
size: u64,
final_path: &Path,
) -> Result<()> {
writer.sync_all().map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
drop(writer);
let f = std::fs::OpenOptions::new()
.write(true)
.open(partial)
.map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
f.set_len(size).map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
f.sync_all().map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
drop(f);
std::fs::rename(partial, final_path).map_err(|e| Error::DirWriteFailed {
errno: e.raw_os_error(),
})?;
if let Some(dir) = final_path.parent() {
crate::io::fsync::dir(dir);
}
Ok(())
}
fn report(opts: &ExtractOptions, done: u64, total: u64) -> bool {
match opts.progress {
Some(p) => {
let pp = crate::progress::PassProgress {
kind: crate::progress::PassKind::Mux,
work_done: done,
work_total: total,
bytes_good_total: done,
bytes_unreadable_total: 0,
bytes_pending_total: 0,
bytes_retryable_total: 0,
bytes_total_disc: total,
disc_duration_secs: None,
bytes_bad_in_main_title: 0,
main_title_duration_secs: None,
main_title_size_bytes: None,
};
p.report(&pp)
}
None => true,
}
}
fn with_partial_suffix(path: &Path) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".partial");
path.with_file_name(name)
}
#[cfg(unix)]
fn available_space(dir: &Path) -> Option<u64> {
use std::os::unix::ffi::OsStrExt;
let cpath = std::ffi::CString::new(dir.as_os_str().as_bytes()).ok()?;
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statvfs(cpath.as_ptr(), &mut st) };
if rc != 0 {
return None;
}
#[allow(clippy::unnecessary_cast, clippy::useless_conversion)]
let avail = (st.f_bavail as u64).saturating_mul(st.f_frsize as u64);
Some(avail)
}
#[cfg(not(unix))]
fn available_space(_dir: &Path) -> Option<u64> {
None
}
fn dir_is_non_empty(dir: &Path) -> bool {
std::fs::read_dir(dir)
.map(|mut it| it.next().is_some())
.unwrap_or(false)
}
fn sanitize_component(name: &str) -> Result<String> {
if name == ".." || name == "." {
return Err(Error::DirNameCollision {
host: name.to_string(),
});
}
let mut out = String::with_capacity(name.len());
for ch in name.chars() {
match ch {
'\0' | '/' | '\\' | ':' | '<' | '>' | '"' | '|' | '?' | '*' => {
return Err(Error::DirNameCollision {
host: name.to_string(),
});
}
c if (c as u32) < 0x20 => {
return Err(Error::DirNameCollision {
host: name.to_string(),
});
}
c => out.push(c),
}
}
let trimmed = out.trim_end_matches([' ', '.']);
if trimmed.is_empty() {
return Err(Error::DirNameCollision {
host: name.to_string(),
});
}
let base = trimmed.split('.').next().unwrap_or(trimmed);
if is_windows_reserved(base) {
return Ok(format!("_{trimmed}"));
}
Ok(trimmed.to_string())
}
fn is_windows_reserved(base: &str) -> bool {
const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$", "CLOCK$"];
if RESERVED.iter().any(|r| base.eq_ignore_ascii_case(r)) {
return true;
}
let up = base.to_ascii_uppercase();
for prefix in ["COM", "LPT"] {
if let Some(rest) = up.strip_prefix(prefix) {
if rest.len() == 1 && matches!(rest.as_bytes()[0], b'1'..=b'9') {
return true;
}
}
}
false
}
fn vts_group_of(name: &str) -> Option<String> {
let up = name.to_ascii_uppercase();
let rest = up.strip_prefix("VTS_")?;
let group = rest.split('_').next()?;
if group.len() == 2 && group.bytes().all(|b| b.is_ascii_digit()) {
Some(format!("VTS_{group}"))
} else {
None
}
}
fn is_title_vob(name: &str) -> bool {
let up = name.to_ascii_uppercase();
if !up.ends_with(".VOB") {
return false;
}
let stem = up.trim_end_matches(".VOB");
match stem.rsplit_once('_') {
Some((_, part)) => part.len() == 1 && matches!(part.as_bytes()[0], b'1'..=b'9'),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::css::lfsr;
use std::collections::HashMap;
const PART_START: u32 = 2000;
struct MemDisc {
sectors: HashMap<u32, [u8; 2048]>,
bad: std::collections::HashSet<u32>,
}
impl MemDisc {
fn new() -> Self {
Self {
sectors: HashMap::new(),
bad: std::collections::HashSet::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 {
if self.bad.contains(&(lba + i)) {
return Err(Error::DiscRead {
sector: (lba + i) as u64,
status: None,
sense: None,
});
}
}
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)
}
}
struct FileSpec {
name: String,
icb_lba: u32,
data_lba: u32,
size: u32,
long_ad: bool,
contents: Vec<u8>,
}
struct DirSpec {
name: String,
icb_lba: u32,
dir_data_lba: u32,
files: Vec<FileSpec>,
subdirs: Vec<DirSpec>,
}
fn file(name: &str, icb_lba: u32, data_lba: u32, contents: Vec<u8>, long_ad: bool) -> FileSpec {
FileSpec {
name: name.to_string(),
icb_lba,
data_lba,
size: contents.len() as u32,
long_ad,
contents,
}
}
fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); if long_ad {
s[34..36].copy_from_slice(&1u16.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()); let ad_size: u32 = if long_ad { 16 } else { 8 };
s[212..216].copy_from_slice(&ad_size.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 build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] {
build_file_icb(dir_data_len, dir_data_lba, false)
}
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 l_fi = name_field.len();
let mut fid = vec![0u8; 38];
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
let mut file_chars = 0u8;
if is_dir {
file_chars |= 0x02;
}
if is_parent {
file_chars |= 0x08;
}
fid[18] = file_chars;
fid[19] = l_fi 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);
}
fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) {
let mut fids = Vec::new();
push_fid(&mut fids, "", dir.icb_lba, true, true);
for f in &dir.files {
push_fid(&mut fids, &f.name, f.icb_lba, false, false);
disc.put(
PART_START + f.icb_lba,
build_file_icb(f.size, f.data_lba, f.long_ad),
);
if !f.contents.is_empty() {
disc.put_bytes(PART_START + f.data_lba, &f.contents);
}
}
for sub in &dir.subdirs {
push_fid(&mut fids, &sub.name, sub.icb_lba, true, false);
}
disc.put(
PART_START + dir.icb_lba,
build_dir_icb(dir.dir_data_lba, fids.len() as u32),
);
disc.put_bytes(PART_START + dir.dir_data_lba, &fids);
for sub in &dir.subdirs {
lay_dir(disc, sub);
}
}
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);
}
fn build_disc(root: DirSpec) -> MemDisc {
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, root.icb_lba);
lay_dir(&mut disc, &root);
disc
}
struct TmpDir(PathBuf);
impl TmpDir {
fn new(tag: &str) -> Self {
let mut p = std::env::temp_dir();
let uniq = format!(
"freemkv_extract_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
p.push(uniq);
Self(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TmpDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn build_two_extent_icb(sectors_each: u32, data_lba_a: u32, data_lba_b: u32) -> [u8; 2048] {
let mut s = [0u8; 2048];
s[0..2].copy_from_slice(&266u16.to_le_bytes()); s[34..36].copy_from_slice(&0u16.to_le_bytes());
let size = sectors_each * SECTOR_BYTES as u32 * 2;
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(&16u32.to_le_bytes()); let ext_len = sectors_each * SECTOR_BYTES as u32; s[216..220].copy_from_slice(&(ext_len & 0x3FFF_FFFF).to_le_bytes());
s[220..224].copy_from_slice(&data_lba_a.to_le_bytes());
s[224..228].copy_from_slice(&(ext_len & 0x3FFF_FFFF).to_le_bytes());
s[228..232].copy_from_slice(&data_lba_b.to_le_bytes());
s
}
fn encrypt_aacs_unit(unit_key: &[u8; 16], tag: u8) -> Vec<u8> {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47; if off + 1 < unit.len() {
unit[off + 1] = tag; }
off += 192;
}
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = crate::aacs::decrypt::aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = crate::aacs::decrypt::AACS_IV;
let blocks = (crate::aacs::ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..blocks {
let o = 16 + i * 16;
for j in 0..16 {
unit[o + j] ^= prev[j];
}
let mut blk = GenericArray::clone_from_slice(&unit[o..o + 16]);
cipher.encrypt_block(&mut blk);
unit[o..o + 16].copy_from_slice(&blk);
prev.copy_from_slice(&unit[o..o + 16]);
}
unit
}
fn clear_aacs_unit(tag: u8) -> Vec<u8> {
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
let mut off = 4;
while off < unit.len() {
unit[off] = 0x47;
if off + 1 < unit.len() {
unit[off + 1] = tag;
}
off += 192;
}
unit[0] |= 0xC0;
unit
}
fn aacs_disc() -> Disc {
let mut d = clear_disc();
d.encrypted = true;
d.aacs = Some(crate::disc::AacsState {
version: 1,
bus_encryption: false,
mkb_version: None,
disc_hash: String::new(),
key_source: crate::disc::KeyOrigin::ExternalUk,
vuk: None,
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
});
d
}
fn clear_disc() -> Disc {
Disc {
volume_id: "TEST".into(),
meta_title: Some("TEST".into()),
format: crate::disc::DiscFormat::BluRay,
capacity_sectors: 100_000,
capacity_bytes: 100_000 * 2048,
layers: 1,
titles: Vec::new(),
region: crate::disc::DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: crate::disc::ContentFormat::BdTs,
}
}
fn read_out(dir: &Path, rel: &str) -> Option<Vec<u8>> {
std::fs::read(dir.join(rel)).ok()
}
#[test]
fn bdmv_extracts_streams_and_nav_and_strips_aacs() {
let m2ts = vec![0xABu8; 3 * 2048]; let index = b"INDEX-NAV".to_vec();
let movieobj = b"MOVIEOBJECT-NAV".to_vec();
let mpls = b"MPLS-PLAYLIST".to_vec();
let clpi = b"CLPI-CLIPINF".to_vec();
let aacs_inf = b"AACS-KEY-FILE".to_vec();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("index.bdmv", 30, 31, index.clone(), false),
file("MovieObject.bdmv", 32, 33, movieobj.clone(), false),
],
subdirs: vec![
DirSpec {
name: "STREAM".to_string(),
icb_lba: 40,
dir_data_lba: 41,
files: vec![file("00001.m2ts", 42, 5000, m2ts.clone(), true)],
subdirs: vec![],
},
DirSpec {
name: "PLAYLIST".to_string(),
icb_lba: 44,
dir_data_lba: 45,
files: vec![file("00000.mpls", 46, 47, mpls.clone(), false)],
subdirs: vec![],
},
DirSpec {
name: "CLIPINF".to_string(),
icb_lba: 48,
dir_data_lba: 49,
files: vec![file("00001.clpi", 50, 51, clpi.clone(), false)],
subdirs: vec![],
},
],
},
DirSpec {
name: "AACS".to_string(),
icb_lba: 60,
dir_data_lba: 61,
files: vec![file("Unit_Key_RO.inf", 62, 63, aacs_inf, false)],
subdirs: vec![],
},
],
};
let mut disc = build_disc(root);
let out = TmpDir::new("bdmv");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
assert_eq!(
read_out(out.path(), "BDMV/STREAM/00001.m2ts"),
Some(m2ts),
"m2ts content extracted intact"
);
assert_eq!(read_out(out.path(), "BDMV/index.bdmv"), Some(index));
assert_eq!(
read_out(out.path(), "BDMV/MovieObject.bdmv"),
Some(movieobj)
);
assert_eq!(read_out(out.path(), "BDMV/PLAYLIST/00000.mpls"), Some(mpls));
assert_eq!(read_out(out.path(), "BDMV/CLIPINF/00001.clpi"), Some(clpi));
assert!(!out.path().join("AACS").exists(), "AACS/ must be stripped");
assert!(res.complete, "clean extraction is complete");
assert_eq!(res.bytes_lost(), 0);
}
#[test]
fn video_ts_extracts_vobs_and_ifo() {
let ifo = b"VIDEO_TS.IFO".to_vec();
let vob = vec![0x5Au8; 2 * 2048];
let bup = b"VIDEO_TS.BUP".to_vec();
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "VIDEO_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("VIDEO_TS.IFO", 30, 31, ifo.clone(), false),
file("VTS_01_1.VOB", 32, 5000, vob.clone(), false),
file("VIDEO_TS.BUP", 34, 35, bup.clone(), false),
],
subdirs: vec![],
}],
};
let mut disc = build_disc(root);
let out = TmpDir::new("videots");
let mut d = clear_disc();
d.content_format = crate::disc::ContentFormat::MpegPs;
let res = d
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
assert_eq!(read_out(out.path(), "VIDEO_TS/VIDEO_TS.IFO"), Some(ifo));
assert_eq!(read_out(out.path(), "VIDEO_TS/VTS_01_1.VOB"), Some(vob));
assert_eq!(read_out(out.path(), "VIDEO_TS/VIDEO_TS.BUP"), Some(bup));
assert!(res.complete);
}
#[test]
fn css_title_vob_is_descrambled() {
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
let mut plain = vec![0u8; 2048];
plain[0x14] = 0x10; let pat: Vec<u8> = (0..8)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
.collect();
for (i, b) in plain.iter_mut().enumerate().skip(0x59) {
*b = pat[i % 8];
}
plain[0x54..0x59].copy_from_slice(&seed);
let mut scrambled = plain.clone();
lfsr::scramble_sector(&title_key, &mut scrambled);
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "VIDEO_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![file("VTS_01_1.VOB", 30, 5000, scrambled, false)],
subdirs: vec![],
}],
};
let mut disc = build_disc(root);
let out = TmpDir::new("css");
let mut d = clear_disc();
d.content_format = crate::disc::ContentFormat::MpegPs;
d.css = Some(crate::css::CssState {
title_key,
crack_span: None,
});
let res = d
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
let got = read_out(out.path(), "VIDEO_TS/VTS_01_1.VOB").expect("vob");
let mut expect = plain.clone();
expect[0x14] = 0x00;
assert_eq!(got, expect, "VOB descrambled to plaintext");
assert!(res.complete);
}
#[test]
fn bad_sector_holes_file_and_accounts_loss() {
let good = vec![0x77u8; 4 * 2048];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00001.m2ts", 24, 5000, good.clone(), true)],
subdirs: vec![],
}],
}],
};
let mut disc = build_disc(root);
for i in 0..4u32 {
disc.bad.insert(PART_START + 5000 + i);
}
let out = TmpDir::new("badsector");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract does not abort on bad sectors");
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
assert_eq!(
got.len(),
good.len(),
"holed file still sized to declared size"
);
assert!(got.iter().all(|&b| b == 0), "bad range zero-filled");
assert!(!res.complete, "lossy extraction is not complete");
assert_eq!(res.bytes_unreadable, good.len() as u64);
assert_eq!(res.files.len(), 1);
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
}
#[test]
fn sanitize_rejects_illegal_component() {
assert!(sanitize_component("good_name.m2ts").is_ok());
assert!(sanitize_component("..").is_err());
assert!(sanitize_component("a/b").is_err());
assert!(sanitize_component("a:b").is_err());
assert!(sanitize_component("a*b").is_err());
assert_eq!(sanitize_component("CON").unwrap(), "_CON");
assert_eq!(sanitize_component("com1").unwrap(), "_com1");
assert_eq!(sanitize_component("LPT9").unwrap(), "_LPT9");
assert_eq!(sanitize_component("NUL.cfg").unwrap(), "_NUL.cfg");
assert_eq!(sanitize_component("conin$").unwrap(), "_conin$");
assert_eq!(sanitize_component("COM10").unwrap(), "COM10");
assert_eq!(sanitize_component("CONSOLE").unwrap(), "CONSOLE");
assert_eq!(sanitize_component("name. ").unwrap(), "name");
assert!(sanitize_component(". ").is_err());
}
#[test]
fn name_collision_is_error() {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![
file("movie", 30, 31, b"a".to_vec(), false),
file("movie.", 32, 33, b"b".to_vec(), false),
],
subdirs: vec![],
};
let mut disc = build_disc(root);
let out = TmpDir::new("collision");
let err = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect_err("collision must error");
assert!(matches!(err, Error::DirNameCollision { .. }));
}
#[test]
fn non_empty_target_requires_force() {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("a.bin", 30, 31, b"hello".to_vec(), false)],
subdirs: vec![],
};
let out = TmpDir::new("nonempty");
std::fs::create_dir_all(out.path()).unwrap();
std::fs::write(out.path().join("preexisting.txt"), b"x").unwrap();
let mut disc = build_disc(root);
let err = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect_err("non-empty dir without --force must error");
assert!(matches!(err, Error::DirNotEmpty));
let mut disc2 = build_disc(DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![file("a.bin", 30, 31, b"hello".to_vec(), false)],
subdirs: vec![],
});
let opts = ExtractOptions {
force: true,
..Default::default()
};
let res = clear_disc()
.extract_tree(&mut disc2, out.path(), &opts)
.expect("force proceeds");
assert_eq!(read_out(out.path(), "a.bin"), Some(b"hello".to_vec()));
assert!(res.complete);
}
#[test]
fn vts_grouping_and_title_vob_classification() {
assert_eq!(vts_group_of("VTS_01_1.VOB").as_deref(), Some("VTS_01"));
assert_eq!(vts_group_of("VTS_12_0.VOB").as_deref(), Some("VTS_12"));
assert_eq!(vts_group_of("VIDEO_TS.IFO"), None);
assert!(is_title_vob("VTS_01_1.VOB"));
assert!(is_title_vob("VTS_01_9.VOB"));
assert!(
!is_title_vob("VTS_01_0.VOB"),
"menu VOB is clear, not title"
);
assert!(!is_title_vob("VTS_01_1.IFO"));
}
#[test]
fn multi_extent_aacs_anchors_unit_base_per_extent() {
const SECTORS_EACH: u32 = 3; const DATA_A: u32 = 5000; const DATA_B: u32 = 5004;
let key = [0u8; 16];
let ext_a = encrypt_aacs_unit(&key, 0xA1);
let ext_b = encrypt_aacs_unit(&key, 0xB2);
let mut expect = clear_aacs_unit(0xA1);
expect.extend_from_slice(&clear_aacs_unit(0xB2));
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
let mut stream_fids = Vec::new();
push_fid(&mut stream_fids, "", 40, true, true);
push_fid(&mut stream_fids, "00001.m2ts", 42, false, false);
disc.put(
PART_START + 42,
build_two_extent_icb(SECTORS_EACH, DATA_A, DATA_B),
);
disc.put_bytes(PART_START + DATA_A, &ext_a);
disc.put_bytes(PART_START + DATA_B, &ext_b);
disc.put(PART_START + 40, build_dir_icb(41, stream_fids.len() as u32));
disc.put_bytes(PART_START + 41, &stream_fids);
let mut bdmv_fids = Vec::new();
push_fid(&mut bdmv_fids, "", 20, true, true);
push_fid(&mut bdmv_fids, "STREAM", 40, true, false);
disc.put(PART_START + 20, build_dir_icb(21, bdmv_fids.len() as u32));
disc.put_bytes(PART_START + 21, &bdmv_fids);
let mut root_fids = Vec::new();
push_fid(&mut root_fids, "", 10, true, true);
push_fid(&mut root_fids, "BDMV", 20, true, false);
disc.put(PART_START + 10, build_dir_icb(11, root_fids.len() as u32));
disc.put_bytes(PART_START + 11, &root_fids);
let out = TmpDir::new("multiextent_aacs");
let res = aacs_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
assert_eq!(
got, expect,
"both extents extract verbatim — the second extent is NOT a hole"
);
assert_eq!(
res.bytes_unreadable, 0,
"per-extent unit base must keep the second extent off the hole path"
);
assert_eq!(
res.bytes_undecryptable, 0,
"clear units decrypt-restore clean"
);
assert!(
res.complete,
"a clean multi-extent AACS file extracts complete"
);
}
#[test]
fn per_extent_base_is_aligned_first_extent_base_is_not() {
use crate::aacs::is_unit_aligned;
let ext_a_start = 7000u32; let ext_b_start = 7004u32;
assert!(
is_unit_aligned(ext_b_start, ext_b_start),
"per-extent base keeps the extent's own first read aligned"
);
assert!(
!is_unit_aligned(ext_b_start, ext_a_start),
"first-extent base mis-aligns a Δ-non-multiple-of-3 later extent"
);
let ext_c_start = ext_a_start + 6; assert!(
is_unit_aligned(ext_c_start, ext_a_start),
"a Δ-multiple-of-3 extent happens to stay aligned even on a stale base"
);
}
#[test]
fn inline_file_extracts() {
let payload = b"INLINE-NAV-DATA".to_vec();
let mut disc = MemDisc::new();
let mut root_fids = Vec::new();
push_fid(&mut root_fids, "", 10, true, true);
push_fid(&mut root_fids, "tiny.inf", 30, false, false);
let mut icb = [0u8; 2048];
icb[0..2].copy_from_slice(&266u16.to_le_bytes());
icb[34..36].copy_from_slice(&3u16.to_le_bytes()); icb[56..64].copy_from_slice(&(payload.len() as u64).to_le_bytes());
icb[208..212].copy_from_slice(&0u32.to_le_bytes()); icb[212..216].copy_from_slice(&(payload.len() as u32).to_le_bytes()); icb[216..216 + payload.len()].copy_from_slice(&payload);
disc.put(PART_START + 30, icb);
disc.put(PART_START + 10, build_dir_icb(11, root_fids.len() as u32));
disc.put_bytes(PART_START + 11, &root_fids);
build_udf_skeleton(&mut disc, 10);
let out = TmpDir::new("inline");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract");
assert_eq!(read_out(out.path(), "tiny.inf"), Some(payload));
assert!(res.complete);
}
}