mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
disc, aacs: key files before the handshake; one whole-disc reader
Scan reads UDF and the AACS files before the drive handshake and records a missing DVD /AACS key file instead of failing. Decryption needs an explicit AACS unit base and refuses off-grid units; an all-broken host-cert list is its own error (E7033). Every whole-disc and image to ISO copy goes through one shared reader that refuses an unkeyable file up front (E7032). HD DVD XPL tick and timecode fixes; CI action bumps.
This commit is contained in:
@@ -48,7 +48,7 @@ jobs:
|
||||
cargo test --release --lib ffmpeg_ -- --ignored --nocapture 2>&1 | tee "$FREEMKV_FFMPEG_ARTIFACT_DIR/tests.log"
|
||||
- name: Upload fixtures, probes, hashes and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ffmpeg-validation-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.FREEMKV_FFMPEG_ARTIFACT_DIR }}
|
||||
|
||||
@@ -22,6 +22,6 @@ jobs:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
- uses: github/codeql-action/upload-sarif@v3
|
||||
- uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
- `Error::ImageTruncated` displays as `E<code>: have/want`.
|
||||
- `json://` audio `sample_rate` is now a number in Hz (`null` when unknown) instead of a display string such as `"48kHz"`; a new `sample_rates` array lists every rate a stream carries (e.g. `[48000, 96000]` for a 48/96 kHz combo).
|
||||
- `network://` / `stdio://` FMKV headers use version 2 when a track carries decoder timing (Opus CodecDelay/SeekPreRoll), adding per-frame DiscardPadding; streams without timing stay version 1.
|
||||
- `Disc::scan` follows the standard AACS order: UDF, then `Unit_Key_RO.inf`, the content certificate and the MKB with plain reads, then the handshake, and only on a disc with an AACS directory. It no longer reads the MKB from the drive, and passes `None` to `KeySource::host_certs`.
|
||||
- `Disc::scan` fails with `Error::AacsKeyFileUnreadable` (E7031) when a live AACS disc's `Unit_Key_RO.inf` is missing or unreadable, before any AACS command. Image and folder scans still record the error and continue.
|
||||
- A transport fault during the AACS or CSS handshake makes `Disc::scan` fail with the same error `Drive::init` returns; a Stop during the handshake returns `Error::Halted`.
|
||||
- `DecryptingSectorSource` has no default AACS unit base: an AACS content read before `set_unit_base` fails `DecryptFailed` (it used to decrypt on the disc-LBA-0 grid). A BD-TS unit flagged encrypted whose clear seed lacks the TS sync at byte 4 (read off its file's unit grid) also fails `DecryptFailed` instead of decrypting to garbage.
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -26,9 +30,15 @@
|
||||
- `AudioChannels` gains 3.0, 3.1, 4.1, 6.0 and 7.0.
|
||||
- `DiscPresence` (`Present` / `Absent` / `Settling`) and `disc_presence(path)`: the tri-state answer `drive_has_disc` collapses (`Settling` counts as a disc).
|
||||
- `Disc::inputs_with_samples` fills `DiscInputs::samples` from the main feature.
|
||||
- `Error::AacsKeyFileUnreadable` (E7031).
|
||||
- `DecryptingSectorSource::clear_unit_base()`: drop the unit base so AACS content reads fail loud until the next `set_unit_base`.
|
||||
- `Error::WholeDiscKeyMissing` (E7032): a decrypted whole-disc image would keep encrypted pieces because a stream file no title plays has no held key. The fix is an MKV rip or a raw copy.
|
||||
- `whole_disc::whole_disc_reader` / `WholeDiscReader`: the decrypting reader for a whole-disc or image → ISO copy, shared by every front end. It keys every stream file no title plays, not just the kept titles. Each is probed at its first unit and up to 32 across it, and a held base key must open two probed units before it keys the file. Ciphertext that no held key opens refuses with E7032 before any output. A file with no proven key stays unkeyed on a multi-key disc, and a copy that reaches an encrypted unit there stops with E7032. `UnitAligned::unit_block_end` tiles sweep blocks on each file's unit grid. An AACS disc with titles but no stream folder fails with `Error::UdfNotFound` (E6003) naming the folder.
|
||||
- `ScanOptions::raw_copy`: a raw disc→ISO copy scans on past an unreadable `Unit_Key_RO.inf`, with E7031 recorded and every key refused.
|
||||
|
||||
### Fixed
|
||||
|
||||
- HD DVD XPL timecodes at a 60fps timeBase run at 60000/1001; title, clip and chapter times were 0.1% short.
|
||||
- TrueHD channel labels keep the LFE split (3.0 was labelled "2.1", 7.0 "6.1"), and a 6-channel presentation counts its Lvh/Rvh pair as 2. An older network:// receiver parses the new layout strings as unknown and omits the MKV Channels element for those tracks; keep both ends on the same version.
|
||||
- `mp4://` DTS tracks write the stream's maximum rate in `ddts` and read back as 96 kHz where the source is 96 kHz.
|
||||
- `mkv://` PCM tracks without BitDepth: bounded read-ahead and a multi-block depth estimate.
|
||||
|
||||
@@ -87,6 +87,19 @@ pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was `unit` cut on its file's unit grid? An encrypted BD-TS unit's clear seed
|
||||
/// always starts a source packet, so its TS sync (0x47) sits at byte 4; a chunk
|
||||
/// read off the grid starts mid-unit on ciphertext. Clear units, and `MpegPs`
|
||||
/// (seed layout unverified), always pass. A wrong-grid chunk with CPI=0 also
|
||||
/// passes (~1 in 4 per unit), so single-unit reads can slip; multi-unit reads are
|
||||
/// caught almost surely. An on-grid unit whose seed is corrupt (media or read
|
||||
/// damage in bytes 0..5) fails the whole read as DecryptFailed.
|
||||
pub(crate) fn aacs_unit_on_grid(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
format != crate::disc::ContentFormat::BdTs
|
||||
|| !aacs_unit_seed_encrypted(unit, format)
|
||||
|| unit.get(4) == Some(&TS_SYNC)
|
||||
}
|
||||
|
||||
/// True when an aligned unit is flagged encrypted AND still looks scrambled
|
||||
/// (structure not yet restored) — genuine encrypted content NOT yet decrypted.
|
||||
///
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
/// Union the host certificates a scan can offer the drive: the explicit
|
||||
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
|
||||
/// keysource-served, never compiled in. `mkb` lets a source pick a
|
||||
/// generation-appropriate cert (the default impl ignores it).
|
||||
/// keysource-served, never compiled in. The scan passes `None` for `mkb`: the
|
||||
/// argument is reserved and will be removed at the next breaking window.
|
||||
pub fn collect_host_certs(
|
||||
opts: &crate::disc::ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
|
||||
@@ -446,6 +446,12 @@ fn apply_aacs_map(
|
||||
}
|
||||
return;
|
||||
};
|
||||
// Off-grid guard: a real unit's clear seed carries its sync byte, so a flagged
|
||||
// chunk without it was cut across two units (wrong unit base) — fail loud.
|
||||
if !aacs::content::aacs_unit_on_grid(chunk, format) {
|
||||
verify_failed.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
// PHASE GATE (FMTS forensic segment): the segment interleaves two variants
|
||||
// at the unit level. Decrypt ONLY our parity; leave the alternate half as
|
||||
// ciphertext (the muxer drops untouched ciphertext cleanly — no garble).
|
||||
|
||||
@@ -212,6 +212,7 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
|
||||
let mut buf = vec![0u8; count as usize * 2048];
|
||||
// `recovery=false`: a single best-effort attempt — the probe must never
|
||||
// stall the mux or hammer a marginal drive. On any error, bail to ordinal.
|
||||
reader.set_unit_base(ext.start_lba); // HD DVD AACS: anchor at the title head
|
||||
let n = match reader.read_sectors(ext.start_lba, count, &mut buf, false) {
|
||||
Ok(n) => n,
|
||||
Err(_) => return,
|
||||
|
||||
+496
-471
File diff suppressed because it is too large
Load Diff
+49
-3
@@ -437,7 +437,8 @@ fn unique_probe_token() -> String {
|
||||
|
||||
/// Recursively plan the host tree: collect directories to create and files to
|
||||
/// extract, sanitizing each component and detecting host-path collisions.
|
||||
/// Skips the top-level `AACS/` and `CERTIFICATE/` directories (§7).
|
||||
/// Skips the top-level `AACS/`, `CERTIFICATE/`, and HD DVD's discovered `X!`
|
||||
/// AACS directories (§7).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plan_tree(
|
||||
reader: &mut dyn SectorSource,
|
||||
@@ -451,16 +452,21 @@ fn plan_tree(
|
||||
dirs: &mut Vec<PathBuf>,
|
||||
seen_hosts: &mut std::collections::HashMap<String, String>,
|
||||
) -> Result<()> {
|
||||
// Same discovery the AACS capture reads use, so the two can't drift.
|
||||
let hddvd_aacs_dir = is_root
|
||||
.then(|| crate::aacs::find_hddvd_aacs_dir(fs))
|
||||
.flatten();
|
||||
for entry in &dir.entries {
|
||||
if entry.name.is_empty() {
|
||||
// The "parent" FID (".") has an empty name — skip.
|
||||
continue;
|
||||
}
|
||||
// Strip AACS / CERTIFICATE at the top level only (a deeper dir of the
|
||||
// same name, if it ever existed, is content).
|
||||
// same name is content).
|
||||
if is_root
|
||||
&& (entry.name.eq_ignore_ascii_case("AACS")
|
||||
|| entry.name.eq_ignore_ascii_case("CERTIFICATE"))
|
||||
|| entry.name.eq_ignore_ascii_case("CERTIFICATE")
|
||||
|| hddvd_aacs_dir.is_some_and(|d| std::ptr::eq(d, entry)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1396,6 +1402,46 @@ mod tests {
|
||||
assert_eq!(res.bytes_lost(), 0);
|
||||
}
|
||||
|
||||
/// An HD DVD's `X!` AACS dir is stripped like BD's `AACS/`, so the decrypted
|
||||
/// folder does not rescan as AACS-encrypted.
|
||||
#[test]
|
||||
fn hddvd_extract_strips_the_aacs_dir() {
|
||||
let evo = vec![0x5Au8; 3 * 2048];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "HVDVD_TS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![file("MAIN.EVO", 30, 5000, evo.clone(), false)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 22,
|
||||
dir_data_lba: 23,
|
||||
files: vec![
|
||||
file("MKBROM.AACS", 32, 33, vec![1; 64], false),
|
||||
file("VTKF000.AACS", 34, 35, vec![2; 64], false),
|
||||
],
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut disc = build_disc(root);
|
||||
let out = TmpDir::new("hddvd");
|
||||
let mut d = clear_disc();
|
||||
d.content_format = crate::disc::ContentFormat::MpegPs;
|
||||
d.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
|
||||
.expect("extract");
|
||||
assert_eq!(read_out(out.path(), "HVDVD_TS/MAIN.EVO"), Some(evo));
|
||||
assert!(!out.path().join("AAC!").exists(), "AAC!/ must be stripped");
|
||||
}
|
||||
|
||||
/// VIDEO_TS extraction writes VOBs + IFO/BUP. Here the content is clear
|
||||
/// (None keys), proving the tree walk + per-file write for the DVD layout;
|
||||
/// CSS descramble correctness is tested separately below.
|
||||
|
||||
+82
-24
@@ -297,25 +297,33 @@ struct XplTitle {
|
||||
chapters: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) timecode at `tick_base` frames/sec into
|
||||
/// seconds. `None` on a malformed field.
|
||||
fn parse_timecode(s: &str, tick_base: u32) -> Option<f64> {
|
||||
/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) title timecode, `FF` in `time_base`
|
||||
/// frames/sec (the `<TitleSet timeBase>`), into seconds. `None` on a malformed field.
|
||||
/// A 60fps timeBase is scaled by 1001/1000: measured, not from a spec (a real disc's
|
||||
/// second feature clip starts at 2951.604 s where 60 exact puts it at 2948.667 s).
|
||||
/// Other timeBases (24/30/50) are unverified on real media and taken at face value.
|
||||
fn parse_timecode(s: &str, time_base: u32) -> Option<f64> {
|
||||
let n: Vec<u32> = s
|
||||
.split(':')
|
||||
.map(|p| p.trim().parse::<u32>())
|
||||
.collect::<std::result::Result<Vec<u32>, _>>()
|
||||
.ok()?;
|
||||
let tb = tick_base.max(1) as f64;
|
||||
let tb = time_base.max(1) as f64;
|
||||
let (h, m, sec, f) = match n.as_slice() {
|
||||
[h, m, s, f] => (*h, *m, *s, *f),
|
||||
[m, s, f] => (0, *m, *s, *f),
|
||||
_ => return None,
|
||||
};
|
||||
Some(h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb)
|
||||
let nominal = h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb;
|
||||
Some(if time_base == 60 {
|
||||
nominal * 1.001
|
||||
} else {
|
||||
nominal
|
||||
})
|
||||
}
|
||||
|
||||
/// `tickBase="60fps"` → 60. Defaults to 60 when absent/unparseable.
|
||||
fn parse_tick_base(s: &str) -> u32 {
|
||||
/// `timeBase="60fps"` → 60. Defaults to 60 when unparseable.
|
||||
fn parse_frame_rate(s: &str) -> u32 {
|
||||
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
digits.parse().unwrap_or(60)
|
||||
}
|
||||
@@ -455,12 +463,12 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
|
||||
};
|
||||
let local = |n: &roxmltree::Node, name: &str| n.tag_name().name() == name;
|
||||
|
||||
// tickBase lives on <TitleSet> (default 60fps).
|
||||
let tick_base = doc
|
||||
.descendants()
|
||||
.find(|n| local(n, "TitleSet"))
|
||||
.and_then(|n| n.attribute("tickBase"))
|
||||
.map(parse_tick_base)
|
||||
// Title timecodes count frames in <TitleSet timeBase>; tickBase is the markup
|
||||
// clock and only a fallback when timeBase is absent (default 60fps).
|
||||
let title_set = doc.descendants().find(|n| local(n, "TitleSet"));
|
||||
let time_base = title_set
|
||||
.and_then(|n| n.attribute("timeBase").or_else(|| n.attribute("tickBase")))
|
||||
.map(parse_frame_rate)
|
||||
.unwrap_or(60);
|
||||
|
||||
let mut titles = Vec::new();
|
||||
@@ -480,7 +488,7 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
|
||||
.to_string();
|
||||
let duration_secs = tnode
|
||||
.attribute("titleDuration")
|
||||
.and_then(|s| parse_timecode(s, tick_base))
|
||||
.and_then(|s| parse_timecode(s, time_base))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let mut clips = Vec::new();
|
||||
@@ -499,11 +507,11 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
|
||||
};
|
||||
let begin_secs = c
|
||||
.attribute("titleTimeBegin")
|
||||
.and_then(|s| parse_timecode(s, tick_base))
|
||||
.and_then(|s| parse_timecode(s, time_base))
|
||||
.unwrap_or(0.0);
|
||||
let end_secs = c
|
||||
.attribute("titleTimeEnd")
|
||||
.and_then(|s| parse_timecode(s, tick_base))
|
||||
.and_then(|s| parse_timecode(s, time_base))
|
||||
.unwrap_or(begin_secs);
|
||||
clips.push(XplClip {
|
||||
evo,
|
||||
@@ -522,7 +530,7 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
|
||||
.filter(|n| local(n, "Chapter"))
|
||||
.filter_map(|ch| {
|
||||
ch.attribute("titleTimeBegin")
|
||||
.and_then(|s| parse_timecode(s, tick_base))
|
||||
.and_then(|s| parse_timecode(s, time_base))
|
||||
})
|
||||
.take(MAX_XPL_CHAPTERS_PER_TITLE)
|
||||
.collect();
|
||||
@@ -1691,17 +1699,67 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_timecode_hhmmssff_at_60fps() {
|
||||
// 01:37:20:00 = 5840 s exactly.
|
||||
assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0).abs() < 1e-6);
|
||||
// 01:37:20:00 = 5840 nominal seconds, x1.001 at the NTSC tick rate.
|
||||
assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0 * 1.001).abs() < 1e-6);
|
||||
// 00:48:29:50 = 48m29s + 50/60 frames.
|
||||
let want = 48.0 * 60.0 + 29.0 + 50.0 / 60.0;
|
||||
let want = (48.0 * 60.0 + 29.0 + 50.0 / 60.0) * 1.001;
|
||||
assert!((parse_timecode("00:48:29:50", 60).unwrap() - want).abs() < 1e-6);
|
||||
// MM:SS:FF short form (hours omitted).
|
||||
assert!((parse_timecode("02:05:15", 60).unwrap() - (125.0 + 15.0 / 60.0)).abs() < 1e-6);
|
||||
let want = (125.0 + 15.0 / 60.0) * 1.001;
|
||||
assert!((parse_timecode("02:05:15", 60).unwrap() - want).abs() < 1e-6);
|
||||
assert_eq!(parse_timecode("garbage", 60), None);
|
||||
assert_eq!(parse_timecode("", 60), None);
|
||||
}
|
||||
|
||||
// A real HD DVD (VPLST000.XPL, timeBase="60fps") against its own EVOs as measured
|
||||
// by ffprobe: title time runs at 60000/1001, so 60 exact reads every title 0.1% short.
|
||||
#[test]
|
||||
fn xpl_60fps_timecodes_run_at_ntsc_rate() {
|
||||
for (tc, evo_secs) in [
|
||||
("00:49:08:40", 2951.604), // PEVOB_2 (feature clip 2) video start
|
||||
("00:14:49:00", 889.888), // hazards
|
||||
("00:04:42:00", 282.272), // daisy
|
||||
("00:02:01:56", 122.064), // intro
|
||||
] {
|
||||
let got = parse_timecode(tc, 60).unwrap();
|
||||
assert!(
|
||||
(got - evo_secs).abs() < 0.1,
|
||||
"{tc}: {got} vs EVO {evo_secs}"
|
||||
);
|
||||
}
|
||||
// PAL tick base is exact.
|
||||
assert!((parse_timecode("00:14:49:00", 50).unwrap() - 889.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
// Timecode frames count in timeBase, not tickBase (the markup tick clock).
|
||||
#[test]
|
||||
fn xpl_timecodes_use_time_base_not_tick_base() {
|
||||
let xpl = |attrs: &str| {
|
||||
format!(
|
||||
r#"<Playlist xmlns="http://www.dvdforum.org/2005/HDDVDVideo/Playlist">
|
||||
<TitleSet {attrs}>
|
||||
<Title titleNumber="1" titleDuration="00:00:10:30">
|
||||
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:10:30" src="file:///dvddisc/HVDVD_TS/A.MAP"/>
|
||||
</Title>
|
||||
</TitleSet>
|
||||
</Playlist>"#
|
||||
)
|
||||
};
|
||||
let dur = |attrs: &str| parse_xpl_titles(xpl(attrs).as_bytes())[0].duration_secs;
|
||||
let ntsc60 = 10.5 * 1.001;
|
||||
let d = dur(r#"timeBase="60fps" tickBase="24fps""#);
|
||||
assert!(
|
||||
(d - ntsc60).abs() < 1e-9,
|
||||
"timeBase 60 wins over tickBase 24: {d}"
|
||||
);
|
||||
let d = dur(r#"tickBase="60fps""#);
|
||||
assert!((d - ntsc60).abs() < 1e-9, "tickBase is the fallback: {d}");
|
||||
let d = dur(r#"timeBase="50fps" tickBase="60fps""#);
|
||||
assert!((d - 10.6).abs() < 1e-9, "50fps unadjusted: {d}");
|
||||
let d = dur("");
|
||||
assert!((d - ntsc60).abs() < 1e-9, "default 60fps: {d}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evo_from_src_maps_map_sidecar_to_evo() {
|
||||
assert_eq!(
|
||||
@@ -1727,7 +1785,7 @@ mod tests {
|
||||
assert_eq!(mm.number, 2);
|
||||
assert_eq!(mm.name, "Main Movie");
|
||||
assert!(
|
||||
(mm.duration_secs - 5840.0).abs() < 1e-6,
|
||||
(mm.duration_secs - 5840.0 * 1.001).abs() < 1e-6,
|
||||
"97:20 from titleDuration"
|
||||
);
|
||||
// The layer-break split is ONE title with TWO clips, contiguous timeline.
|
||||
@@ -1743,7 +1801,7 @@ mod tests {
|
||||
"second clip carries a title-time offset"
|
||||
);
|
||||
assert_eq!(mm.chapters.len(), 2);
|
||||
assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0)).abs() < 1e-6);
|
||||
assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0) * 1.001).abs() < 1e-6);
|
||||
|
||||
let del = &titles[1];
|
||||
assert_eq!(del.name, "Deleted Scenes - Veronica Past");
|
||||
@@ -2005,7 +2063,7 @@ mod tests {
|
||||
.expect("MainMovie composed from the playlist");
|
||||
assert_eq!(mm.playlist_id, 2);
|
||||
assert!(
|
||||
(mm.duration_secs - 5840.0).abs() < 1.0,
|
||||
(mm.duration_secs - 5840.0 * 1.001).abs() < 1.0,
|
||||
"97:20 duration from titleDuration, not 0/unknown"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
+179
-223
@@ -16,14 +16,14 @@ mod extract;
|
||||
mod hddvd;
|
||||
pub(crate) mod pgs_forced_probe;
|
||||
pub mod profile;
|
||||
#[cfg(test)]
|
||||
mod scan_order_tests;
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
use encrypt::HandshakeResult;
|
||||
|
||||
// Re-export label classification enums alongside AudioStream / SubtitleStream
|
||||
// so the public surface keeps the structured metadata together. Callers map
|
||||
// these to display text in their own locale.
|
||||
@@ -496,9 +496,12 @@ pub struct Extent {
|
||||
}
|
||||
|
||||
// THE ONE definition of "structurally AACS-encrypted" — shared by the fast identify and the
|
||||
// full scan so they can't silently desync. Structural, not cryptographic.
|
||||
// full scan so they can't silently desync. Structural, not cryptographic; HD DVD's `X!` dir
|
||||
// is found by the same discovery the key-file reads use.
|
||||
pub(crate) fn aacs_dir_present(udf_fs: &crate::udf::UdfFs) -> bool {
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some()
|
||||
udf_fs.find_dir("/AACS").is_some()
|
||||
|| udf_fs.find_dir("/BDMV/AACS").is_some()
|
||||
|| crate::aacs::find_hddvd_aacs_dir(udf_fs).is_some()
|
||||
}
|
||||
|
||||
// Title-ranking heuristic thresholds. Each gates a DISTINCT decision — several share a value
|
||||
@@ -1640,6 +1643,11 @@ pub struct ScanOptions {
|
||||
/// scanned title (e.g. `freemkv info`) opt in. The rip path leaves it off:
|
||||
/// the muxer detects forced during muxing without a second read.
|
||||
pub probe_forced_subtitles: bool,
|
||||
/// The caller copies sectors raw and never decrypts or accepts keys (a raw
|
||||
/// disc→ISO copy). A live AACS disc whose `Unit_Key_RO.inf` is unreadable then
|
||||
/// still scans, with [`Error::AacsKeyFileUnreadable`] recorded and every key refused;
|
||||
/// otherwise `Disc::scan` returns that error.
|
||||
pub raw_copy: bool,
|
||||
}
|
||||
|
||||
/// Quick disc identification — name, format, capacity. No title/stream parsing.
|
||||
@@ -1698,7 +1706,9 @@ impl Disc {
|
||||
|
||||
/// Read UDF filesystem and set up buffered reader with metadata prefetched.
|
||||
/// Shared setup for both identify() and scan().
|
||||
fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> {
|
||||
fn read_udf(
|
||||
session: &mut Drive,
|
||||
) -> Result<(u32, udf::BufferedSectorReader<'_, Drive>, udf::UdfFs)> {
|
||||
// READ CAPACITY is the sole authoritative whole-disc size (the UDF partition
|
||||
// is a subset — 288 sectors short on a real BD, truncating the backup anchor).
|
||||
// Its sporadic failures are ridden out by `read_capacity_retrying` (0 on hard fail).
|
||||
@@ -1708,7 +1718,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
// UDF filesystem + metadata-prefetched reader, without the READ CAPACITY step.
|
||||
fn open_udf(session: &mut Drive) -> Result<(udf::BufferedSectorReader<'_>, udf::UdfFs)> {
|
||||
fn open_udf(session: &mut Drive) -> Result<(udf::BufferedSectorReader<'_, Drive>, udf::UdfFs)> {
|
||||
let batch = detect_max_batch_sectors(session.device_path());
|
||||
let mut buffered = udf::BufferedSectorReader::new(session, batch);
|
||||
let udf_fs = udf::read_filesystem(&mut buffered)?;
|
||||
@@ -1796,136 +1806,117 @@ impl Disc {
|
||||
})
|
||||
}
|
||||
|
||||
/// Scan a disc — parse filesystem, playlists, streams, and set up AACS
|
||||
/// decryption. This is the main entry point; after `scan()` the Disc is
|
||||
/// ready (titles populated with streams, AACS inputs captured, content
|
||||
/// readable and decryptable transparently). One pipeline, one order:
|
||||
/// (1) read capacity + UDF filesystem, (2) AACS handshake + key
|
||||
/// resolution, (3) parse playlists + streams, (4) apply labels. The
|
||||
/// session must be open and unlocked (`Drive::open` handles this). All
|
||||
/// disc reads use standard READ(10) via UDF — no vendor SCSI commands.
|
||||
/// Scan a disc — parse filesystem, playlists, streams, and capture AACS
|
||||
/// inputs. The main entry point; the session must be open. One order, the
|
||||
/// standard one: (1) DVD only: CSS bus-auth, (2) read capacity + UDF, (3) read
|
||||
/// the AACS files (`Unit_Key_RO.inf`, content cert, MKB) with plain READs,
|
||||
/// (4) the AACS handshake when the disc has AACS, (5) titles + labels.
|
||||
///
|
||||
/// A live AACS disc whose `Unit_Key_RO.inf` cannot be read fails with
|
||||
/// [`Error::AacsKeyFileUnreadable`] before any AACS command (see
|
||||
/// [`ScanOptions::raw_copy`]). A dead bus during a handshake aborts the scan.
|
||||
pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result<Self> {
|
||||
// AACS handshake (Blu-ray/UHD) acquires the Volume ID via cert-based
|
||||
// mutual-auth; drive unlock runs separately behind the `Unlocker` seam.
|
||||
// DVD uses CSS, so skip AACS entirely — no OEM-VID/cert SCSI before bus-auth.
|
||||
let (handshake, handshake_error) = if session.disc_is_dvd() {
|
||||
(None, None)
|
||||
} else {
|
||||
tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
|
||||
Self::do_handshake(session, opts)
|
||||
};
|
||||
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
|
||||
Self::scan_after_handshake(session, handshake, handshake_error, opts)
|
||||
}
|
||||
|
||||
// Everything in `scan` after the AACS handshake; split out so the bus wiring is
|
||||
// testable against a mock transport with a synthetic handshake.
|
||||
fn scan_after_handshake(
|
||||
session: &mut Drive,
|
||||
handshake: Option<encrypt::HandshakeResult>,
|
||||
handshake_error: Option<Error>,
|
||||
opts: &ScanOptions,
|
||||
) -> Result<Self> {
|
||||
let firmware_unlocked = session.unlocker_name().is_some();
|
||||
// Request max read speed — removes riplock on DVD
|
||||
// (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED)
|
||||
let dvd = session.disc_is_dvd();
|
||||
// Max read speed; removes riplock on DVD.
|
||||
session.set_speed(0xFFFF);
|
||||
|
||||
// CSS bus-auth unlock — run BEFORE any scrambled-sector read, else the UDF
|
||||
// metadata prefetch below hits scrambled VOB extents (sense 05/6F/03).
|
||||
// Title keys are cracked later via `css::resolve_dvd_title_key` instead.
|
||||
if session.disc_is_dvd() {
|
||||
tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)");
|
||||
let drive_id = session.drive_id.clone();
|
||||
let (_, css_unlock_res) = crate::unlock_bridge::run_bus(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Css,
|
||||
&[],
|
||||
);
|
||||
match css_unlock_res {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
"CSS bus-auth unlock declined; scrambled sectors may be unavailable"
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
outcome = ?e,
|
||||
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
||||
),
|
||||
}
|
||||
// CSS bus-auth before any read, else the UDF prefetch hits scrambled VOB extents.
|
||||
if dvd {
|
||||
Self::css_bus_step(session)?;
|
||||
}
|
||||
|
||||
// Read UDF filesystem with buffered sector reader
|
||||
tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem");
|
||||
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
||||
tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read");
|
||||
|
||||
// Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv).
|
||||
// Without this, each read_file() triggers individual SCSI commands at 500ms each.
|
||||
// Pre-read small files (AACS, MPLS, CLPI, META, *.bdmv): one command each otherwise.
|
||||
if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) {
|
||||
buffered.prefetch_ranges(&ranges);
|
||||
}
|
||||
|
||||
// The AACS bus stage is decided ONCE from the handshake result, captured
|
||||
// before `handshake` is moved into `scan_with` (see `BusStage`).
|
||||
let bus_key = Self::bus_key_for_disc(
|
||||
&mut buffered,
|
||||
&udf_fs,
|
||||
handshake.as_ref().and_then(|h| h.read_data_key),
|
||||
);
|
||||
let aacs = if aacs_dir_present(&udf_fs) {
|
||||
// A DVD with /AACS gets no handshake, so its key file keeps the image rule.
|
||||
let from = if dvd {
|
||||
encrypt::CaptureFrom::Image
|
||||
} else {
|
||||
encrypt::CaptureFrom::Live {
|
||||
raw_copy: opts.raw_copy,
|
||||
}
|
||||
};
|
||||
let cap = encrypt::capture(&mut buffered, &udf_fs, from)?;
|
||||
let bus = if !dvd {
|
||||
tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
|
||||
encrypt::aacs_bus_step(buffered.inner_mut(), opts)?
|
||||
} else if buffered.inner_mut().unlocker_name().is_some() {
|
||||
encrypt::BusOutcome::FirmwareUnlocked
|
||||
} else {
|
||||
encrypt::BusOutcome::FileOrIso
|
||||
};
|
||||
Some((cap, bus))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self::live_finish(buffered, capacity, udf_fs, aacs, opts)
|
||||
}
|
||||
|
||||
// `finish` over the live reader, then the drive's bus wiring from the bus outcome.
|
||||
fn live_finish(
|
||||
mut buffered: udf::BufferedSectorReader<'_, Drive>,
|
||||
capacity: u32,
|
||||
udf_fs: udf::UdfFs,
|
||||
aacs: Option<(encrypt::AacsCapture, encrypt::BusOutcome)>,
|
||||
opts: &ScanOptions,
|
||||
) -> Result<Self> {
|
||||
let bus_key = aacs.as_ref().and_then(|(c, b)| encrypt::bus_key(c, b));
|
||||
let stream_files = match bus_key {
|
||||
Some(_) => Self::stream_file_extents(&mut buffered, &udf_fs),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams");
|
||||
let disc = Self::scan_with(
|
||||
&mut buffered,
|
||||
capacity,
|
||||
handshake,
|
||||
handshake_error,
|
||||
firmware_unlocked,
|
||||
opts,
|
||||
udf_fs,
|
||||
)?;
|
||||
let disc = Self::finish(&mut buffered, capacity, udf_fs, aacs, opts)?;
|
||||
tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed");
|
||||
|
||||
// Wire the SINGLE de-bus point onto the drive from the handshake result,
|
||||
// BEFORE the caller samples keys or muxes (the earlier UDF/metadata reads
|
||||
// ran under default Passthrough).
|
||||
// The SINGLE de-bus point, wired before the caller samples keys or muxes (the
|
||||
// metadata reads above ran under Passthrough).
|
||||
let session = buffered.into_inner();
|
||||
Self::wire_bus_removal(session, bus_key, bus_map(stream_files, &disc.titles));
|
||||
|
||||
// No CSS key recovery at scan time: DVD CSS keys are per-title and are
|
||||
// re-cracked keylessly at read/decrypt time (`disc.css` stays unset),
|
||||
// so log the format rather than a key state that is always `None`.
|
||||
// No CSS key recovery at scan time: DVD CSS keys are re-cracked keylessly at read time.
|
||||
tracing::info!(target: "freemkv::scan", format = ?disc.format, titles = disc.titles.len(), "phase: scan complete");
|
||||
Ok(disc)
|
||||
}
|
||||
|
||||
// Drops the Read Data Key when the content cert's BEE flag is clear (AACS spec;
|
||||
// libaacs gates on `bee && bec`). An unreadable cert keeps the key. HD DVD
|
||||
// CONTENT_CERT.AACS BEE semantics are unverified; the same rule applies.
|
||||
pub(crate) fn bus_key_for_disc(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
read_data_key: Option<[u8; 16]>,
|
||||
) -> Option<[u8; 16]> {
|
||||
read_data_key?;
|
||||
let bee = crate::aacs::read_first(
|
||||
&crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::ContentCert),
|
||||
|p| udf_fs.read_file(reader, p),
|
||||
)
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(crate::aacs::inf::parse_content_cert)
|
||||
.map(|c| c.bus_encryption);
|
||||
if bee == Some(false) {
|
||||
tracing::info!(target: "freemkv::scan", "content cert BEE=0: bus removal off");
|
||||
return None;
|
||||
// CSS bus-auth for a live DVD. A dead bus aborts like `Drive::init`; a Stop is `Halted`.
|
||||
fn css_bus_step(session: &mut Drive) -> Result<()> {
|
||||
tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)");
|
||||
if session.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
read_data_key
|
||||
let drive_id = session.drive_id.clone();
|
||||
let (_, res) = crate::unlock_bridge::run_bus(
|
||||
session.scsi_mut(),
|
||||
&drive_id,
|
||||
freemkv_unlock::DiscKind::Css,
|
||||
&[],
|
||||
);
|
||||
if session.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
match res {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
"CSS bus-auth unlock declined; scrambled sectors may be unavailable"
|
||||
),
|
||||
Err(freemkv_unlock::UnlockError::Transport) => {
|
||||
return Err(crate::unlock_bridge::unlock_transport_error());
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
outcome = ?e,
|
||||
"CSS bus-auth unlock did not apply; scrambled sectors may be unavailable"
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The AACS stream files' sectors (every file under `/BDMV/STREAM`: m2ts,
|
||||
@@ -2014,7 +2005,7 @@ impl Disc {
|
||||
opts: &ScanOptions,
|
||||
) -> Result<Self> {
|
||||
let udf_fs = udf::read_filesystem(reader)?;
|
||||
let mut disc = Self::scan_with(reader, capacity, None, None, false, opts, udf_fs)?;
|
||||
let mut disc = Self::scan_fs(reader, capacity, opts, udf_fs)?;
|
||||
|
||||
// CSS for a raw (still-scrambled) DVD image: recover the title key via
|
||||
// known-plaintext crack (no SCSI auth needed). Gated on `DiscFormat::Dvd`,
|
||||
@@ -2241,50 +2232,40 @@ impl Disc {
|
||||
Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs)
|
||||
}
|
||||
|
||||
// Core scan pipeline — works with any SectorSource. `handshake_error` is
|
||||
// plumbed so failures are preserved as `disc.aacs_error`, unless key
|
||||
// resolution still succeeds (built-in keys + disc-hash hit).
|
||||
fn scan_with(
|
||||
// Image scan body (no SCSI): capture the AACS files, then `finish`. A missing
|
||||
// `Unit_Key_RO.inf` is recorded, not fatal (decrypted folders, deferred mux).
|
||||
fn scan_fs(
|
||||
reader: &mut dyn SectorSource,
|
||||
capacity: u32,
|
||||
handshake: Option<HandshakeResult>,
|
||||
handshake_error: Option<Error>,
|
||||
firmware_unlocked: bool,
|
||||
opts: &ScanOptions,
|
||||
udf_fs: udf::UdfFs,
|
||||
) -> Result<Self> {
|
||||
let aacs = if aacs_dir_present(&udf_fs) {
|
||||
let cap = encrypt::capture(reader, &udf_fs, encrypt::CaptureFrom::Image)?;
|
||||
Some((cap, encrypt::BusOutcome::FileOrIso))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self::finish(reader, capacity, udf_fs, aacs, opts)
|
||||
}
|
||||
|
||||
// Everything after the AACS bus step, for live and image scans alike: the AACS
|
||||
// verdict, titles, labels, format. `aacs` is `None` for a disc with no AACS dir.
|
||||
fn finish(
|
||||
reader: &mut dyn SectorSource,
|
||||
capacity: u32,
|
||||
udf_fs: udf::UdfFs,
|
||||
aacs: Option<(encrypt::AacsCapture, encrypt::BusOutcome)>,
|
||||
opts: &ScanOptions,
|
||||
) -> Result<Self> {
|
||||
let scan_with_t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
|
||||
// 2. Resolve encryption (AACS, CSS, or none)
|
||||
let encrypted = aacs_dir_present(&udf_fs);
|
||||
|
||||
let (aacs, aacs_error) = if !encrypted {
|
||||
(None, None)
|
||||
} else {
|
||||
// Lookup-free: capture the disc's AACS inputs (MKB, VID, Unit_Key_RO.inf)
|
||||
// but resolve no key — the caller resolves one and applies it via
|
||||
// `Disc::decrypt_with`; until then the disc reports "encrypted, no keys".
|
||||
let source = encrypt::BusSource::classify(
|
||||
handshake.as_ref(),
|
||||
handshake_error.is_some(),
|
||||
firmware_unlocked,
|
||||
);
|
||||
match Self::resolve_vid_only(&udf_fs, reader, source) {
|
||||
Ok(state) => {
|
||||
let err =
|
||||
encrypt::aacs_scan_error(state.bus_encryption, source, handshake_error);
|
||||
(Some(state), err)
|
||||
}
|
||||
// The capture error is surfaced, except that a handshake-class failure
|
||||
// is kept: it is what makes `bus_blocked_error` refuse keys fail-safe.
|
||||
Err(e) => match handshake_error {
|
||||
Some(h) if encrypt::handshake_class_error(&h).is_some() => {
|
||||
tracing::warn!(target: "freemkv::scan", capture_error = e.code(), handshake_error = h.code(), "AACS inputs unreadable after a failed handshake");
|
||||
(None, Some(h))
|
||||
}
|
||||
_ => (None, Some(e)),
|
||||
},
|
||||
}
|
||||
let encrypted = aacs.is_some();
|
||||
// Lookup-free: the state carries the disc's AACS inputs but no key; the caller
|
||||
// resolves one and applies it via `Disc::decrypt_with`.
|
||||
let (aacs, aacs_error) = match aacs {
|
||||
Some((cap, bus)) => encrypt::resolve_aacs(cap, &bus),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
// 3. Titles + container — dispatched by on-disc tree (HD-DVD/DVD peers,
|
||||
@@ -4069,7 +4050,7 @@ mod tests {
|
||||
inner: &mut disc,
|
||||
clip_reads: 0,
|
||||
};
|
||||
let res = Disc::scan_with(&mut reader, 3_997_952, None, None, false, &opts, udf);
|
||||
let res = Disc::scan_fs(&mut reader, 3_997_952, &opts, udf);
|
||||
let clip_reads = reader.clip_reads;
|
||||
|
||||
assert!(
|
||||
@@ -4111,16 +4092,7 @@ mod tests {
|
||||
// Sanity: the same disc scans clean when nothing is cancelled, so a
|
||||
// pass below cannot be some unrelated failure wearing Halted.
|
||||
assert!(
|
||||
Disc::scan_with(
|
||||
&mut disc,
|
||||
500_000,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
&ScanOptions::default(),
|
||||
udf
|
||||
)
|
||||
.is_ok(),
|
||||
Disc::scan_fs(&mut disc, 500_000, &ScanOptions::default(), udf).is_ok(),
|
||||
"fixture must scan successfully when not cancelled"
|
||||
);
|
||||
|
||||
@@ -4131,7 +4103,7 @@ mod tests {
|
||||
halt: Some(halt),
|
||||
..Default::default()
|
||||
};
|
||||
let res = Disc::scan_with(&mut disc, 500_000, None, None, false, &opts, udf);
|
||||
let res = Disc::scan_fs(&mut disc, 500_000, &opts, udf);
|
||||
assert!(
|
||||
matches!(res, Err(Error::Halted)),
|
||||
"a cancelled BD scan must say so; returning a title list built \
|
||||
@@ -4168,16 +4140,7 @@ mod tests {
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
assert!(
|
||||
Disc::scan_with(
|
||||
&mut disc,
|
||||
500_000,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
&ScanOptions::default(),
|
||||
udf
|
||||
)
|
||||
.is_ok(),
|
||||
Disc::scan_fs(&mut disc, 500_000, &ScanOptions::default(), udf).is_ok(),
|
||||
"fixture must scan successfully when not cancelled"
|
||||
);
|
||||
|
||||
@@ -4188,7 +4151,7 @@ mod tests {
|
||||
halt: Some(halt),
|
||||
..Default::default()
|
||||
};
|
||||
let res = Disc::scan_with(&mut disc, 500_000, None, None, false, &opts, udf);
|
||||
let res = Disc::scan_fs(&mut disc, 500_000, &opts, udf);
|
||||
assert!(
|
||||
matches!(res, Err(Error::Halted)),
|
||||
"a cancelled DVD scan must say so, not hand back whatever it had \
|
||||
@@ -4227,15 +4190,7 @@ mod tests {
|
||||
|
||||
let (mut disc, udf) = hddvd_two_clip_disc(3_000_000, 5_000_000);
|
||||
let mut reader = HaltingReader { inner: &mut disc };
|
||||
let res = Disc::scan_with(
|
||||
&mut reader,
|
||||
3_997_952,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
&ScanOptions::default(),
|
||||
udf,
|
||||
);
|
||||
let res = Disc::scan_fs(&mut reader, 3_997_952, &ScanOptions::default(), udf);
|
||||
assert!(
|
||||
matches!(res, Err(Error::Halted)),
|
||||
"reads cancelled by the drive's own halt flag must surface as a \
|
||||
@@ -4255,16 +4210,8 @@ mod tests {
|
||||
fn scan_with_capacity_bytes_uses_multiplication_not_addition() {
|
||||
const CAPACITY_SECTORS: u32 = 3_997_952; // *2048 = 8_187_805_696; +2048 = 4_000_000
|
||||
let (mut disc, udf) = hddvd_two_clip_disc(3_000_000, 5_000_000);
|
||||
let scanned = Disc::scan_with(
|
||||
&mut disc,
|
||||
CAPACITY_SECTORS,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
&ScanOptions::default(),
|
||||
udf,
|
||||
)
|
||||
.expect("scan_with must succeed on this synthetic HD-DVD image");
|
||||
let scanned = Disc::scan_fs(&mut disc, CAPACITY_SECTORS, &ScanOptions::default(), udf)
|
||||
.expect("scan_with must succeed on this synthetic HD-DVD image");
|
||||
assert_eq!(
|
||||
scanned.titles.first().map(|t| t.playlist.as_str()),
|
||||
Some("OTHER.EVO"),
|
||||
@@ -4283,16 +4230,8 @@ mod tests {
|
||||
fn scan_with_capacity_bytes_uses_multiplication_not_division() {
|
||||
const CAPACITY_SECTORS: u32 = 204_800_000; // *2048 = huge; /2048 = 100_000
|
||||
let (mut disc, udf) = hddvd_two_clip_disc(1_000, 5_000_000);
|
||||
let scanned = Disc::scan_with(
|
||||
&mut disc,
|
||||
CAPACITY_SECTORS,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
&ScanOptions::default(),
|
||||
udf,
|
||||
)
|
||||
.expect("scan_with must succeed on this synthetic HD-DVD image");
|
||||
let scanned = Disc::scan_fs(&mut disc, CAPACITY_SECTORS, &ScanOptions::default(), udf)
|
||||
.expect("scan_with must succeed on this synthetic HD-DVD image");
|
||||
assert_eq!(
|
||||
scanned.titles.first().map(|t| t.playlist.as_str()),
|
||||
Some("OTHER.EVO"),
|
||||
@@ -9252,6 +9191,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The de-bus key a live scan picks for `rdk` over this disc's captured content cert.
|
||||
fn bus_key_for_disc(
|
||||
mem: &mut crate::udf::fixture::MemDisc,
|
||||
udf: &udf::UdfFs,
|
||||
rdk: Option<[u8; 16]>,
|
||||
) -> Option<[u8; 16]> {
|
||||
let from = encrypt::CaptureFrom::Live { raw_copy: true };
|
||||
let cap = encrypt::capture(mem, udf, from).expect("capture");
|
||||
let bus = encrypt::BusOutcome::Handshake(encrypt::HandshakeResult {
|
||||
volume_id: [0x11; 16],
|
||||
read_data_key: rdk,
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
});
|
||||
encrypt::bus_key(&cap, &bus)
|
||||
}
|
||||
|
||||
// libaacs gates bus decrypt on the content cert BEE flag (`bee && bec`): a
|
||||
// BEE=0 disc must not be de-bussed even when the drive served a Read Data Key.
|
||||
#[test]
|
||||
@@ -9259,13 +9215,13 @@ mod tests {
|
||||
let rdk = Some([0x5Au8; 16]);
|
||||
let (mut mem, udf) = bus_fixture(0x00);
|
||||
assert_eq!(
|
||||
Disc::bus_key_for_disc(&mut mem, &udf, rdk),
|
||||
bus_key_for_disc(&mut mem, &udf, rdk),
|
||||
None,
|
||||
"BEE=0 content cert must force Passthrough"
|
||||
);
|
||||
let (mut mem, udf) = bus_fixture(0x80);
|
||||
assert_eq!(
|
||||
Disc::bus_key_for_disc(&mut mem, &udf, rdk),
|
||||
bus_key_for_disc(&mut mem, &udf, rdk),
|
||||
rdk,
|
||||
"BEE=1 keeps the cert-route Read Data Key"
|
||||
);
|
||||
@@ -9277,23 +9233,15 @@ mod tests {
|
||||
fn bus_key_kept_when_content_cert_absent_or_unparseable() {
|
||||
let rdk = Some([0x5Au8; 16]);
|
||||
let (mut mem, udf) = bus_fixture_with(None, Vec::new());
|
||||
assert_eq!(Disc::bus_key_for_disc(&mut mem, &udf, rdk), rdk, "no cert");
|
||||
assert_eq!(bus_key_for_disc(&mut mem, &udf, rdk), rdk, "no cert");
|
||||
let (mut mem, udf) = bus_fixture_with(Some(vec![0x10, 0x00, 0x00]), Vec::new());
|
||||
assert_eq!(
|
||||
Disc::bus_key_for_disc(&mut mem, &udf, rdk),
|
||||
rdk,
|
||||
"short cert"
|
||||
);
|
||||
assert_eq!(bus_key_for_disc(&mut mem, &udf, rdk), rdk, "short cert");
|
||||
let mut odd = vec![0u8; 32];
|
||||
odd[0] = 0x55;
|
||||
let (mut mem, udf) = bus_fixture_with(Some(odd), Vec::new());
|
||||
assert_eq!(bus_key_for_disc(&mut mem, &udf, rdk), rdk, "unknown type");
|
||||
assert_eq!(
|
||||
Disc::bus_key_for_disc(&mut mem, &udf, rdk),
|
||||
rdk,
|
||||
"unknown type"
|
||||
);
|
||||
assert_eq!(
|
||||
Disc::bus_key_for_disc(&mut mem, &udf, None),
|
||||
bus_key_for_disc(&mut mem, &udf, None),
|
||||
None,
|
||||
"no key stays none"
|
||||
);
|
||||
@@ -9345,13 +9293,11 @@ mod tests {
|
||||
let mut wire = clear.clone();
|
||||
crate::aacs::content::encrypt_bus(&mut wire[..3 * 2048], &rdk);
|
||||
crate::aacs::content::encrypt_bus(&mut wire[3 * 2048..], &rdk);
|
||||
let handshake = || {
|
||||
Some(encrypt::HandshakeResult {
|
||||
volume_id: [0x11; 16],
|
||||
read_data_key: Some(rdk),
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
})
|
||||
let handshake = || encrypt::HandshakeResult {
|
||||
volume_id: [0x11; 16],
|
||||
read_data_key: Some(rdk),
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let lba = PART_START + 2_000;
|
||||
for (bee, want) in [(0x80u8, &clear), (0x00u8, &wire)] {
|
||||
@@ -9360,8 +9306,18 @@ mod tests {
|
||||
cert[1] = bee;
|
||||
let (mem, _) = bus_fixture_with(Some(cert), wire.clone());
|
||||
let mut d = Drive::from_transport_for_test(Box::new(MemTransport(mem)));
|
||||
Disc::scan_after_handshake(&mut d, handshake(), None, &ScanOptions::default())
|
||||
.expect("scan");
|
||||
let (capacity, mut buffered, udf) = Disc::read_udf(&mut d).expect("udf");
|
||||
let from = encrypt::CaptureFrom::Live { raw_copy: true };
|
||||
let cap = encrypt::capture(&mut buffered, &udf, from).expect("capture");
|
||||
let bus = encrypt::BusOutcome::Handshake(handshake());
|
||||
Disc::live_finish(
|
||||
buffered,
|
||||
capacity,
|
||||
udf,
|
||||
Some((cap, bus)),
|
||||
&ScanOptions::default(),
|
||||
)
|
||||
.expect("scan");
|
||||
let mut got = vec![0u8; 6 * 2048];
|
||||
d.read_sectors(lba, 6, &mut got, false).unwrap();
|
||||
assert_eq!(&got, want, "BEE byte {bee:#04x}: non-title m2ts bus state");
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
//! Live-scan command order: UDF and the AACS files are read before any AACS
|
||||
//! CDB, and the handshake's failures land where they belong.
|
||||
|
||||
use super::*;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiSense};
|
||||
use crate::udf::fixture::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// A real AACS1 host keypair (pubkey = d·G on the AACS1 curve); T0 guards it.
|
||||
fn test_hc() -> crate::aacs::types::HostCert {
|
||||
let mut cert = vec![0u8; 92];
|
||||
cert[0] = 0x02;
|
||||
cert[3] = 92;
|
||||
cert[4..10].copy_from_slice(&[0xF7, 0xEE, 0x00, 0x00, 0x00, 0x01]);
|
||||
cert[12..32].copy_from_slice(&hex20("603c6514caab3f999bdd5790624434741fb875cf"));
|
||||
cert[32..52].copy_from_slice(&hex20("1cc0b11e2c894c62ba253f7e268eac2ca9a1f30b"));
|
||||
crate::aacs::types::HostCert {
|
||||
private_key: *b"freemkv-test-hc-key!",
|
||||
certificate: cert,
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn hex20(s: &str) -> [u8; 20] {
|
||||
let mut out = [0u8; 20];
|
||||
for (i, b) in out.iter_mut().enumerate() {
|
||||
*b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
type Log = Arc<Mutex<Vec<Vec<u8>>>>;
|
||||
type Pred = fn(&[u8]) -> bool;
|
||||
type ReadFail = Box<dyn Fn(u32, u16, &[Vec<u8>]) -> Option<Error> + Send>;
|
||||
type HaltOn = Box<dyn Fn(&[u8]) -> bool + Send>;
|
||||
|
||||
fn is_aacs_cdb(c: &[u8]) -> bool {
|
||||
matches!(c[0], 0xA3 | 0xA4 | 0xAD)
|
||||
}
|
||||
fn is_read10(c: &[u8]) -> bool {
|
||||
c[0] == crate::scsi::SCSI_READ_10
|
||||
}
|
||||
fn is_aacs_agid_alloc(c: &[u8]) -> bool {
|
||||
c[0] == 0xA4 && c[7] == 0x02 && c[10] & 0x3F == 0x00
|
||||
}
|
||||
fn is_send_host_cert(c: &[u8]) -> bool {
|
||||
c[0] == 0xA3 && c[10] & 0x3F == 0x01
|
||||
}
|
||||
fn is_css_agid_alloc(c: &[u8]) -> bool {
|
||||
c[0] == 0xA4 && c[7] == 0x00 && c[10] & 0x3F == 0x00
|
||||
}
|
||||
fn is_drive_mkb_read(c: &[u8]) -> bool {
|
||||
c[0] == 0xAD && c[7] == 0x83
|
||||
}
|
||||
fn read_range(c: &[u8]) -> (u32, u32) {
|
||||
let lba = u32::from_be_bytes([c[2], c[3], c[4], c[5]]);
|
||||
(lba, u16::from_be_bytes([c[7], c[8]]) as u32)
|
||||
}
|
||||
fn check(sense_key: u8, asc: u8, ascq: u8, opcode: u8) -> Error {
|
||||
Error::ScsiError {
|
||||
opcode,
|
||||
status: 2,
|
||||
sense: Some(ScsiSense {
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn transport_fault(opcode: u8) -> Error {
|
||||
Error::ScsiError {
|
||||
opcode,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}
|
||||
}
|
||||
|
||||
// A CDB-logging drive over a MemDisc: SEND KEY host cert → 05/6F/00, so the AKE
|
||||
// really issues REPORT/SEND KEY but can never complete.
|
||||
struct AkeMemTransport {
|
||||
mem: MemDisc,
|
||||
log: Log,
|
||||
profile: u16,
|
||||
fail_read: Option<ReadFail>,
|
||||
fault_on: Option<Pred>,
|
||||
halt_on: Option<HaltOn>,
|
||||
halt: Arc<Mutex<Option<Arc<AtomicBool>>>>,
|
||||
mkb_pack: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl crate::scsi::ScsiTransport for AkeMemTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let history = self.log.lock().unwrap().clone();
|
||||
self.log.lock().unwrap().push(cdb.to_vec());
|
||||
if self.halt_on.as_ref().is_some_and(|p| p(cdb))
|
||||
&& let Some(h) = self.halt.lock().unwrap().as_ref()
|
||||
{
|
||||
h.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if self.fault_on.is_some_and(|p| p(cdb)) {
|
||||
return Err(transport_fault(cdb[0]));
|
||||
}
|
||||
data.fill(0);
|
||||
match cdb[0] {
|
||||
crate::scsi::SCSI_READ_10 => {
|
||||
let (lba, count) = read_range(cdb);
|
||||
if let Some(f) = &self.fail_read
|
||||
&& let Some(e) = f(lba, count as u16, &history)
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
self.mem.read_sectors(lba, count as u16, data, false)?;
|
||||
}
|
||||
crate::scsi::SCSI_READ_CAPACITY => {
|
||||
data[..4].copy_from_slice(&9_999u32.to_be_bytes());
|
||||
data[4..8].copy_from_slice(&2048u32.to_be_bytes());
|
||||
}
|
||||
0x46 if data.len() >= 8 => data[6..8].copy_from_slice(&self.profile.to_be_bytes()),
|
||||
0xA3 if is_send_host_cert(cdb) => return Err(check(5, 0x6F, 0, 0xA3)),
|
||||
0xAD if cdb[7] == 0x83 => {
|
||||
if let Some(m) = &self.mkb_pack {
|
||||
data[..2].copy_from_slice(&((m.len() + 2) as u16).to_be_bytes());
|
||||
data[3] = 1;
|
||||
data[4..4 + m.len()].copy_from_slice(m);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn mkb_gen(version: u32) -> Vec<u8> {
|
||||
let mut v = vec![0x10, 0x00, 0x00, 0x10, 0, 0, 0, 0];
|
||||
v.extend_from_slice(&version.to_be_bytes());
|
||||
v.extend_from_slice(&[0u8; 4]);
|
||||
v
|
||||
}
|
||||
|
||||
fn cert(bee: bool) -> Vec<u8> {
|
||||
let mut c = vec![0u8; 20];
|
||||
c[0] = 0x10;
|
||||
c[1] = if bee { 0x80 } else { 0x00 };
|
||||
c
|
||||
}
|
||||
|
||||
fn mpls_2h() -> Vec<u8> {
|
||||
let mut b = b"MPLS0200".to_vec();
|
||||
b.extend_from_slice(&40u32.to_be_bytes());
|
||||
b.extend_from_slice(&[0u8; 28]);
|
||||
let pl = b.len();
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.extend_from_slice(&[0, 0, 0, 1, 0, 0]);
|
||||
let mut item = b"00001M2TS".to_vec();
|
||||
item.extend_from_slice(&[0, 0, 0]);
|
||||
item.extend_from_slice(&0u32.to_be_bytes());
|
||||
item.extend_from_slice(&(7200u32 * 45_000).to_be_bytes());
|
||||
item.extend_from_slice(&[0u8; 12]);
|
||||
item.extend_from_slice(&[0, 14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
b.extend_from_slice(&(item.len() as u16).to_be_bytes());
|
||||
b.extend_from_slice(&item);
|
||||
let len = (b.len() - pl - 4) as u32;
|
||||
b[pl..pl + 4].copy_from_slice(&len.to_be_bytes());
|
||||
let marks = b.len() as u32;
|
||||
b[12..16].copy_from_slice(&marks.to_be_bytes());
|
||||
b.extend_from_slice(&2u32.to_be_bytes());
|
||||
b.extend_from_slice(&0u16.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
fn clpi() -> Vec<u8> {
|
||||
let mut d = vec![0u8; 60];
|
||||
d[0..4].copy_from_slice(b"HDMV");
|
||||
d[4..8].copy_from_slice(b"0200");
|
||||
d[56..60].copy_from_slice(&4000u32.to_be_bytes());
|
||||
d
|
||||
}
|
||||
|
||||
const UK_LBA: u32 = PART_START + 600;
|
||||
const CERT_LBA: u32 = PART_START + 610;
|
||||
const MKB_LBA: u32 = PART_START + 620;
|
||||
|
||||
// A BD with one 2 h title; `aacs` adds /AACS (Unit_Key_RO.inf, Content000.cer, MKB_RO.inf).
|
||||
fn bd_disc(aacs: Option<bool>) -> MemDisc {
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".into(),
|
||||
icb_lba: 22,
|
||||
dir_data_lba: 23,
|
||||
files: vec![file_with(
|
||||
"index.bdmv",
|
||||
43,
|
||||
630,
|
||||
b"INDX0200".to_vec(),
|
||||
false,
|
||||
)],
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "PLAYLIST".into(),
|
||||
icb_lba: 24,
|
||||
dir_data_lba: 25,
|
||||
files: vec![file_with("00800.mpls", 44, 700, mpls_2h(), false)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "CLIPINF".into(),
|
||||
icb_lba: 26,
|
||||
dir_data_lba: 27,
|
||||
files: vec![file_with("00001.clpi", 45, 720, clpi(), false)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "STREAM".into(),
|
||||
icb_lba: 28,
|
||||
dir_data_lba: 29,
|
||||
files: vec![file("00001.m2ts", 46, 5_000, 1_000 * 2048, true)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut subdirs = vec![bdmv];
|
||||
if let Some(bee) = aacs {
|
||||
subdirs.push(DirSpec {
|
||||
name: "AACS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file_with("Unit_Key_RO.inf", 40, 600, vec![0xAB; 64], false),
|
||||
file_with("Content000.cer", 41, 610, cert(bee), false),
|
||||
file_with("MKB_RO.inf", 42, 620, mkb_gen(70), false),
|
||||
],
|
||||
subdirs: vec![],
|
||||
});
|
||||
}
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs,
|
||||
};
|
||||
let mut mem = MemDisc::new();
|
||||
build_udf_skeleton(&mut mem, 10);
|
||||
lay_dir(&mut mem, &root);
|
||||
mem
|
||||
}
|
||||
|
||||
struct Rig {
|
||||
drive: Drive,
|
||||
log: Log,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new(mem: MemDisc, f: impl FnOnce(&mut AkeMemTransport)) -> Self {
|
||||
let log: Log = Arc::default();
|
||||
let halt: Arc<Mutex<Option<Arc<AtomicBool>>>> = Arc::default();
|
||||
let mut t = AkeMemTransport {
|
||||
mem,
|
||||
log: log.clone(),
|
||||
profile: 0x0040,
|
||||
fail_read: None,
|
||||
fault_on: None,
|
||||
halt_on: None,
|
||||
halt: halt.clone(),
|
||||
mkb_pack: Some(mkb_gen(70)),
|
||||
};
|
||||
f(&mut t);
|
||||
let drive = Drive::from_transport_for_test(Box::new(t));
|
||||
*halt.lock().unwrap() = Some(drive.halt_flag());
|
||||
Rig { drive, log }
|
||||
}
|
||||
fn cdbs(&self) -> Vec<Vec<u8>> {
|
||||
self.log.lock().unwrap().clone()
|
||||
}
|
||||
fn count(&self, p: Pred) -> usize {
|
||||
self.cdbs().iter().filter(|c| p(c)).count()
|
||||
}
|
||||
}
|
||||
|
||||
fn with_hc() -> ScanOptions {
|
||||
ScanOptions {
|
||||
credentials: Some(crate::DriveCredentials {
|
||||
host_certs: vec![test_hc()],
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn touches(c: &[u8], lbas: &[u32]) -> bool {
|
||||
is_read10(c) && {
|
||||
let (lba, n) = read_range(c);
|
||||
lbas.iter().any(|x| (lba..lba + n).contains(x))
|
||||
}
|
||||
}
|
||||
|
||||
// AACS-path WARN+ events (the drive's per-CDB read diagnostics are out of scope).
|
||||
fn aacs_warns(ev: &[crate::testlog::CapturedEvent]) -> Vec<&crate::testlog::CapturedEvent> {
|
||||
ev.iter()
|
||||
.filter(|e| e.level <= tracing::Level::WARN && e.target != "freemkv::drive")
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn verdicts(ev: &[crate::testlog::CapturedEvent]) -> Vec<&crate::testlog::CapturedEvent> {
|
||||
ev.iter()
|
||||
.filter(|e| e.field("phase") == Some("aacs_verdict"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
const KEY_FILE_CODE: u16 = crate::error::E_AACS_KEY_FILE_UNREADABLE;
|
||||
|
||||
#[test]
|
||||
fn test_hc_is_a_valid_aacs1_pairing() {
|
||||
let hc = test_hc();
|
||||
assert!(freemkv_unlock::aacs1_keypair_matches(
|
||||
&hc.private_key,
|
||||
&hc.certificate
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reads_udf_and_aacs_files_before_any_aacs_scsi() {
|
||||
let mut rig = Rig::new(bd_disc(Some(true)), |_| {});
|
||||
let _ = Disc::scan(&mut rig.drive, &with_hc());
|
||||
let cdbs = rig.cdbs();
|
||||
let first_aacs = cdbs.iter().position(|c| is_aacs_cdb(c)).expect("AACS CDBs");
|
||||
let last_file = cdbs
|
||||
.iter()
|
||||
.rposition(|c| touches(c, &[UK_LBA, CERT_LBA, MKB_LBA]))
|
||||
.expect("AACS file reads");
|
||||
assert!(
|
||||
first_aacs > last_file,
|
||||
"first AACS CDB #{first_aacs} precedes the last AACS file read #{last_file}"
|
||||
);
|
||||
assert!(rig.count(is_aacs_agid_alloc) >= 1 && rig.count(is_send_host_cert) >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unencrypted_bd_issues_no_aacs_scsi() {
|
||||
let mut rig = Rig::new(bd_disc(None), |_| {});
|
||||
let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan");
|
||||
assert!(d.aacs.is_none() && d.aacs_error.is_none());
|
||||
assert_eq!(rig.count(is_aacs_cdb), 0, "{:02x?}", rig.cdbs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_unreadable_uk_ro_fails_scan_before_any_aacs_scsi() {
|
||||
for bee in [true, false] {
|
||||
// Both copies absent: the file is simply not on the disc.
|
||||
let mut mem = bd_disc(Some(bee));
|
||||
lay_dir(
|
||||
&mut mem,
|
||||
&DirSpec {
|
||||
name: "AACS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file_with("Content000.cer", 41, 610, cert(bee), false),
|
||||
file_with("MKB_RO.inf", 42, 620, mkb_gen(70), false),
|
||||
],
|
||||
subdirs: vec![],
|
||||
},
|
||||
);
|
||||
let mut rig = Rig::new(mem, |_| {});
|
||||
let r = Disc::scan(&mut rig.drive, &with_hc());
|
||||
assert_eq!(
|
||||
r.err().map(|e| e.code()),
|
||||
Some(KEY_FILE_CODE),
|
||||
"absent, bee={bee}"
|
||||
);
|
||||
assert_eq!(rig.count(is_aacs_cdb), 0, "absent, bee={bee}");
|
||||
|
||||
// Present but unreadable.
|
||||
let mut rig = Rig::new(bd_disc(Some(bee)), |t| {
|
||||
t.fail_read = Some(Box::new(|lba, n, _| {
|
||||
(lba..lba + n as u32)
|
||||
.contains(&UK_LBA)
|
||||
.then(|| check(3, 0x11, 0, 0x28))
|
||||
}));
|
||||
});
|
||||
let r = Disc::scan(&mut rig.drive, &with_hc());
|
||||
assert_eq!(
|
||||
r.err().map(|e| e.code()),
|
||||
Some(KEY_FILE_CODE),
|
||||
"read error, bee={bee}"
|
||||
);
|
||||
assert_eq!(rig.count(is_aacs_cdb), 0, "read error, bee={bee}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_copy_scan_records_unreadable_uk_ro_and_refuses_keys() {
|
||||
let mut rig = Rig::new(bd_disc(Some(false)), |t| {
|
||||
t.fail_read = Some(Box::new(|lba, n, _| {
|
||||
(lba..lba + n as u32)
|
||||
.contains(&UK_LBA)
|
||||
.then(|| check(3, 0x11, 0, 0x28))
|
||||
}));
|
||||
});
|
||||
let opts = ScanOptions {
|
||||
raw_copy: true,
|
||||
..with_hc()
|
||||
};
|
||||
let mut d = Disc::scan(&mut rig.drive, &opts).expect("raw copy scans on");
|
||||
assert!(d.aacs.is_none() && !d.titles.is_empty());
|
||||
assert_eq!(d.aacs_error.as_ref().map(|e| e.code()), Some(KEY_FILE_CODE));
|
||||
assert!(
|
||||
d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_handshake_cannot_poison_uk_ro_read() {
|
||||
let mut rig = Rig::new(bd_disc(Some(false)), |t| {
|
||||
t.fail_read = Some(Box::new(|lba, n, hist| {
|
||||
(hist.iter().any(|c| is_aacs_agid_alloc(c)) && (lba..lba + n as u32).contains(&UK_LBA))
|
||||
.then(|| check(3, 0x11, 0, 0x28))
|
||||
}));
|
||||
});
|
||||
let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan");
|
||||
assert!(d.aacs.is_some(), "{:?}", d.aacs_error);
|
||||
assert!(d.aacs_error.is_none(), "{:?}", d.aacs_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_issues_no_drive_mkb_read() {
|
||||
let mut rig = Rig::new(bd_disc(Some(true)), |_| {});
|
||||
let _ = Disc::scan(&mut rig.drive, &with_hc());
|
||||
assert_eq!(rig.count(is_drive_mkb_read), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_bus_failed_handshake_is_info_only() {
|
||||
let mut rig = Rig::new(bd_disc(Some(false)), |_| {});
|
||||
let (r, ev) = crate::testlog::capture(|| Disc::scan(&mut rig.drive, &with_hc()));
|
||||
let d = r.expect("scan");
|
||||
assert!(d.aacs_error.is_none(), "{:?}", d.aacs_error);
|
||||
assert!(aacs_warns(&ev).is_empty(), "{:?}", aacs_warns(&ev));
|
||||
let v = verdicts(&ev);
|
||||
assert_eq!(v.len(), 1, "{ev:?}");
|
||||
assert_eq!(v[0].level, tracing::Level::INFO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bus_disc_failed_handshake_lists_titles_one_error_refuses_keys() {
|
||||
let mut rig = Rig::new(bd_disc(Some(true)), |_| {});
|
||||
let (r, ev) = crate::testlog::capture(|| Disc::scan(&mut rig.drive, &with_hc()));
|
||||
let mut d = r.expect("scan");
|
||||
assert!(!d.titles.is_empty());
|
||||
assert!(
|
||||
matches!(d.aacs_error, Some(Error::AacsHostCertRejected)),
|
||||
"{:?}",
|
||||
d.aacs_error
|
||||
);
|
||||
let w = aacs_warns(&ev);
|
||||
assert_eq!(w.len(), 1, "{w:?}");
|
||||
assert_eq!(w[0].field("phase"), Some("aacs_verdict"));
|
||||
assert!(
|
||||
d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[])
|
||||
.is_err()
|
||||
);
|
||||
let inputs = d.inputs().expect("inputs");
|
||||
let (ok, _) = crate::keysource::resolve_and_apply_traced(&[], &inputs, &mut d);
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_mid_ake_aborts_scan() {
|
||||
for bee in [true, false] {
|
||||
let mut rig = Rig::new(bd_disc(Some(bee)), |t| t.fault_on = Some(is_send_host_cert));
|
||||
let r = Disc::scan(&mut rig.drive, &with_hc());
|
||||
assert!(
|
||||
matches!(
|
||||
r,
|
||||
Err(Error::ScsiError {
|
||||
status: 0xFF,
|
||||
sense: None,
|
||||
..
|
||||
})
|
||||
),
|
||||
"bee={bee}: {:?}",
|
||||
r.map(|d| d.aacs_error)
|
||||
);
|
||||
let cdbs = rig.cdbs();
|
||||
let fault = cdbs
|
||||
.iter()
|
||||
.position(|c| is_send_host_cert(c))
|
||||
.expect("fault");
|
||||
assert!(!cdbs[fault..].iter().any(|c| is_read10(c)), "bee={bee}");
|
||||
}
|
||||
}
|
||||
|
||||
fn dvd_disc() -> MemDisc {
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "VIDEO_TS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
let mut mem = MemDisc::new();
|
||||
build_udf_skeleton(&mut mem, 10);
|
||||
lay_dir(&mut mem, &root);
|
||||
mem
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_transport_fault_aborts_scan() {
|
||||
let mut rig = Rig::new(dvd_disc(), |t| {
|
||||
t.profile = 0x0010;
|
||||
t.fault_on = Some(is_css_agid_alloc);
|
||||
});
|
||||
let r = Disc::scan(&mut rig.drive, &ScanOptions::default());
|
||||
assert!(
|
||||
matches!(
|
||||
r,
|
||||
Err(Error::ScsiError {
|
||||
status: 0xFF,
|
||||
sense: None,
|
||||
..
|
||||
})
|
||||
),
|
||||
"{:?}",
|
||||
r.map(|d| d.titles.len())
|
||||
);
|
||||
assert_eq!(rig.count(is_read10), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_css_bus_auth_precedes_the_first_udf_read() {
|
||||
let mut rig = Rig::new(dvd_disc(), |t| t.profile = 0x0010);
|
||||
let _ = Disc::scan(&mut rig.drive, &ScanOptions::default());
|
||||
let cdbs = rig.cdbs();
|
||||
let css = cdbs.iter().position(|c| is_css_agid_alloc(c)).expect("CSS");
|
||||
let read = cdbs.iter().position(|c| is_read10(c)).expect("READ");
|
||||
assert!(css < read);
|
||||
}
|
||||
|
||||
// A DVD carrying /AACS gets no AACS handshake, so its missing key file is recorded.
|
||||
#[test]
|
||||
fn dvd_with_aacs_dir_records_missing_key_file_without_aacs_scsi() {
|
||||
let mut mem = dvd_disc();
|
||||
lay_dir(
|
||||
&mut mem,
|
||||
&DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "VIDEO_TS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AACS".into(),
|
||||
icb_lba: 22,
|
||||
dir_data_lba: 23,
|
||||
files: vec![file_with("Content000.cer", 41, 610, cert(true), false)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
let mut rig = Rig::new(mem, |t| t.profile = 0x0010);
|
||||
let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan");
|
||||
assert!(d.aacs.is_none());
|
||||
assert!(
|
||||
matches!(d.aacs_error, Some(Error::AacsNoKeys)),
|
||||
"{:?}",
|
||||
d.aacs_error
|
||||
);
|
||||
let aacs_class = |c: &[u8]| c[0] == 0xAD || (matches!(c[0], 0xA3 | 0xA4) && c[7] == 0x02);
|
||||
assert_eq!(rig.count(aacs_class), 0, "{:02x?}", rig.cdbs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_during_ake_returns_halted() {
|
||||
let mut rig = Rig::new(bd_disc(Some(true)), |t| {
|
||||
t.halt_on = Some(Box::new(is_aacs_agid_alloc))
|
||||
});
|
||||
let r = Disc::scan(&mut rig.drive, &with_hc());
|
||||
assert!(
|
||||
matches!(r, Err(Error::Halted)),
|
||||
"{:?}",
|
||||
r.map(|d| d.aacs_error)
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct SpySource(Arc<Mutex<Vec<Option<u32>>>>);
|
||||
impl crate::KeySource for SpySource {
|
||||
fn get_unit_keys(
|
||||
&self,
|
||||
_ctx: &dyn crate::keysource::ResolveCtx,
|
||||
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, mkb: Option<u32>) -> Vec<crate::aacs::types::HostCert> {
|
||||
self.0.lock().unwrap().push(mkb);
|
||||
vec![test_hc()]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_sources_receive_no_mkb_hint() {
|
||||
let spy = SpySource::default();
|
||||
let mut rig = Rig::new(bd_disc(Some(true)), |_| {});
|
||||
let opts = ScanOptions {
|
||||
key_sources: vec![Box::new(spy.clone())],
|
||||
..Default::default()
|
||||
};
|
||||
let _ = Disc::scan(&mut rig.drive, &opts);
|
||||
let seen = spy.0.lock().unwrap().clone();
|
||||
assert!(!seen.is_empty());
|
||||
assert!(seen.iter().all(Option::is_none), "{seen:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_ake_read_refusal_is_a_plain_scan_error() {
|
||||
let mut rig = Rig::new(bd_disc(Some(true)), |t| {
|
||||
t.fail_read = Some(Box::new(|_, _, hist| {
|
||||
(!hist.iter().any(|c| c[0] == 0xA4)).then(|| check(5, 0x21, 0, 0x28))
|
||||
}));
|
||||
});
|
||||
let r = Disc::scan(&mut rig.drive, &with_hc());
|
||||
let Err(e) = r else { panic!("scan must fail") };
|
||||
let s = e.scsi_sense().copied();
|
||||
assert!(
|
||||
s.is_some_and(|s| s.sense_key == 5 && s.asc == 0x21),
|
||||
"{e:?}"
|
||||
);
|
||||
assert_eq!(rig.count(|c| matches!(c[0], 0xA3 | 0xA4)), 0);
|
||||
}
|
||||
|
||||
fn hddvd_disc() -> MemDisc {
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "HVDVD_TS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![file("MAIN.EVO", 100, 5_000, 3_000_000, true)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AACS!".into(),
|
||||
icb_lba: 22,
|
||||
dir_data_lba: 23,
|
||||
files: vec![
|
||||
file_with("MKBROM.AACS", 101, 900, mkb_gen(70), false),
|
||||
file_with("VTKF000.AACS", 102, 910, vec![0xAB; 64], false),
|
||||
],
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut mem = MemDisc::new();
|
||||
build_udf_skeleton(&mut mem, 10);
|
||||
lay_dir(&mut mem, &root);
|
||||
mem
|
||||
}
|
||||
|
||||
const VTKF_LBA: u32 = PART_START + 910;
|
||||
|
||||
// An HD DVD's `X!` AACS dir is an AACS dir: identified as encrypted, its VTKF captured
|
||||
// before the handshake, and a keyless rip refused instead of writing ciphertext.
|
||||
#[test]
|
||||
fn hddvd_aacs_dir_is_captured_and_handshaked() {
|
||||
let mut rig = Rig::new(hddvd_disc(), |_| {});
|
||||
assert!(Disc::identify(&mut rig.drive).expect("identify").encrypted);
|
||||
let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan");
|
||||
assert!(d.encrypted && !d.titles.is_empty());
|
||||
let a = d.aacs.as_ref().expect("HD DVD AACS state");
|
||||
assert!(
|
||||
!a.bus_encryption && d.aacs_error.is_none(),
|
||||
"{:?}",
|
||||
d.aacs_error
|
||||
);
|
||||
assert!(matches!(
|
||||
d.ensure_decryptable(false),
|
||||
Err(Error::NoDiscKey { .. })
|
||||
));
|
||||
let cdbs = rig.cdbs();
|
||||
let first_aacs = cdbs.iter().position(|c| is_aacs_cdb(c)).expect("AACS CDBs");
|
||||
let vtkf = cdbs.iter().rposition(|c| touches(c, &[VTKF_LBA]));
|
||||
assert!(vtkf.is_some_and(|i| i < first_aacs), "{cdbs:02x?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hddvd_image_aacs_dir_is_captured() {
|
||||
let mut mem = hddvd_disc();
|
||||
let d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan");
|
||||
assert!(d.encrypted && d.aacs.is_some() && d.aacs_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halted_during_capture_propagates() {
|
||||
for lba in [CERT_LBA, MKB_LBA] {
|
||||
let mut rig = Rig::new(bd_disc(Some(true)), |t| {
|
||||
t.halt_on = Some(Box::new(move |c: &[u8]| touches(c, &[lba])));
|
||||
});
|
||||
let r = Disc::scan(&mut rig.drive, &with_hc());
|
||||
assert!(
|
||||
matches!(r, Err(Error::Halted)),
|
||||
"lba {lba}: {:?}",
|
||||
r.map(|d| d.aacs_error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A source whose reads covering `lba` fail with `Halted`, as a stopped drive's do.
|
||||
struct HaltAt {
|
||||
mem: MemDisc,
|
||||
lba: u32,
|
||||
}
|
||||
impl crate::sector::SectorSource for HaltAt {
|
||||
fn read_sectors(&mut self, lba: u32, n: u16, buf: &mut [u8], rec: bool) -> Result<usize> {
|
||||
if (lba..lba + n as u32).contains(&self.lba) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
self.mem.read_sectors(lba, n, buf, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// No later check may mask a Stop swallowed by one of capture's own reads.
|
||||
#[test]
|
||||
fn capture_propagates_a_stop_from_every_key_file_read() {
|
||||
use super::encrypt::{CaptureFrom, capture};
|
||||
for lba in [UK_LBA, CERT_LBA, MKB_LBA] {
|
||||
for from in [
|
||||
CaptureFrom::Image,
|
||||
CaptureFrom::Live { raw_copy: true },
|
||||
CaptureFrom::Live { raw_copy: false },
|
||||
] {
|
||||
let mut src = HaltAt {
|
||||
mem: bd_disc(Some(true)),
|
||||
lba,
|
||||
};
|
||||
let udf = crate::udf::read_filesystem(&mut src).expect("udf");
|
||||
let r = capture(&mut src, &udf, from);
|
||||
assert!(matches!(r, Err(Error::Halted)), "lba {lba}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_unlocker_runs_cert_route_and_issues_ake() {
|
||||
let mut rig = Rig::new(bd_disc(Some(true)), |_| {});
|
||||
let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan");
|
||||
assert!(rig.count(is_aacs_agid_alloc) >= 1 && rig.count(is_send_host_cert) >= 1);
|
||||
assert_eq!(rig.count(|c| c[0] == 0xAD), 0);
|
||||
assert!(matches!(d.aacs_error, Some(Error::AacsHostCertRejected)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_unreadable_uk_ro_is_recorded_not_fatal() {
|
||||
let mut mem = bd_disc(Some(false));
|
||||
lay_dir(
|
||||
&mut mem,
|
||||
&DirSpec {
|
||||
name: "AACS".into(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![file_with("Content000.cer", 41, 610, cert(false), false)],
|
||||
subdirs: vec![],
|
||||
},
|
||||
);
|
||||
let mut d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan");
|
||||
assert!(d.aacs.is_none());
|
||||
assert!(
|
||||
matches!(d.aacs_error, Some(Error::AacsNoKeys)),
|
||||
"{:?}",
|
||||
d.aacs_error
|
||||
);
|
||||
d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[])
|
||||
.expect("a mapfile unit key is accepted on an image");
|
||||
}
|
||||
+5
-7
@@ -289,7 +289,7 @@ impl Drive {
|
||||
self.event_fn = Some(Box::new(f));
|
||||
}
|
||||
|
||||
fn is_halted(&self) -> bool {
|
||||
pub(crate) fn is_halted(&self) -> bool {
|
||||
self.halt.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
@@ -611,7 +611,7 @@ impl Drive {
|
||||
// Record which firmware unlocker ran, not the id-only lookup.
|
||||
self.unlocker_name = Some(matched.to_string());
|
||||
// Stash the OEM Volume ID (best-effort: a transient miss leaves
|
||||
// the drive unlocked with no VID). do_handshake reads `oem_vid()`.
|
||||
// the drive unlocked with no VID). do_handshake_cert reads `oem_vid()`.
|
||||
if let Some(vid) = unlocked.vid {
|
||||
self.oem_vid = Some(vid);
|
||||
}
|
||||
@@ -620,11 +620,9 @@ impl Drive {
|
||||
// No firmware unlocker claimed the drive — a stock/cert-only drive;
|
||||
// the AACS cert route runs later at the handshake phase.
|
||||
Ok(None) => Ok(()),
|
||||
Err(freemkv_unlock::UnlockError::Transport) => Err(Error::ScsiError {
|
||||
opcode: 0,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}),
|
||||
Err(freemkv_unlock::UnlockError::Transport) => {
|
||||
Err(crate::unlock_bridge::unlock_transport_error())
|
||||
}
|
||||
Err(_) => Ok(()),
|
||||
};
|
||||
// Raise to max read speed UNCONDITIONALLY whenever the bus is alive, for
|
||||
|
||||
@@ -113,6 +113,19 @@ pub const E_KEY_SERVICE_UNAUTHORIZED: u16 = 7029;
|
||||
/// The operator action is to back off and retry more slowly; the disc's key may
|
||||
/// well exist.
|
||||
pub const E_KEY_SERVICE_RATE_LIMITED: u16 = 7030;
|
||||
/// A live AACS disc's `Unit_Key_RO.inf` (both copies) is missing or unreadable.
|
||||
/// The key file is required, so the live scan stops. NOT [`E_AACS_NO_KEYS`]:
|
||||
/// no key lookup was reached, and the fix is the disc, not a key database.
|
||||
pub const E_AACS_KEY_FILE_UNREADABLE: u16 = 7031;
|
||||
/// A decrypted whole-disc image (disc/image -> ISO) cannot be made: a stream
|
||||
/// file no title plays is encrypted with a key none of the held keys opens.
|
||||
/// NOT [`E_DECRYPT_FAILED`]: the fix is an MKV rip or a raw copy, not a key.
|
||||
pub const E_WHOLE_DISC_KEY_MISSING: u16 = 7032;
|
||||
/// A key source offered host cert(s), but every one failed a local check
|
||||
/// before any drive round-trip (keydb data problem). NOT
|
||||
/// [`E_AACS_NO_HOST_CERT`] (no cert offered at all) or
|
||||
/// [`E_AACS_HOST_CERT_REJECTED`] (the drive itself rejected a cert).
|
||||
pub const E_AACS_NO_USABLE_HOST_CERT: u16 = 7033;
|
||||
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
@@ -584,6 +597,13 @@ pub enum Error {
|
||||
AacsNoHostCert {
|
||||
path: String,
|
||||
},
|
||||
/// A key source offered host cert(s) for the drive's handshake, but every
|
||||
/// one failed a LOCAL check (stored private key vs cert public key)
|
||||
/// before any drive round-trip — a keydb data problem, not a drive
|
||||
/// rejection. Distinct from [`Error::AacsNoHostCert`] (no cert offered at
|
||||
/// all) and [`Error::AacsHostCertRejected`] (the drive rejected a cert
|
||||
/// it actually saw).
|
||||
AacsNoUsableHostCert,
|
||||
/// A bus-encrypted disc (AACS 2.0 / UHD, Content Certificate bus-encryption bit set) was
|
||||
/// scanned on a live drive, the Volume ID was obtained, but no `read_data_key` (bus key)
|
||||
/// was produced — so the on-disc bytes are still bus-encrypted and would decrypt to
|
||||
@@ -592,6 +612,13 @@ pub enum Error {
|
||||
/// of silently producing a corrupt rip. NOT raised for AACS 1.0 BD or file-backed (ISO)
|
||||
/// scans, where no bus-key handshake runs.
|
||||
AacsBusKeyUnavailable,
|
||||
/// A live AACS disc's `Unit_Key_RO.inf` is missing or unreadable (both copies).
|
||||
/// `Disc::scan` returns it; a `raw_copy` scan records it and refuses every key.
|
||||
AacsKeyFileUnreadable,
|
||||
/// A decrypted whole-disc image would keep encrypted pieces: a stream file
|
||||
/// no title plays is encrypted and no held key opens it. See
|
||||
/// [`E_WHOLE_DISC_KEY_MISSING`]. Raised before the copy where it can be.
|
||||
WholeDiscKeyMissing,
|
||||
|
||||
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
|
||||
/// (variant) key is available to open them. Raised UPFRONT — before the mux —
|
||||
@@ -922,7 +949,10 @@ impl Error {
|
||||
Error::KeyServiceUnauthorized => E_KEY_SERVICE_UNAUTHORIZED,
|
||||
Error::KeyServiceRateLimited => E_KEY_SERVICE_RATE_LIMITED,
|
||||
Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT,
|
||||
Error::AacsNoUsableHostCert => E_AACS_NO_USABLE_HOST_CERT,
|
||||
Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE,
|
||||
Error::AacsKeyFileUnreadable => E_AACS_KEY_FILE_UNREADABLE,
|
||||
Error::WholeDiscKeyMissing => E_WHOLE_DISC_KEY_MISSING,
|
||||
Error::FmtsKeyMissing => E_FMTS_KEY_MISSING,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
@@ -1584,6 +1614,9 @@ mod tests {
|
||||
Error::SinkWroteNothing.code(),
|
||||
Error::StreamClosed.code(),
|
||||
Error::StreamHeaderWritten.code(),
|
||||
Error::AacsKeyFileUnreadable.code(),
|
||||
Error::AacsNoUsableHostCert.code(),
|
||||
Error::WholeDiscKeyMissing.code(),
|
||||
Error::ShortImageRead {
|
||||
lba: 0,
|
||||
expected: 1,
|
||||
@@ -1667,6 +1700,9 @@ mod tests {
|
||||
(Error::KeyServiceUnavailable, E_KEY_SERVICE_UNAVAILABLE),
|
||||
(Error::KeyServiceUnauthorized, E_KEY_SERVICE_UNAUTHORIZED),
|
||||
(Error::KeyServiceRateLimited, E_KEY_SERVICE_RATE_LIMITED),
|
||||
(Error::AacsKeyFileUnreadable, E_AACS_KEY_FILE_UNREADABLE),
|
||||
(Error::AacsNoUsableHostCert, E_AACS_NO_USABLE_HOST_CERT),
|
||||
(Error::WholeDiscKeyMissing, E_WHOLE_DISC_KEY_MISSING),
|
||||
];
|
||||
for (e, want_code) in cases {
|
||||
let s = e.to_string();
|
||||
@@ -1980,6 +2016,8 @@ mod tests {
|
||||
// AACS (7xxx)
|
||||
assert!((7000..8000).contains(&E_AACS_NO_KEYS));
|
||||
assert!((7000..8000).contains(&E_NO_DISC_KEY));
|
||||
assert!((7000..8000).contains(&E_AACS_KEY_FILE_UNREADABLE));
|
||||
assert!((7000..8000).contains(&E_WHOLE_DISC_KEY_MISSING));
|
||||
// Keydb (8xxx)
|
||||
assert!((8000..9000).contains(&E_KEYDB_CONNECT));
|
||||
assert!((8000..9000).contains(&E_KEYDB_TOO_MANY_REDIRECTS));
|
||||
@@ -2081,6 +2119,7 @@ mod tests {
|
||||
(Error::DecryptFailed, E_DECRYPT_FAILED),
|
||||
(Error::CssAuthFailed, E_CSS_AUTH_FAILED),
|
||||
(Error::AacsHostCertRejected, E_AACS_HOST_CERT_REJECTED),
|
||||
(Error::AacsNoUsableHostCert, E_AACS_NO_USABLE_HOST_CERT),
|
||||
(Error::AacsRawReadUnsupported, E_AACS_RAW_READ_UNSUPPORTED),
|
||||
(Error::AacsVidUnavailable, E_AACS_VID_UNAVAILABLE),
|
||||
(Error::AacsMkUnavailable, E_AACS_MK_UNAVAILABLE),
|
||||
|
||||
+67
-3
@@ -22,9 +22,10 @@ const BATCH_SECTORS: u32 = 2048;
|
||||
|
||||
/// Write `total_sectors` sectors from `reader` to `dest`.
|
||||
///
|
||||
/// Reads sequentially, writes in order; does no decryption itself (caller's call).
|
||||
/// `on_progress` runs after each batch with cumulative bytes and must not block; `halt` is
|
||||
/// checked once per batch, and on cancellation the partial file is kept. Returns bytes written.
|
||||
/// Reads 2048-sector batches from LBA 0 in order; decrypts nothing. Batches are not whole
|
||||
/// 3-sector AACS units: wrap an AACS decrypting source to read on the unit grid, or it fails
|
||||
/// the first misaligned batch ([`Error::DecryptFailed`]). `on_progress` gets cumulative bytes
|
||||
/// per batch (must not block); `halt` is checked per batch, keeping the partial file.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -143,6 +144,69 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// `lba`s from `file` hold the same encrypted AACS unit, on the file's own grid.
|
||||
struct AacsFileImage {
|
||||
file: u32,
|
||||
unit: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SectorSource for AacsFileImage {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> FmResult<usize> {
|
||||
for i in 0..count as usize {
|
||||
let sector = &mut buf[i * SECTOR_BYTES..(i + 1) * SECTOR_BYTES];
|
||||
let s = lba + i as u32;
|
||||
match s.checked_sub(self.file).filter(|&o| o < 30) {
|
||||
Some(o) => {
|
||||
let k = (o % 3) as usize * SECTOR_BYTES;
|
||||
sector.copy_from_slice(&self.unit[k..k + SECTOR_BYTES]);
|
||||
}
|
||||
None => sector.fill(0),
|
||||
}
|
||||
}
|
||||
Ok(count as usize * SECTOR_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_aacs_unit() -> Vec<u8> {
|
||||
let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
for off in (4..unit.len()).step_by(192) {
|
||||
unit[off] = 0x47;
|
||||
}
|
||||
unit[0] |= 0xC0;
|
||||
unit
|
||||
}
|
||||
|
||||
// Batches are not whole AACS units: an unwrapped AACS decrypting source fails
|
||||
// loud at the first misaligned batch that touches content, never ships ciphertext.
|
||||
#[test]
|
||||
fn write_image_over_an_unwrapped_aacs_source_fails_loud_at_a_misaligned_batch() {
|
||||
use crate::decrypt::{AacsKeyMap, DecryptKeys};
|
||||
use std::sync::Arc;
|
||||
let key = [0x5A; 16];
|
||||
let mut unit = clear_aacs_unit();
|
||||
assert!(crate::aacs::content::encrypt_unit(&mut unit, &key));
|
||||
let src = AacsFileImage { file: 3000, unit };
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key)],
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut dec = crate::sector::DecryptingSectorSource::new(src, keys)
|
||||
.with_key_map(Arc::new(AacsKeyMap::from_ranges(vec![(3000, 3030, 0)])))
|
||||
.with_content_ranges(Arc::from(vec![(3000u32, 30u32)]));
|
||||
dec.set_unit_base(0); // file grid (3000 % 3 == 0): fails loud past the no-base gate
|
||||
let dest = tmp("aacs-batch");
|
||||
let r = write_image(&mut dec, &dest, 3100, &Halt::new(), |_| {});
|
||||
let _ = std::fs::remove_file(&dest);
|
||||
let e = r.expect_err("batch 2048 is off the unit grid and touches content");
|
||||
assert_eq!(e.code(), Error::DecryptFailed.code());
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("fmkv-image-writer-{name}-{}", std::process::id()));
|
||||
|
||||
+3
-3
@@ -288,9 +288,9 @@ pub trait KeySource {
|
||||
}
|
||||
|
||||
/// The AACS host certificate(s) this source can supply for the live-drive
|
||||
/// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the
|
||||
/// disc's MKB generation when known, so a source MAY return only certs whose
|
||||
/// generation matches (the default ignores it). A host cert unlocks the
|
||||
/// SCSI mutual-auth handshake (the OEM/AACS baseline route), best first.
|
||||
/// libfreemkv passes `None` for `mkb`: the argument is reserved and will be
|
||||
/// removed at the next breaking window. A host cert unlocks the
|
||||
/// authenticated bus so the drive reports the Volume ID and bus key; it is
|
||||
/// **perishable** (revocable on a drive's HRL), so it is served by a source,
|
||||
/// never compiled in. A source holding no cert returns the empty vec.
|
||||
|
||||
@@ -122,6 +122,8 @@ pub(crate) mod testlog;
|
||||
pub(crate) mod udf;
|
||||
#[cfg(feature = "rip")]
|
||||
pub(crate) mod unlock_bridge;
|
||||
#[cfg(feature = "rip")]
|
||||
pub mod whole_disc;
|
||||
|
||||
// ─── Drive lifecycle ────────────────────────────────────────────────────────
|
||||
// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive` owns
|
||||
|
||||
+2
-1
@@ -269,7 +269,7 @@ impl DiscStream {
|
||||
// `reader` is already wrapped above (DecryptKeys::None makes it a
|
||||
// pass-through). Reset the unit base the probe read advanced so the
|
||||
// first fill_extents read starts cleanly.
|
||||
reader.set_unit_base(0);
|
||||
reader.clear_unit_base();
|
||||
|
||||
// B1 resync gates: one per stream, video flagged so the gate only
|
||||
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
||||
@@ -2587,6 +2587,7 @@ mod tests {
|
||||
// are installed, a read not starting on a 6144-byte unit boundary
|
||||
// hard-fails (it would mis-decrypt every following unit).
|
||||
let mut buf = vec![0u8; 2048];
|
||||
s.reader.set_unit_base(0); // anchored: witnesses the keys, not the no-base gate
|
||||
let before = s.reader.read_sectors(1, 1, &mut buf, false);
|
||||
assert!(
|
||||
matches!(before, Err(crate::error::Error::DecryptFailed)),
|
||||
|
||||
+2
-2
@@ -802,7 +802,7 @@ fn single_base_key_slot(unit_keys: &[(u32, [u8; 16])]) -> Option<usize> {
|
||||
/// The pool slots of the disc's BASE CPS Unit Keys, in pool order — i.e. every
|
||||
/// entry that is not a forensic index key banked by [`resolve_fmts_key_map`]
|
||||
/// ([`FMTS_POOL_TAG_BASE`]). One element per CPS unit whose key is held.
|
||||
fn base_key_slots(unit_keys: &[(u32, [u8; 16])]) -> Vec<usize> {
|
||||
pub(crate) fn base_key_slots(unit_keys: &[(u32, [u8; 16])]) -> Vec<usize> {
|
||||
unit_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -1774,7 +1774,7 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
}
|
||||
}
|
||||
crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title);
|
||||
decrypting.set_unit_base(0);
|
||||
decrypting.clear_unit_base();
|
||||
|
||||
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
|
||||
decrypting,
|
||||
|
||||
@@ -76,11 +76,12 @@ pub struct DecryptingSectorSource<S: SectorSource> {
|
||||
/// extent `start_lba` that AACS aligned units are anchored at. The unit-
|
||||
/// alignment gate measures `lba` relative to THIS, not absolute disc LBA 0,
|
||||
/// so a clip whose `start_lba` is not 3-aligned still gates correctly. Set
|
||||
/// per-extent by the mux read paths via [`set_unit_base`]; defaults to 0
|
||||
/// (absolute alignment) for callers that read from a 3-aligned base.
|
||||
/// per-extent via [`set_unit_base`]. `None` (the default): there is no implicit
|
||||
/// disc-LBA-0 grid, so an AACS content read fails loud until a base is set —
|
||||
/// callers of [`new`](Self::new) must anchor before reading AACS content.
|
||||
///
|
||||
/// [`set_unit_base`]: Self::set_unit_base
|
||||
unit_base: u32,
|
||||
unit_base: Option<u32>,
|
||||
/// Encrypted-content map: every stream file's sorted/merged `(start_lba,
|
||||
/// sector_count)` (e.g. [`Disc::stream_content_ranges`](crate::Disc::stream_content_ranges)).
|
||||
/// When `Some`, a unit outside these ranges is clear and passed through
|
||||
@@ -113,7 +114,7 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
Self {
|
||||
inner,
|
||||
keys,
|
||||
unit_base: 0,
|
||||
unit_base: None,
|
||||
content_ranges: None,
|
||||
key_map: None,
|
||||
}
|
||||
@@ -156,6 +157,12 @@ impl<S: SectorSource> DecryptingSectorSource<S> {
|
||||
self.keys = keys;
|
||||
}
|
||||
|
||||
/// Drop the unit base: AACS content reads fail loud until
|
||||
/// [`set_unit_base`](SectorSource::set_unit_base) anchors the next one.
|
||||
pub fn clear_unit_base(&mut self) {
|
||||
self.unit_base = None;
|
||||
}
|
||||
|
||||
/// Borrow the inner source. Useful for tests and for adapters
|
||||
/// that want to introspect the underlying drive / file without
|
||||
/// unwrapping the decorator.
|
||||
@@ -223,12 +230,14 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
let content = self.content_ranges.clone();
|
||||
let content_ref = content.as_deref();
|
||||
|
||||
// Defense-in-depth: AACS units are 3-sector aligned; misalignment silently mis-
|
||||
// decrypts, so reject loud (DecryptFailed) before reading — gated vs `unit_base`
|
||||
// (not raw `lba % 3`), only when the span TOUCHES content (else UDF/nav passes through).
|
||||
// AACS units are 3-sector aligned on each file's grid; a content read with no
|
||||
// base, or off it, silently mis-decrypts, so reject loud before reading. Only
|
||||
// spans that TOUCH content are gated (UDF/nav passes through).
|
||||
if matches!(self.keys, DecryptKeys::Aacs { .. })
|
||||
&& span_touches_content(content_ref, lba, count)
|
||||
&& !crate::aacs::content::is_unit_aligned(lba, self.unit_base)
|
||||
&& !self
|
||||
.unit_base
|
||||
.is_some_and(|b| crate::aacs::content::is_unit_aligned(lba, b))
|
||||
{
|
||||
return Err(crate::error::Error::DecryptFailed);
|
||||
}
|
||||
@@ -262,7 +271,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
|
||||
}
|
||||
|
||||
fn set_unit_base(&mut self, lba: u32) {
|
||||
self.unit_base = lba;
|
||||
self.unit_base = Some(lba);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,6 +750,7 @@ mod tests {
|
||||
0,
|
||||
)]));
|
||||
let mut dec = DecryptingSectorSource::new(src, keys).with_key_map(map);
|
||||
dec.set_unit_base(0);
|
||||
let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let n = dec.read_sectors(0, 3, &mut buf, false).unwrap();
|
||||
assert_eq!(n, crate::aacs::content::ALIGNED_UNIT_LEN);
|
||||
@@ -767,6 +777,7 @@ mod tests {
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut dec = DecryptingSectorSource::new(src, keys); // no with_key_map
|
||||
dec.set_unit_base(0);
|
||||
let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let err = dec
|
||||
.read_sectors(0, 3, &mut buf, false)
|
||||
@@ -860,6 +871,7 @@ mod tests {
|
||||
)]));
|
||||
let mut dec =
|
||||
DecryptingSectorSource::new(CountingSource(reads.clone()), keys).with_key_map(map);
|
||||
dec.set_unit_base(0);
|
||||
let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let err = dec
|
||||
.read_sectors(1, 3, &mut buf, false)
|
||||
@@ -879,4 +891,75 @@ mod tests {
|
||||
assert!(dec.read_sectors(3, 3, &mut buf, false).is_err());
|
||||
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// A file whose first sector is LBA 1001 (1001 % 3 == 2): two encrypted units on
|
||||
// its own grid (1001, 1004), zeroes elsewhere.
|
||||
struct MisalignedFile(Vec<u8>);
|
||||
const FILE_LBA: u32 = 1001;
|
||||
impl SectorSource for MisalignedFile {
|
||||
fn read_sectors(&mut self, lba: u32, c: u16, b: &mut [u8], _r: bool) -> Result<usize> {
|
||||
for i in 0..c as usize {
|
||||
let rel = (lba as usize + i).wrapping_sub(FILE_LBA as usize) * 2048;
|
||||
let dst = &mut b[i * 2048..(i + 1) * 2048];
|
||||
match self.0.get(rel..rel + 2048) {
|
||||
Some(src) => dst.copy_from_slice(src),
|
||||
None => dst.fill(0),
|
||||
}
|
||||
}
|
||||
Ok(c as usize * 2048)
|
||||
}
|
||||
}
|
||||
|
||||
fn misaligned_file_source() -> DecryptingSectorSource<MisalignedFile> {
|
||||
let key = [0x5Au8; 16];
|
||||
let mut data = encrypt_aacs_unit(&key);
|
||||
data.extend(encrypt_aacs_unit(&key));
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, key)],
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let map = Arc::new(crate::decrypt::AacsKeyMap::from_ranges(vec![(
|
||||
FILE_LBA,
|
||||
FILE_LBA + 6,
|
||||
0,
|
||||
)]));
|
||||
DecryptingSectorSource::new(MisalignedFile(data), keys)
|
||||
.with_key_map(map)
|
||||
.with_content_ranges(Arc::from(vec![(FILE_LBA, 6u32)]))
|
||||
}
|
||||
|
||||
// No default grid: an AACS content read before any `set_unit_base` fails loud,
|
||||
// never decrypts on the disc-LBA-0 grid (1002 % 3 == 0 once passed the gate).
|
||||
#[test]
|
||||
fn aacs_content_read_without_unit_base_fails_loud() {
|
||||
let mut dec = misaligned_file_source();
|
||||
let mut buf = vec![0u8; 2 * crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let r = dec.read_sectors(FILE_LBA + 1, 6, &mut buf, false);
|
||||
assert!(
|
||||
matches!(r, Err(crate::error::Error::DecryptFailed)),
|
||||
"no unit base must be DecryptFailed, got {r:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// A wrong explicit base (disc grid) cuts chunks across real units: their seed has
|
||||
// no TS sync at byte 4, so the decrypt refuses instead of returning garbage.
|
||||
#[test]
|
||||
fn aacs_read_on_wrong_unit_grid_fails_loud() {
|
||||
let mut dec = misaligned_file_source();
|
||||
let mut buf = vec![0u8; 2 * crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
dec.set_unit_base(FILE_LBA + 1);
|
||||
let r = dec.read_sectors(FILE_LBA + 1, 6, &mut buf, false);
|
||||
assert!(
|
||||
matches!(r, Err(crate::error::Error::DecryptFailed)),
|
||||
"an off-grid read must be DecryptFailed, got Ok"
|
||||
);
|
||||
|
||||
// The file's own grid decrypts both units byte-exact.
|
||||
dec.set_unit_base(FILE_LBA);
|
||||
dec.read_sectors(FILE_LBA, 6, &mut buf, false)
|
||||
.expect("file-grid read decrypts");
|
||||
let mut want = clear_aacs_unit();
|
||||
want.extend(clear_aacs_unit());
|
||||
assert_eq!(buf, want);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-6
@@ -1575,8 +1575,8 @@ pub(crate) fn parse_dstring_for_test(data: &[u8]) -> String {
|
||||
|
||||
// Buffered sector reader — coalesces single-sector reads into `batch`-sized SCSI commands,
|
||||
// since per-command latency dominates on USB drives.
|
||||
pub(crate) struct BufferedSectorReader<'a> {
|
||||
inner: &'a mut dyn SectorSource,
|
||||
pub(crate) struct BufferedSectorReader<'a, S: SectorSource + ?Sized = dyn SectorSource> {
|
||||
inner: &'a mut S,
|
||||
cache_start: u32,
|
||||
cache: Vec<u8>,
|
||||
cache_sectors: u32,
|
||||
@@ -1585,8 +1585,8 @@ pub(crate) struct BufferedSectorReader<'a> {
|
||||
prefetched: std::collections::HashMap<u32, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<'a> BufferedSectorReader<'a> {
|
||||
pub(crate) fn new(inner: &'a mut dyn SectorSource, batch: u16) -> Self {
|
||||
impl<'a, S: SectorSource + ?Sized> BufferedSectorReader<'a, S> {
|
||||
pub(crate) fn new(inner: &'a mut S, batch: u16) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
cache_start: u32::MAX,
|
||||
@@ -1596,9 +1596,20 @@ impl<'a> BufferedSectorReader<'a> {
|
||||
prefetched: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapped source, so a live scan can run the handshake on the drive
|
||||
// without dropping the prefetched metadata cache.
|
||||
pub(crate) fn inner_mut(&mut self) -> &mut S {
|
||||
self.inner
|
||||
}
|
||||
|
||||
// Ends the buffering and hands the source back (the live scan's bus wiring).
|
||||
pub(crate) fn into_inner(self) -> &'a mut S {
|
||||
self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl BufferedSectorReader<'_> {
|
||||
impl<S: SectorSource + ?Sized> BufferedSectorReader<'_, S> {
|
||||
/// Pre-read a contiguous range of sectors into the sliding cache.
|
||||
/// Used to bulk-load the UDF metadata partition so subsequent reads are instant.
|
||||
pub(crate) fn prefetch(&mut self, start_lba: u32, count: u32) {
|
||||
@@ -1680,7 +1691,7 @@ impl BufferedSectorReader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for BufferedSectorReader<'_> {
|
||||
impl<S: SectorSource + ?Sized> SectorSource for BufferedSectorReader<'_, S> {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
|
||||
@@ -75,6 +75,16 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// The one Error for an unlocker's `Transport` (dead bus): `Drive::init` and both
|
||||
// scan bus steps (AACS, CSS) abort with it.
|
||||
pub(crate) fn unlock_transport_error() -> crate::error::Error {
|
||||
crate::error::Error::ScsiError {
|
||||
opcode: 0,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map libfreemkv's host certs (keysource-collected) to the unlock contract's.
|
||||
pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec<fu::HostCert> {
|
||||
certs
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
//! The whole-disc decrypting reader behind every decrypted disc/image → ISO copy, so the
|
||||
//! GUI, the CLI and the image path share one set of rules. AACS content is every content
|
||||
//! file (`/BDMV/STREAM`, HD DVD `/HVDVD_TS/*.EVO`), not just the kept titles. Each file no
|
||||
//! title plays is keyed on its own, probed at up to 32 units across it: a held key must
|
||||
//! open TWO probed units before it keys the file (one chance TS-sync pass is no proof);
|
||||
//! ciphertext no held key opens refuses up front ([`Error::WholeDiscKeyMissing`]); a file
|
||||
//! with no proven key is keyed only on a provably single-CPS disc, else left unkeyed so an
|
||||
//! encrypted unit there stops the pass with the same error (never ships as ciphertext).
|
||||
//! Reads follow each file's own 3-sector unit grid ([`UnitAligned`]).
|
||||
|
||||
use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::{DecryptingSectorSource, KeyFetch, SectorSource};
|
||||
|
||||
/// Sectors in one AACS aligned unit (6144 bytes).
|
||||
const UNIT: u64 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u64;
|
||||
|
||||
/// Units probed per unplayed content file: its first unit, then evenly across it.
|
||||
const PROBES: u64 = 32;
|
||||
|
||||
/// Probed units a key must open before it keys a file.
|
||||
const PROOFS: u8 = 2;
|
||||
|
||||
/// The decrypting reader a whole-disc copy reads through.
|
||||
pub type WholeDiscReader<S> = UnitAligned<DecryptingSectorSource<S>>;
|
||||
|
||||
/// Build the whole-disc reader over `reader`. For AACS every content file is keyed here, so
|
||||
/// a refusal (or a stop during keying) comes before the caller creates any output; the one
|
||||
/// exception is a file with no provable key on a multi-key disc, which stops the pass with
|
||||
/// E7032 at its first encrypted unit. `fetch` goes to [`crate::mux::resolve_mux_key_map`],
|
||||
/// which asks it only when the pool holds several base keys and none opens a file's
|
||||
/// samples. CSS is descrambled; `decrypt == false` (raw copy) and clear discs pass through.
|
||||
pub fn whole_disc_reader<S: SectorSource>(
|
||||
disc: &crate::Disc,
|
||||
mut reader: S,
|
||||
decrypt: bool,
|
||||
fetch: Option<&KeyFetch>,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Result<WholeDiscReader<S>> {
|
||||
let mut keys = if decrypt {
|
||||
disc.decrypt_keys()
|
||||
} else {
|
||||
DecryptKeys::None
|
||||
};
|
||||
let mut content = disc.encrypted_content_ranges();
|
||||
let mut spans = Vec::new();
|
||||
let mut unproven = Vec::new();
|
||||
let mut key_map = None;
|
||||
if matches!(keys, DecryptKeys::Aacs { .. }) {
|
||||
let r: &mut dyn SectorSource = &mut reader;
|
||||
let map = disc.resolve_content_key_map(r, &mut keys, fetch, halt)?;
|
||||
// freemkv#55: the key map alone is no gate; clear UDF/nav outside it would refuse.
|
||||
let files = content_files(r)?;
|
||||
if files.is_empty() && !content.is_empty() {
|
||||
tracing::warn!(target: "freemkv::scan", "titles but no AACS content files");
|
||||
let path = match disc.format {
|
||||
crate::DiscFormat::HdDvd => "/HVDVD_TS",
|
||||
_ => "/BDMV/STREAM",
|
||||
};
|
||||
return Err(Error::UdfNotFound { path: path.into() });
|
||||
}
|
||||
spans = unit_spans(&files, &content);
|
||||
let rule = KeyRule {
|
||||
format: disc.content_format,
|
||||
single: single_cps_key_slot(disc, &keys, &map),
|
||||
fetch,
|
||||
halt,
|
||||
};
|
||||
let keyed = key_content_files(r, &rule, &mut keys, map, &files, &spans)?;
|
||||
key_map = Some(std::sync::Arc::new(keyed.map));
|
||||
unproven = keyed.unproven;
|
||||
content.extend(files.iter().flatten());
|
||||
content = merge_ranges(content);
|
||||
// A stop that landed during the last key lookup still ends before any output.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
}
|
||||
let mut dec = DecryptingSectorSource::new(reader, keys);
|
||||
if let Some(map) = key_map {
|
||||
dec = dec.with_key_map(map);
|
||||
}
|
||||
// CSS self-descrambles and `None` decrypts nothing: the gate only matters for AACS.
|
||||
if decrypt && !content.is_empty() {
|
||||
dec = dec.with_content_ranges(std::sync::Arc::from(content));
|
||||
}
|
||||
let mut out = UnitAligned::new(dec, spans);
|
||||
out.unproven = unproven;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// Every AACS content file's extents, one entry per file (contiguous extents joined):
|
||||
// `/BDMV/STREAM` (m2ts before SSIF, which re-lists them) or else HD DVD `/HVDVD_TS/*.EVO`.
|
||||
// An unreadable UDF or unmappable file fails loud: it would otherwise ship as ciphertext.
|
||||
fn content_files(reader: &mut dyn SectorSource) -> Result<Vec<Vec<(u32, u32)>>> {
|
||||
let fs = crate::udf::read_filesystem(reader)?;
|
||||
let (top, evo_only) = if fs.find_dir("/BDMV/STREAM").is_some() {
|
||||
("/BDMV/STREAM", false)
|
||||
} else if fs.find_dir("/HVDVD_TS").is_some() {
|
||||
("/HVDVD_TS", true)
|
||||
} else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut paths = Vec::new();
|
||||
let mut stack: Vec<_> = fs
|
||||
.find_dir(top)
|
||||
.map(|d| (top.to_string(), d))
|
||||
.into_iter()
|
||||
.collect();
|
||||
while let Some((dir, entry)) = stack.pop() {
|
||||
for e in &entry.entries {
|
||||
let path = format!("{dir}/{}", e.name);
|
||||
if e.is_dir {
|
||||
stack.push((path, e));
|
||||
} else if !evo_only || e.name.to_ascii_uppercase().ends_with(".EVO") {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.sort_by_key(|p| (p.to_ascii_uppercase().contains("/SSIF/"), p.clone()));
|
||||
let mut files = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
let mut extents: Vec<(u32, u32)> = Vec::new();
|
||||
for (lba, n) in fs.file_extents(reader, &path)? {
|
||||
match extents.last_mut() {
|
||||
Some(last) if n > 0 && last.0 as u64 + last.1 as u64 == lba as u64 => last.1 += n,
|
||||
_ if n > 0 => extents.push((lba, n)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !extents.is_empty() {
|
||||
files.push(extents);
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// A content extent `(start, count)` and the LBA its unit grid is anchored at: the
|
||||
/// owning file's first sector, carried across extents by file offset.
|
||||
type UnitSpan = (u32, u32, u64);
|
||||
|
||||
// The unit grid of every content extent: per file by file offset (never a merged run:
|
||||
// adjacent files each start their own grid), then title extents no file covers (anchored
|
||||
// at the extent start). Sorted, disjoint; on overlap the earlier-listed source wins.
|
||||
fn unit_spans(files: &[Vec<(u32, u32)>], title_extents: &[(u32, u32)]) -> Vec<UnitSpan> {
|
||||
let mut raw: Vec<(UnitSpan, usize)> = Vec::new();
|
||||
for (i, file) in files.iter().enumerate() {
|
||||
let mut off = 0u64;
|
||||
for &(lba, n) in file {
|
||||
raw.push(((lba, n, (lba as u64).saturating_sub(off % UNIT)), i));
|
||||
off += n as u64;
|
||||
}
|
||||
}
|
||||
for &(lba, n) in title_extents {
|
||||
raw.push(((lba, n, lba as u64), files.len()));
|
||||
}
|
||||
raw.retain(|&((_, n, _), _)| n > 0);
|
||||
raw.sort_by_key(|&((lba, _, _), i)| (lba, i));
|
||||
let mut spans: Vec<UnitSpan> = Vec::with_capacity(raw.len());
|
||||
let mut covered = 0u64;
|
||||
for ((lba, n, anchor), _) in raw {
|
||||
let end = lba as u64 + n as u64;
|
||||
if end <= covered {
|
||||
continue;
|
||||
}
|
||||
let start = (lba as u64).max(covered);
|
||||
spans.push((start as u32, (end - start) as u32, anchor));
|
||||
covered = end;
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
// The span holding `lba`, if any.
|
||||
fn span_at(spans: &[UnitSpan], lba: u64) -> Option<UnitSpan> {
|
||||
let i = spans
|
||||
.partition_point(|&(s, _, _)| s as u64 <= lba)
|
||||
.checked_sub(1)?;
|
||||
let span = spans[i];
|
||||
(lba < span.0 as u64 + span.1 as u64).then_some(span)
|
||||
}
|
||||
|
||||
// The first unit head at or after `lba` on the grid anchored at `anchor`.
|
||||
fn unit_head(lba: u32, anchor: u64) -> u64 {
|
||||
lba as u64 + (UNIT - (lba as u64).saturating_sub(anchor) % UNIT) % UNIT
|
||||
}
|
||||
|
||||
// How content files are keyed: the disc's content format, the single-CPS slot an
|
||||
// unproven file may take, and the key source + stop token.
|
||||
struct KeyRule<'a> {
|
||||
format: crate::ContentFormat,
|
||||
single: Option<usize>,
|
||||
fetch: Option<&'a KeyFetch>,
|
||||
halt: Option<&'a crate::halt::Halt>,
|
||||
}
|
||||
|
||||
// The keyed content map plus the `[start, end)` pieces left unkeyed (no proven key).
|
||||
struct ContentKeys {
|
||||
map: AacsKeyMap,
|
||||
unproven: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
// Key every content file no kept title plays, ONE FILE AT A TIME (CPS units sit back to
|
||||
// back), with a held key proven on its ciphertext; ciphertext none opens refuses now.
|
||||
fn key_content_files(
|
||||
reader: &mut dyn SectorSource,
|
||||
rule: &KeyRule,
|
||||
keys: &mut DecryptKeys,
|
||||
map: AacsKeyMap,
|
||||
files: &[Vec<(u32, u32)>],
|
||||
spans: &[UnitSpan],
|
||||
) -> Result<ContentKeys> {
|
||||
let mut keyed: Vec<(u32, u32)> = map.ranges().iter().map(|&(s, e, _, _)| (s, e)).collect();
|
||||
let mut ranges = map.ranges().to_vec();
|
||||
let mut unproven = Vec::new();
|
||||
for file in files {
|
||||
let orphans = subtract_ranges(file, &keyed);
|
||||
if orphans.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Each piece from its first unit head, so key resolution samples on the file grid.
|
||||
let heads: Vec<(u32, u32, u64)> = orphans
|
||||
.iter()
|
||||
.map(|&(s, e)| {
|
||||
let anchor = span_at(spans, s as u64).map_or(s as u64, |sp| sp.2);
|
||||
(unit_head(s, anchor).min(e as u64) as u32, e, anchor)
|
||||
})
|
||||
.collect();
|
||||
let mut title = crate::DiscTitle::empty();
|
||||
title.content_format = rule.format;
|
||||
title.extents = heads
|
||||
.iter()
|
||||
.filter(|&&(h, e, _)| h < e)
|
||||
.map(|&(h, e, _)| crate::Extent {
|
||||
start_lba: h,
|
||||
sector_count: e - h,
|
||||
})
|
||||
.collect();
|
||||
let fmap = crate::mux::resolve_mux_key_map(
|
||||
reader,
|
||||
&title,
|
||||
keys,
|
||||
rule.fetch,
|
||||
rule.format,
|
||||
rule.halt,
|
||||
)
|
||||
.map_err(|e| unkeyable(Error::from(e), file))?;
|
||||
for (&(s, e), &(_, _, anchor)) in orphans.iter().zip(&heads) {
|
||||
let probe = Probe {
|
||||
map: &fmap,
|
||||
keys,
|
||||
format: rule.format,
|
||||
halt: rule.halt,
|
||||
};
|
||||
match probe
|
||||
.run(reader, (s, e), anchor)
|
||||
.map_err(|e| unkeyable(e, file))?
|
||||
{
|
||||
Proof::Forensic => {
|
||||
ranges.extend(fmap.ranges().iter().filter_map(|&(rs, re, i, p)| {
|
||||
(rs < e && re > s).then_some((rs.max(s), re.min(e), i, p))
|
||||
}));
|
||||
}
|
||||
Proof::Key(slot) => ranges.push((s, e, slot, Phase::All)),
|
||||
Proof::Unproven => match rule.single {
|
||||
Some(slot) => ranges.push((s, e, slot, Phase::All)),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
start = s,
|
||||
end = e,
|
||||
probes = PROBES,
|
||||
"unplayed stream file: no key opened two readable encrypted \
|
||||
probes (damaged area, or too little ciphertext), so its key \
|
||||
is unproven on this multi-key disc. Left unkeyed: if the copy \
|
||||
meets an encrypted unit here it stops with E7032. An MKV rip \
|
||||
or a raw copy avoids this."
|
||||
);
|
||||
unproven.push((s, e));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
// Settled either way: a file re-listing these sectors (SSIF) is not re-keyed.
|
||||
keyed.extend(orphans);
|
||||
keyed.sort_unstable();
|
||||
}
|
||||
Ok(ContentKeys {
|
||||
map: AacsKeyMap::from_ranges_phased(merge_key_ranges(ranges)),
|
||||
unproven: merge_spans(unproven),
|
||||
})
|
||||
}
|
||||
|
||||
// A key-resolution refusal for an unplayed file: "no held key opens it" is
|
||||
// [`Error::WholeDiscKeyMissing`] (the fix is an MKV rip or a raw copy).
|
||||
fn unkeyable(e: Error, file: &[(u32, u32)]) -> Error {
|
||||
match e {
|
||||
Error::DecryptFailed | Error::WholeDiscKeyMissing => {
|
||||
tracing::error!(
|
||||
target: "freemkv::scan",
|
||||
lba = file.first().map_or(0, |f| f.0),
|
||||
code = crate::error::E_WHOLE_DISC_KEY_MISSING,
|
||||
"unplayed stream file is encrypted and no held key opens it: a decrypted \
|
||||
image would keep encrypted pieces. Refusing before the copy starts."
|
||||
);
|
||||
Error::WholeDiscKeyMissing
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
// What probing an unplayed file's ciphertext proved.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Proof {
|
||||
/// A forensic (FMTS) segment entry: libfreemkv verifies each such unit as it decrypts.
|
||||
Forensic,
|
||||
/// This pool slot opened [`PROOFS`] probed units.
|
||||
Key(usize),
|
||||
/// No key opened enough readable encrypted units, and none was unopenable.
|
||||
Unproven,
|
||||
}
|
||||
|
||||
// Inputs for proving an unplayed file's key.
|
||||
struct Probe<'a> {
|
||||
map: &'a AacsKeyMap,
|
||||
keys: &'a DecryptKeys,
|
||||
format: crate::ContentFormat,
|
||||
halt: Option<&'a crate::halt::Halt>,
|
||||
}
|
||||
|
||||
impl Probe<'_> {
|
||||
// Probe `[start, end)` on the file's grid (`anchor`): its first unit, then evenly
|
||||
// across it. Each encrypted unit is tried with the mapped key, then every held base
|
||||
// key (never forensic ones). Err(WholeDiscKeyMissing): a unit no held key opens.
|
||||
fn run(
|
||||
&self,
|
||||
reader: &mut dyn SectorSource,
|
||||
(start, end): (u32, u32),
|
||||
anchor: u64,
|
||||
) -> Result<Proof> {
|
||||
use crate::aacs::content::{aacs_unit_encrypted, decrypt_unit, is_clean};
|
||||
let DecryptKeys::Aacs { unit_keys, .. } = self.keys else {
|
||||
return Ok(Proof::Unproven);
|
||||
};
|
||||
let base = crate::mux::resolve::base_key_slots(unit_keys);
|
||||
let head = unit_head(start, anchor);
|
||||
let units = (end as u64).saturating_sub(head) / UNIT;
|
||||
let mut opened = vec![0u8; unit_keys.len()];
|
||||
let mut unopened = false;
|
||||
let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
|
||||
let opens = |buf: &[u8], slot: usize| {
|
||||
unit_keys.get(slot).is_some_and(|(_, key)| {
|
||||
let mut u = buf.to_vec();
|
||||
decrypt_unit(&mut u, key);
|
||||
is_clean(&u, self.format)
|
||||
})
|
||||
};
|
||||
for unit in probe_units(units) {
|
||||
if self.halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let Ok(lba) = u32::try_from(head + unit * UNIT) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(reader.read_sectors(lba, UNIT as u16, &mut buf, false),
|
||||
Ok(n) if n == buf.len())
|
||||
|| !aacs_unit_encrypted(&buf, self.format)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mapped = match self.map.entry_for(lba) {
|
||||
Some((_, p, _)) if p != Phase::All => return Ok(Proof::Forensic),
|
||||
entry => entry.map(|(slot, _, _)| slot),
|
||||
};
|
||||
let mut candidates = mapped
|
||||
.into_iter()
|
||||
.chain(base.iter().copied().filter(|&s| Some(s) != mapped));
|
||||
match candidates.find(|&slot| opens(&buf, slot)) {
|
||||
Some(slot) => {
|
||||
opened[slot] = opened[slot].saturating_add(1);
|
||||
if opened[slot] >= PROOFS {
|
||||
return Ok(Proof::Key(slot));
|
||||
}
|
||||
}
|
||||
None => unopened = true,
|
||||
}
|
||||
}
|
||||
if unopened {
|
||||
return Err(Error::WholeDiscKeyMissing);
|
||||
}
|
||||
Ok(Proof::Unproven)
|
||||
}
|
||||
}
|
||||
|
||||
// Which of a piece's `units` to probe, in order: all of them when few, else the
|
||||
// first unit, then `PROBES - 1` more spread evenly to the end.
|
||||
fn probe_units(units: u64) -> Vec<u64> {
|
||||
if units <= PROBES {
|
||||
return (0..units).collect();
|
||||
}
|
||||
(0..PROBES).map(|p| units * p / PROBES).collect()
|
||||
}
|
||||
|
||||
/// The key-pool slot of the disc's only CPS unit, when provably single-CPS:
|
||||
/// `Unit_Key_RO.inf` declares exactly one unit, the disc is not FMTS, and `map`
|
||||
/// uses at most one key. Never the pool size: one held key on a two-unit disc is
|
||||
/// not single-CPS.
|
||||
fn single_cps_key_slot(disc: &crate::Disc, keys: &DecryptKeys, map: &AacsKeyMap) -> Option<usize> {
|
||||
use crate::aacs::mkb::AacsVersion;
|
||||
let aacs = disc.aacs.as_ref()?;
|
||||
if disc.format == crate::DiscFormat::Fmts || map.ranges().iter().any(|r| r.3 != Phase::All) {
|
||||
return None;
|
||||
}
|
||||
let version = if aacs.version >= 2 {
|
||||
AacsVersion::V20
|
||||
} else {
|
||||
AacsVersion::V10
|
||||
};
|
||||
let ukf = crate::aacs::inf::parse_unit_key_ro(&aacs.uk_ro, version)?;
|
||||
if ukf.encrypted_keys.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
match (map.key_indices(), keys) {
|
||||
([only], _) => Some(*only),
|
||||
([], DecryptKeys::Aacs { unit_keys, .. }) if unit_keys.len() == 1 => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Sorted, disjoint key ranges (`AacsKeyMap::entry_for` checks one neighbour), merged as
|
||||
// the per-disc map is: same key + phase overlap unions; a conflicting overlap drops.
|
||||
fn merge_key_ranges(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Vec<(u32, u32, usize, Phase)> {
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
let mut merged: Vec<(u32, u32, usize, Phase)> = Vec::with_capacity(ranges.len());
|
||||
for r in ranges {
|
||||
match merged.last_mut() {
|
||||
Some(last) if r.0 < last.1 => {
|
||||
if r.2 == last.2 && r.3 == last.3 {
|
||||
last.1 = last.1.max(r.1);
|
||||
}
|
||||
}
|
||||
_ => merged.push(r),
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
// `content` `(start, count)` ranges minus the sorted, disjoint `[start, end)` `keyed`
|
||||
// ranges, as `[start, end)` pieces.
|
||||
fn subtract_ranges(content: &[(u32, u32)], keyed: &[(u32, u32)]) -> Vec<(u32, u32)> {
|
||||
let mut out = Vec::new();
|
||||
for &(start, count) in content {
|
||||
let end = start.saturating_add(count);
|
||||
let mut pos = start;
|
||||
for &(ks, ke) in keyed {
|
||||
if ke <= pos || ks >= end {
|
||||
continue;
|
||||
}
|
||||
if ks > pos {
|
||||
out.push((pos, ks));
|
||||
}
|
||||
pos = pos.max(ke);
|
||||
}
|
||||
if pos < end {
|
||||
out.push((pos, end));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Sort + coalesce overlapping/adjacent `(start, count)` ranges (the content gate's
|
||||
// binary search needs them sorted and disjoint); empty ranges are dropped.
|
||||
fn merge_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
|
||||
ranges.retain(|&(_, count)| count > 0);
|
||||
ranges.sort_unstable();
|
||||
let mut out: Vec<(u32, u32)> = Vec::with_capacity(ranges.len());
|
||||
for (start, count) in ranges {
|
||||
let end = start as u64 + count as u64;
|
||||
if let Some(last) = out.last_mut() {
|
||||
let last_end = last.0 as u64 + last.1 as u64;
|
||||
if start as u64 <= last_end {
|
||||
let merged = last_end.max(end) - last.0 as u64;
|
||||
last.1 = u32::try_from(merged).unwrap_or(u32::MAX);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push((start, count));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Sort + coalesce `[start, end)` ranges.
|
||||
fn merge_spans(mut v: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
|
||||
v.sort_unstable();
|
||||
let mut out: Vec<(u32, u32)> = Vec::with_capacity(v.len());
|
||||
for (s, e) in v {
|
||||
match out.last_mut() {
|
||||
Some(last) if s <= last.1 => last.1 = last.1.max(e),
|
||||
_ => out.push((s, e)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads through `inner` on each content file's AACS unit grid: a read touching a span
|
||||
/// is widened to whole units of that span (sweep batches, `write_image` batches and
|
||||
/// patch's single-sector reads are not unit multiples). A unit whose head lies outside
|
||||
/// its span fails loud; a decrypt refusal inside an unproven file is E7032.
|
||||
pub struct UnitAligned<S> {
|
||||
inner: S,
|
||||
spans: Vec<UnitSpan>,
|
||||
scratch: Vec<u8>,
|
||||
/// Unplayed-file pieces left unkeyed (no proven key), `[start, end)`.
|
||||
unproven: Vec<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl<S: SectorSource> UnitAligned<S> {
|
||||
fn new(inner: S, spans: Vec<UnitSpan>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
spans,
|
||||
scratch: Vec::new(),
|
||||
unproven: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a block of sectors `[start, end)` should end so consecutive blocks tile each
|
||||
/// file's unit grid: `end` pulled back to its unit's head when that unit straddles it
|
||||
/// (else a bad sector there fails both neighbouring blocks). Never at or before `start`.
|
||||
pub fn unit_block_end(&self, start: u64, end: u64) -> u64 {
|
||||
match span_at(&self.spans, end) {
|
||||
Some((s, _, anchor)) if end > anchor => {
|
||||
let head = anchor + (end - anchor) / UNIT * UNIT;
|
||||
if head > start && head >= s as u64 {
|
||||
head
|
||||
} else {
|
||||
end
|
||||
}
|
||||
}
|
||||
_ => end,
|
||||
}
|
||||
}
|
||||
|
||||
// A decrypt refusal inside an unproven piece is the key it could not prove up front.
|
||||
fn refusal(&self, err: Error, a0: u64, a1: u64) -> Error {
|
||||
let hit = self
|
||||
.unproven
|
||||
.iter()
|
||||
.any(|&(s, e)| (s as u64) < a1 && (e as u64) > a0);
|
||||
match err {
|
||||
Error::DecryptFailed if hit => {
|
||||
tracing::error!(
|
||||
target: "freemkv::disc",
|
||||
lba = a0,
|
||||
code = crate::error::E_WHOLE_DISC_KEY_MISSING,
|
||||
"encrypted unit in an unplayed stream file whose key could not be \
|
||||
proven before the copy (no readable probe); stopping rather than \
|
||||
writing ciphertext into a decrypted image"
|
||||
);
|
||||
Error::WholeDiscKeyMissing
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: SectorSource> SectorSource for UnitAligned<S> {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.inner.capacity_sectors()
|
||||
}
|
||||
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.read_sectors_fua(lba, count, buf, recovery, false)
|
||||
}
|
||||
|
||||
fn read_sectors_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
const SECTOR: usize = 2048;
|
||||
let end = lba as u64 + count as u64;
|
||||
let mut cur = lba as u64;
|
||||
while cur < end {
|
||||
let off = (cur - lba as u64) as usize * SECTOR;
|
||||
let Some((s, n, anchor)) = span_at(&self.spans, cur) else {
|
||||
// Outside every content span: a plain read up to the next span.
|
||||
let i = self.spans.partition_point(|&(s, _, _)| s as u64 <= cur);
|
||||
let next = self
|
||||
.spans
|
||||
.get(i)
|
||||
.map_or(end, |&(s, _, _)| end.min(s as u64));
|
||||
let want = (next - cur) as usize * SECTOR;
|
||||
self.inner.set_unit_base(cur as u32);
|
||||
let got = self.inner.read_sectors_fua(
|
||||
cur as u32,
|
||||
(next - cur) as u16,
|
||||
&mut buf[off..off + want],
|
||||
recovery,
|
||||
fua,
|
||||
)?;
|
||||
if got < want {
|
||||
return Ok(off + got);
|
||||
}
|
||||
cur = next;
|
||||
continue;
|
||||
};
|
||||
let (s, e) = (s as u64, s as u64 + n as u64);
|
||||
let a0 = anchor + (cur - anchor) / UNIT * UNIT;
|
||||
if a0 < s {
|
||||
// The unit straddles a non-contiguous extent boundary: undecryptable here.
|
||||
return Err(Error::DecryptFailed);
|
||||
}
|
||||
// Capped so the widened read still fits one u16-count request.
|
||||
let piece_end = end.min(e).min(a0 + (u16::MAX as u64 / UNIT - 1) * UNIT);
|
||||
let a1 = e.min(a0 + (piece_end - a0).div_ceil(UNIT) * UNIT);
|
||||
let len = (a1 - a0) as usize * SECTOR;
|
||||
self.scratch.resize(len, 0);
|
||||
self.inner.set_unit_base(a0 as u32);
|
||||
let got = self
|
||||
.inner
|
||||
.read_sectors_fua(
|
||||
a0 as u32,
|
||||
(a1 - a0) as u16,
|
||||
&mut self.scratch[..len],
|
||||
recovery,
|
||||
fua,
|
||||
)
|
||||
.map_err(|e| self.refusal(e, a0, a1))?;
|
||||
let skip = (cur - a0) as usize * SECTOR;
|
||||
let want = (piece_end - cur) as usize * SECTOR;
|
||||
let have = got.saturating_sub(skip).min(want);
|
||||
buf[off..off + have].copy_from_slice(&self.scratch[skip..skip + have]);
|
||||
if have < want {
|
||||
return Ok(off + have);
|
||||
}
|
||||
cur = piece_end;
|
||||
}
|
||||
Ok(count as usize * SECTOR)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
self.inner.set_speed(kbs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "whole_disc_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,575 @@
|
||||
use super::*;
|
||||
use crate::aacs::content::encrypt_unit;
|
||||
|
||||
const SECTOR: usize = 2048;
|
||||
const K0: [u8; 16] = [0x11; 16];
|
||||
const K1: [u8; 16] = [0x22; 16];
|
||||
const K2: [u8; 16] = [0x33; 16];
|
||||
const STRANGER: [u8; 16] = [0x77; 16];
|
||||
|
||||
// A clear content unit for the unit starting at `lba`: varied payload, TS sync at
|
||||
// offset 4 of every 192-byte source packet, CPI bits clear.
|
||||
fn clear_unit(lba: u32) -> Vec<u8> {
|
||||
let mut u: Vec<u8> = (0..6144u32)
|
||||
.map(|i| (lba.wrapping_mul(7919).wrapping_add(i) % 251) as u8 | 1)
|
||||
.collect();
|
||||
for off in (4..6144).step_by(192) {
|
||||
u[off] = 0x47;
|
||||
}
|
||||
u[0] &= 0x3F;
|
||||
u
|
||||
}
|
||||
|
||||
/// An in-memory image: stream files laid out on their own unit grid, the listed
|
||||
/// units encrypted under the file's key; sectors outside every file are
|
||||
/// CPI-flagged-looking clear data (the freemkv#55 shape).
|
||||
struct Img {
|
||||
data: Vec<u8>,
|
||||
plain: Vec<u8>,
|
||||
/// One-unit reads starting in `[start, end)` fail (probes of a damaged area).
|
||||
probe_fail: Option<(u32, u32)>,
|
||||
reads: Vec<(u32, u16)>,
|
||||
}
|
||||
|
||||
// One file: start, sectors, key, and which of its units are encrypted.
|
||||
type FileSpec = (u32, u32, [u8; 16], &'static [u32]);
|
||||
|
||||
// Every unit of a 30-sector file.
|
||||
const ALL: &[u32] = &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
fn img(capacity: u32, files: &[FileSpec]) -> Img {
|
||||
let mut data = vec![0u8; capacity as usize * SECTOR];
|
||||
for lba in 0..capacity {
|
||||
let o = lba as usize * SECTOR;
|
||||
for (i, b) in data[o..o + SECTOR].iter_mut().enumerate() {
|
||||
*b = (lba as usize + i) as u8;
|
||||
}
|
||||
data[o] = 0xC0;
|
||||
}
|
||||
let mut plain = data.clone();
|
||||
for (start, n, key, enc) in files {
|
||||
for u in 0..n / 3 {
|
||||
let lba = start + u * 3;
|
||||
let o = lba as usize * SECTOR;
|
||||
let mut unit = clear_unit(lba);
|
||||
if enc.contains(&u) {
|
||||
unit[0] |= 0xC0;
|
||||
plain[o..o + 6144].copy_from_slice(&unit);
|
||||
assert!(encrypt_unit(&mut unit, key));
|
||||
} else {
|
||||
plain[o..o + 6144].copy_from_slice(&unit);
|
||||
}
|
||||
data[o..o + 6144].copy_from_slice(&unit);
|
||||
}
|
||||
}
|
||||
Img {
|
||||
data,
|
||||
plain,
|
||||
probe_fail: None,
|
||||
reads: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for Img {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
(self.data.len() / SECTOR) as u32
|
||||
}
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result<usize> {
|
||||
self.reads.push((lba, count));
|
||||
if count == 3 && self.probe_fail.is_some_and(|(s, e)| (s..e).contains(&lba)) {
|
||||
return Err(Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let (o, n) = (lba as usize * SECTOR, count as usize * SECTOR);
|
||||
buf[..n].copy_from_slice(&self.data[o..o + n]);
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
|
||||
fn pool(keys: &[[u8; 16]]) -> DecryptKeys {
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, k)| (i as u32, *k))
|
||||
.collect(),
|
||||
format: crate::ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
fn rule(single: Option<usize>) -> KeyRule<'static> {
|
||||
KeyRule {
|
||||
format: crate::ContentFormat::BdTs,
|
||||
single,
|
||||
fetch: None,
|
||||
halt: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Key `files` the way [`whole_disc_reader`] does; the kept title plays file 0 with slot 0.
|
||||
fn plan(
|
||||
src: &mut Img,
|
||||
keys: &mut DecryptKeys,
|
||||
files: &[Vec<(u32, u32)>],
|
||||
rule: &KeyRule,
|
||||
) -> Result<ContentKeys> {
|
||||
let (s, n) = files[0][0];
|
||||
let title = AacsKeyMap::from_ranges(vec![(s, s + n, 0)]);
|
||||
let spans = unit_spans(files, &[]);
|
||||
key_content_files(src, rule, keys, title, files, &spans)
|
||||
}
|
||||
|
||||
fn one_file_each(specs: &[FileSpec]) -> Vec<Vec<(u32, u32)>> {
|
||||
specs.iter().map(|f| vec![(f.0, f.1)]).collect()
|
||||
}
|
||||
|
||||
/// Wire the reader over `src` from a plan and copy the whole image with `write_image`.
|
||||
fn write_through(
|
||||
src: Img,
|
||||
keys: DecryptKeys,
|
||||
files: &[Vec<(u32, u32)>],
|
||||
planned: ContentKeys,
|
||||
) -> Result<Vec<u8>> {
|
||||
let content = merge_ranges(files.iter().flatten().copied().collect());
|
||||
let cap = src.capacity_sectors();
|
||||
let dec = DecryptingSectorSource::new(src, keys)
|
||||
.with_key_map(std::sync::Arc::new(planned.map))
|
||||
.with_content_ranges(std::sync::Arc::from(content));
|
||||
let mut r = UnitAligned::new(dec, unit_spans(files, &[]));
|
||||
r.unproven = planned.unproven;
|
||||
let dir = tempfile::tempdir().map_err(|e| Error::IoError { source: e })?;
|
||||
let dest = dir.path().join("out.iso");
|
||||
crate::write_image(&mut r, &dest, cap, &crate::halt::Halt::new(), |_| {})?;
|
||||
std::fs::read(&dest).map_err(|e| Error::IoError { source: e })
|
||||
}
|
||||
|
||||
fn assert_plain(img_plain: &[u8], bytes: &[u8]) {
|
||||
assert_eq!(bytes.len(), img_plain.len());
|
||||
for (lba, (got, want)) in bytes
|
||||
.chunks(SECTOR)
|
||||
.zip(img_plain.chunks(SECTOR))
|
||||
.enumerate()
|
||||
{
|
||||
assert!(got == want, "sector {lba} not decrypted as expected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_unplayed_file_is_keyed_and_the_image_decrypts_across_batches() {
|
||||
// Second file misaligned (start % 3 != 0) and crossing a 2048-sector batch edge.
|
||||
let specs = [(300, 30, K0, ALL), (2041, 30, K0, ALL)];
|
||||
let files = one_file_each(&specs);
|
||||
let mut src = img(2200, &specs);
|
||||
let mut keys = pool(&[K0]);
|
||||
let planned = plan(&mut src, &mut keys, &files, &rule(Some(0))).unwrap();
|
||||
assert!(planned.unproven.is_empty());
|
||||
let want = src.plain.clone();
|
||||
let bytes = write_through(img(2200, &specs), keys, &files, planned).unwrap();
|
||||
assert_plain(&want, &bytes);
|
||||
}
|
||||
|
||||
/// Back-to-back unplayed files in different CPS units each get their own key.
|
||||
#[test]
|
||||
fn adjacent_files_in_different_cps_units_each_get_their_own_key() {
|
||||
let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL), (630, 30, K2, ALL)];
|
||||
let files = one_file_each(&specs);
|
||||
let mut keys = pool(&[K0, K1, K2]);
|
||||
let planned = plan(&mut img(1200, &specs), &mut keys, &files, &rule(None)).unwrap();
|
||||
assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(1));
|
||||
assert_eq!(planned.map.entry_for(630).map(|e| e.0), Some(2));
|
||||
let bytes = write_through(img(1200, &specs), keys, &files, planned).unwrap();
|
||||
assert_plain(&img(1200, &specs).plain, &bytes);
|
||||
}
|
||||
|
||||
/// Ciphertext no held key opens refuses the plan: multi-CPS pool, and a single key.
|
||||
#[test]
|
||||
fn a_file_no_held_key_opens_is_refused_up_front() {
|
||||
let specs = [(300, 30, K0, ALL), (600, 30, STRANGER, ALL)];
|
||||
let files = one_file_each(&specs);
|
||||
for (p, single) in [(&[K0, K1][..], None), (&[K0][..], Some(0))] {
|
||||
let r = plan(&mut img(1200, &specs), &mut pool(p), &files, &rule(single));
|
||||
assert!(
|
||||
matches!(r, Err(Error::WholeDiscKeyMissing)),
|
||||
"pool {}: {:?}",
|
||||
p.len(),
|
||||
r.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolver's evenly spaced samples miss a file encrypted only at its ends; the
|
||||
/// probes cover every unit of a short file and prove the other held key twice.
|
||||
#[test]
|
||||
fn another_held_key_opening_two_probes_keys_the_file() {
|
||||
let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0, 9][..])];
|
||||
let files = one_file_each(&specs);
|
||||
let mut src = img(1200, &specs);
|
||||
let planned = plan(&mut src, &mut pool(&[K0, K1]), &files, &rule(None)).unwrap();
|
||||
assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(1));
|
||||
assert!(planned.unproven.is_empty());
|
||||
}
|
||||
|
||||
/// One opened probe is no proof: a chance TS-sync pass must not key a whole file.
|
||||
/// With no unit left unopened the file is unproven (keyed only on single-CPS).
|
||||
#[test]
|
||||
fn a_key_opening_a_single_probe_is_not_trusted() {
|
||||
let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0])];
|
||||
let files = one_file_each(&specs);
|
||||
let planned = plan(
|
||||
&mut img(1200, &specs),
|
||||
&mut pool(&[K0, K1]),
|
||||
&files,
|
||||
&rule(None),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(planned.map.entry_for(600), None);
|
||||
assert_eq!(planned.unproven, vec![(600, 630)]);
|
||||
let planned = plan(
|
||||
&mut img(1200, &specs),
|
||||
&mut pool(&[K0, K1]),
|
||||
&files,
|
||||
&rule(Some(0)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(0));
|
||||
}
|
||||
|
||||
/// A key that opens one probe while another encrypted unit opens under none is
|
||||
/// demonstrably unkeyable: refused, not trusted on the one pass.
|
||||
#[test]
|
||||
fn a_single_opened_probe_beside_an_unopenable_unit_is_refused() {
|
||||
let mut src = img(1200, &[(300, 30, K0, ALL), (600, 30, K1, &[0])]);
|
||||
let other = img(1200, &[(600, 30, STRANGER, &[5])]);
|
||||
let (a, b) = (615 * SECTOR, 618 * SECTOR);
|
||||
src.data[a..b].copy_from_slice(&other.data[a..b]);
|
||||
let files = vec![vec![(300, 30)], vec![(600, 30)]];
|
||||
let r = plan(&mut src, &mut pool(&[K0, K1]), &files, &rule(None));
|
||||
assert!(
|
||||
matches!(r, Err(Error::WholeDiscKeyMissing)),
|
||||
"{:?}",
|
||||
r.err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Alternates are the held BASE keys only: a forensic (FMTS-tagged) pool key never
|
||||
/// keys a whole file, while another base key still does.
|
||||
#[test]
|
||||
fn alternates_never_use_forensic_pool_keys() {
|
||||
// Only the ends encrypted: the resolver's samples miss the file.
|
||||
let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0, 9][..])];
|
||||
let files = one_file_each(&specs);
|
||||
let mut src = img(1200, &specs);
|
||||
let tagged = |extra: &[(u32, [u8; 16])]| {
|
||||
let mut unit_keys = vec![(0u32, K0)];
|
||||
unit_keys.extend_from_slice(extra);
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys,
|
||||
format: crate::ContentFormat::BdTs,
|
||||
}
|
||||
};
|
||||
let mut forensic = tagged(&[(1 << 24, K1)]);
|
||||
let r = plan(&mut src, &mut forensic, &files, &rule(None));
|
||||
assert!(
|
||||
matches!(r, Err(Error::WholeDiscKeyMissing)),
|
||||
"{:?}",
|
||||
r.err()
|
||||
);
|
||||
let mut base = tagged(&[(1 << 24, STRANGER), (1, K1)]);
|
||||
let planned = plan(&mut src, &mut base, &files, &rule(None)).unwrap();
|
||||
assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(2));
|
||||
}
|
||||
|
||||
/// Last resort: no probe is readable, so the file stays unkeyed and the copy stops
|
||||
/// at its first encrypted unit with E7032, never writing ciphertext.
|
||||
#[test]
|
||||
fn an_unreadable_probe_leaves_the_file_unproven_and_the_copy_stops_with_e7032() {
|
||||
let specs = [(300, 30, K0, ALL), (600, 30, STRANGER, ALL)];
|
||||
let files = one_file_each(&specs);
|
||||
let mut src = img(1200, &specs);
|
||||
src.probe_fail = Some((600, 630));
|
||||
let mut keys = pool(&[K0, K1]);
|
||||
let planned = plan(&mut src, &mut keys, &files, &rule(None)).unwrap();
|
||||
assert_eq!(planned.unproven, vec![(600, 630)]);
|
||||
let r = write_through(img(1200, &specs), keys, &files, planned);
|
||||
assert!(
|
||||
matches!(r, Err(Error::WholeDiscKeyMissing)),
|
||||
"{:?}",
|
||||
r.err()
|
||||
);
|
||||
}
|
||||
|
||||
/// A stop request is honoured between probes.
|
||||
#[test]
|
||||
fn probing_honours_a_stop() {
|
||||
let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL)];
|
||||
let files = one_file_each(&specs);
|
||||
let halt = crate::halt::Halt::new();
|
||||
halt.cancel();
|
||||
let rule = KeyRule {
|
||||
halt: Some(&halt),
|
||||
..rule(None)
|
||||
};
|
||||
let r = plan(&mut img(1200, &specs), &mut pool(&[K0, K1]), &files, &rule);
|
||||
assert!(matches!(r, Err(Error::Halted)), "{:?}", r.err());
|
||||
}
|
||||
|
||||
/// SSIF re-lists an m2ts's extents: that file is probed once, the map stays disjoint.
|
||||
#[test]
|
||||
fn a_relisted_file_is_keyed_once_into_a_disjoint_map() {
|
||||
let files = vec![vec![(300, 30)], vec![(600, 30)], vec![(600, 30)]];
|
||||
let mut src = img(1200, &[(300, 30, K0, ALL), (600, 30, K0, ALL)]);
|
||||
let planned = plan(&mut src, &mut pool(&[K0]), &files, &rule(Some(0))).unwrap();
|
||||
let r = planned.map.ranges();
|
||||
assert!(r.windows(2).all(|w| w[0].1 <= w[1].0), "disjoint: {r:?}");
|
||||
let probes = src
|
||||
.reads
|
||||
.iter()
|
||||
.filter(|&&(l, n)| l >= 600 && n == 3)
|
||||
.count();
|
||||
assert!(probes <= PROBES as usize + 8, "probed twice: {probes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probes_start_at_the_first_unit_and_spread_to_the_end() {
|
||||
assert_eq!(probe_units(0), Vec::<u64>::new());
|
||||
assert_eq!(probe_units(3), vec![0, 1, 2]);
|
||||
assert_eq!(probe_units(PROBES), (0..PROBES).collect::<Vec<_>>());
|
||||
let long = probe_units(3200);
|
||||
assert_eq!(long.len(), PROBES as usize);
|
||||
assert_eq!((long[0], long[1], long[31]), (0, 100, 3100));
|
||||
}
|
||||
|
||||
// A minimal AACS 1.0 `Unit_Key_RO.inf` declaring `units` CPS units.
|
||||
fn unit_key_ro(units: u16) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 96 + 48 * units as usize];
|
||||
v[..4].copy_from_slice(&48u32.to_be_bytes());
|
||||
v[48..50].copy_from_slice(&units.to_be_bytes());
|
||||
v
|
||||
}
|
||||
|
||||
fn aacs_disc(declared: u16) -> crate::Disc {
|
||||
crate::Disc {
|
||||
volume_id: String::new(),
|
||||
meta_title: None,
|
||||
format: crate::DiscFormat::BluRay,
|
||||
capacity_sectors: 1000,
|
||||
capacity_bytes: 1000 * 2048,
|
||||
layers: 1,
|
||||
titles: Vec::new(),
|
||||
region: crate::disc::DiscRegion::Free,
|
||||
aacs: Some(crate::disc::AacsState {
|
||||
version: 1,
|
||||
bus_encryption: false,
|
||||
mkb_version: None,
|
||||
disc_hash: String::new(),
|
||||
key_source: crate::disc::KeyOrigin::DeviceKey,
|
||||
vuk: None,
|
||||
unit_keys: vec![(1, K0)],
|
||||
volume_id: [0; 16],
|
||||
uk_ro: unit_key_ro(declared),
|
||||
mkb: Vec::new(),
|
||||
}),
|
||||
css: None,
|
||||
encrypted: true,
|
||||
aacs_error: None,
|
||||
css_error: None,
|
||||
content_format: crate::ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-CPS is the DECLARED unit count, never the pool size.
|
||||
#[test]
|
||||
fn single_cps_follows_the_declared_unit_count_not_the_pool() {
|
||||
let empty = AacsKeyMap::from_ranges(Vec::new());
|
||||
let keys = pool(&[K0]);
|
||||
assert_eq!(single_cps_key_slot(&aacs_disc(1), &keys, &empty), Some(0));
|
||||
assert_eq!(single_cps_key_slot(&aacs_disc(2), &keys, &empty), None);
|
||||
let mut fmts = aacs_disc(1);
|
||||
fmts.format = crate::DiscFormat::Fmts;
|
||||
assert_eq!(single_cps_key_slot(&fmts, &keys, &empty), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtract_leaves_only_the_unkeyed_pieces() {
|
||||
let got = subtract_ranges(&[(10, 30), (50, 10)], &[(0, 12), (20, 25), (38, 55)]);
|
||||
assert_eq!(got, vec![(12, 20), (25, 38), (55, 60)]);
|
||||
assert_eq!(subtract_ranges(&[(10, 5)], &[(10, 15)]), Vec::new());
|
||||
assert_eq!(subtract_ranges(&[(10, 5)], &[]), vec![(10, 15)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_sorts_coalesces_overlap_and_adjacency_and_drops_empties() {
|
||||
let got = merge_ranges(vec![(50, 10), (10, 5), (12, 10), (22, 3), (40, 0), (30, 1)]);
|
||||
assert_eq!(got, vec![(10, 15), (30, 1), (50, 10)]);
|
||||
assert_eq!(merge_ranges(vec![(0, 100), (10, 5)]), vec![(0, 100)]);
|
||||
let got = merge_ranges(vec![(0, u32::MAX), (u32::MAX - 1, u32::MAX)]);
|
||||
assert_eq!(got, vec![(0, u32::MAX)]);
|
||||
}
|
||||
|
||||
/// Each file anchors its own grid at its first sector; a later extent keeps the grid
|
||||
/// by FILE OFFSET; title extents outside every file get their own.
|
||||
#[test]
|
||||
fn unit_spans_follow_each_file_by_offset_across_extents() {
|
||||
let files = vec![vec![(100, 4), (300, 5)], vec![(104, 7)]];
|
||||
let spans = unit_spans(&files, &[]);
|
||||
assert_eq!(spans, vec![(100, 4, 100), (104, 7, 104), (300, 5, 299)]);
|
||||
assert_eq!(unit_spans(&[], &[(7, 9)]), vec![(7, 9, 7)]);
|
||||
let spans = unit_spans(&[vec![(10, 7)], vec![(10, 7), (40, 3)]], &[]);
|
||||
assert_eq!(spans, vec![(10, 7, 10), (40, 3, 39)]);
|
||||
let spans = unit_spans(&[vec![(100, 6)]], &[(100, 6), (200, 4)]);
|
||||
assert_eq!(spans, vec![(100, 6, 100), (200, 4, 200)]);
|
||||
}
|
||||
|
||||
/// Synthetic drive: each sector filled with its LBA's low byte; records reads,
|
||||
/// unit bases and FUA flags.
|
||||
#[derive(Default)]
|
||||
struct Mem {
|
||||
reads: Vec<(u32, u16)>,
|
||||
bases: Vec<u32>,
|
||||
fua: Vec<bool>,
|
||||
short: Option<usize>,
|
||||
fail: Option<fn() -> Error>,
|
||||
}
|
||||
impl SectorSource for Mem {
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
u32::MAX
|
||||
}
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], r: bool) -> Result<usize> {
|
||||
self.read_sectors_fua(lba, count, buf, r, false)
|
||||
}
|
||||
fn read_sectors_fua(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_: bool,
|
||||
fua: bool,
|
||||
) -> Result<usize> {
|
||||
self.reads.push((lba, count));
|
||||
self.fua.push(fua);
|
||||
if let Some(make) = self.fail {
|
||||
return Err(make());
|
||||
}
|
||||
let n = self.short.unwrap_or(usize::MAX).min(count as usize * 2048);
|
||||
for (i, b) in buf[..n].iter_mut().enumerate() {
|
||||
*b = (lba as usize + i / 2048) as u8;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
fn set_unit_base(&mut self, lba: u32) {
|
||||
self.bases.push(lba);
|
||||
}
|
||||
}
|
||||
|
||||
fn reader(spans: Vec<UnitSpan>) -> UnitAligned<Mem> {
|
||||
UnitAligned::new(Mem::default(), spans)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_aligned_splits_a_max_length_read_on_the_grid() {
|
||||
let mut r = reader(vec![(0, 70_000, 0)]);
|
||||
let mut buf = vec![0u8; u16::MAX as usize * 2048];
|
||||
assert_eq!(
|
||||
r.read_sectors(1, u16::MAX, &mut buf, false).unwrap(),
|
||||
buf.len()
|
||||
);
|
||||
assert_eq!(r.inner.reads, vec![(0, 65_532), (65_532, 6)]);
|
||||
assert!(
|
||||
buf.chunks(2048)
|
||||
.enumerate()
|
||||
.all(|(i, c)| c[0] == (1 + i) as u8)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_aligned_reports_a_short_inner_transfer() {
|
||||
let mut r = reader(vec![(100, 30, 100)]);
|
||||
r.inner.short = Some(2048);
|
||||
let mut buf = vec![0u8; 3 * 2048];
|
||||
assert_eq!(r.read_sectors(100, 3, &mut buf, false).unwrap(), 2048);
|
||||
r.inner.short = Some(0);
|
||||
assert_eq!(r.read_sectors(98, 2, &mut buf, false).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_aligned_passes_fua_and_bases_plain_reads_at_their_start() {
|
||||
let mut r = reader(vec![(100, 30, 100)]);
|
||||
let mut buf = vec![0u8; 5 * 2048];
|
||||
r.read_sectors_fua(98, 5, &mut buf, true, true).unwrap();
|
||||
assert_eq!(r.inner.fua, vec![true, true]);
|
||||
assert_eq!(r.inner.bases, vec![98, 100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_aligned_widens_reads_onto_each_files_grid() {
|
||||
let mut r = reader(vec![(100, 30, 100), (300, 9, 299)]);
|
||||
let mut buf = vec![0u8; 5 * 2048];
|
||||
assert_eq!(r.read_sectors(98, 5, &mut buf, false).unwrap(), 5 * 2048);
|
||||
let got: Vec<u8> = buf.chunks(2048).map(|c| c[0]).collect();
|
||||
assert_eq!(got, vec![98, 99, 100, 101, 102]);
|
||||
assert_eq!(r.inner.reads, vec![(98, 2), (100, 3)]);
|
||||
r.inner.reads.clear();
|
||||
let mut one = vec![0u8; 2048];
|
||||
r.read_sectors(305, 1, &mut one, false).unwrap();
|
||||
assert_eq!(one[0], (305u32 % 256) as u8);
|
||||
assert_eq!(r.inner.reads, vec![(305, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_aligned_refuses_a_unit_split_across_extents() {
|
||||
let mut r = reader(vec![(100, 4, 100), (300, 5, 299)]);
|
||||
let mut buf = vec![0u8; 2048];
|
||||
assert!(matches!(
|
||||
r.read_sectors(300, 1, &mut buf, false),
|
||||
Err(Error::DecryptFailed)
|
||||
));
|
||||
}
|
||||
|
||||
/// A decrypt refusal is E7032 only inside an unproven piece.
|
||||
#[test]
|
||||
fn a_refusal_in_an_unproven_piece_is_the_mkv_or_raw_error() {
|
||||
let mut r = reader(vec![(100, 30, 100)]);
|
||||
r.unproven = vec![(110, 130)];
|
||||
r.inner.fail = Some(|| Error::DecryptFailed);
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let got = r.read_sectors(111, 1, &mut buf, false);
|
||||
assert!(matches!(got, Err(Error::WholeDiscKeyMissing)), "{got:?}");
|
||||
let got = r.read_sectors(101, 1, &mut buf, false);
|
||||
assert!(matches!(got, Err(Error::DecryptFailed)), "{got:?}");
|
||||
r.inner.fail = Some(|| Error::Halted);
|
||||
let got = r.read_sectors(111, 1, &mut buf, false);
|
||||
assert!(matches!(got, Err(Error::Halted)), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_block_end_pulls_back_to_the_straddling_units_head() {
|
||||
let r = reader(vec![(100, 30, 100)]);
|
||||
assert_eq!(r.unit_block_end(90, 104), 103);
|
||||
assert_eq!(r.unit_block_end(90, 103), 103);
|
||||
assert_eq!(r.unit_block_end(103, 104), 104);
|
||||
assert_eq!(r.unit_block_end(0, 50), 50);
|
||||
}
|
||||
|
||||
/// An AACS disc with titles but no stream folder names the missing folder (E6003),
|
||||
/// not a key failure: no key refresh can fix a tree with no content files.
|
||||
#[test]
|
||||
fn titles_without_a_stream_folder_name_the_missing_folder() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("BDMV")).unwrap();
|
||||
std::fs::write(dir.path().join("BDMV/index.bdmv"), b"INDX0200").unwrap();
|
||||
let img = crate::DirImage::open(dir.path()).unwrap();
|
||||
let mut d = aacs_disc(1);
|
||||
let mut title = crate::DiscTitle::empty();
|
||||
title.extents = vec![crate::Extent {
|
||||
start_lba: 300,
|
||||
sector_count: 30,
|
||||
}];
|
||||
d.titles = vec![title];
|
||||
match whole_disc_reader(&d, img, true, None, None) {
|
||||
Err(Error::UdfNotFound { path }) => assert_eq!(path, "/BDMV/STREAM"),
|
||||
other => panic!("expected UdfNotFound, got {:?}", other.err()),
|
||||
}
|
||||
}
|
||||
+15
-21
@@ -1,36 +1,30 @@
|
||||
//! Drive-level timeout bound on a real device. The SgIoTransport fd-recovery
|
||||
//! test lives beside the transport (its fields are private) in
|
||||
//! `src/scsi/linux.rs`. Requires a real /dev/sg* device, hence #[ignore].
|
||||
//! Linux-only at the crate level: elsewhere it would report a pass having run nothing.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_drive_read_per_cdb_timeout_bounds_call() {
|
||||
let device = "/dev/sg2";
|
||||
let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string());
|
||||
let _path = Path::new(&device);
|
||||
let path = Path::new(&device);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::time::Duration;
|
||||
let mut drive = libfreemkv::Drive::open(path).expect("open drive");
|
||||
let timeout_ms: u32 = 5_000;
|
||||
|
||||
let mut drive = libfreemkv::Drive::open(_path).expect("open drive");
|
||||
let timeout_ms: u32 = 5_000;
|
||||
let start = std::time::Instant::now();
|
||||
let _ = drive.read(0, 1, &mut [0u8; 2048], false);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let _ = drive.read(0, 1, &mut [0u8; 2048], false);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let overhead = Duration::from_millis(500);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(timeout_ms as u64) + overhead,
|
||||
"Drive::read should return within timeout_ms + overhead, took {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
eprintln!("SKIP: test requires Linux / SgIoTransport");
|
||||
}
|
||||
let overhead = Duration::from_millis(500);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(timeout_ms as u64) + overhead,
|
||||
"Drive::read should return within timeout_ms + overhead, took {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user