mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
hddvd: read AACS-wrapped playlists, judge encryption from the packs, join PEVOB_n features
- A genuine disc's VPLST*.XPL is an AACS-encapsulated ARF ([HD] §4.4.2, Table 4-13: "AACS", type 12h, Nfs at bytes 7..11, a 272-byte file name field, then the XML). The wrapper is stripped before parsing; rips ship it bare. - An AACS directory no longer marks an HD DVD encrypted on its own: rips keep it renamed (ANY!, AAC!) over clear EVOs. Identify and scan sample the largest EVOs' packs and keep the AACS verdict only when a pack is scrambled. - The clip-derived feature also joins PEVOB_1/PEVOB_2 splits. - Comments: the VTI is the Advanced VTS information file; 0xFD ext 0x56 is the VC-1 sub video, and TrueHD rides private_stream_1.
This commit is contained in:
+130
-8
@@ -26,9 +26,9 @@ const EVO_PROBE_SECTORS: u32 = 8192;
|
||||
/// is generous while bounding probe memory.
|
||||
const EVO_ES_SAMPLE_CAP: usize = 128 * 1024;
|
||||
|
||||
/// HD-DVD Standard Content navigation file magic (`HVDVD_TS/HVA*.VTI`). The VTI
|
||||
/// is the DVD-IFO analogue: it holds a fixed-stride clip table naming every
|
||||
/// `.evo` in authored order.
|
||||
/// HD-DVD Advanced VTS information file magic (`HVDVD_TS/HVA00001.VTI`). The VTI
|
||||
/// is the Advanced Content's DVD-IFO analogue: it holds a fixed-stride clip table
|
||||
/// naming every `.evo` in authored order.
|
||||
const HDDVD_VTI_MAGIC: &[u8] = b"ADVANCED-VTS";
|
||||
|
||||
/// Byte stride between clip-table entries in the VTI. Each entry holds a
|
||||
@@ -41,6 +41,67 @@ const VTI_CLIP_ENTRY_STRIDE: usize = 0x140;
|
||||
/// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory.
|
||||
const MAX_VTI_HITS: usize = 8192;
|
||||
|
||||
// Windows sampled per EVO, sectors per window, and EVOs sampled when judging whether an
|
||||
// HD DVD's content is scrambled.
|
||||
const SCRAMBLE_SAMPLE_WINDOWS: u64 = 2;
|
||||
const SCRAMBLE_SAMPLE_SECTORS: u32 = 32;
|
||||
const SCRAMBLE_SAMPLE_EVOS: usize = 3;
|
||||
|
||||
/// Whether an HD DVD's EVOs are AACS-scrambled, judged per pack (`[HD]` §4.3.2:
|
||||
/// `PES_scrambling_control` is `01` on an encrypted pack). A rip keeps a renamed AACS
|
||||
/// directory (`ANY!`, `AAC!`) over clear EVOs, so the directory alone proves nothing.
|
||||
/// Samples windows of the largest EVOs; `None` when no window could be read.
|
||||
pub(crate) fn hddvd_content_scrambled(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Option<bool> {
|
||||
let dir = udf_fs.find_dir("/HVDVD_TS")?;
|
||||
let mut evos: Vec<(&str, u64)> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(HDDVD_CLIP_EXT))
|
||||
.map(|e| (e.name.as_str(), e.size))
|
||||
.collect();
|
||||
evos.sort_by_key(|e| std::cmp::Reverse(e.1));
|
||||
let mut read_any = false;
|
||||
for (name, _) in evos.into_iter().take(SCRAMBLE_SAMPLE_EVOS) {
|
||||
let Ok(exts) = udf_fs.file_extents(reader, &format!("/HVDVD_TS/{name}")) else {
|
||||
continue;
|
||||
};
|
||||
let total: u64 = exts.iter().map(|e| u64::from(e.1)).sum();
|
||||
for w in 1..=SCRAMBLE_SAMPLE_WINDOWS {
|
||||
let mut off = total * w / (SCRAMBLE_SAMPLE_WINDOWS + 1);
|
||||
let Some(&(lba, left)) = exts.iter().find(|e| {
|
||||
let hit = off < u64::from(e.1);
|
||||
if !hit {
|
||||
off -= u64::from(e.1);
|
||||
}
|
||||
hit
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let n = (u64::from(left) - off).min(u64::from(SCRAMBLE_SAMPLE_SECTORS)) as u32;
|
||||
let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES];
|
||||
let Ok(got) = reader.read_sectors(lba + off as u32, n as u16, &mut buf, false) else {
|
||||
continue;
|
||||
};
|
||||
read_any = true;
|
||||
let pack_scrambled = |p: &[u8; crate::consts::SECTOR_BYTES]| {
|
||||
crate::aacs::hddvd::classify(p) == crate::aacs::hddvd::PackKind::Scrambled
|
||||
};
|
||||
if buf[..got.min(buf.len())]
|
||||
.as_chunks::<{ crate::consts::SECTOR_BYTES }>()
|
||||
.0
|
||||
.iter()
|
||||
.any(pack_scrambled)
|
||||
{
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
read_any.then_some(false)
|
||||
}
|
||||
|
||||
// Parses the ADVANCED-VTS VTI clip-name table (authored order): collects every NUL-terminated
|
||||
// `*.EVO` name and keeps the largest group sharing one residue mod the stride (the clip table).
|
||||
fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
|
||||
@@ -87,11 +148,15 @@ fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
|
||||
best.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
|
||||
// Whether a clip name begins `feature` (case-insensitive): `FEATURE_1`/`_2`
|
||||
// (layer-break split) or `feature`/`feature_Divide`, imaged as ONE title.
|
||||
// Whether a clip is a part of the split feature (case-insensitive): a name beginning `feature`
|
||||
// (`FEATURE_1`/`_2`, `feature`/`feature_Divide`) or a primary-EVOB part `PEVOB_<n>`.
|
||||
fn is_feature_clip(name: &str) -> bool {
|
||||
let base = name.rsplit_once('.').map(|(b, _)| b).unwrap_or(name);
|
||||
base.to_ascii_lowercase().starts_with("feature")
|
||||
let lower = base.to_ascii_lowercase();
|
||||
lower.starts_with("feature")
|
||||
|| lower
|
||||
.strip_prefix("pevob_")
|
||||
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
// Sniffs a video codec from an MPEG-PS video ES sample by start code: MPEG-2 (`B3`), VC-1
|
||||
@@ -244,8 +309,8 @@ fn collect_es(
|
||||
) {
|
||||
use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX};
|
||||
const EXTENDED_STREAM_ID: u8 = 0xFD;
|
||||
// VC-1 rides extended-stream-id `0xFD` ext `0x55`; other `0xFD` exts are HD audio
|
||||
// (MLP/TrueHD, routing deferred), so only this ext is video.
|
||||
// Main-video VC-1 rides extended-stream-id `0xFD` ext `0x55`. Ext `0x56` is the VC-1 sub
|
||||
// video (picture-in-picture), not routed; TrueHD rides private_stream_1, never `0xFD`.
|
||||
const VC1_STREAM_ID_EXT: u8 = 0x55;
|
||||
// Whether this packet is the VC-1 video sub-stream of the 0xFD extended id.
|
||||
let is_vc1_ext =
|
||||
@@ -472,10 +537,36 @@ fn xpl_depth_within_limit(text: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
// `[HD]` §4.4.2: the 283-byte header of an AACS-encapsulated Advanced Resource File, and its
|
||||
// FILE_ID. Bytes 7..11 hold the Resource Data size Nfs (Tables 4-12 to 4-14).
|
||||
const ARF_HEADER_LEN: usize = 283;
|
||||
const ARF_FILE_ID: &[u8; 4] = b"AACS";
|
||||
|
||||
// The Resource Data of an AACS-encapsulated ARF (a genuine disc's playlist), or the bytes
|
||||
// themselves when bare (a rip strips the wrapper). Hash (`12h`), MAC (`02h`) and Non-Protected
|
||||
// (`21h`) formats carry it in the clear after the header; `None` for the encrypted ones.
|
||||
fn arf_resource(bytes: &[u8]) -> Option<&[u8]> {
|
||||
if bytes.len() < ARF_HEADER_LEN || !bytes.starts_with(ARF_FILE_ID) {
|
||||
return Some(bytes);
|
||||
}
|
||||
match bytes[4] {
|
||||
0x02 | 0x12 | 0x21 => {
|
||||
let nfs = u32::from_be_bytes([bytes[7], bytes[8], bytes[9], bytes[10]]) as usize;
|
||||
let end = ARF_HEADER_LEN.saturating_add(nfs).min(bytes.len());
|
||||
Some(&bytes[ARF_HEADER_LEN..end])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Parses the Advanced-Content playlist into its titles, matching elements by
|
||||
// LOCAL name (default `HDDVDVideo/Playlist` namespace). Empty for a
|
||||
// non-XML/non-playlist blob or one over MAX_XPL_DEPTH (falls back to clip-name).
|
||||
fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
|
||||
let Some(xpl) = arf_resource(xpl) else {
|
||||
tracing::warn!(target: "freemkv::disc", "playlist is an encrypted AACS ARF: not parsed");
|
||||
return Vec::new();
|
||||
};
|
||||
let text = String::from_utf8_lossy(xpl);
|
||||
if !xpl_depth_within_limit(&text) {
|
||||
return Vec::new();
|
||||
@@ -1112,6 +1203,10 @@ mod tests {
|
||||
assert!(is_feature_clip("feature.EVO"));
|
||||
assert!(is_feature_clip("feature_Divide.EVO"));
|
||||
// Extras are not the feature.
|
||||
assert!(is_feature_clip("PEVOB_1.EVO"));
|
||||
assert!(is_feature_clip("pevob_2.evo"));
|
||||
assert!(!is_feature_clip("PEVOB_.EVO"));
|
||||
assert!(!is_feature_clip("PEVOB_MENU.EVO"));
|
||||
assert!(!is_feature_clip("TRAILER.EVO"));
|
||||
assert!(!is_feature_clip("DLS_01.EVO"));
|
||||
assert!(!is_feature_clip("EPK.EVO"));
|
||||
@@ -1931,6 +2026,33 @@ mod tests {
|
||||
assert_eq!(del.clips[0].evo, "del5_veronicapast.evo");
|
||||
}
|
||||
|
||||
// `[HD]` §4.4.2 Table 4-13 (Encapsulation Format for Hash): "AACS", type 12h, reserved,
|
||||
// Nfs at bytes 7..11, the 272-byte Resource File Name field, the data, a Hash Pointer.
|
||||
fn arf(kind: u8, data: &[u8]) -> Vec<u8> {
|
||||
let mut v = b"AACS".to_vec();
|
||||
v.extend_from_slice(&[kind, 0, 0]);
|
||||
v.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
let mut name = b"VPLST000.XPL.AACS".to_vec();
|
||||
name.resize(272, 0);
|
||||
v.extend_from_slice(&name);
|
||||
v.extend_from_slice(data);
|
||||
v.extend_from_slice(&1u32.to_be_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_xpl_titles_reads_a_playlist_wrapped_as_an_aacs_arf() {
|
||||
let bare = parse_xpl_titles(SYNTH_XPL.as_bytes());
|
||||
assert!(!bare.is_empty());
|
||||
for kind in [0x12, 0x02, 0x21] {
|
||||
let wrapped = parse_xpl_titles(&arf(kind, SYNTH_XPL.as_bytes()));
|
||||
assert_eq!(wrapped.len(), bare.len(), "type {kind:#04x}");
|
||||
assert_eq!(wrapped[0].duration_secs, bare[0].duration_secs);
|
||||
}
|
||||
// Encrypted ARFs (01h, 11h) cannot be read without the Title Key.
|
||||
assert!(parse_xpl_titles(&arf(0x11, SYNTH_XPL.as_bytes())).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_xpl_titles_returns_empty_on_non_xml() {
|
||||
assert!(parse_xpl_titles(b"not xml at all").is_empty());
|
||||
|
||||
+13
-3
@@ -1779,7 +1779,9 @@ impl Disc {
|
||||
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
|
||||
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
|
||||
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
|
||||
let encrypted = aacs_dir_present(&udf_fs);
|
||||
let encrypted = aacs_dir_present(&udf_fs)
|
||||
&& (format != DiscFormat::HdDvd
|
||||
|| hddvd::hddvd_content_scrambled(&mut buffered, &udf_fs) != Some(false));
|
||||
let layers = Self::layers_for(format, capacity);
|
||||
|
||||
Ok(DiscId {
|
||||
@@ -2447,10 +2449,10 @@ impl Disc {
|
||||
) -> Result<Self> {
|
||||
let scan_with_t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
|
||||
let encrypted = aacs.is_some();
|
||||
let mut encrypted = aacs.is_some();
|
||||
// Lookup-free: the state carries the disc's AACS inputs but no key; the caller
|
||||
// resolves one into a `KeyRing`.
|
||||
let (aacs, aacs_error) = match aacs {
|
||||
let (mut aacs, mut aacs_error) = match aacs {
|
||||
Some((cap, bus)) => encrypt::resolve_aacs(cap, &bus),
|
||||
None => (None, None),
|
||||
};
|
||||
@@ -2466,6 +2468,14 @@ impl Disc {
|
||||
ContentFormat::BdTs,
|
||||
)
|
||||
} else if udf_fs.find_dir("/HVDVD_TS").is_some() {
|
||||
// An AACS directory over EVOs whose packs are all unscrambled is a decrypted rip.
|
||||
if encrypted && hddvd::hddvd_content_scrambled(reader, &udf_fs) == Some(false) {
|
||||
tracing::info!(
|
||||
target: "freemkv::scan",
|
||||
"HD DVD carries an AACS directory but its EVO packs are clear: not encrypted"
|
||||
);
|
||||
(encrypted, aacs, aacs_error) = (false, None, None);
|
||||
}
|
||||
(
|
||||
Self::scan_hddvd_titles(reader, &udf_fs, halt)?,
|
||||
ContentFormat::MpegPs,
|
||||
|
||||
@@ -673,7 +673,24 @@ fn pre_ake_read_refusal_is_a_plain_scan_error() {
|
||||
assert_eq!(rig.count(|c| matches!(c[0], 0xA3 | 0xA4)), 0);
|
||||
}
|
||||
|
||||
// 64 video packs, each flagged scrambled (`[HD]` §4.3.2) or each clear (a decrypted rip).
|
||||
fn evo(scrambled: bool) -> Vec<u8> {
|
||||
(0..64u8)
|
||||
.flat_map(|i| {
|
||||
let mut p = crate::aacs::hddvd::tests::video_pack(i);
|
||||
if scrambled {
|
||||
p[20] |= 0x10;
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hddvd_disc() -> MemDisc {
|
||||
hddvd_disc_with(true)
|
||||
}
|
||||
|
||||
fn hddvd_disc_with(scrambled: bool) -> MemDisc {
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
@@ -684,7 +701,7 @@ fn hddvd_disc() -> MemDisc {
|
||||
name: "HVDVD_TS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![file("MAIN.EVO", 100, 5_000, 3_000_000, true)],
|
||||
files: vec![file_with("MAIN.EVO", 100, 5_000, evo(scrambled), true)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
@@ -738,6 +755,17 @@ fn hddvd_image_aacs_dir_is_captured() {
|
||||
assert!(d.encrypted && d.aacs.is_some() && d.aacs_error.is_none());
|
||||
}
|
||||
|
||||
// A rip keeps a renamed AACS dir (`AACS!` here) over clear EVOs: encryption follows the packs'
|
||||
// scrambling flags, not the directory, for the fast identify and the full scan alike.
|
||||
#[test]
|
||||
fn hddvd_rip_with_an_aacs_dir_over_clear_packs_is_not_encrypted() {
|
||||
let mut rig = Rig::new(hddvd_disc_with(false), |_| {});
|
||||
assert!(!Disc::identify(&mut rig.drive).expect("identify").encrypted);
|
||||
let mut mem = hddvd_disc_with(false);
|
||||
let d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan");
|
||||
assert!(!d.encrypted && d.aacs.is_none() && !d.titles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halted_during_capture_propagates() {
|
||||
for lba in [CERT_LBA, MKB_LBA] {
|
||||
|
||||
Reference in New Issue
Block a user