Compare commits
16
Commits
422f2b6bcf
...
v1.4.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93571d9181 | ||
|
|
89af9876ae | ||
|
|
0471e0ca40 | ||
|
|
38207d2272 | ||
|
|
add9d8e0cd | ||
|
|
edc60582ec | ||
|
|
ccb7cafc68 | ||
|
|
0183bfb58c | ||
|
|
830d1e360c | ||
|
|
04728d7d94 | ||
|
|
e62ffed2b1 | ||
|
|
9d37043b3e | ||
|
|
6858cd064d | ||
|
|
f99670ceaa | ||
|
|
75b0e68b85 | ||
|
|
4a341331e2 |
@@ -1,5 +1,89 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.4.2] — 2026-07-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Mux no longer nulls decryptable video or storms the key server on a
|
||||||
|
bad-encoded region.** 1.4.1 relaxed the decrypt gate but left the surrounding
|
||||||
|
machinery in place. On a unit whose key *decrypted* but whose plaintext didn't
|
||||||
|
reassemble to clean MPEG-TS, the read path still restored ciphertext, tallied
|
||||||
|
loss, and re-asked the online key server (forever returning the same correct key)
|
||||||
|
while the mux concealed the unit as NULL TS. The root cause: *"did a key produce
|
||||||
|
clean TS?"* was used as the verdict *"did we decrypt?"* — they are not the same.
|
||||||
|
A correct key can decrypt content with broken encoding; broken TS is a muxer
|
||||||
|
concern, never a decrypt verdict.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **One decrypt authority; policy at the caller.** `decrypt_sectors` is now a
|
||||||
|
pure decrypt: applies the CPS unit key in place, leaves plaintext, and reports
|
||||||
|
unverified bytes. It never restores ciphertext, nulls, or re-fetches a key.
|
||||||
|
Clean-TS status is only a key-*selection* hint (multi-CPS) or a read-*verify*
|
||||||
|
signal (sweep/patch). Callers own the policy: the mux passes decrypted bytes
|
||||||
|
through unconditionally (the demuxer handles bad TS); sweep/patch treat an
|
||||||
|
unverified unit as a failed read and re-read it. Removes the decrypt-time
|
||||||
|
ciphertext restore, the mux NULL-TS conceal loop, and the per-unit key-server
|
||||||
|
refetch, plus the dead `aacs_unit_still_ciphertext` predicate.
|
||||||
|
|
||||||
|
- **Decrypt and TS-structure are now separate primitives.** AACS has no MAC;
|
||||||
|
the only "did it decrypt?" signal is whether plaintext looks like MPEG-TS —
|
||||||
|
a data-quality / key-selection question, not a decrypt verdict. The old
|
||||||
|
`decrypt_unit(...) -> bool` is split into `decrypt_unit_raw` (pure crypto) and
|
||||||
|
`is_clean_ts` (structural check), composed explicitly only where needed. The
|
||||||
|
mux calls only `decrypt_unit_raw`.
|
||||||
|
|
||||||
|
- **Key-proof floor replaces the 75% supermajority.** The old proportion
|
||||||
|
(≥75% of content packets synced) conflated *the key worked* with *the content
|
||||||
|
is well-encoded*. `is_clean_ts` now requires `synced >= min(E, 4)` on
|
||||||
|
**encrypted** packets (skipping packet 0 whose `0x47` is in the clear seed):
|
||||||
|
four synced packets ≈ 1-in-4-billion false-positive; `min(E, 4)` scales to
|
||||||
|
short fragment tails so they're never false-rejected. A unit is "opened" when
|
||||||
|
a handful of packets prove the key — bad-encoded packets are the muxer's job.
|
||||||
|
|
||||||
|
## [1.4.1] — 2026-07-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Mux no longer discards good video over a single defective packet.** AACS
|
||||||
|
decryption required **every** content packet to be conformant MPEG-TS: one
|
||||||
|
authored-bad packet (encoding defect, AACS 2.1 forensic-variant frame) made
|
||||||
|
the mux conceal the **whole** 6144-byte aligned unit as NULL TS (up to 31/32
|
||||||
|
good packets discarded, tallied as loss). On affected discs this produced
|
||||||
|
false "corruption" over otherwise-perfect video (~466 MB concealed across two
|
||||||
|
UHD titles). The gate is now a padding-aware **≥75% supermajority** of content
|
||||||
|
packets restoring their `0x47` sync — no wrong key reaches this threshold
|
||||||
|
(uniform-AES noise floor ≈ 256⁻ⁿ), but a minority of authored-bad packets
|
||||||
|
still passes. Opened units flow through verbatim; the demuxer drops
|
||||||
|
non-conforming packets on sync-loss. TS-sync conformance is a muxer concern,
|
||||||
|
never a decrypt verdict. (The supermajority threshold is tightened in 1.4.2.)
|
||||||
|
- **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC`
|
||||||
|
`CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's
|
||||||
|
`BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord`
|
||||||
|
built once per track, so they can no longer diverge. A track is flagged 3D
|
||||||
|
only when that record actually builds — a malformed dependent-view parameter
|
||||||
|
set no longer emits a mapping with no matching record (previously the flag was
|
||||||
|
taken from `mvc_params.is_some()`, which could orphan a `BlockAddID`). The base
|
||||||
|
track's `CodecPrivate` now carries the `mvcC` extension block
|
||||||
|
(`avcC ‖ u32be(size) ‖ "mvcC" ‖ record`, Matroska-spec size = block − 4) so
|
||||||
|
players and mediainfo detect MVC at the track level.
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Blu-ray 3D (MVC) support.** A 3D disc now rips to an MKV that preserves
|
||||||
|
**both eyes** as a single MVC video track — the AVC base (left) view in each
|
||||||
|
Block, and the MVC dependent (right-eye) view as a per-frame `BlockAdditional`
|
||||||
|
under an `mvcC` `BlockAdditionMapping` (`MVCDecoderConfigurationRecord` per
|
||||||
|
ISO/IEC 14496-15 §7.6.2), paired to the base by PTS. Remux only — no
|
||||||
|
transcode, no side-by-side conversion. The Blu-ray scan reads the interleaved
|
||||||
|
`STREAM/SSIF/<clip>.ssif`, enumerates the dependent view (stream_type `0x20`)
|
||||||
|
by the BD-3D PID convention, and parses it in a parameter-set-passthrough mode
|
||||||
|
so every dependent frame is a self-contained access unit. Verified on
|
||||||
|
*300: Rise of an Empire*: one MVC track, ~8.7 GB dependent payload carried in
|
||||||
|
per-frame BlockAdditionals, base view byte-identical to the 2D rip.
|
||||||
|
|
||||||
## [1.3.2] — 2026-07-10
|
## [1.3.2] — 2026-07-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "1.3.2"
|
version = "1.4.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -28,7 +28,7 @@ cbc = "0.1"
|
|||||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||||
freemkv-unlock = { path = "../freemkv-unlock" }
|
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.4" }
|
||||||
num-bigint = "0.4"
|
num-bigint = "0.4"
|
||||||
num-traits = "0.2"
|
num-traits = "0.2"
|
||||||
num-integer = "0.1"
|
num-integer = "0.1"
|
||||||
|
|||||||
+603
-726
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -463,7 +463,7 @@ pub enum KeyCandidate {
|
|||||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||||
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
||||||
/// its declared CPS-unit number); the caller runs
|
/// its declared CPS-unit number); the caller runs
|
||||||
/// [`super::content::unit_key_validates`] to find which one actually opens the
|
/// `decrypt_unit` + `is_clean_ts` to find which one actually opens the
|
||||||
/// disc. Rungs above the candidate are `None`.
|
/// disc. Rungs above the candidate are `None`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ResolvedChain {
|
pub struct ResolvedChain {
|
||||||
@@ -487,7 +487,7 @@ pub struct ResolvedChain {
|
|||||||
///
|
///
|
||||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
||||||
/// `unit_keys` against a real encrypted unit with
|
/// `unit_keys` against a real encrypted unit with
|
||||||
/// [`super::content::unit_key_validates`] to prove the candidate opens the disc.
|
/// `decrypt_unit` + `is_clean_ts` to prove the candidate opens the disc.
|
||||||
///
|
///
|
||||||
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
||||||
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||||
@@ -505,7 +505,8 @@ pub fn resolve_candidate(
|
|||||||
let version = mkb_type(mkb)
|
let version = mkb_type(mkb)
|
||||||
.map(|t| t.generation())
|
.map(|t| t.generation())
|
||||||
.unwrap_or(AacsVersion::V10);
|
.unwrap_or(AacsVersion::V10);
|
||||||
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
|
// BD/UHD Unit_Key_RO.inf or HD DVD VTKF000.AACS — dispatched by magic.
|
||||||
|
let ukf = parse_title_keys(unit_key_ro, version)?;
|
||||||
if ukf.encrypted_keys.is_empty() {
|
if ukf.encrypted_keys.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,73 @@
|
|||||||
//! FMTS variant selection — the pure decode-time decision for a 2.1 disc.
|
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
|
||||||
//!
|
//!
|
||||||
//! A 2.1 disc resolves to exactly one forensic variant (1..=32) for a given
|
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
|
||||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see
|
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
|
||||||
//! [`super::segment`]); the decode keeps the segments matching our variant,
|
//! [`super::segment`]); the decode keeps the segments matching our index,
|
||||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
//! drops the other 31, and treats everything outside a segment as ordinary
|
||||||
//! (variant-0) content. This module owns that classification and nothing else —
|
//! (index-0) content. This module owns that classification and nothing else —
|
||||||
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
||||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||||
//!
|
//!
|
||||||
//! Where the resolved variant comes from is a separate concern
|
//! Where the resolved index comes from is a separate concern
|
||||||
//! ([`resolve_disc_variant`]): today it is read off the variant keys the key
|
//! ([`resolve_disc_index`]): today it is read off the index keys the key
|
||||||
//! source handed us; when Processing Keys are available it will come from the
|
//! source handed us; when Processing Keys are available it will come from the
|
||||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
//! VK derivation instead. Either way the disposition logic below is identical.
|
||||||
|
|
||||||
use super::segment::{Segment, variant_segment_for_unit};
|
use super::segment::{Segment, segment_for_unit};
|
||||||
use super::types::UnitKey;
|
use super::types::UnitKey;
|
||||||
|
|
||||||
/// What the decode should do with one AACS aligned unit.
|
/// What the decode should do with one AACS aligned unit.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum UnitDisposition {
|
pub enum UnitDisposition {
|
||||||
/// Outside every forensic segment: ordinary content, decrypt with the
|
/// Outside every forensic segment: ordinary content, decrypt with the
|
||||||
/// default (variant-0) unit key.
|
/// default (index-0) unit key.
|
||||||
Default,
|
Default,
|
||||||
/// Inside a forensic segment tagged with OUR resolved variant: decrypt with
|
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
|
||||||
/// that variant's key.
|
/// that index's key.
|
||||||
Variant(u8),
|
Index(u8),
|
||||||
/// Inside a forensic segment tagged with a DIFFERENT variant: not our
|
/// Inside a forensic segment tagged with a DIFFERENT index: not our
|
||||||
/// watermark, so it is not part of our output — drop it.
|
/// watermark, so it is not part of our output — drop it.
|
||||||
DropForeignVariant(u8),
|
DropForeignIndex(u8),
|
||||||
/// Inside a forensic segment but no variant key is held (the disc's variant
|
/// Inside a forensic segment but no index key is held (the disc's index
|
||||||
/// was never resolved): the segment cannot be decoded, so it is concealed
|
/// was never resolved): the segment cannot be decoded, so it is concealed
|
||||||
/// as loss. Carries the segment's variant for diagnostics.
|
/// as loss. Carries the segment's index for diagnostics.
|
||||||
ForensicNoKey(u8),
|
ForensicNoKey(u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the disc's single forensic variant from the keys we hold.
|
/// Resolve the disc's single forensic index from the keys we hold.
|
||||||
///
|
///
|
||||||
/// Scans for a variant key (`variant_number` in `1..=32`) and returns its
|
/// Scans for an index key (`index_number` in `1..=32`) and returns its
|
||||||
/// variant. `None` when only default (variant-0) keys are held — i.e. no
|
/// index. `None` when only default (index-0) keys are held — i.e. no
|
||||||
/// variant source answered, so forensic segments are not decodable. A disc has
|
/// index source answered, so forensic segments are not decodable. A disc has
|
||||||
/// exactly one variant, so the first non-zero key decides; if several distinct
|
/// exactly one index, so the first non-zero key decides; if several distinct
|
||||||
/// variant keys were somehow supplied the lowest wins (deterministic), which is
|
/// index keys were somehow supplied the lowest wins (deterministic), which is
|
||||||
/// only a defensive tiebreak — the probe/derivation yields one.
|
/// only a defensive tiebreak — the probe/derivation yields one.
|
||||||
pub fn resolve_disc_variant(unit_keys: &[UnitKey]) -> Option<u8> {
|
pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||||
unit_keys
|
unit_keys
|
||||||
.iter()
|
.iter()
|
||||||
.map(|k| k.variant_number)
|
.map(|k| k.index_number)
|
||||||
.filter(|&v| v != 0)
|
.filter(|&v| v != 0)
|
||||||
.min()
|
.min()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
||||||
/// the forensic segment map and the disc's resolved variant (`None` if no
|
/// the forensic segment map and the disc's resolved index (`None` if no
|
||||||
/// variant key is held).
|
/// index key is held).
|
||||||
pub fn unit_disposition(
|
pub fn unit_disposition(
|
||||||
unit_offset: u64,
|
unit_offset: u64,
|
||||||
segments: &[Segment],
|
segments: &[Segment],
|
||||||
disc_variant: Option<u8>,
|
disc_index: Option<u8>,
|
||||||
) -> UnitDisposition {
|
) -> UnitDisposition {
|
||||||
match variant_segment_for_unit(segments, unit_offset) {
|
match segment_for_unit(segments, unit_offset) {
|
||||||
// Not in any forensic segment → ordinary content.
|
// Not in any forensic segment → ordinary content.
|
||||||
None => UnitDisposition::Default,
|
None => UnitDisposition::Default,
|
||||||
// In a forensic segment → decide by whether it is our variant.
|
// In a forensic segment → decide by whether it is our index.
|
||||||
Some(seg) => {
|
Some(seg) => {
|
||||||
let seg_variant = seg.variant as u8;
|
let seg_index = seg.index as u8;
|
||||||
match disc_variant {
|
match disc_index {
|
||||||
Some(v) if v == seg_variant => UnitDisposition::Variant(v),
|
Some(v) if v == seg_index => UnitDisposition::Index(v),
|
||||||
Some(_) => UnitDisposition::DropForeignVariant(seg_variant),
|
Some(_) => UnitDisposition::DropForeignIndex(seg_index),
|
||||||
None => UnitDisposition::ForensicNoKey(seg_variant),
|
None => UnitDisposition::ForensicNoKey(seg_index),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ mod tests {
|
|||||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||||
|
|
||||||
/// Build a one-record segment table (variant, start_spn, end_spn).
|
/// Build a one-record segment table (index, start_spn, end_spn).
|
||||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||||
@@ -95,23 +95,23 @@ mod tests {
|
|||||||
parse_individual_segments(&v).expect("parse")
|
parse_individual_segments(&v).expect("parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uk(idx: u32, variant: u8) -> UnitKey {
|
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||||
if variant == 0 {
|
if index == 0 {
|
||||||
UnitKey::new(idx, [0u8; 16])
|
UnitKey::new(idx, [0u8; 16])
|
||||||
} else {
|
} else {
|
||||||
UnitKey::variant(idx, [variant; 16], variant)
|
UnitKey::forensic(idx, [index; 16], index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_picks_the_single_variant_key() {
|
fn resolve_picks_the_single_index_key() {
|
||||||
// Default keys only → no variant resolved.
|
// Default keys only → no index resolved.
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None);
|
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||||
assert_eq!(resolve_disc_variant(&[]), None);
|
assert_eq!(resolve_disc_index(&[]), None);
|
||||||
// One variant key among defaults → that variant.
|
// One index key among defaults → that index.
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7));
|
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||||
// Defensive: lowest of several distinct variants (deterministic).
|
// Defensive: lowest of several distinct indexes (deterministic).
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3));
|
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -130,29 +130,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_in_our_variant_decrypts() {
|
fn unit_in_our_index_decrypts() {
|
||||||
let segs = tbl(&[(7, 100, 200)]);
|
let segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(7)),
|
unit_disposition(off, &segs, Some(7)),
|
||||||
UnitDisposition::Variant(7)
|
UnitDisposition::Index(7)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_in_foreign_variant_drops() {
|
fn unit_in_foreign_index_drops() {
|
||||||
// Segment tagged variant 7, but our disc variant is 3 → drop it.
|
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||||
let segs = tbl(&[(7, 100, 200)]);
|
let segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(3)),
|
unit_disposition(off, &segs, Some(3)),
|
||||||
UnitDisposition::DropForeignVariant(7)
|
UnitDisposition::DropForeignIndex(7)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forensic_unit_with_no_key_is_concealed() {
|
fn forensic_unit_with_no_key_is_concealed() {
|
||||||
// A forensic segment but we never resolved a variant → conceal as loss.
|
// A forensic segment but we never resolved an index → conceal as loss.
|
||||||
let segs = tbl(&[(7, 100, 200)]);
|
let segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -164,7 +164,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn straddling_unit_still_classified_as_its_segment() {
|
fn straddling_unit_still_classified_as_its_segment() {
|
||||||
// A unit whose 32-packet span only tails into the segment still routes
|
// A unit whose 32-packet span only tails into the segment still routes
|
||||||
// to the segment (matches variant_segment_for_unit's span test).
|
// to the segment (matches segment_for_unit's span test).
|
||||||
let segs = tbl(&[(5, 100, 200)]);
|
let segs = tbl(&[(5, 100, 200)]);
|
||||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||||
@@ -172,7 +172,7 @@ mod tests {
|
|||||||
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(5)),
|
unit_disposition(off, &segs, Some(5)),
|
||||||
UnitDisposition::Variant(5)
|
UnitDisposition::Index(5)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+172
@@ -161,6 +161,87 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HD DVD Video Title Key File (`VTKF000.AACS`) magic — "DVD HD Video TKF".
|
||||||
|
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
|
||||||
|
/// Fixed header length before the first title-key entry.
|
||||||
|
const VTKF_HEADER_LEN: usize = 0x80;
|
||||||
|
/// Each title-key entry: BE32 flag + 16-byte encrypted key + 12-byte 0xFF pad.
|
||||||
|
const VTKF_ENTRY_LEN: usize = 0x20;
|
||||||
|
|
||||||
|
/// Parse an HD DVD `VTKF000.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
|
||||||
|
/// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` →
|
||||||
|
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change. Only
|
||||||
|
/// the on-disc CONTAINER differs between BD and HD DVD; the title-key unwrap is
|
||||||
|
/// the identical AES-128 VUK step (`Kt = AES-128D(Kvu, Kte)`).
|
||||||
|
///
|
||||||
|
/// Layout (grounded in real discs — Shaun of the Dead, Anchorman, Harry Potter):
|
||||||
|
/// ```text
|
||||||
|
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
|
||||||
|
/// [0x0C..0x10] BE32 total file length
|
||||||
|
/// [0x10..0x1C] associated playlist name ("VPLST000.XPL")
|
||||||
|
/// [0x1C..0x80] reserved (zero)
|
||||||
|
/// [0x80..] 32-byte entries: BE32 flag | 16-byte ENCRYPTED title key | 12-byte 0xFF pad
|
||||||
|
/// flag bit 31 (0x8000_0000) set = present; a cleared flag ends the table
|
||||||
|
/// [tail] 16-byte signature/MAC (never a key — the cleared-flag stop guards it)
|
||||||
|
/// ```
|
||||||
|
/// Entries number 1..=N as CPS units, matching `Unit_Key_RO`'s 1-based CPS
|
||||||
|
/// numbering, so a title's CPS unit indexes this list identically. The
|
||||||
|
/// title→CPS mapping itself is playlist-driven (`VPLST000.XPL`) and owned by the
|
||||||
|
/// HD DVD enumerator, so `title_cps_unit` is left empty here.
|
||||||
|
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
|
||||||
|
if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// SHA1 of the WHOLE file — the KEYDB lookup key. BackupHDDVD-family key
|
||||||
|
// databases index an HD DVD disc by SHA1(VTKF000.AACS), the same role the
|
||||||
|
// BD disc_hash plays for `Unit_Key_RO.inf`.
|
||||||
|
let hash = disc_hash(data);
|
||||||
|
|
||||||
|
let mut encrypted_keys = Vec::new();
|
||||||
|
let mut pos = VTKF_HEADER_LEN;
|
||||||
|
let mut cps: u32 = 1;
|
||||||
|
while pos + VTKF_ENTRY_LEN <= data.len() {
|
||||||
|
let flag = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
|
||||||
|
// A cleared present-bit terminates the key table. The file's trailing
|
||||||
|
// 16-byte signature then follows and must NOT be read as a key.
|
||||||
|
if flag & 0x8000_0000 == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut key = [0u8; 16];
|
||||||
|
key.copy_from_slice(&data[pos + 4..pos + 20]);
|
||||||
|
encrypted_keys.push((cps, key));
|
||||||
|
cps += 1;
|
||||||
|
pos += VTKF_ENTRY_LEN;
|
||||||
|
}
|
||||||
|
if encrypted_keys.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(UnitKeyFile {
|
||||||
|
disc_hash: hash,
|
||||||
|
app_type: 0, // HD DVD VTKF carries no BD-ROM app_type
|
||||||
|
num_bdmv_dir: 0, // BD-only concept
|
||||||
|
use_skb_mkb: false,
|
||||||
|
version: AacsVersion::V10, // HD DVD is always AACS 1.0
|
||||||
|
encrypted_keys,
|
||||||
|
title_cps_unit: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a disc's title-key file, dispatching on the self-describing magic:
|
||||||
|
/// an HD DVD `VTKF000.AACS` (`DVD_HD_V_TKF`) → [`parse_vtkf`]; anything else is a
|
||||||
|
/// BD/UHD `Unit_Key_RO.inf` → [`parse_unit_key_ro`]. Both return the same
|
||||||
|
/// [`UnitKeyFile`], so every downstream AACS derivation stays container-agnostic
|
||||||
|
/// — the single seam where BD-vs-HD-DVD key layout is resolved (mirrors the key
|
||||||
|
/// service, which classifies HD DVD by the very same magic).
|
||||||
|
pub fn parse_title_keys(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||||
|
if data.len() >= 12 && &data[..12] == VTKF_MAGIC {
|
||||||
|
parse_vtkf(data)
|
||||||
|
} else {
|
||||||
|
parse_unit_key_ro(data, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// MKB disc structure format code.
|
/// MKB disc structure format code.
|
||||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||||
|
|
||||||
@@ -283,3 +364,94 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
|||||||
version,
|
version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod vtkf_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Build a synthetic `VTKF000.AACS` matching the real on-disc layout
|
||||||
|
/// (Shaun of the Dead / Anchorman): magic, BE32 size, playlist name,
|
||||||
|
/// reserved to 0x80, then 32-byte present-flagged entries, a cleared-flag
|
||||||
|
/// terminator, and a 16-byte trailer.
|
||||||
|
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||||
|
v.extend_from_slice(&0u32.to_be_bytes()); // 0x0C size (patched below)
|
||||||
|
v.extend_from_slice(b"VPLST000.XPL"); // 0x10
|
||||||
|
v.resize(0x80, 0); // reserve to first entry
|
||||||
|
for k in keys {
|
||||||
|
v.extend_from_slice(&0x8000_0000u32.to_be_bytes()); // present flag
|
||||||
|
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||||
|
v.extend_from_slice(&[0xFFu8; 12]); // 0xFF pad → 32-byte entry
|
||||||
|
}
|
||||||
|
// Cleared-flag terminator entry (must NOT be read as a key).
|
||||||
|
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]);
|
||||||
|
// 16-byte trailing signature (must NOT be read as a key).
|
||||||
|
v.extend_from_slice(&[0xABu8; 16]);
|
||||||
|
let len = v.len() as u32;
|
||||||
|
v[0x0C..0x10].copy_from_slice(&len.to_be_bytes());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_vtkf_extracts_present_entries_and_stops_at_terminator() {
|
||||||
|
let k1 = [0x11u8; 16];
|
||||||
|
let k2 = [0x22u8; 16];
|
||||||
|
let k3 = [0x33u8; 16];
|
||||||
|
let data = synth_vtkf(&[k1, k2, k3]);
|
||||||
|
|
||||||
|
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
|
||||||
|
// Exactly the three present entries — the cleared-flag terminator and
|
||||||
|
// the 16-byte trailer are NOT mistaken for keys.
|
||||||
|
assert_eq!(ukf.encrypted_keys.len(), 3, "must stop at the cleared flag");
|
||||||
|
assert_eq!(ukf.encrypted_keys[0], (1, k1), "CPS units number 1..=N");
|
||||||
|
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||||||
|
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||||||
|
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||||||
|
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
|
||||||
|
assert_eq!(ukf.disc_hash, disc_hash(&data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_vtkf_rejects_non_magic() {
|
||||||
|
let mut data = synth_vtkf(&[[0x11u8; 16]]);
|
||||||
|
data[0] = b'X'; // corrupt magic
|
||||||
|
assert!(
|
||||||
|
parse_vtkf(&data).is_none(),
|
||||||
|
"non-VTKF magic must be rejected"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse_vtkf(&[0u8; 4]).is_none(),
|
||||||
|
"too short must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_title_keys_dispatches_by_magic() {
|
||||||
|
// VTKF magic → parse_vtkf.
|
||||||
|
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
|
||||||
|
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
|
||||||
|
assert_eq!(ukf.encrypted_keys.len(), 2);
|
||||||
|
|
||||||
|
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
|
||||||
|
// this proves it ROUTED to the BD parser rather than parse_vtkf).
|
||||||
|
assert!(
|
||||||
|
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
|
||||||
|
"non-magic input must route to parse_unit_key_ro"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
|
||||||
|
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
|
||||||
|
/// no HD-DVD-specific crypto path.
|
||||||
|
#[test]
|
||||||
|
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
|
||||||
|
let enc = [0x9Au8; 16];
|
||||||
|
let data = synth_vtkf(&[enc]);
|
||||||
|
let ukf = parse_vtkf(&data).unwrap();
|
||||||
|
let vuk = [0x5Cu8; 16];
|
||||||
|
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
|
||||||
|
// Same as applying the shared unwrap directly to the stored enc key.
|
||||||
|
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+59
-8
@@ -29,6 +29,7 @@ pub mod content;
|
|||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod derive;
|
pub mod derive;
|
||||||
pub mod host_certs;
|
pub mod host_certs;
|
||||||
|
pub mod index_select;
|
||||||
pub mod inf;
|
pub mod inf;
|
||||||
pub mod mkb;
|
pub mod mkb;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
@@ -38,12 +39,19 @@ pub mod segment_key;
|
|||||||
pub mod trace;
|
pub mod trace;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod variant;
|
pub mod variant;
|
||||||
pub mod variant_select;
|
|
||||||
|
|
||||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
/// On-disc UDF paths to the AACS key-input files.
|
||||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
///
|
||||||
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
|
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
|
||||||
/// or changing a fallback in one place can then never silently diverge the
|
/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the
|
||||||
|
/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The
|
||||||
|
/// container difference is expressed here purely as DATA: each ROLE
|
||||||
|
/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered
|
||||||
|
/// candidate list, and every reader walks it with [`read_first`] taking the
|
||||||
|
/// first that reads. No reader ever branches on disc type — a BD/UHD disc has
|
||||||
|
/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through
|
||||||
|
/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||||
|
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||||||
@@ -51,6 +59,46 @@ pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
|
|||||||
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||||
|
/// HD DVD title-key file (`/ANY!/`), forwarded as `inf_b64`; the key service
|
||||||
|
/// recognises it by its `DVD_HD_V_TKF` magic.
|
||||||
|
pub const PATH_VTKF_HDDVD: &str = "/ANY!/VTKF000.AACS";
|
||||||
|
/// HD DVD Media Key Block (`/ANY!/`), forwarded as `mkb_b64`.
|
||||||
|
pub const PATH_MKBROM_HDDVD: &str = "/ANY!/MKBROM.AACS";
|
||||||
|
/// HD DVD content certificate (`/ANY!/`); byte 0 gives the AACS major (0x00 → V10).
|
||||||
|
pub const PATH_CONTENT_CERT_HDDVD: &str = "/ANY!/CONTENT_CERT.AACS";
|
||||||
|
|
||||||
|
/// Title-key / `Unit_Key_RO.inf` role, in resolution order (BD/UHD, then HD DVD).
|
||||||
|
pub const UNIT_KEY_RO_PATHS: &[&str] = &[
|
||||||
|
PATH_UNIT_KEY_RO,
|
||||||
|
PATH_UNIT_KEY_RO_DUPLICATE,
|
||||||
|
PATH_VTKF_HDDVD,
|
||||||
|
];
|
||||||
|
/// MKB role, in resolution order (BD/UHD RO then RW, then HD DVD).
|
||||||
|
pub const MKB_PATHS: &[&str] = &[PATH_MKB_RO, PATH_MKB_RW, PATH_MKBROM_HDDVD];
|
||||||
|
/// Content-certificate role, in resolution order (BD/UHD, then HD DVD).
|
||||||
|
pub const CONTENT_CERT_PATHS: &[&str] = &[
|
||||||
|
PATH_CONTENT_CERT,
|
||||||
|
PATH_CONTENT_CERT_ALT,
|
||||||
|
PATH_CONTENT_CERT_HDDVD,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Walk an AACS role's candidate paths and return the first that reads.
|
||||||
|
///
|
||||||
|
/// `read` performs the actual per-path read (full file or bounded prefix), so
|
||||||
|
/// callers share the same first-present walk regardless of read style. Returns
|
||||||
|
/// [`Error::AacsNoKeys`] if no candidate is present. This is the single place
|
||||||
|
/// the `/AACS/` (BD/UHD) vs `/ANY!/` (HD DVD) layout difference is resolved.
|
||||||
|
pub(crate) fn read_first<F>(candidates: &[&str], mut read: F) -> crate::error::Result<Vec<u8>>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
|
||||||
|
{
|
||||||
|
for path in candidates {
|
||||||
|
if let Ok(buf) = read(path) {
|
||||||
|
return Ok(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(crate::error::Error::AacsNoKeys)
|
||||||
|
}
|
||||||
|
|
||||||
// The module structure IS the public API — consumers import from the owning
|
// The module structure IS the public API — consumers import from the owning
|
||||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||||
@@ -61,7 +109,7 @@ pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
|||||||
// content-decrypt entry points that downstream key-source crates import through
|
// content-decrypt entry points that downstream key-source crates import through
|
||||||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
||||||
// lets those crates track the module refactor without a lockstep re-pin.
|
// lets those crates track the module refactor without a lockstep re-pin.
|
||||||
pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys};
|
pub use content::ALIGNED_UNIT_LEN;
|
||||||
pub use derive::derive_vuk;
|
pub use derive::derive_vuk;
|
||||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||||
|
|
||||||
@@ -71,7 +119,7 @@ mod tests {
|
|||||||
//! Touching one representative item per module keeps these as a
|
//! Touching one representative item per module keeps these as a
|
||||||
//! compile-time contract that the module paths stay stable.
|
//! compile-time contract that the module paths stay stable.
|
||||||
|
|
||||||
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
use super::content::ALIGNED_UNIT_LEN;
|
||||||
use super::inf::{disc_hash, disc_hash_hex};
|
use super::inf::{disc_hash, disc_hash_hex};
|
||||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||||
use super::variant::is_variant_mkb;
|
use super::variant::is_variant_mkb;
|
||||||
@@ -99,7 +147,10 @@ mod tests {
|
|||||||
fn public_helpers_are_callable_by_module_path() {
|
fn public_helpers_are_callable_by_module_path() {
|
||||||
// Touch a representative function from each module so a dropped/renamed
|
// Touch a representative function from each module so a dropped/renamed
|
||||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
let _ = !crate::aacs::content::is_clean(
|
||||||
|
&[0u8; ALIGNED_UNIT_LEN],
|
||||||
|
crate::disc::ContentFormat::BdTs,
|
||||||
|
);
|
||||||
let _ = mkb_content_len(&[]);
|
let _ = mkb_content_len(&[]);
|
||||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||||
|
|||||||
+4
-3
@@ -156,7 +156,7 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
|||||||
/// equivalent of path 2 — there's no host-side PK derivation against a
|
/// equivalent of path 2 — there's no host-side PK derivation against a
|
||||||
/// Variant MKB.)
|
/// Variant MKB.)
|
||||||
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
|
let uk_file = parse_title_keys(ctx.unit_key_ro, AacsVersion::V20)?;
|
||||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
let bus_encryption = ctx
|
let bus_encryption = ctx
|
||||||
.content_cert
|
.content_cert
|
||||||
@@ -280,8 +280,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
|||||||
.map(|cc| cc.bus_encryption)
|
.map(|cc| cc.bus_encryption)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Parse Unit_Key_RO.inf at the version-appropriate stride.
|
// Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the
|
||||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
|
// version-appropriate stride, or HD DVD VTKF000.AACS) → common UnitKeyFile.
|
||||||
|
let uk_file = parse_title_keys(ctx.unit_key_ro, version)?;
|
||||||
|
|
||||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||||
|
|||||||
+193
-58
@@ -1,40 +1,49 @@
|
|||||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||||
//!
|
//!
|
||||||
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
|
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
|
||||||
//! forensic-watermark mechanism. The same frames are authored as several
|
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
|
||||||
//! slightly different variants; each variant is encrypted under its own SEGMENT
|
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
|
||||||
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
|
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
|
||||||
//! right device keys can decrypt exactly one variant per segment, and which one
|
|
||||||
//! silently identifies the player (traitor tracing). Decrypting a variant
|
|
||||||
//! segment with the Unit Key yields garbage — broken HEVC reference frames
|
|
||||||
//! (empirically: `Could not find ref with POC …` on a plain unit-key rip).
|
|
||||||
//!
|
//!
|
||||||
//! This table says WHERE the variant segments live so a decoder can decrypt
|
//! Terminology (see the project AACS reference): the **index** here is NOT the
|
||||||
//! them with segment keys and select one coherent variant instead of muxing
|
//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the
|
||||||
|
//! MKB that decides *which set* of index keys a device receives, a layer this
|
||||||
|
//! module does not deal with. All the index keys belong to one variant, whose
|
||||||
|
//! number is unknown and irrelevant to the segment map. Decrypting a segment with
|
||||||
|
//! the Unit Key yields garbage — broken HEVC reference frames (empirically:
|
||||||
|
//! `Could not find ref with POC …` on a plain unit-key rip).
|
||||||
|
//!
|
||||||
|
//! This table says WHERE the segments live and which index each carries, so a
|
||||||
|
//! decoder can decrypt them with the matching index key instead of muxing
|
||||||
//! unit-key garbage.
|
//! unit-key garbage.
|
||||||
//!
|
//!
|
||||||
//! Format (validated against a retail AACS 2.1 disc):
|
//! Format (validated against a retail AACS 2.1 disc):
|
||||||
//! ```text
|
//! ```text
|
||||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||||
//! record[count] (16 bytes each):
|
//! record[count] (16 bytes each):
|
||||||
//! u32 marker (= 0x01000000) | u16 variant | u16 flag (= 1)
|
//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1)
|
||||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||||
//! ```
|
//! ```
|
||||||
//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id:
|
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
|
||||||
//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across
|
//! on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across records in
|
||||||
//! records in file order — 24 full cycles of 32 plus a final partial cycle of
|
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
|
||||||
//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index:
|
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
|
||||||
//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread
|
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
|
||||||
//! across the entire 54 GB feature (one roughly every 67 MB).
|
//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a
|
||||||
|
//! segment the 80 units interleave in two stride-2 halves: applying the segment's
|
||||||
|
//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second
|
||||||
|
//! interleaved half, unidentified), which the demux then drops — leaving one
|
||||||
|
//! coherent stream. Confirmed by decoding a retail disc with a full set of 32
|
||||||
|
//! index keys.
|
||||||
|
|
||||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||||
|
|
||||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
|
/// Whether a 2.1 (FMTS) disc may rip WITHOUT the forensic index keys.
|
||||||
///
|
///
|
||||||
/// `true` (today): the forensic variant segments are skipped as expected loss
|
/// `true` (today): the forensic segments are skipped as expected loss
|
||||||
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
|
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
|
||||||
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
|
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
|
||||||
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
|
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
|
||||||
@@ -45,17 +54,17 @@ pub const SOURCE_PACKET_LEN: u64 = 192;
|
|||||||
/// refused. Hardcoded on purpose — not a user setting.
|
/// refused. Hardcoded on purpose — not a user setting.
|
||||||
///
|
///
|
||||||
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
||||||
pub const BYPASS_FMTS_KEY: bool = true;
|
pub const BYPASS_FMTS_KEY: bool = false;
|
||||||
|
|
||||||
/// One forensic variant segment: the inclusive source-packet range it occupies
|
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||||
/// in the FMTS clip.
|
/// FMTS clip.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Segment {
|
pub struct Segment {
|
||||||
/// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the
|
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
|
||||||
/// table rather than counting up — it selects WHICH variant this range is,
|
/// table rather than counting up — it selects WHICH of the 32 index keys
|
||||||
/// which is what a variant-keyed decode routes on. (`0` is not used here;
|
/// decrypts this range. (`0` is not used here; the default/non-forensic
|
||||||
/// the default/non-forensic content carries no segment record at all.)
|
/// content carries no segment record at all.)
|
||||||
pub variant: u16,
|
pub index: u16,
|
||||||
/// First source packet of the segment (inclusive).
|
/// First source packet of the segment (inclusive).
|
||||||
pub start_spn: u32,
|
pub start_spn: u32,
|
||||||
/// Last source packet of the segment (inclusive).
|
/// Last source packet of the segment (inclusive).
|
||||||
@@ -109,8 +118,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
|
|||||||
/// unit's clip-relative byte offset.
|
/// unit's clip-relative byte offset.
|
||||||
///
|
///
|
||||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
||||||
/// overlaps a forensic segment must be opened with that segment's **variant
|
/// overlaps a forensic segment must be opened with that segment's **index key**
|
||||||
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
|
/// (selected by the segment's `index`), not the CPS Unit Key. Opening it with
|
||||||
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
||||||
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
||||||
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
||||||
@@ -118,16 +127,16 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
|
|||||||
///
|
///
|
||||||
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
||||||
/// unit that only partly overlaps a segment edge is still classified as
|
/// unit that only partly overlaps a segment edge is still classified as
|
||||||
/// variant; on the observed disc segments are unit-aligned, but the span test
|
/// forensic; on the observed disc segments are unit-aligned, but the span test
|
||||||
/// does not rely on that.
|
/// does not rely on that.
|
||||||
pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
pub fn segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||||
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
||||||
segments.iter().find(|s| s.overlaps_spn(first, last))
|
segments.iter().find(|s| s.overlaps_spn(first, last))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table
|
/// Parse `IndividualSegment.tbl` into its forensic segments, in table
|
||||||
/// order. Returns `None` when the header is malformed, the record size is not
|
/// order. Returns `None` when the header is malformed, the record size is not
|
||||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
/// so a truncated / foreign table degrades to "no segment map" rather than
|
||||||
@@ -147,12 +156,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
|||||||
let mut segments = Vec::with_capacity(count);
|
let mut segments = Vec::with_capacity(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let o = 8 + i * record_size;
|
let o = 8 + i * record_size;
|
||||||
// o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||||
let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
let index = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||||
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
||||||
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||||
segments.push(Segment {
|
segments.push(Segment {
|
||||||
variant,
|
index,
|
||||||
start_spn,
|
start_spn,
|
||||||
end_spn,
|
end_spn,
|
||||||
});
|
});
|
||||||
@@ -160,12 +169,74 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
|||||||
Some(segments)
|
Some(segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a clip-relative byte offset to the absolute LBA that holds it, by walking
|
||||||
|
/// the title's extents (the `.fmts` clip's sectors in file order). Segment
|
||||||
|
/// offsets in [`Segment`] are clip-relative source-packet numbers, so this is how
|
||||||
|
/// a segment's `spn` range becomes disc LBAs. `None` if the offset is past the
|
||||||
|
/// clip.
|
||||||
|
pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Option<u32> {
|
||||||
|
let mut cum = 0u64;
|
||||||
|
for e in extents {
|
||||||
|
let len = e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64;
|
||||||
|
if clip_byte < cum + len {
|
||||||
|
let sector_in_ext = ((clip_byte - cum) / crate::consts::SECTOR_BYTES as u64) as u32;
|
||||||
|
return Some(e.start_lba.saturating_add(sector_in_ext));
|
||||||
|
}
|
||||||
|
cum += len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map.
|
||||||
|
///
|
||||||
|
/// Each forensic segment's clip-relative source-packet span becomes an absolute
|
||||||
|
/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`,
|
||||||
|
/// e.g. `|i| i as usize` when the pool is `[base, idx1, idx2, …]`). Applying that
|
||||||
|
/// one key across the whole segment decodes the ~40 units of its interleave half
|
||||||
|
/// to clean TS and garbles the other ~40 (the second interleaved half), which the
|
||||||
|
/// demux then drops — yielding one coherent stream. Ranges outside every segment
|
||||||
|
/// are left for the map's default (the ordinary Unit Key). A segment that straddles
|
||||||
|
/// a UDF extent boundary is emitted as one range per whole-sector slice it covers.
|
||||||
|
///
|
||||||
|
/// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges)
|
||||||
|
/// with the Unit-Key index as the default — the same structure the CPS map uses,
|
||||||
|
/// only finer-grained.
|
||||||
|
pub fn fmts_key_ranges(
|
||||||
|
segments: &[Segment],
|
||||||
|
extents: &[crate::disc::Extent],
|
||||||
|
index_to_key_idx: &dyn Fn(u16) -> usize,
|
||||||
|
) -> Vec<(u32, u32, usize)> {
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
for s in segments {
|
||||||
|
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
|
||||||
|
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
|
||||||
|
// A segment is unit-aligned and contiguous in clip bytes; map its first
|
||||||
|
// and last sector to LBAs. Segments are ~480 KB and extents are GB-sized,
|
||||||
|
// so a segment almost never crosses an extent boundary — but if the two
|
||||||
|
// ends land in different extents (non-contiguous LBAs), skip rather than
|
||||||
|
// emit a wrong span; the units there fall to the Unit Key (garble+drop),
|
||||||
|
// never a mis-decrypt.
|
||||||
|
let (Some(a), Some(b)) = (
|
||||||
|
clip_byte_to_lba(extents, start_byte),
|
||||||
|
clip_byte_to_lba(extents, end_byte - 1),
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if b >= a
|
||||||
|
&& (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64
|
||||||
|
{
|
||||||
|
ranges.push((a, b + 1, index_to_key_idx(s.index)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||||
/// records. `recs` are `(variant, start_spn, end_spn)`.
|
/// records. `recs` are `(index, start_spn, end_spn)`.
|
||||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||||
@@ -181,11 +252,75 @@ mod tests {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
// One big clip extent starting at LBA 1000. Clip byte B lives at
|
||||||
|
// LBA 1000 + B/2048.
|
||||||
|
let extents = vec![Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 1_000_000,
|
||||||
|
}];
|
||||||
|
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
|
||||||
|
let segs = vec![
|
||||||
|
Segment {
|
||||||
|
index: 5,
|
||||||
|
start_spn: 100,
|
||||||
|
end_spn: 199,
|
||||||
|
},
|
||||||
|
Segment {
|
||||||
|
index: 7,
|
||||||
|
start_spn: 10_000,
|
||||||
|
end_spn: 10_099,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
|
||||||
|
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||||
|
assert_eq!(ranges.len(), 2, "one LBA range per segment");
|
||||||
|
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
|
||||||
|
// → LBA 1009..1019, key index 5.
|
||||||
|
assert_eq!(ranges[0], (1009, 1019, 5));
|
||||||
|
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
|
||||||
|
// sectors 937..=946 → LBA 1937..1947, key index 7.
|
||||||
|
assert_eq!(ranges[1], (1937, 1947, 7));
|
||||||
|
|
||||||
|
// The ranges drive an AacsKeyMap with the Unit Key (index 0) as default.
|
||||||
|
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges, 0);
|
||||||
|
assert_eq!(map.key_idx_for(500), 0, "outside any segment → Unit Key");
|
||||||
|
assert_eq!(map.key_idx_for(1012), 5, "inside index-5 segment → key 5");
|
||||||
|
assert_eq!(map.key_idx_for(1940), 7, "inside index-7 segment → key 7");
|
||||||
|
assert_eq!(
|
||||||
|
map.key_idx_for(1019),
|
||||||
|
0,
|
||||||
|
"segment end is exclusive → Unit Key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_byte_to_lba_walks_extents() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
let extents = vec![
|
||||||
|
Extent {
|
||||||
|
start_lba: 100,
|
||||||
|
sector_count: 10,
|
||||||
|
}, // clip bytes [0, 20480)
|
||||||
|
Extent {
|
||||||
|
start_lba: 500,
|
||||||
|
sector_count: 10,
|
||||||
|
}, // clip bytes [20480, 40960)
|
||||||
|
];
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_real_disc_layout() {
|
fn parses_real_disc_layout() {
|
||||||
// First three records observed on retail 2.1 (Zombieland): the variant
|
// First three records observed on retail 2.1 (Zombieland): the variant
|
||||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||||
// `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||||
let tbl = build_tbl(&[
|
let tbl = build_tbl(&[
|
||||||
(1, 343680, 346239),
|
(1, 343680, 346239),
|
||||||
(2, 695616, 698175),
|
(2, 695616, 698175),
|
||||||
@@ -193,9 +328,9 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||||
assert_eq!(segs.len(), 3);
|
assert_eq!(segs.len(), 3);
|
||||||
assert_eq!(segs[0].variant, 1);
|
assert_eq!(segs[0].index, 1);
|
||||||
assert_eq!(segs[1].variant, 2);
|
assert_eq!(segs[1].index, 2);
|
||||||
assert_eq!(segs[2].variant, 3);
|
assert_eq!(segs[2].index, 3);
|
||||||
assert_eq!(segs[0].start_spn, 343680);
|
assert_eq!(segs[0].start_spn, 343680);
|
||||||
assert_eq!(segs[0].end_spn, 346239);
|
assert_eq!(segs[0].end_spn, 346239);
|
||||||
assert_eq!(segs[0].packet_count(), 2560);
|
assert_eq!(segs[0].packet_count(), 2560);
|
||||||
@@ -234,18 +369,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_inside_segment_routes_to_variant() {
|
fn unit_inside_segment_routes_to_index() {
|
||||||
// A real first-record segment: packets [343680, 346239].
|
// A real first-record segment: packets [343680, 346239].
|
||||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||||
assert_eq!(hit.variant, 1);
|
assert_eq!(hit.index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn variant_field_cycles_one_to_thirty_two() {
|
fn index_field_cycles_one_to_thirty_two() {
|
||||||
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
|
// Reality on Zombieland: field@4 is the index, cycling 1..=32 in file
|
||||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||||
let mut recs = Vec::new();
|
let mut recs = Vec::new();
|
||||||
let mut spn = 1000u32;
|
let mut spn = 1000u32;
|
||||||
@@ -258,9 +393,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||||
assert_eq!(segs.len(), 64);
|
assert_eq!(segs.len(), 64);
|
||||||
assert_eq!(segs[31].variant, 32); // end of first cycle
|
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||||
assert_eq!(segs[32].variant, 1); // wraps, does not become 33
|
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.variant)));
|
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -268,29 +403,29 @@ mod tests {
|
|||||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||||
assert!(variant_segment_for_unit(&segs, off).is_none());
|
assert!(segment_for_unit(&segs, off).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_straddling_a_segment_edge_counts_as_variant() {
|
fn unit_straddling_a_segment_edge_counts_as_forensic() {
|
||||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||||
// packets straddle the boundary) must still route to the variant key,
|
// packets straddle the boundary) must still route to the index key,
|
||||||
// because part of its ciphertext is variant-encrypted.
|
// because part of its ciphertext is forensic-encrypted.
|
||||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||||
assert_eq!(hit.variant, 7);
|
assert_eq!(hit.index, 7);
|
||||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||||
assert!(variant_segment_for_unit(&segs, before).is_none());
|
assert!(segment_for_unit(&segs, before).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_segments_never_routes_to_variant() {
|
fn no_segments_never_routes_to_index() {
|
||||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+15
-16
@@ -57,41 +57,40 @@ pub struct ProcessingKey(pub [u8; 16]);
|
|||||||
pub struct UnitKey {
|
pub struct UnitKey {
|
||||||
pub idx: u32,
|
pub idx: u32,
|
||||||
pub key: [u8; 16],
|
pub key: [u8; 16],
|
||||||
/// AACS 2.1 (FMTS) forensic-variant tag.
|
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
|
||||||
///
|
///
|
||||||
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
||||||
/// key and for the bulk of a 2.1 title. `1..=32` = a variant key that
|
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
|
||||||
/// decrypts the forensic segments tagged with that same variant in
|
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
|
||||||
/// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at
|
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
|
||||||
/// most one non-zero value is ever in play for a given rip; the decode
|
/// (the 65536-value device selector), which is a separate MKB-layer concern.
|
||||||
/// selects the segments matching it and drops the other variants.
|
pub index_number: u8,
|
||||||
pub variant_number: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UnitKey {
|
impl UnitKey {
|
||||||
/// An ordinary (non-forensic) unit key: `variant_number == 0`. The value
|
/// An ordinary (non-forensic) unit key: `index_number == 0`. The value
|
||||||
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
||||||
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
idx,
|
idx,
|
||||||
key,
|
key,
|
||||||
variant_number: 0,
|
index_number: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A forensic-variant key: `variant_number` in `1..=32`, decrypting the
|
/// A forensic index key: `index_number` in `1..=32`, decrypting the
|
||||||
/// `IndividualSegment.tbl` segments tagged with that variant.
|
/// `IndividualSegment.tbl` segments tagged with that index.
|
||||||
pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self {
|
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
|
||||||
Self {
|
Self {
|
||||||
idx,
|
idx,
|
||||||
key,
|
key,
|
||||||
variant_number,
|
index_number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this key decrypts ordinary (non-forensic) content.
|
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
|
||||||
pub const fn is_default_variant(&self) -> bool {
|
pub const fn is_default_index(&self) -> bool {
|
||||||
self.variant_number == 0
|
self.index_number == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+349
-80
@@ -150,10 +150,15 @@ pub fn decrypt_threads() -> usize {
|
|||||||
pub enum DecryptKeys {
|
pub enum DecryptKeys {
|
||||||
/// No encryption on this disc.
|
/// No encryption on this disc.
|
||||||
None,
|
None,
|
||||||
/// AACS (Blu-ray / UHD). Unit keys + optional read data key.
|
/// AACS (Blu-ray / UHD / HD-DVD). Unit keys + optional read data key. The
|
||||||
|
/// `format` is the disc's content container (BD/UHD/FMTS = Transport Stream,
|
||||||
|
/// HD-DVD `.evo` = Program Stream); it travels with the keys because both are
|
||||||
|
/// resolved once per disc, and the key SELECTOR (`is_clean`) needs it to prove
|
||||||
|
/// a key structurally against the right container.
|
||||||
Aacs {
|
Aacs {
|
||||||
unit_keys: Vec<(u32, [u8; 16])>,
|
unit_keys: Vec<(u32, [u8; 16])>,
|
||||||
read_data_key: Option<[u8; 16]>,
|
read_data_key: Option<[u8; 16]>,
|
||||||
|
format: crate::disc::ContentFormat,
|
||||||
},
|
},
|
||||||
/// CSS (DVD). Title key for sector descrambling.
|
/// CSS (DVD). Title key for sector descrambling.
|
||||||
Css { title_key: [u8; 5] },
|
Css { title_key: [u8; 5] },
|
||||||
@@ -166,6 +171,183 @@ impl DecryptKeys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a
|
||||||
|
/// title's encrypted content, decided ONCE before mux from the disc's CPS-unit
|
||||||
|
/// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per
|
||||||
|
/// unit at mux time.
|
||||||
|
///
|
||||||
|
/// This is the pivot that ends the mux "key-server storm": the old path decrypts
|
||||||
|
/// a unit, checks whether the plaintext looks like clean MPEG-TS, and — because
|
||||||
|
/// authored-bad content never reaches that bar — concludes "wrong key, fetch a
|
||||||
|
/// fresh one" and re-asks the key service for units it already holds the correct
|
||||||
|
/// key for. There is NO per-unit byte pattern that separates "correctly decrypted
|
||||||
|
/// but authored-bad" from "still encrypted", so that check is unanswerable. The
|
||||||
|
/// map removes the question: we resolve one key per CPS unit / segment up front
|
||||||
|
/// (see `resolve_mux_key_map`), record which LBA ranges each covers, and at mux
|
||||||
|
/// time simply "decrypt this LBA with key K" and trust it — bad TS is the muxer's
|
||||||
|
/// concern, exactly as for a physically-read clear disc.
|
||||||
|
///
|
||||||
|
/// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` pool,
|
||||||
|
/// sorted and disjoint. `default_idx` covers any LBA no range claims — the
|
||||||
|
/// single-CPS case is just an empty range list with `default_idx = 0`, so the
|
||||||
|
/// common disc pays zero lookup cost and needs no structural walk.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AacsKeyMap {
|
||||||
|
ranges: Vec<(u32, u32, usize)>,
|
||||||
|
default_idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AacsKeyMap {
|
||||||
|
/// The whole title is one CPS unit → one key (`idx`) everywhere. This is the
|
||||||
|
/// overwhelmingly common disc (incl. every single-CPS UHD); no LBA walk.
|
||||||
|
pub fn single(idx: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
ranges: Vec::new(),
|
||||||
|
default_idx: idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build from explicit `[start_lba, end_lba) → key_idx` ranges (multi-CPS /
|
||||||
|
/// FMTS). Ranges are sorted; `default_idx` answers any uncovered LBA.
|
||||||
|
pub fn from_ranges(mut ranges: Vec<(u32, u32, usize)>, default_idx: usize) -> Self {
|
||||||
|
ranges.sort_by_key(|&(start, _, _)| start);
|
||||||
|
Self {
|
||||||
|
ranges,
|
||||||
|
default_idx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unit-key index to decrypt the aligned unit at `lba` with. O(log n) —
|
||||||
|
/// the last range whose start is `<= lba` and whose end is `> lba`, else the
|
||||||
|
/// default. Cheap enough to call per aligned unit on the mux hot path.
|
||||||
|
pub fn key_idx_for(&self, lba: u32) -> usize {
|
||||||
|
if self.ranges.is_empty() {
|
||||||
|
return self.default_idx;
|
||||||
|
}
|
||||||
|
match self
|
||||||
|
.ranges
|
||||||
|
.binary_search_by(|&(start, _, _)| start.cmp(&lba))
|
||||||
|
{
|
||||||
|
Ok(i) => self.ranges[i].2,
|
||||||
|
Err(0) => self.default_idx,
|
||||||
|
Err(i) => {
|
||||||
|
let (start, end, idx) = self.ranges[i - 1];
|
||||||
|
if lba >= start && lba < end {
|
||||||
|
idx
|
||||||
|
} else {
|
||||||
|
self.default_idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `[start_lba, end_lba) → key_idx` ranges (sorted, disjoint). Empty for a
|
||||||
|
/// single-CPS map (everything uses [`default_idx`](Self::default_idx)).
|
||||||
|
pub fn ranges(&self) -> &[(u32, u32, usize)] {
|
||||||
|
&self.ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key index for any LBA no explicit range claims (the single-CPS key).
|
||||||
|
pub fn default_idx(&self) -> usize {
|
||||||
|
self.default_idx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct key indices this map can select — the CPS units / segments a
|
||||||
|
/// title actually reaches. Used by the resolver to know which keys to secure
|
||||||
|
/// up front.
|
||||||
|
pub fn key_indices(&self) -> Vec<usize> {
|
||||||
|
let mut v: Vec<usize> = self.ranges.iter().map(|&(_, _, i)| i).collect();
|
||||||
|
v.push(self.default_idx);
|
||||||
|
v.sort_unstable();
|
||||||
|
v.dedup();
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the
|
||||||
|
/// mux's TRUSTED decrypt. `base_lba` is the absolute LBA of `buf`'s first sector;
|
||||||
|
/// each aligned unit (3 sectors) is decrypted with the key the map assigns to its
|
||||||
|
/// LBA. There is NO key trial and NO `is_clean` verdict: the map already decided
|
||||||
|
/// the key from disc structure, so we apply it and move on — a unit that decrypts
|
||||||
|
/// to authored-bad TS passes through for the muxer to drop, never re-fetched.
|
||||||
|
///
|
||||||
|
/// Only [`DecryptKeys::Aacs`] uses a map (CSS self-cracks per region inside
|
||||||
|
/// [`decrypt_sectors`]; `None` is clear) — other variants are a no-op here so the
|
||||||
|
/// decorator can dispatch uniformly. A map index outside the held pool is a
|
||||||
|
/// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every
|
||||||
|
/// selectable index is present, so a gap here is a resolver bug, not silent loss.
|
||||||
|
pub fn decrypt_sectors_mapped(
|
||||||
|
buf: &mut [u8],
|
||||||
|
keys: &DecryptKeys,
|
||||||
|
base_lba: u32,
|
||||||
|
map: &AacsKeyMap,
|
||||||
|
) -> Result<(), crate::error::Error> {
|
||||||
|
let (unit_keys, rdk, format) = match keys {
|
||||||
|
DecryptKeys::Aacs {
|
||||||
|
unit_keys,
|
||||||
|
read_data_key,
|
||||||
|
format,
|
||||||
|
} => (unit_keys, *read_data_key, *format),
|
||||||
|
// Clear / CSS: the mapped path is AACS-only. Leave the buffer untouched;
|
||||||
|
// CSS descrambles via `decrypt_sectors` and `None` is already clear.
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
let unit_sectors = (unit_len / 2048) as u32;
|
||||||
|
|
||||||
|
// Validate every selectable index up front (fail loud) so the per-unit hot
|
||||||
|
// loop can index without bounds churn and a resolver gap never silently
|
||||||
|
// passes ciphertext through as "decrypted".
|
||||||
|
for idx in map.key_indices() {
|
||||||
|
if unit_keys.get(idx).is_none() {
|
||||||
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| {
|
||||||
|
if chunk.len() != unit_len {
|
||||||
|
return; // trailing partial unit: clear tail on disc, leave as-is
|
||||||
|
}
|
||||||
|
// Gate on the authoritative encrypted flag ONLY (the CPI bits in the clear
|
||||||
|
// seed) — no `is_clean`. A clear unit (flag unset) is left untouched; an
|
||||||
|
// encrypted unit is decrypted with its MAPPED key and trusted.
|
||||||
|
if !aacs::content::aacs_unit_encrypted(chunk, format) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors);
|
||||||
|
let key_idx = map.key_idx_for(unit_lba);
|
||||||
|
// Bounds already proven above; index directly.
|
||||||
|
let key = &unit_keys[key_idx].1;
|
||||||
|
if let Some(ref rdk_key) = rdk {
|
||||||
|
aacs::content::decrypt_bus(chunk, rdk_key);
|
||||||
|
}
|
||||||
|
aacs::content::decrypt_unit(chunk, key);
|
||||||
|
};
|
||||||
|
|
||||||
|
let nthreads = decrypt_threads();
|
||||||
|
let nunits = buf.len() / unit_len;
|
||||||
|
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
|
||||||
|
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
||||||
|
decrypt_one(i, chunk);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match decrypt_pool() {
|
||||||
|
Some(pool) => pool.install(|| {
|
||||||
|
buf.par_chunks_mut(unit_len)
|
||||||
|
.enumerate()
|
||||||
|
.for_each(|(i, chunk)| decrypt_one(i, chunk));
|
||||||
|
}),
|
||||||
|
None => {
|
||||||
|
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
|
||||||
|
decrypt_one(i, chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Decrypt a buffer of sectors in-place.
|
/// Decrypt a buffer of sectors in-place.
|
||||||
///
|
///
|
||||||
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
/// For AACS: processes in 6144-byte aligned units (3 sectors).
|
||||||
@@ -180,14 +362,16 @@ impl DecryptKeys {
|
|||||||
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
/// Returns `Err` if decryption was expected but keys are missing or invalid.
|
||||||
/// Never produces silently corrupted output.
|
/// Never produces silently corrupted output.
|
||||||
///
|
///
|
||||||
/// On success returns the number of bytes belonging to scrambled AACS units
|
/// Pure decrypt: every encrypted unit has a key APPLIED in place and the
|
||||||
/// that **no available key could decrypt** — those units are restored to their
|
/// plaintext is left as-is — this function applies NO policy (it never restores
|
||||||
/// original encrypted bytes (so a clear nav-file is never corrupted), but for
|
/// ciphertext, nulls, or re-fetches). On success it returns the number of bytes
|
||||||
/// genuine encrypted content this is silent data loss the downstream TS
|
/// belonging to units a key was applied to but that did NOT reassemble to clean
|
||||||
/// assembler will drop without a sync. The decrypt-on-read decorator folds this
|
/// MPEG-TS ("unverified"). "Did a key open it to clean TS?" is a key-SELECTION /
|
||||||
/// count into the mux loss accounting so a partial key failure can't be reported
|
/// read-VERIFY signal, NOT a "did we decrypt?" verdict — a correct key can
|
||||||
/// as a perfect rip. `0` for `None` / `Css` and for any AACS buffer where every
|
/// decrypt content whose encoding is broken. The caller decides what an
|
||||||
/// scrambled unit decrypted.
|
/// unverified unit means: the mux passes the bytes to the muxer; the sweep/patch
|
||||||
|
/// verify path recovers a key and retries, or fails the read. `0` for `None` /
|
||||||
|
/// `Css` and for any AACS buffer where every unit reached clean TS.
|
||||||
pub fn decrypt_sectors(
|
pub fn decrypt_sectors(
|
||||||
buf: &mut [u8],
|
buf: &mut [u8],
|
||||||
keys: &mut DecryptKeys,
|
keys: &mut DecryptKeys,
|
||||||
@@ -204,7 +388,8 @@ pub fn decrypt_sectors(
|
|||||||
/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is
|
/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is
|
||||||
/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors.
|
/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors.
|
||||||
///
|
///
|
||||||
/// `content_ranges` is sorted, merged, disjoint `[start_lba, end_lba)`.
|
/// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)`
|
||||||
|
/// tuples (each covering `[start_lba, start_lba + sector_count)`).
|
||||||
pub fn decrypt_sectors_in_content(
|
pub fn decrypt_sectors_in_content(
|
||||||
buf: &mut [u8],
|
buf: &mut [u8],
|
||||||
keys: &mut DecryptKeys,
|
keys: &mut DecryptKeys,
|
||||||
@@ -240,6 +425,7 @@ fn decrypt_sectors_impl(
|
|||||||
DecryptKeys::Aacs {
|
DecryptKeys::Aacs {
|
||||||
unit_keys,
|
unit_keys,
|
||||||
read_data_key,
|
read_data_key,
|
||||||
|
format,
|
||||||
} => {
|
} => {
|
||||||
// Validate that unit_key_idx is in-range before doing anything else.
|
// Validate that unit_key_idx is in-range before doing anything else.
|
||||||
// This preserves the existing contract: an out-of-range explicit index
|
// This preserves the existing contract: an out-of-range explicit index
|
||||||
@@ -248,8 +434,13 @@ fn decrypt_sectors_impl(
|
|||||||
return Err(crate::error::Error::DecryptFailed);
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
|
// Container of this disc's content — the key SELECTOR (`is_clean`)
|
||||||
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
|
// checks the decrypted plaintext against the right structure (TS vs PS).
|
||||||
|
let format = *format;
|
||||||
|
// Index `unit_keys` directly for the raw key bytes (the `.1` of each
|
||||||
|
// `(cps_id, key)`); no per-call `Vec` of stripped keys — the decrypt
|
||||||
|
// closures only ever need `len()` / `[idx].1`, so collecting one would
|
||||||
|
// just be a heap alloc/free on every batch of the mux hot path.
|
||||||
let rdk: Option<[u8; 16]> = *read_data_key;
|
let rdk: Option<[u8; 16]> = *read_data_key;
|
||||||
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
|
||||||
// AACS decrypts whole 6144-byte aligned units. The live mux path
|
// AACS decrypts whole 6144-byte aligned units. The live mux path
|
||||||
@@ -272,7 +463,7 @@ fn decrypt_sectors_impl(
|
|||||||
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
// silent corruption. We fail loud (Error::DecryptFailed), matching
|
||||||
// the highway path's Error::ExtentNotUnitAligned policy.
|
// the highway path's Error::ExtentNotUnitAligned policy.
|
||||||
//
|
//
|
||||||
// Detection: ts_sync_destroyed() short-circuits to false for any
|
// Detection: !crate::aacs::content::is_clean(, crate::disc::ContentFormat::BdTs) short-circuits to false for any
|
||||||
// buffer shorter than a full unit, so it cannot judge a partial. We
|
// buffer shorter than a full unit, so it cannot judge a partial. We
|
||||||
// instead apply the same TS-sync-intactness test it uses internally
|
// instead apply the same TS-sync-intactness test it uses internally
|
||||||
// (ts_sync_count vs ts_packet_total) directly to the available
|
// (ts_sync_count vs ts_packet_total) directly to the available
|
||||||
@@ -293,10 +484,14 @@ fn decrypt_sectors_impl(
|
|||||||
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
|
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
|
||||||
None => true,
|
None => true,
|
||||||
};
|
};
|
||||||
if partial_in_content {
|
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
|
||||||
|
// unit) can't be unit-decrypted, so fail loud. Validity is the SAME
|
||||||
|
// `is_clean` proof floor used everywhere — a clear TS tail passes it,
|
||||||
|
// a scrambled one fails. PS (`.evo`) partials lack the TS structure,
|
||||||
|
// so this stays TS-only (HD-DVD partial-scramble is not yet wired).
|
||||||
|
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
|
||||||
let partial = &buf[buf.len() - partial_len..];
|
let partial = &buf[buf.len() - partial_len..];
|
||||||
let packets = aacs::content::ts_packet_total(partial);
|
if !aacs::content::is_clean(partial, format) {
|
||||||
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
|
|
||||||
return Err(crate::error::Error::DecryptFailed);
|
return Err(crate::error::Error::DecryptFailed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,27 +515,34 @@ fn decrypt_sectors_impl(
|
|||||||
// accounting so a partial key failure isn't reported as a clean rip.
|
// accounting so a partial key failure isn't reported as a clean rip.
|
||||||
let dropped_bytes = AtomicUsize::new(0);
|
let dropped_bytes = AtomicUsize::new(0);
|
||||||
|
|
||||||
// Per-unit decrypt closure. For a scrambled full aligned unit:
|
// Per-unit PURE decrypt closure. For a scrambled full aligned unit:
|
||||||
// 1. Try the cached key index first (avoids scanning all keys on the
|
// 1. Try the cached key index first (avoids scanning all keys on the
|
||||||
// common case where a disc run uses one CPS unit throughout).
|
// common case where a disc run uses one CPS unit throughout).
|
||||||
// 2. On miss, try every key in order (multi-CPS-unit discs).
|
// 2. On miss, try every key in order (multi-CPS-unit discs).
|
||||||
// 3. Accept the first key whose output passes the TS-sync verify.
|
// 3. Select the first key whose output passes the TS-sync verify.
|
||||||
// 4. Only restore-to-original if NO key validates (non-m2ts unit or
|
// 4. If NONE yields clean TS, keep the applied-key plaintext anyway
|
||||||
// genuine decrypt failure). See test
|
// (a key WAS applied — bad TS is the caller's/muxer's concern) and
|
||||||
// `nav_file_unit_survives_decrypt_attempt`.
|
// tally the unit as unverified. Never restore ciphertext / null.
|
||||||
|
// Nav protection is the caller's content gate, not a restore here.
|
||||||
//
|
//
|
||||||
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
|
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
|
||||||
// must happen first — it's a shared layer on top that is key-independent
|
// must happen first — it's a shared layer on top that is key-independent
|
||||||
// across all CPS units on the disc.
|
// across all CPS units on the disc.
|
||||||
let decrypt_one = |chunk: &mut [u8]| {
|
let decrypt_one = |chunk: &mut [u8]| {
|
||||||
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) {
|
// Gate on `aacs_unit_needs_decrypt` (encrypted-flag set AND structure
|
||||||
|
// not yet restored): the flag alone isn't enough because it lives in
|
||||||
|
// the plaintext header and survives decryption, so an already-decrypted
|
||||||
|
// unit would be decrypted a SECOND time (scrambling it) on any re-run of
|
||||||
|
// this pass. The structure-restored half makes it idempotent. This is
|
||||||
|
// ALSO the sole gate protecting the now-pure `decrypt_unit` from
|
||||||
|
// decrypting a clear unit.
|
||||||
|
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk, format)
|
||||||
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Save original bytes so we can restore if no key validates.
|
|
||||||
let original: Vec<u8> = chunk.to_vec();
|
|
||||||
|
|
||||||
// Build a bus-decrypted copy to try unit keys against, or work
|
// Bus-decrypt (AACS 2.0) in place first — a shared layer under every
|
||||||
// in-place when there is no bus layer.
|
// CPS unit key. Whatever we do below operates on the bus-clear bytes.
|
||||||
if let Some(ref rdk_key) = rdk {
|
if let Some(ref rdk_key) = rdk {
|
||||||
aacs::content::decrypt_bus(chunk, rdk_key);
|
aacs::content::decrypt_bus(chunk, rdk_key);
|
||||||
}
|
}
|
||||||
@@ -349,29 +551,68 @@ fn decrypt_sectors_impl(
|
|||||||
// back to the full list skipping the hint.
|
// back to the full list skipping the hint.
|
||||||
let hint = last_key_idx.load(Ordering::Relaxed);
|
let hint = last_key_idx.load(Ordering::Relaxed);
|
||||||
let try_order =
|
let try_order =
|
||||||
std::iter::once(hint).chain((0..raw_keys.len()).filter(move |&i| i != hint));
|
std::iter::once(hint).chain((0..unit_keys.len()).filter(move |&i| i != hint));
|
||||||
|
|
||||||
|
// Compose the two SEGREGATED primitives explicitly. `decrypt_unit`
|
||||||
|
// is the decrypt (apply the key, leave the plaintext). `is_clean`
|
||||||
|
// is a SEPARATE structural question used here ONLY as a multi-CPS-unit
|
||||||
|
// key SELECTOR — the first key whose output is clean for the disc's
|
||||||
|
// container (`format`: TS or PS) is the match. "Did a key produce
|
||||||
|
// clean structure?" is NOT "did we decrypt?": a correct key can
|
||||||
|
// decrypt content whose encoding is broken (a muxer concern). When
|
||||||
|
// NO key yields clean structure we STILL decrypted (the cached-hint
|
||||||
|
// key is applied): keep those bytes and report the unit UNVERIFIED.
|
||||||
|
// This function applies no policy; the caller decides what unverified
|
||||||
|
// means (mux passes it to the muxer; sweep/patch recover or fail).
|
||||||
|
|
||||||
|
// Single-key fast path (the vast majority of titles): with no
|
||||||
|
// alternate key to fall back on there is nothing to try/rollback,
|
||||||
|
// so decrypt in place — no per-unit scratch alloc or copy-back.
|
||||||
|
// Clean → cache the hint; unclean → keep the applied bytes and
|
||||||
|
// tally unverified, exactly as the loop below would with one key.
|
||||||
|
if unit_keys.len() == 1 {
|
||||||
|
aacs::content::decrypt_unit(chunk, &unit_keys[0].1);
|
||||||
|
if aacs::content::is_clean(chunk, format) {
|
||||||
|
last_key_idx.store(0, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trial each key against a STACK scratch (unit_len is always
|
||||||
|
// ALIGNED_UNIT_LEN and the guard above proved chunk.len() == unit_len)
|
||||||
|
// so a failing attempt doesn't clobber the bus-decrypted base in
|
||||||
|
// `chunk` that the next key retries on — with no per-key heap Vec.
|
||||||
|
// `chunk` is NOT mutated in this loop, so on total miss we simply
|
||||||
|
// re-apply the first key in place (decrypt_unit is pure), which
|
||||||
|
// reproduces the first attempt without stashing its bytes.
|
||||||
|
let mut scratch = [0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
|
let scratch = &mut scratch[..chunk.len()];
|
||||||
|
let mut first_idx: Option<usize> = None;
|
||||||
for idx in try_order {
|
for idx in try_order {
|
||||||
if let Some(key) = raw_keys.get(idx) {
|
if let Some((_, key)) = unit_keys.get(idx) {
|
||||||
// Work on a per-key copy so a failing attempt doesn't
|
scratch.copy_from_slice(chunk);
|
||||||
// clobber the bus-decrypted base we'll retry on.
|
aacs::content::decrypt_unit(scratch, key);
|
||||||
let mut attempt: Vec<u8> = chunk.to_vec();
|
if aacs::content::is_clean(scratch, format) {
|
||||||
if aacs::content::decrypt_unit(&mut attempt, key) {
|
chunk.copy_from_slice(scratch);
|
||||||
chunk.copy_from_slice(&attempt);
|
|
||||||
last_key_idx.store(idx, Ordering::Relaxed);
|
last_key_idx.store(idx, Ordering::Relaxed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if first_idx.is_none() {
|
||||||
|
first_idx = Some(idx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No key validated — restore the original encrypted bytes and
|
// No key yielded clean structure. Keep the first-tried key's
|
||||||
// tally the loss. The unit is flagged encrypted (we only reach
|
// plaintext (the pool is non-empty past the guard, so `first_idx` is
|
||||||
// here past the CPI gate) but no key applied: genuine encrypted
|
// always `Some`) and tally the unit as unverified. Never restore
|
||||||
// content with a missing/wrong sub-key. We always tally; the mux
|
// ciphertext; that is a caller concern, threaded through the recovery
|
||||||
// read path treats
|
// ciphertext, not this seam.
|
||||||
// the count as loss (its extents are real content), while
|
if let Some(idx) = first_idx {
|
||||||
// metadata-probe callers that don't install a loss sink ignore it.
|
aacs::content::decrypt_unit(chunk, &unit_keys[idx].1);
|
||||||
chunk.copy_from_slice(&original);
|
}
|
||||||
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -408,11 +649,14 @@ fn decrypt_sectors_impl(
|
|||||||
// back to the serial path rather than panic.
|
// back to the serial path rather than panic.
|
||||||
match decrypt_pool() {
|
match decrypt_pool() {
|
||||||
Some(pool) => {
|
Some(pool) => {
|
||||||
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
|
// `par_chunks_mut` iterates the units in place — no
|
||||||
|
// intermediate `Vec<&mut [u8]>` allocation per batch.
|
||||||
pool.install(|| {
|
pool.install(|| {
|
||||||
chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| {
|
buf.par_chunks_mut(unit_len)
|
||||||
process(idx, chunk);
|
.enumerate()
|
||||||
});
|
.for_each(|(idx, chunk)| {
|
||||||
|
process(idx, chunk);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -442,13 +686,15 @@ fn decrypt_sectors_impl(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
|
/// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts
|
||||||
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
|
/// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally
|
||||||
/// under `ts_sync_destroyed`, gets AES-decrypted with the unit key, fails
|
/// sets the CPI bits, so it reads as encrypted) must never be scrambled by a
|
||||||
/// the TS-sync verification, and must be restored to its original bytes —
|
/// decrypt attempt. The decrypter applies NO policy and no longer restores — so
|
||||||
/// not left scrambled.
|
/// nav protection is the CALLER's content gate: a real read (sweep/patch) is
|
||||||
|
/// content-gated, and every whole-disc caller passes the encrypted-content
|
||||||
|
/// extents so nav LBAs are skipped entirely and left untouched.
|
||||||
#[test]
|
#[test]
|
||||||
fn nav_file_unit_survives_decrypt_attempt() {
|
fn nav_file_unit_survives_when_gated_out_of_content() {
|
||||||
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
unit[0] = b'M';
|
unit[0] = b'M';
|
||||||
unit[1] = b'P';
|
unit[1] = b'P';
|
||||||
@@ -462,11 +708,14 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
decrypt_sectors(&mut unit, &mut keys, 0).unwrap();
|
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110),
|
||||||
|
// so this nav unit is OUTSIDE content and the gate skips it untouched.
|
||||||
|
decrypt_sectors_in_content(&mut unit, &mut keys, 0, 0, &[(100, 10)]).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit, snapshot,
|
unit, snapshot,
|
||||||
"non-m2ts unit must be restored after failed decrypt"
|
"a nav unit outside the content extents must be left untouched by the gate"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +780,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
|
|
||||||
@@ -564,6 +814,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||||
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
|
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
|
||||||
@@ -582,6 +833,7 @@ mod tests {
|
|||||||
let mut keys_g = DecryptKeys::Aacs {
|
let mut keys_g = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut keys_u = keys_g.clone();
|
let mut keys_u = keys_g.clone();
|
||||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
@@ -632,6 +884,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
let mut buf = original.clone();
|
let mut buf = original.clone();
|
||||||
@@ -650,6 +903,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
let mut buf = original.clone();
|
let mut buf = original.clone();
|
||||||
@@ -768,6 +1022,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let u = aacs::content::ALIGNED_UNIT_LEN;
|
let u = aacs::content::ALIGNED_UNIT_LEN;
|
||||||
let mut buf = vec![0u8; 3 * u];
|
let mut buf = vec![0u8; 3 * u];
|
||||||
@@ -785,6 +1040,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
|
||||||
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
|
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
|
||||||
@@ -804,6 +1060,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
|
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
|
||||||
// content → the partial must be tolerated (Ok), not DecryptFailed.
|
// content → the partial must be tolerated (Ok), not DecryptFailed.
|
||||||
@@ -826,6 +1083,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
|
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
|
||||||
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
@@ -851,6 +1109,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
|
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
|
||||||
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
@@ -873,6 +1132,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
|
||||||
@@ -886,6 +1146,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
|
||||||
let snapshot = buf.clone();
|
let snapshot = buf.clone();
|
||||||
@@ -927,6 +1188,7 @@ mod tests {
|
|||||||
DecryptKeys::Aacs {
|
DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0; 16])],
|
unit_keys: vec![(0, [0; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
}
|
}
|
||||||
.is_encrypted()
|
.is_encrypted()
|
||||||
);
|
);
|
||||||
@@ -1141,6 +1403,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0xAB; 16])],
|
unit_keys: vec![(0, [0xAB; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
let err = decrypt_sectors(&mut buf, &mut keys, 5)
|
let err = decrypt_sectors(&mut buf, &mut keys, 5)
|
||||||
@@ -1161,6 +1424,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
|
||||||
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
|
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
|
||||||
@@ -1235,13 +1499,14 @@ mod tests {
|
|||||||
let mut unit = clear_ts_unit();
|
let mut unit = clear_ts_unit();
|
||||||
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
aacs_encrypt_unit_for_test(&mut unit, &key1);
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&unit),
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must look scrambled before decrypt"
|
"encrypted unit must look scrambled before decrypt"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
|
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call with the default hint (idx 0) — the fix must fall back to key1.
|
// Call with the default hint (idx 0) — the fix must fall back to key1.
|
||||||
@@ -1249,7 +1514,7 @@ mod tests {
|
|||||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&buf),
|
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
||||||
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
|
||||||
);
|
);
|
||||||
// Every sync position must carry 0x47.
|
// Every sync position must carry 0x47.
|
||||||
@@ -1276,11 +1541,12 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, key)],
|
unit_keys: vec![(0, key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = unit;
|
let mut buf = unit;
|
||||||
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&buf),
|
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
|
||||||
"single-key disc: TS syncs must be restored"
|
"single-key disc: TS syncs must be restored"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1290,21 +1556,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression for the silent partial-decrypt-loss defect: a scrambled AACS
|
/// A unit no supplied key opens to clean TS is still DECRYPTED in place (the
|
||||||
/// unit that NO supplied key can decrypt is restored to its original
|
/// key is applied — decryption ran; a broken result is bad data, not a decrypt
|
||||||
/// ciphertext (so a clear nav-file is never corrupted) AND `decrypt_sectors`
|
/// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns
|
||||||
/// returns the unit's byte length as the dropped count. Before the fix this
|
/// the unit's byte length as the UNVERIFIED count — the read-verify signal the
|
||||||
/// returned `()` and the still-encrypted bytes flowed downstream to be
|
/// sweep/patch caller consumes (the mux ignores it and passes the bytes to the
|
||||||
/// silently dropped by the TS assembler with zero loss accounting — a rip
|
/// muxer). This is the single decrypt authority applying no policy.
|
||||||
/// missing real content reported `lost_video_secs=0` and passed the abort
|
|
||||||
/// gate even under `abort_on_lost_secs=0`.
|
|
||||||
///
|
///
|
||||||
/// Grounding: the `dropped_bytes.fetch_add(chunk.len(), …)` on the
|
/// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and
|
||||||
/// no-key-validated restore path; the function returns that tally.
|
/// the removal of the `copy_from_slice(&original)` restore.
|
||||||
/// Mutation: drop the `fetch_add` (or return a constant 0) → dropped == 0,
|
/// Mutation: re-add the restore → `buf == ciphertext`, this fails.
|
||||||
/// this fails.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn aacs_undecryptable_unit_reports_dropped_bytes() {
|
fn aacs_undecryptable_unit_is_decrypted_not_restored() {
|
||||||
let real_key = [0x33u8; 16];
|
let real_key = [0x33u8; 16];
|
||||||
let wrong_key = [0x44u8; 16]; // not the encrypting key
|
let wrong_key = [0x44u8; 16]; // not the encrypting key
|
||||||
|
|
||||||
@@ -1313,26 +1576,27 @@ mod tests {
|
|||||||
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
aacs_encrypt_unit_for_test(&mut unit, &real_key);
|
||||||
let ciphertext = unit.clone();
|
let ciphertext = unit.clone();
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&unit),
|
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must look scrambled going in"
|
"encrypted unit must look scrambled going in"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, wrong_key)],
|
unit_keys: vec![(0, wrong_key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = unit;
|
let mut buf = unit;
|
||||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0)
|
let unverified =
|
||||||
.expect("undecryptable unit is not a hard error");
|
decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dropped,
|
unverified,
|
||||||
aacs::content::ALIGNED_UNIT_LEN,
|
aacs::content::ALIGNED_UNIT_LEN,
|
||||||
"the whole scrambled unit must be reported as dropped when no key validates"
|
"a unit that did not reach clean TS is reported unverified"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_ne!(
|
||||||
buf, ciphertext,
|
buf, ciphertext,
|
||||||
"an undecryptable unit must be restored to its original ciphertext, not garbled"
|
"the unit must be DECRYPTED in place (key applied), never restored to ciphertext"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1363,22 +1627,26 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, key)],
|
unit_keys: vec![(0, key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
|
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
dropped,
|
dropped,
|
||||||
aacs::content::ALIGNED_UNIT_LEN,
|
aacs::content::ALIGNED_UNIT_LEN,
|
||||||
"exactly one unit's worth of bytes must be reported dropped"
|
"exactly one unit's worth of bytes must be reported unverified"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
|
crate::aacs::content::is_clean(
|
||||||
|
&buf[..aacs::content::ALIGNED_UNIT_LEN],
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
),
|
||||||
"the decryptable unit must come out clear"
|
"the decryptable unit must come out clear"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_ne!(
|
||||||
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
&buf[aacs::content::ALIGNED_UNIT_LEN..],
|
||||||
&unit_b_ciphertext[..],
|
&unit_b_ciphertext[..],
|
||||||
"the undecryptable unit must be restored to ciphertext"
|
"the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1392,6 +1660,7 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, key)],
|
unit_keys: vec![(0, key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = unit;
|
let mut buf = unit;
|
||||||
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
|
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
|
||||||
|
|||||||
+3
-7
@@ -311,15 +311,11 @@ impl Disc {
|
|||||||
) -> Result<AacsState> {
|
) -> Result<AacsState> {
|
||||||
use crate::aacs;
|
use crate::aacs;
|
||||||
|
|
||||||
let uk_ro_data = udf_fs
|
let uk_ro_data =
|
||||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
|
||||||
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||||
|
|
||||||
let cc = udf_fs
|
let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
|
||||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
|
||||||
.ok()
|
.ok()
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(aacs::inf::parse_content_cert);
|
.and_then(aacs::inf::parse_content_cert);
|
||||||
|
|||||||
@@ -288,63 +288,6 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF
|
|
||||||
/// file is clear (nav / playlists / filesystem) and needs no decrypt verify.
|
|
||||||
fn is_aacs_clip(name: &str) -> bool {
|
|
||||||
let lower = name.to_ascii_lowercase();
|
|
||||||
lower.ends_with(".m2ts") || lower.ends_with(".ssif")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enumerate the disc's AACS clip (`.m2ts`/`.ssif`) files as
|
|
||||||
/// [`crate::disc::verify::ClipLayout`]s for the post-read verify gate: each
|
|
||||||
/// clip's declared size plus its absolute disc extents in FILE order. Reads the
|
|
||||||
/// UDF tree through `reader`.
|
|
||||||
///
|
|
||||||
/// FAIL-SAFE: any enumeration error (bad UDF read, name collision, …) yields an
|
|
||||||
/// EMPTY list — the verify gate then covers nothing and the sweep behaves as
|
|
||||||
/// today. Enumeration must never break a rip, so the error is logged, not
|
|
||||||
/// propagated.
|
|
||||||
pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec<crate::disc::verify::ClipLayout> {
|
|
||||||
let result = (|| -> Result<Vec<crate::disc::verify::ClipLayout>> {
|
|
||||||
let fs = udf::read_filesystem(reader)?;
|
|
||||||
let mut planned: Vec<PlannedFile> = Vec::new();
|
|
||||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
|
||||||
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
plan_tree(
|
|
||||||
reader,
|
|
||||||
&fs,
|
|
||||||
&fs.root,
|
|
||||||
Path::new(""),
|
|
||||||
"",
|
|
||||||
true,
|
|
||||||
&mut planned,
|
|
||||||
&mut dirs,
|
|
||||||
&mut seen_hosts,
|
|
||||||
)?;
|
|
||||||
Ok(planned
|
|
||||||
.into_iter()
|
|
||||||
.filter(|pf| pf.inline.is_none() && is_aacs_clip(&pf.disc_name))
|
|
||||||
.map(|pf| crate::disc::verify::ClipLayout {
|
|
||||||
size: pf.size,
|
|
||||||
extents: pf.extents,
|
|
||||||
// Every AACS clip we enumerate today is BD-TS (`.m2ts`/`.ssif`).
|
|
||||||
// HD-DVD `.evo` (program stream) maps to `ContainerKind::Ps` here
|
|
||||||
// once `is_aacs_clip` recognises it — the one-line HD-DVD hook.
|
|
||||||
container: crate::disc::verify::ContainerKind::Ts,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
})();
|
|
||||||
result.unwrap_or_else(|e| {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::verify",
|
|
||||||
error = %e,
|
|
||||||
"clip enumeration failed; post-read verify disabled for this pass"
|
|
||||||
);
|
|
||||||
Vec::new()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
|
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
|
||||||
/// inner source for its lifetime while the caller keeps the underlying
|
/// inner source for its lifetime while the caller keeps the underlying
|
||||||
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
|
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
|
||||||
|
|||||||
+91
-152
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
mod bluray;
|
mod bluray;
|
||||||
mod dvd;
|
mod dvd;
|
||||||
pub mod dvd_audio_probe;
|
pub(crate) mod dvd_audio_probe;
|
||||||
mod encrypt;
|
mod encrypt;
|
||||||
mod extract;
|
mod extract;
|
||||||
mod hddvd;
|
mod hddvd;
|
||||||
@@ -19,7 +19,6 @@ mod patch;
|
|||||||
pub mod read_error;
|
pub mod read_error;
|
||||||
mod section_recover;
|
mod section_recover;
|
||||||
mod sweep;
|
mod sweep;
|
||||||
pub mod verify;
|
|
||||||
|
|
||||||
use crate::drive::{Drive, extract_scsi_context};
|
use crate::drive::{Drive, extract_scsi_context};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
@@ -85,7 +84,8 @@ pub struct Disc {
|
|||||||
pub enum ContentFormat {
|
pub enum ContentFormat {
|
||||||
/// Blu-ray BD Transport Stream (192-byte packets)
|
/// Blu-ray BD Transport Stream (192-byte packets)
|
||||||
BdTs,
|
BdTs,
|
||||||
/// DVD MPEG-2 Program Stream (VOB)
|
/// MPEG-2 Program Stream — DVD (`.vob`) and HD-DVD (`.evo`). For AACS content
|
||||||
|
/// this selects the PS-aware encrypted-flag / structural checks.
|
||||||
MpegPs,
|
MpegPs,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1623,12 +1623,11 @@ impl Disc {
|
|||||||
// detection needs the read, the read needs auth, auth needs detection.
|
// detection needs the read, the read needs auth, auth needs detection.
|
||||||
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD
|
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD
|
||||||
// the disc-key read fails, `resolve` returns None, and the disc is left
|
// the disc-key read fails, `resolve` returns None, and the disc is left
|
||||||
// in the clear. This block is DVD-only (MPEG-PS); BD/UHD (MPEG-TS) goes
|
// in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
|
||||||
// through the AACS handshake above and never reaches here.
|
// `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
|
||||||
if disc.css.is_none()
|
// not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
|
||||||
&& disc.content_format == ContentFormat::MpegPs
|
// through the AACS path above). BD/UHD are MPEG-TS and never reach here.
|
||||||
&& !disc.titles.is_empty()
|
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
|
||||||
{
|
|
||||||
// CSS title keys are per-VTS, and ONLY the scrambled movie content
|
// CSS title keys are per-VTS, and ONLY the scrambled movie content
|
||||||
// carries a non-zero key. Menu / VMG / logo cells (often the
|
// carries a non-zero key. Menu / VMG / logo cells (often the
|
||||||
// low-LBA first extent) return a ZERO title key over REPORT KEY —
|
// low-LBA first extent) return a ZERO title key over REPORT KEY —
|
||||||
@@ -1744,10 +1743,13 @@ impl Disc {
|
|||||||
// pre-decrypted one. A pre-decrypted image has its scramble flags clear,
|
// pre-decrypted one. A pre-decrypted image has its scramble flags clear,
|
||||||
// so `crack_key` finds no crackable sector and the disc stays in the
|
// so `crack_key` finds no crackable sector and the disc stays in the
|
||||||
// clear. AACS images go through KEYDB VUK lookup, not here.
|
// clear. AACS images go through KEYDB VUK lookup, not here.
|
||||||
if disc.css.is_none()
|
//
|
||||||
&& disc.content_format == ContentFormat::MpegPs
|
// Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
|
||||||
&& !disc.titles.is_empty()
|
// `.evo` images are ALSO MPEG-PS but are AACS, not CSS — they must not
|
||||||
{
|
// enter the CSS crack path. A CSS DVD's IFO (which defines the titles
|
||||||
|
// this branch reads) is unscrambled, so `detect_format` reliably sets
|
||||||
|
// `Dvd` from the SD-resolution titles even on a still-scrambled image.
|
||||||
|
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
|
||||||
let main_extents = match disc
|
let main_extents = match disc
|
||||||
.titles
|
.titles
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1799,10 +1801,9 @@ impl Disc {
|
|||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
udf_fs: &udf::UdfFs,
|
udf_fs: &udf::UdfFs,
|
||||||
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
||||||
let inf = udf_fs
|
let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| {
|
||||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
udf_fs.read_file(reader, p)
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
})?;
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
|
||||||
let mkb = Self::read_mkb_content(reader, udf_fs)?;
|
let mkb = Self::read_mkb_content(reader, udf_fs)?;
|
||||||
let version = Self::read_aacs_version(reader, udf_fs);
|
let version = Self::read_aacs_version(reader, udf_fs);
|
||||||
Ok((inf, mkb, version))
|
Ok((inf, mkb, version))
|
||||||
@@ -1820,12 +1821,12 @@ impl Disc {
|
|||||||
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
|
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
|
||||||
/// not quietly pick the V10 stride for a UHD disc.
|
/// not quietly pick the V10 stride for a UHD disc.
|
||||||
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
|
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
|
||||||
match udf_fs
|
match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| {
|
||||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
udf_fs.read_file(reader, p)
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(crate::aacs::inf::parse_content_cert)
|
.and_then(crate::aacs::inf::parse_content_cert)
|
||||||
{
|
{
|
||||||
Some(c) => c.version.major(),
|
Some(c) => c.version.major(),
|
||||||
None => {
|
None => {
|
||||||
@@ -1856,10 +1857,9 @@ impl Disc {
|
|||||||
const MAX_BYTES: usize = 64 * 1024 * 1024;
|
const MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||||
let mut want = START_BYTES;
|
let mut want = START_BYTES;
|
||||||
loop {
|
loop {
|
||||||
let buf = udf_fs
|
let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| {
|
||||||
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
|
udf_fs.read_file_prefix(reader, p, want)
|
||||||
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
|
})?;
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
|
||||||
let n = crate::aacs::mkb::mkb_content_len(&buf);
|
let n = crate::aacs::mkb::mkb_content_len(&buf);
|
||||||
// `n` strictly inside `buf` => the record walk reached the padding
|
// `n` strictly inside `buf` => the record walk reached the padding
|
||||||
// boundary (full content captured). `buf` shorter than `want` =>
|
// boundary (full content captured). `buf` shorter than `want` =>
|
||||||
@@ -2297,12 +2297,15 @@ fn aligned_unit_keys_validate(
|
|||||||
unit_keys: &[(u32, [u8; 16])],
|
unit_keys: &[(u32, [u8; 16])],
|
||||||
read_data_key: Option<&[u8; 16]>,
|
read_data_key: Option<&[u8; 16]>,
|
||||||
samples: &[Vec<u8>],
|
samples: &[Vec<u8>],
|
||||||
|
format: ContentFormat,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
|
use crate::aacs::content::{
|
||||||
|
ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, is_clean,
|
||||||
|
};
|
||||||
let scrambled: Vec<&[u8]> = samples
|
let scrambled: Vec<&[u8]> = samples
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.as_slice())
|
.map(|s| s.as_slice())
|
||||||
.filter(|s| aacs_unit_needs_decrypt(s))
|
.filter(|s| aacs_unit_needs_decrypt(s, format))
|
||||||
.collect();
|
.collect();
|
||||||
if scrambled.is_empty() {
|
if scrambled.is_empty() {
|
||||||
return true; // nothing to disprove against — accept
|
return true; // nothing to disprove against — accept
|
||||||
@@ -2324,7 +2327,13 @@ fn aligned_unit_keys_validate(
|
|||||||
hb.tick_cpu(tried, total);
|
hb.tick_cpu(tried, total);
|
||||||
tried += 1;
|
tried += 1;
|
||||||
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
|
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
|
||||||
if decrypt_unit_full(&mut probe, k, read_data_key) {
|
// bus layer (AACS 2.0) first, then the CPS unit key, then the structural
|
||||||
|
// proof — the composed form of the old `decrypt_unit_full`.
|
||||||
|
if let Some(rdk) = read_data_key {
|
||||||
|
decrypt_bus(&mut probe, rdk);
|
||||||
|
}
|
||||||
|
decrypt_unit(&mut probe, k);
|
||||||
|
if is_clean(&probe, format) {
|
||||||
covered = true;
|
covered = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2351,6 +2360,7 @@ impl Disc {
|
|||||||
crate::decrypt::DecryptKeys::Aacs {
|
crate::decrypt::DecryptKeys::Aacs {
|
||||||
unit_keys: aacs.unit_keys.clone(),
|
unit_keys: aacs.unit_keys.clone(),
|
||||||
read_data_key: aacs.read_data_key,
|
read_data_key: aacs.read_data_key,
|
||||||
|
format: self.content_format,
|
||||||
}
|
}
|
||||||
} else if let Some(ref css) = self.css {
|
} else if let Some(ref css) = self.css {
|
||||||
crate::decrypt::DecryptKeys::Css {
|
crate::decrypt::DecryptKeys::Css {
|
||||||
@@ -2645,28 +2655,6 @@ impl Disc {
|
|||||||
self.ensure_decryptable_keys(raw, keys)
|
self.ensure_decryptable_keys(raw, keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upfront FMTS (AACS 2.1) key gate, parallel to
|
|
||||||
/// [`ensure_title_decryptable`](Self::ensure_title_decryptable). A 2.1 disc
|
|
||||||
/// carries forensic variant segments that need segment (variant) keys the
|
|
||||||
/// unit-key path cannot provide. When
|
|
||||||
/// [`BYPASS_FMTS_KEY`](crate::aacs::segment::BYPASS_FMTS_KEY) is `false`,
|
|
||||||
/// their absence is a hard upfront failure ([`Error::FmtsKeyMissing`]) — the
|
|
||||||
/// same policy as a missing unit key, so a forensic-holed rip is refused, not
|
|
||||||
/// produced. When `true` (the default today) the segments are skipped as
|
|
||||||
/// expected loss and this passes. `raw` mode and non-FMTS discs always pass.
|
|
||||||
pub fn ensure_forensic_segments_decryptable(&self, raw: bool) -> Result<()> {
|
|
||||||
if raw || crate::aacs::segment::BYPASS_FMTS_KEY {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
// A 2.1 (FMTS) disc carries forensic variant segments with no segment-key
|
|
||||||
// source (none exists yet), so its variant segments cannot be opened.
|
|
||||||
// Refuse upfront rather than emit a forensic-holed rip.
|
|
||||||
if self.format == DiscFormat::Fmts {
|
|
||||||
return Err(Error::FmtsKeyMissing);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
|
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
|
||||||
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
|
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
|
||||||
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
|
||||||
@@ -2840,7 +2828,12 @@ impl Disc {
|
|||||||
// de-scramble it. With no samples (or only clear ones) there is nothing
|
// de-scramble it. With no samples (or only clear ones) there is nothing
|
||||||
// to disprove against, so the key is accepted as-is — keeping the
|
// to disprove against, so the key is accepted as-is — keeping the
|
||||||
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
|
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
|
||||||
if !aligned_unit_keys_validate(&candidate_unit_keys, read_data_key.as_ref(), samples) {
|
if !aligned_unit_keys_validate(
|
||||||
|
&candidate_unit_keys,
|
||||||
|
read_data_key.as_ref(),
|
||||||
|
samples,
|
||||||
|
self.content_format,
|
||||||
|
) {
|
||||||
return Err(crate::error::Error::AacsKeyRejected);
|
return Err(crate::error::Error::AacsKeyRejected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3073,10 +3066,6 @@ impl Disc {
|
|||||||
progress: opts.progress,
|
progress: opts.progress,
|
||||||
halt: opts.halt.clone(),
|
halt: opts.halt.clone(),
|
||||||
key_fetch: opts.key_fetch.clone(),
|
key_fetch: opts.key_fetch.clone(),
|
||||||
// Disc::copy's internal patch grinds each range fully (it's a
|
|
||||||
// single-call recovery); the breadth-first fast-capture ordering is
|
|
||||||
// an autorip multi-pass concern.
|
|
||||||
fast_capture: false,
|
|
||||||
};
|
};
|
||||||
let pr = self.patch(reader, path, &patch_opts)?;
|
let pr = self.patch(reader, path, &patch_opts)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -3131,27 +3120,15 @@ impl Disc {
|
|||||||
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
|
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
|
||||||
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
|
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
|
||||||
//
|
//
|
||||||
// A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the
|
// Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and
|
||||||
// autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we
|
// plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a
|
||||||
// still resolve the keys and VERIFY each unit on a scratch copy: a unit
|
// pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI
|
||||||
// that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like
|
// read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure.
|
||||||
// a SCSI error, and flows into the SAME read-error recovery (skip /
|
// (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute
|
||||||
// NonTrimmed / patch). This is the one spot that makes "a read succeeded"
|
// unit grid against clip-file-anchored AACS units and false-failed good
|
||||||
// mean "read AND decrypts" — everything downstream is unchanged. With no
|
// clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
|
||||||
// usable AACS keys (no keydb) it degrades to a plain pass-through.
|
// verify and no post-sweep clip-anchored pass; decryptability is proven at
|
||||||
//
|
// mux time, not at capture time.)
|
||||||
// A plain `--raw` single-pass (no `skip_on_error`) stays a pass-through:
|
|
||||||
// the user asked for the raw image, untouched and unchecked.
|
|
||||||
// The sweep COPIES ciphertext (multipass / `--raw`) or decrypts IN PLACE
|
|
||||||
// (`opts.decrypt`, the rare disc→decrypted-ISO). It deliberately does NOT
|
|
||||||
// decrypt-VERIFY: a whole-disc sweep reads disc-absolute, but AACS aligned
|
|
||||||
// units are anchored to each clip's FILE start and clips can be non-6144-
|
|
||||||
// aligned OR fragmented across UDF extents — so a disc-absolute verify
|
|
||||||
// mis-aligns the unit grid and false-fails good clips (it skipped the
|
|
||||||
// ~990 MB orphan-CPS clip on Dunkirk). Verification moved to the
|
|
||||||
// clip-anchored [`Disc::verify_clips`] pass that runs AFTER the sweep,
|
|
||||||
// reading each clip file-order-anchored from the ISO. The read here stays
|
|
||||||
// a fail-safe copy; alignment is never assumed.
|
|
||||||
let keys = if opts.decrypt {
|
let keys = if opts.decrypt {
|
||||||
self.decrypt_keys()
|
self.decrypt_keys()
|
||||||
} else {
|
} else {
|
||||||
@@ -3177,22 +3154,6 @@ impl Disc {
|
|||||||
};
|
};
|
||||||
let reader = &mut reader;
|
let reader = &mut reader;
|
||||||
|
|
||||||
// Post-read verify gate (universal `read -> verify -> sign-off`). Built
|
|
||||||
// ONLY for the ciphertext sweep (`!opts.decrypt`, the multipass rip
|
|
||||||
// path) so `observe` always sees on-disc ciphertext and never
|
|
||||||
// double-decrypts already-plaintext bytes. `UnitVerifier::new` is itself
|
|
||||||
// fail-safe: it returns `None` (verify disabled, behavior unchanged) for
|
|
||||||
// a non-AACS disc, no keys, the kill-switch off, or an empty clip
|
|
||||||
// enumeration. We resolve the REAL AACS keys here even though the sweep
|
|
||||||
// copies ciphertext, and reuse the application's key-fetch seam.
|
|
||||||
let mut verifier = if opts.decrypt {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let verify_keys = self.decrypt_keys();
|
|
||||||
let layouts = extract::clip_layouts(&mut *reader);
|
|
||||||
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mapfile: load if resuming, else wipe + recreate.
|
// Mapfile: load if resuming, else wipe + recreate.
|
||||||
let mapfile_path = self.mapfile_for(path);
|
let mapfile_path = self.mapfile_for(path);
|
||||||
// covers_disc reconciliation. A resume against a mapfile whose total
|
// covers_disc reconciliation. A resume against a mapfile whose total
|
||||||
@@ -3483,18 +3444,6 @@ impl Disc {
|
|||||||
// The consumer thread sees decrypted bytes; the
|
// The consumer thread sees decrypted bytes; the
|
||||||
// pre-0.18 inline decrypt_sectors call lived here.
|
// pre-0.18 inline decrypt_sectors call lived here.
|
||||||
|
|
||||||
// Post-read verify: observe the just-read ciphertext
|
|
||||||
// BEFORE it is moved into the channel, collecting the
|
|
||||||
// clip units this batch completes that are confidently
|
|
||||||
// undecryptable. Sent as `MarkBad` AFTER the `Good`
|
|
||||||
// below so the FIFO pipe records `Finished` first and the
|
|
||||||
// downgrade to `NonTrimmed` last. No-op when the gate is
|
|
||||||
// disabled (`verifier` is `None`).
|
|
||||||
let verify_bad = verifier
|
|
||||||
.as_mut()
|
|
||||||
.map(|v| v.observe(block_lba, &buf[..block_bytes as usize]))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Move the batch into the channel via fresh
|
// Move the batch into the channel via fresh
|
||||||
// owned Vec. The producer's `buf` is reused
|
// owned Vec. The producer's `buf` is reused
|
||||||
// for the next read.
|
// for the next read.
|
||||||
@@ -3503,26 +3452,6 @@ impl Disc {
|
|||||||
producer_err = Some(consumer_gone());
|
producer_err = Some(consumer_gone());
|
||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
// Downgrade any unit that failed verify (decrypt-fail ==
|
|
||||||
// bad read). decrypt-fail is NOT physical damage, so it
|
|
||||||
// deliberately does not touch the damage-jump window.
|
|
||||||
let mut send_failed = false;
|
|
||||||
for (bad_lba, bad_cnt) in verify_bad {
|
|
||||||
if pipe
|
|
||||||
.send(WorkItem::MarkBad {
|
|
||||||
pos: bad_lba as u64 * 2048,
|
|
||||||
len: bad_cnt as u64 * 2048,
|
|
||||||
})
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
producer_err = Some(consumer_gone());
|
|
||||||
send_failed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if send_failed {
|
|
||||||
break 'outer;
|
|
||||||
}
|
|
||||||
bytes_done = bytes_done.saturating_add(block_bytes);
|
bytes_done = bytes_done.saturating_add(block_bytes);
|
||||||
pos += block_bytes;
|
pos += block_bytes;
|
||||||
}
|
}
|
||||||
@@ -4009,18 +3938,6 @@ pub struct PatchOptions<'a> {
|
|||||||
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
|
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
|
||||||
/// recover an orphan CPS unit's key when re-reading its bad range.
|
/// recover an orphan CPS unit's key when re-reading its bad range.
|
||||||
pub key_fetch: Option<crate::sector::KeyFetch>,
|
pub key_fetch: Option<crate::sector::KeyFetch>,
|
||||||
/// Fast-capture pass: read each bad range ONCE at the full batch and leave
|
|
||||||
/// every failed block `NonTrimmed` for a later pass — WITHOUT bisecting,
|
|
||||||
/// re-reading, or grinding it here. This lets a first retry pass grab the
|
|
||||||
/// readable blocks (the sweep's good skip-ahead overshoot) of EVERY range
|
|
||||||
/// quickly, before any single range's slow per-sector recovery — so
|
|
||||||
/// recovered data surfaces across the whole disc first instead of grinding
|
|
||||||
/// section 1 to exhaustion before even touching section 2. A later pass
|
|
||||||
/// (`fast_capture = false`) does the granular bisect/retry on what's left.
|
|
||||||
/// No data is dropped: a failed block stays `NonTrimmed` until a granular
|
|
||||||
/// pass recovers it or finally gives up. A transport fault (bridge crash)
|
|
||||||
/// still aborts — it isn't a recoverable bad sector.
|
|
||||||
pub fast_capture: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result returned by [`Disc::patch`].
|
/// Result returned by [`Disc::patch`].
|
||||||
@@ -4867,6 +4784,7 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::Aacs {
|
crate::decrypt::DecryptKeys::Aacs {
|
||||||
unit_keys,
|
unit_keys,
|
||||||
read_data_key,
|
read_data_key,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
|
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
|
||||||
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
|
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
|
||||||
@@ -5386,14 +5304,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_key_validation_gates_on_real_ciphertext() {
|
fn unit_key_validation_gates_on_real_ciphertext() {
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
|
||||||
// No samples -> nothing to disprove against -> accept (sample-less paths
|
// No samples -> nothing to disprove against -> accept (sample-less paths
|
||||||
// like resume / mapfile must be unaffected).
|
// like resume / mapfile must be unaffected).
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(0, [0x11u8; 16])],
|
&[(0, [0x11u8; 16])],
|
||||||
None,
|
None,
|
||||||
&[]
|
&[],
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
|
|
||||||
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
|
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
|
||||||
@@ -5404,18 +5323,22 @@ mod tests {
|
|||||||
clear[off] = 0x47;
|
clear[off] = 0x47;
|
||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
assert!(!ts_sync_destroyed(&clear));
|
assert!(crate::aacs::content::is_clean(
|
||||||
|
&clear,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(0, [0x11u8; 16])],
|
&[(0, [0x11u8; 16])],
|
||||||
None,
|
None,
|
||||||
&[clear.clone()]
|
&[clear.clone()],
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
|
|
||||||
// A genuinely scrambled unit the RIGHT key restores to clear TS.
|
// A genuinely scrambled unit the RIGHT key restores to clear TS.
|
||||||
let uk = [0x5au8; 16];
|
let uk = [0x5au8; 16];
|
||||||
let enc = encrypt_unit_for_test(&clear, &uk);
|
let enc = encrypt_unit_for_test(&clear, &uk);
|
||||||
assert!(
|
assert!(
|
||||||
ts_sync_destroyed(&enc),
|
!crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs),
|
||||||
"encrypted unit must read scrambled"
|
"encrypted unit must read scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -5423,16 +5346,23 @@ mod tests {
|
|||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(7, uk)],
|
&[(7, uk)],
|
||||||
None,
|
None,
|
||||||
&[enc.clone()]
|
&[enc.clone()],
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
|
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
|
||||||
assert!(!super::aligned_unit_keys_validate(
|
assert!(!super::aligned_unit_keys_validate(
|
||||||
&[(7, [0x00u8; 16])],
|
&[(7, [0x00u8; 16])],
|
||||||
None,
|
None,
|
||||||
&[enc.clone()]
|
&[enc.clone()],
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
// Empty key set against a scrambled sample -> reject.
|
// Empty key set against a scrambled sample -> reject.
|
||||||
assert!(!super::aligned_unit_keys_validate(&[], None, &[enc]));
|
assert!(!super::aligned_unit_keys_validate(
|
||||||
|
&[],
|
||||||
|
None,
|
||||||
|
&[enc],
|
||||||
|
ContentFormat::BdTs
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -5444,7 +5374,7 @@ mod tests {
|
|||||||
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
|
||||||
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
// ISO/MKV with no error surfaced. The gate must now reject a key set
|
||||||
// that leaves any scrambled sample uncovered.
|
// that leaves any scrambled sample uncovered.
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
|
||||||
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
let mut off = 4;
|
let mut off = 4;
|
||||||
@@ -5457,8 +5387,14 @@ mod tests {
|
|||||||
let uk1 = [0x22u8; 16];
|
let uk1 = [0x22u8; 16];
|
||||||
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
||||||
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
||||||
assert!(ts_sync_destroyed(&sample0));
|
assert!(!crate::aacs::content::is_clean(
|
||||||
assert!(ts_sync_destroyed(&sample1));
|
&sample0,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
assert!(!crate::aacs::content::is_clean(
|
||||||
|
&sample1,
|
||||||
|
crate::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
let samples = vec![sample0.clone(), sample1.clone()];
|
let samples = vec![sample0.clone(), sample1.clone()];
|
||||||
|
|
||||||
@@ -5467,21 +5403,24 @@ mod tests {
|
|||||||
assert!(!super::aligned_unit_keys_validate(
|
assert!(!super::aligned_unit_keys_validate(
|
||||||
&[(0, uk0)],
|
&[(0, uk0)],
|
||||||
None,
|
None,
|
||||||
&samples
|
&samples,
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
|
|
||||||
// Complete key set (both CPS units) -> accept.
|
// Complete key set (both CPS units) -> accept.
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(0, uk0), (1, uk1)],
|
&[(0, uk0), (1, uk1)],
|
||||||
None,
|
None,
|
||||||
&samples
|
&samples,
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
|
|
||||||
// Order-independent: covering key present anywhere in the set is fine.
|
// Order-independent: covering key present anywhere in the set is fine.
|
||||||
assert!(super::aligned_unit_keys_validate(
|
assert!(super::aligned_unit_keys_validate(
|
||||||
&[(1, uk1), (0, uk0)],
|
&[(1, uk1), (0, uk0)],
|
||||||
None,
|
None,
|
||||||
&samples
|
&samples,
|
||||||
|
ContentFormat::BdTs
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-86
@@ -405,9 +405,15 @@ pub(super) fn compute_initial_state(
|
|||||||
bad_ranges.reverse();
|
bad_ranges.reverse();
|
||||||
}
|
}
|
||||||
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
|
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
|
||||||
|
// Fail SAFE when metadata is indeterminate: assume a regular file so a
|
||||||
|
// real `sync_all` failure is surfaced, not swallowed. `/dev/null` and pipes
|
||||||
|
// report success-with-non-file here (so they still correctly map to
|
||||||
|
// `false`); only a genuine metadata error (e.g. transient NFS ESTALE) hits
|
||||||
|
// the default, and for a data-integrity guard "surface the error" is the
|
||||||
|
// right side to err on.
|
||||||
let is_regular = std::fs::metadata(path)
|
let is_regular = std::fs::metadata(path)
|
||||||
.map(|m| m.file_type().is_file())
|
.map(|m| m.file_type().is_file())
|
||||||
.unwrap_or(false);
|
.unwrap_or(true);
|
||||||
Ok((
|
Ok((
|
||||||
map,
|
map,
|
||||||
initial_stats,
|
initial_stats,
|
||||||
@@ -1247,7 +1253,24 @@ impl Disc {
|
|||||||
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
|
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
|
||||||
let map = match mapfile::Mapfile::load(mapfile_path) {
|
let map = match mapfile::Mapfile::load(mapfile_path) {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(_) => return 0,
|
// A MISSING mapfile is legitimate (no damage was ever tracked — e.g. a
|
||||||
|
// clean single-pass rip): 0 bad bytes is correct. Any OTHER load error
|
||||||
|
// (corrupt / unreadable mapfile) means we CANNOT know the damage — and
|
||||||
|
// a returned 0 reads to the caller as "clean." Logging alone is not
|
||||||
|
// fail-safe: the RETURN VALUE drives the loss/abort accounting, not the
|
||||||
|
// log. So fail safe by reporting the ENTIRE title as bad (its full
|
||||||
|
// in-extent byte count) — a corrupt damage record must surface as
|
||||||
|
// maximal loss, never as a clean rip.
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return 0,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "freemkv::disc",
|
||||||
|
path = %mapfile_path.display(),
|
||||||
|
error = %e,
|
||||||
|
"bytes_bad_in_title: mapfile load failed; reporting whole title bad (fail-safe: cannot confirm clean)"
|
||||||
|
);
|
||||||
|
return bytes_bad_in_title(title, &[(0, u64::MAX)]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let bad_ranges = map.ranges_with(&[
|
let bad_ranges = map.ranges_with(&[
|
||||||
mapfile::SectorStatus::NonTrimmed,
|
mapfile::SectorStatus::NonTrimmed,
|
||||||
@@ -1299,33 +1322,13 @@ impl Disc {
|
|||||||
let bytes_good_before = initial_stats.bytes_good;
|
let bytes_good_before = initial_stats.bytes_good;
|
||||||
let bytes_good_start = bytes_good_before;
|
let bytes_good_start = bytes_good_before;
|
||||||
|
|
||||||
// Post-read verify gate for the patch pass (ciphertext multipass only,
|
|
||||||
// `!opts.decrypt`). Built here from the raw reader's UDF enumeration;
|
|
||||||
// reused AFTER the recovery loop (`reverify_iso`) to re-check the units
|
|
||||||
// this pass touched by reading them WHOLE back from the patched ISO —
|
|
||||||
// patch re-reads only the bad sectors of a unit, so per-unit verify
|
|
||||||
// can't run live. Fail-safe `None` when disabled / non-AACS / no keys.
|
|
||||||
let mut verifier = if opts.decrypt {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let verify_keys = self.decrypt_keys();
|
|
||||||
let layouts = crate::disc::extract::clip_layouts(&mut *reader);
|
|
||||||
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
|
|
||||||
};
|
|
||||||
// Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
|
// Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
|
||||||
// (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting
|
// (`opts.decrypt`) decrypts in place (plaintext ISO); a NON-decrypting
|
||||||
// patch (the multipass / `--raw --multipass` path) resolves the keys and
|
// patch (the multipass / `--raw --multipass` path) copies ciphertext
|
||||||
// VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt
|
// verbatim (keys = `None` → pass-through). Bad sectors are found by
|
||||||
// fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the
|
// PHYSICAL read success, not by decrypt structure: a re-read that returns
|
||||||
// retry loop keeps re-reading it "until it decrypts or retries exhaust"
|
// good bytes recovers the range; a read that errors leaves it NonTrimmed
|
||||||
// exactly as for a SCSI read error — and a unit that DOES decrypt on a
|
// for the next pass. (The old decrypt-VERIFY read gate was removed.)
|
||||||
// fresh read (the drive returned different bytes) is recovered for free.
|
|
||||||
// With no usable AACS keys this degrades to a plain pass-through.
|
|
||||||
// Symmetric with `Disc::sweep`: the patch COPIES ciphertext (multipass /
|
|
||||||
// `--raw`) or decrypts IN PLACE (`opts.decrypt`). It does NOT decrypt-
|
|
||||||
// VERIFY — the disc-absolute read can't anchor to a clip's file-relative
|
|
||||||
// unit grid (see `Disc::sweep` + `Disc::verify_clips`). Re-reads recover
|
|
||||||
// bad sectors; the clip-anchored verify pass re-checks them afterward.
|
|
||||||
let keys = if opts.decrypt {
|
let keys = if opts.decrypt {
|
||||||
self.decrypt_keys()
|
self.decrypt_keys()
|
||||||
} else {
|
} else {
|
||||||
@@ -1435,64 +1438,7 @@ impl Disc {
|
|||||||
// sink's summary. `close` failing on a regular-file sync_all is
|
// sink's summary. `close` failing on a regular-file sync_all is
|
||||||
// surfaced here as `Error::IoError`, matching pre-split
|
// surfaced here as `Error::IoError`, matching pre-split
|
||||||
// behaviour.
|
// behaviour.
|
||||||
let mut summary = pipe.finish()?;
|
let summary = pipe.finish()?;
|
||||||
|
|
||||||
// Scoped post-read re-verify (decrypt-fail == bad read). The consumer
|
|
||||||
// has flushed the ISO + mapfile; re-read each clip unit this pass touched
|
|
||||||
// WHOLE from the patched ISO and downgrade any that still won't decrypt
|
|
||||||
// to NonTrimmed, so the orchestrator's end-of-recovery promotion
|
|
||||||
// terminalizes it. Reuses the same verifier as the sweep. Fail-safe:
|
|
||||||
// disabled gate / unreadable ISO / load failure all leave the pass as-is.
|
|
||||||
if let Some(mut v) = verifier.take() {
|
|
||||||
if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) {
|
|
||||||
// Only units whose every backing sector was actually READ
|
|
||||||
// (Finished) may be re-verified — we can't verify what wasn't read
|
|
||||||
// (a non-Finished sector is zero-filled because the read failed),
|
|
||||||
// and must not waste a key lookup on a known-bad block.
|
|
||||||
let finished = m.ranges_with(&[mapfile::SectorStatus::Finished]);
|
|
||||||
let is_finished = |lba: u32| -> bool {
|
|
||||||
let p = lba as u64 * 2048;
|
|
||||||
finished.iter().any(|&(s, sz)| p >= s && p < s + sz)
|
|
||||||
};
|
|
||||||
if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) {
|
|
||||||
let bad = v.reverify_iso(&mut iso, &bad_ranges, &is_finished);
|
|
||||||
if !bad.is_empty() {
|
|
||||||
let n: usize = bad.len();
|
|
||||||
for (lba, cnt) in bad {
|
|
||||||
if let Err(e) = m.record(
|
|
||||||
lba as u64 * 2048,
|
|
||||||
cnt as u64 * 2048,
|
|
||||||
mapfile::SectorStatus::NonTrimmed,
|
|
||||||
) {
|
|
||||||
tracing::warn!(
|
|
||||||
lba,
|
|
||||||
"reverify downgrade: mapfile record failed ({e}) — unit may stay mismarked as good"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(e) = m.flush() {
|
|
||||||
tracing::warn!(
|
|
||||||
"reverify downgrade: mapfile flush failed ({e}) — downgrade not persisted; a resume could mismark it good"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The re-verify ran AFTER `pipe.finish()` snapshotted
|
|
||||||
// `summary.stats`, so those stats still count the just-
|
|
||||||
// downgraded units as good. Refresh from the mapfile so
|
|
||||||
// `build_outcome` reports the true post-downgrade picture
|
|
||||||
// (bytes_good ↓, bytes_pending ↑) — otherwise the caller
|
|
||||||
// over-reports recovery and can call an imperfect rip
|
|
||||||
// "complete".
|
|
||||||
summary.stats = m.stats();
|
|
||||||
tracing::info!(
|
|
||||||
target: "freemkv::verify",
|
|
||||||
phase = "patch.reverify",
|
|
||||||
downgraded_ranges = n,
|
|
||||||
"post-read re-verify downgraded undecryptable units to NonTrimmed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let outcome = build_outcome(
|
let outcome = build_outcome(
|
||||||
&state,
|
&state,
|
||||||
|
|||||||
@@ -66,14 +66,6 @@ pub(super) enum WorkItem {
|
|||||||
/// tell them apart without parsing a flag.
|
/// tell them apart without parsing a flag.
|
||||||
GapFill { pos: u64, len: u64 },
|
GapFill { pos: u64, len: u64 },
|
||||||
|
|
||||||
/// Post-read verify downgrade. The producer's `UnitVerifier` found that the
|
|
||||||
/// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable
|
|
||||||
/// (a silent bad read). The consumer re-records the range as `NonTrimmed` so
|
|
||||||
/// the patch pass re-reads it — the ISO bytes (ciphertext) already written by
|
|
||||||
/// the preceding `Good` are left in place for the patch to overwrite. FIFO
|
|
||||||
/// pipe ordering guarantees this arrives AFTER the `Good` that wrote them.
|
|
||||||
MarkBad { pos: u64, len: u64 },
|
|
||||||
|
|
||||||
/// Producer wants the latest mapfile stats for the progress
|
/// Producer wants the latest mapfile stats for the progress
|
||||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||||
@@ -182,12 +174,6 @@ impl Sink<WorkItem> for SweepSink {
|
|||||||
}
|
}
|
||||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
||||||
}
|
}
|
||||||
WorkItem::MarkBad { pos, len } => {
|
|
||||||
// Verify downgrade: the ISO bytes are already written by the
|
|
||||||
// preceding Good; only the mapfile status changes so patch
|
|
||||||
// re-reads this range. No file write.
|
|
||||||
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
|
|
||||||
}
|
|
||||||
WorkItem::StatsRequest => {
|
WorkItem::StatsRequest => {
|
||||||
let stats = self.map.stats();
|
let stats = self.map.stats();
|
||||||
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
||||||
|
|||||||
-1022
File diff suppressed because it is too large
Load Diff
@@ -78,8 +78,8 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
|||||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||||
/// cache stays bounded the same way the write side does.
|
/// cache stays bounded the same way the write side does.
|
||||||
///
|
///
|
||||||
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
|
||||||
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
/// smaller windows (8 / 16 MiB) shorten the
|
||||||
/// kernel-readahead overlap and slow the producer; larger windows
|
/// kernel-readahead overlap and slow the producer; larger windows
|
||||||
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||||
|
|||||||
+129
-5
@@ -23,6 +23,64 @@ use crate::aacs::types::{UnitKey, Vid};
|
|||||||
use crate::disc::Key;
|
use crate::disc::Key;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// Minimum encrypted-content unit samples a single online key request must carry.
|
||||||
|
///
|
||||||
|
/// The key service identifies a key by which of the submitted units it decrypts,
|
||||||
|
/// so too few samples — especially on FMTS, where a segment interleaves several
|
||||||
|
/// variants at the unit level — can return a key that matches an incidental unit
|
||||||
|
/// rather than the one asked about (a false positive). This many distinct units
|
||||||
|
/// make the request unambiguous.
|
||||||
|
///
|
||||||
|
/// Canonical here (the base crate) so BOTH consumers agree on one value: the
|
||||||
|
/// online source in `freemkv-keysources` (which refuses to send an under-sampled
|
||||||
|
/// request) re-exports it, and libfreemkv's own FMTS forensic query
|
||||||
|
/// ([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the
|
||||||
|
/// reverse import (keysources depends on libfreemkv, not vice versa), so the
|
||||||
|
/// value lives at the lower layer both share.
|
||||||
|
pub const MIN_SAMPLE_UNITS: usize = 8;
|
||||||
|
|
||||||
|
/// A set of encrypted content-unit samples PROVEN to carry at least
|
||||||
|
/// [`MIN_SAMPLE_UNITS`] units — the online `/decode` request's proof-of-ownership.
|
||||||
|
///
|
||||||
|
/// "Parse, don't validate": the only constructor, [`DecodeSampleSet::new`], returns
|
||||||
|
/// `None` for an under-sized slice, so an online key request simply *cannot be built*
|
||||||
|
/// from too few samples. The runtime `len() < MIN_SAMPLE_UNITS` check that used to
|
||||||
|
/// live at the request site (and was silently forgotten by an under-sampling caller,
|
||||||
|
/// reading as "key service down") becomes a compile-time obligation: a request builder
|
||||||
|
/// that takes `&DecodeSampleSet` can never receive an unchecked `Vec`.
|
||||||
|
///
|
||||||
|
/// The *count* enforced here is a runtime property of the disc (how many encrypted
|
||||||
|
/// units it yields); the *requested* count is a caller-side compile-time constant that
|
||||||
|
/// callers pin to `MIN_SAMPLE_UNITS` (see e.g. autorip's `SAMPLE_UNITS`). Together the
|
||||||
|
/// two make under-sampling unrepresentable at the request boundary.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DecodeSampleSet(Vec<Vec<u8>>);
|
||||||
|
|
||||||
|
impl DecodeSampleSet {
|
||||||
|
/// Wrap `units` iff it carries at least [`MIN_SAMPLE_UNITS`] samples; `None`
|
||||||
|
/// otherwise (the caller then skips the online source rather than sending an
|
||||||
|
/// ambiguous request). This is the sole way to obtain a `DecodeSampleSet`.
|
||||||
|
pub fn new(units: Vec<Vec<u8>>) -> Option<Self> {
|
||||||
|
(units.len() >= MIN_SAMPLE_UNITS).then_some(Self(units))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The proven-sufficient samples. Guaranteed `>= MIN_SAMPLE_UNITS` in length.
|
||||||
|
pub fn units(&self) -> &[Vec<u8>] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of samples — always `>= MIN_SAMPLE_UNITS`.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always `false` (a `DecodeSampleSet` never holds fewer than `MIN_SAMPLE_UNITS`);
|
||||||
|
/// provided so the type satisfies the usual `len`/`is_empty` pairing.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
||||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||||
/// structures a source or key server may key on.
|
/// structures a source or key server may key on.
|
||||||
@@ -318,7 +376,28 @@ pub fn key_fetch(
|
|||||||
inputs: DiscInputs,
|
inputs: DiscInputs,
|
||||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||||
) -> crate::sector::KeyFetch {
|
) -> crate::sector::KeyFetch {
|
||||||
|
// Memoize by the fingerprint of the sample batch. The resolved keys are
|
||||||
|
// disc-level (the same clip's index / CPS keys are identical for every title
|
||||||
|
// that references it), and this one closure is shared across every title's mux
|
||||||
|
// — so the first title resolves a given batch over the network and every later
|
||||||
|
// title (or repeated batch) is answered from the cache with no request. Empty
|
||||||
|
// replies are cached too: a key the service does not have for a batch will not
|
||||||
|
// appear on a re-ask, so re-hitting the network buys nothing.
|
||||||
|
let cache: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<u64, Vec<[u8; 16]>>>> =
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||||
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
||||||
|
let fp = {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
samples.len().hash(&mut h);
|
||||||
|
for s in samples {
|
||||||
|
s.hash(&mut h);
|
||||||
|
}
|
||||||
|
h.finish()
|
||||||
|
};
|
||||||
|
if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) {
|
||||||
|
return hit.clone();
|
||||||
|
}
|
||||||
let sources = make_sources();
|
let sources = make_sources();
|
||||||
let mut di = inputs.clone();
|
let mut di = inputs.clone();
|
||||||
di.samples = samples.to_vec();
|
di.samples = samples.to_vec();
|
||||||
@@ -327,10 +406,15 @@ pub fn key_fetch(
|
|||||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
||||||
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||||
let ctx = DiscInputsCtx::new(&di);
|
let ctx = DiscInputsCtx::new(&di);
|
||||||
fetch_unit_keys(&sources, &ctx)
|
let keys: Vec<[u8; 16]> = fetch_unit_keys(&sources, &ctx)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|u| u.key)
|
.map(|u| u.key)
|
||||||
.collect()
|
.collect();
|
||||||
|
cache
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.insert(fp, keys.clone());
|
||||||
|
keys
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +491,7 @@ pub fn read_encrypted_units(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
||||||
if aacs_unit_encrypted(u) {
|
if aacs_unit_encrypted(u, title.content_format) {
|
||||||
out.push(u.to_vec());
|
out.push(u.to_vec());
|
||||||
if out.len() >= n {
|
if out.len() >= n {
|
||||||
return out;
|
return out;
|
||||||
@@ -425,6 +509,46 @@ mod tests {
|
|||||||
use crate::aacs::types::UnitKey;
|
use crate::aacs::types::UnitKey;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn units(n: usize) -> Vec<Vec<u8>> {
|
||||||
|
(0..n).map(|i| vec![i as u8; 4]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DecodeSampleSet: the online request can't be built under-sized ─────────
|
||||||
|
|
||||||
|
/// Fewer than MIN_SAMPLE_UNITS → no set. Mutation: accepting a short slice
|
||||||
|
/// resurrects the exact autorip bug (a 4-sample request silently skipped /
|
||||||
|
/// read as "service down").
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_rejects_under_min() {
|
||||||
|
for n in 0..MIN_SAMPLE_UNITS {
|
||||||
|
assert!(
|
||||||
|
DecodeSampleSet::new(units(n)).is_none(),
|
||||||
|
"{n} samples (< {MIN_SAMPLE_UNITS}) must not build a DecodeSampleSet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exactly the minimum, and above it, construct — and expose all samples.
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_accepts_min_and_above() {
|
||||||
|
let exact = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS)).expect("min builds");
|
||||||
|
assert_eq!(exact.len(), MIN_SAMPLE_UNITS);
|
||||||
|
assert_eq!(exact.units().len(), MIN_SAMPLE_UNITS);
|
||||||
|
assert!(!exact.is_empty());
|
||||||
|
|
||||||
|
let more = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS + 5)).expect("above min builds");
|
||||||
|
assert_eq!(more.len(), MIN_SAMPLE_UNITS + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wrapped units round-trip byte-for-byte (the request carries exactly what
|
||||||
|
/// was gathered — no reordering/truncation).
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_preserves_units() {
|
||||||
|
let raw = units(MIN_SAMPLE_UNITS);
|
||||||
|
let set = DecodeSampleSet::new(raw.clone()).unwrap();
|
||||||
|
assert_eq!(set.units(), raw.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
// ── KeySource default-method behaviour ────────────────────────────────────
|
// ── KeySource default-method behaviour ────────────────────────────────────
|
||||||
|
|
||||||
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
||||||
@@ -717,7 +841,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
for s in &samples {
|
for s in &samples {
|
||||||
assert!(
|
assert!(
|
||||||
aacs_unit_encrypted(s),
|
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
|
||||||
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
|
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -800,7 +924,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
for s in &samples {
|
for s in &samples {
|
||||||
assert!(
|
assert!(
|
||||||
aacs_unit_encrypted(s),
|
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
|
||||||
"only CPI-flagged units are selected"
|
"only CPI-flagged units are selected"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+5
-1
@@ -187,7 +187,10 @@ pub use identity::DriveId;
|
|||||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||||
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
||||||
pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
|
pub use decrypt::{
|
||||||
|
AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_sectors_mapped, decrypt_threads,
|
||||||
|
set_decrypt_threads,
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Disc structure ─────────────────────────────────────────────────────────
|
// ─── Disc structure ─────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -248,6 +251,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
|||||||
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||||
// `SectorSource` to get plaintext sectors out.
|
// `SectorSource` to get plaintext sectors out.
|
||||||
pub use mux::build_iso_pipeline;
|
pub use mux::build_iso_pipeline;
|
||||||
|
pub use mux::resolve_mux_key_map;
|
||||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||||
pub use sector::{
|
pub use sector::{
|
||||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
||||||
|
|||||||
+17
-5
@@ -241,11 +241,23 @@ impl EsWriter for AnnexBWriter {
|
|||||||
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
||||||
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
||||||
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
||||||
match codec {
|
let converted = match codec {
|
||||||
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
|
Codec::Hevc => hvcc_to_annex_b(record),
|
||||||
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
|
Codec::H264 => avcc_to_annex_b(record),
|
||||||
_ => Vec::new(),
|
_ => return Vec::new(),
|
||||||
}
|
};
|
||||||
|
converted.unwrap_or_else(|| {
|
||||||
|
// A malformed hvcC/avcC record yields no parameter sets. Returning empty
|
||||||
|
// means keyframes ship WITHOUT in-band SPS/PPS — playable from the first
|
||||||
|
// keyframe but broken for seek-to-arbitrary-point and hardware decoders.
|
||||||
|
// Surface it rather than silently degrading the output.
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
?codec,
|
||||||
|
"codec-private (hvcC/avcC) parse failed; keyframes will lack in-band SPS/PPS"
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
||||||
//! already runs on a producer thread; the *consumer* (main) thread
|
//! already runs on a producer thread; the *consumer* (main) thread
|
||||||
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
|
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
|
||||||
//! parsers. Profiling on the rip1 testbed showed feed at ~37 % and
|
//! parsers. Profiling showed feed at ~37 % and
|
||||||
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
|
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
|
||||||
//! enough that pipelining it with parse pays for itself.
|
//! enough that pipelining it with parse pays for itself.
|
||||||
//!
|
//!
|
||||||
|
|||||||
+12
-22
@@ -108,11 +108,6 @@ pub struct DiscStream {
|
|||||||
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
||||||
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
||||||
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
||||||
/// Shared decrypt-loss counter, cloned once at construction from
|
|
||||||
/// `reader.decrypt_loss()`. `lost_bytes()` loads it directly so the
|
|
||||||
/// per-frame hot path performs no per-call `Arc::clone` (matching the
|
|
||||||
/// `PipelinedPesStream` pattern).
|
|
||||||
decrypt_loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
/// Mirror of the keys handed in at construction. The decorator
|
/// Mirror of the keys handed in at construction. The decorator
|
||||||
/// owns the cryptographic state; this field is kept for
|
/// owns the cryptographic state; this field is kept for
|
||||||
@@ -237,12 +232,12 @@ impl DiscStream {
|
|||||||
|
|
||||||
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
||||||
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
|
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
|
||||||
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
|
// MUX path (read > decrypt > mux): decrypt every unit in place and pass the
|
||||||
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
|
// bytes to the muxer; a unit that decrypts to broken TS is the muxer's
|
||||||
// decode/mux stream (live-drive single-pass / direct), never the
|
// concern, never conceal / re-fetch / count as loss (fail loud only on a
|
||||||
// ciphertext-preserving sweep, so concealment is always correct here.
|
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
|
||||||
let mut reader =
|
// single-pass / direct), never the ciphertext-preserving sweep.
|
||||||
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
|
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
|
||||||
|
|
||||||
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
||||||
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
|
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
|
||||||
@@ -293,9 +288,6 @@ impl DiscStream {
|
|||||||
// the decorator is a pass-through). Reset the unit base the probe read
|
// the decorator is a pass-through). Reset the unit base the probe read
|
||||||
// advanced so the first fill_extents read starts cleanly.
|
// advanced so the first fill_extents read starts cleanly.
|
||||||
reader.set_unit_base(0);
|
reader.set_unit_base(0);
|
||||||
// Clone the shared loss counter once here so `lost_bytes()` never
|
|
||||||
// clones an Arc per frame on the mux hot path.
|
|
||||||
let decrypt_loss = reader.decrypt_loss();
|
|
||||||
|
|
||||||
// B1 resync gates: one per stream, video flagged so the gate only
|
// B1 resync gates: one per stream, video flagged so the gate only
|
||||||
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
||||||
@@ -311,7 +303,6 @@ impl DiscStream {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
reader,
|
reader,
|
||||||
decrypt_loss,
|
|
||||||
title,
|
title,
|
||||||
decrypt_keys,
|
decrypt_keys,
|
||||||
unit_align,
|
unit_align,
|
||||||
@@ -1003,14 +994,12 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn lost_bytes(&self) -> u64 {
|
fn lost_bytes(&self) -> u64 {
|
||||||
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
|
// Read-error zero-fill loss (counted in fill_extents) — real missing
|
||||||
// loss — bytes of scrambled AACS units the decorator could not decrypt
|
// content the abort gate must see. There is no decrypt-loss term: the
|
||||||
// and passed through still encrypted (the TS assembler silently drops
|
// decrypt path passes bad-encoded/undecryptable units through (a broken-TS
|
||||||
// them). Both are real missing content the abort gate must see; without
|
// unit is the muxer's concern, and a missing key is indistinguishable from
|
||||||
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
|
// bad authoring here), so only physical read loss is reported.
|
||||||
// missing segments passes even under abort_on_lost_secs=0.
|
|
||||||
self.lost_bytes
|
self.lost_bytes
|
||||||
.saturating_add(self.decrypt_loss.load(std::sync::atomic::Ordering::Relaxed))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1489,6 +1478,7 @@ mod tests {
|
|||||||
let keys = crate::decrypt::DecryptKeys::Aacs {
|
let keys = crate::decrypt::DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0u8; 16])],
|
unit_keys: vec![(0, [0u8; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
|
|||||||
+150
-21
@@ -121,6 +121,29 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
|
|||||||
Some(record)
|
Some(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the `CodecPrivate` for an MVC (Blu-ray 3D) base track: the base view's
|
||||||
|
/// `AVCDecoderConfigurationRecord` (`avcc`) followed by an `mvcC` extension
|
||||||
|
/// block, per the Matroska Codec Specifications §4.3.9:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// avcC ‖ u32be(extension_block_size − 4) ‖ "mvcC" ‖ MVCDecoderConfigurationRecord
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The size field is the extension block length **excluding the 4-byte size
|
||||||
|
/// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level
|
||||||
|
/// MVC signal that decoders and mediainfo read (the per-frame `BlockAdditional`
|
||||||
|
/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A
|
||||||
|
/// plain (2D) track never calls this — it writes its `avcc` verbatim.
|
||||||
|
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
|
||||||
|
let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size − 4
|
||||||
|
let mut out = Vec::with_capacity(avcc.len() + 8 + record.len());
|
||||||
|
out.extend_from_slice(avcc);
|
||||||
|
out.extend_from_slice(&ext_size.to_be_bytes());
|
||||||
|
out.extend_from_slice(b"mvcC");
|
||||||
|
out.extend_from_slice(record);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
|
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
|
||||||
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
|
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
|
||||||
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
|
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
|
||||||
@@ -762,23 +785,35 @@ fn block_ts(is_video: bool, prev: Option<i64>, pts_ticks: i64) -> i64 {
|
|||||||
|
|
||||||
/// Encode a Matroska track number as an EBML VINT into a stack buffer,
|
/// Encode a Matroska track number as an EBML VINT into a stack buffer,
|
||||||
/// returning the buffer and the used length. Track numbers are small (1-based,
|
/// returning the buffer and the used length. Track numbers are small (1-based,
|
||||||
/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest;
|
/// a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers `< 0x4000`,
|
||||||
/// no heap allocation, called once per block on the mux hot path.
|
/// and 3 bytes covers `< 0x20_0000`; no heap allocation, called once per block
|
||||||
|
/// on the mux hot path.
|
||||||
///
|
///
|
||||||
/// The 2-byte form holds 14 payload bits (max 0x3FFF). The `debug_assert`
|
/// Each width uses a marker bit that must NOT collide with the payload's top
|
||||||
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and
|
/// byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40
|
||||||
/// OR-ing the 0x40 length marker would clobber it, corrupting the track
|
/// (14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all
|
||||||
/// number. Not reachable today (track numbers are `i+1` over a few streams),
|
/// three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range
|
||||||
/// so this documents the bound rather than handling 3-byte VINTs.
|
/// track number can never silently clobber the marker bit and corrupt the
|
||||||
fn track_vint(track_num: usize) -> ([u8; 2], usize) {
|
/// block. Real discs never approach even the 2-byte range; the 21-bit ceiling
|
||||||
|
/// is an absurd upper bound kept as a `debug_assert`.
|
||||||
|
fn track_vint(track_num: usize) -> ([u8; 3], usize) {
|
||||||
if track_num < 0x80 {
|
if track_num < 0x80 {
|
||||||
([(track_num as u8) | 0x80, 0], 1)
|
([(track_num as u8) | 0x80, 0, 0], 1)
|
||||||
|
} else if track_num < 0x4000 {
|
||||||
|
([0x40 | ((track_num >> 8) as u8), track_num as u8, 0], 2)
|
||||||
} else {
|
} else {
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
track_num < 0x4000,
|
track_num < 0x20_0000,
|
||||||
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range"
|
"track number {track_num} exceeds the 21-bit 3-byte EBML VINT range"
|
||||||
);
|
);
|
||||||
([0x40 | ((track_num >> 8) as u8), track_num as u8], 2)
|
(
|
||||||
|
[
|
||||||
|
0x20 | ((track_num >> 16) as u8),
|
||||||
|
(track_num >> 8) as u8,
|
||||||
|
track_num as u8,
|
||||||
|
],
|
||||||
|
3,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,9 +917,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
||||||
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
|
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
|
||||||
|
// Filled below from the SAME built record that drives the CodecPrivate
|
||||||
|
// mvcC extension, so the three MVC signals never diverge.
|
||||||
|
let mut track_has_mvc_mapping: Vec<bool> = Vec::with_capacity(tracks.len());
|
||||||
for (i, track) in tracks.iter().enumerate() {
|
for (i, track) in tracks.iter().enumerate() {
|
||||||
let track_uid = (i + 1) as u64 | 0x100_0000;
|
let track_uid = (i + 1) as u64 | 0x100_0000;
|
||||||
track_uids.push(track_uid);
|
track_uids.push(track_uid);
|
||||||
|
// Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per
|
||||||
|
// track from the dependent view's subset-SPS/PPS. `None` for every
|
||||||
|
// non-3D track (and if the params are malformed) — the single source
|
||||||
|
// of truth for the CodecPrivate mvcC extension, the
|
||||||
|
// BlockAdditionMapping, and whether BlockAdditionals are conforming.
|
||||||
|
let mvc_record = track
|
||||||
|
.mvc_params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|(sps, pps)| mvc_decoder_config_record(sps, pps));
|
||||||
|
track_has_mvc_mapping.push(mvc_record.is_some());
|
||||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
||||||
@@ -904,7 +953,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref cp) = track.codec_private {
|
if let Some(ref cp) = track.codec_private {
|
||||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
match mvc_record.as_ref() {
|
||||||
|
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC
|
||||||
|
// followed by the `mvcC` extension block. This is the
|
||||||
|
// track-level signal decoders/mediainfo read to recognise the
|
||||||
|
// stereoscopic MVC track (the per-frame dependent view rides
|
||||||
|
// in BlockAdditional under the mapping below).
|
||||||
|
Some(record) => {
|
||||||
|
let cp_mvc = mvc_codec_private(cp, record);
|
||||||
|
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?;
|
||||||
|
}
|
||||||
|
// Non-MVC (2D/UHD/audio/…): write the codec_private verbatim —
|
||||||
|
// the unchanged path, byte-identical to a 2D mux.
|
||||||
|
None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
||||||
// (placeholder reserve + later seek-back fill via
|
// (placeholder reserve + later seek-back fill via
|
||||||
@@ -1004,8 +1066,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
||||||
// mediainfo recognise the dependent (right-eye) view that rides as a
|
// mediainfo recognise the dependent (right-eye) view that rides as a
|
||||||
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
||||||
if let Some((subset_sps, pps)) = track.mvc_params.as_ref() {
|
match mvc_record.as_ref() {
|
||||||
if let Some(record) = mvc_decoder_config_record(subset_sps, pps) {
|
Some(record) => {
|
||||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||||
ebml::write_uint(
|
ebml::write_uint(
|
||||||
&mut writer,
|
&mut writer,
|
||||||
@@ -1013,18 +1075,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
BLOCK_ADD_ID_VALUE_MVC,
|
BLOCK_ADD_ID_VALUE_MVC,
|
||||||
)?;
|
)?;
|
||||||
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
|
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
|
||||||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, &record)?;
|
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, record)?;
|
||||||
ebml::end_master(&mut writer, map_pos)?;
|
ebml::end_master(&mut writer, map_pos)?;
|
||||||
} else {
|
}
|
||||||
|
// `mvc_params` present but the record failed to build (malformed
|
||||||
|
// parameter sets): no mapping, and `track_has_mvc_mapping` above
|
||||||
|
// is already `false`, so BlockAdditionals are dropped — the file
|
||||||
|
// stays conforming rather than carrying an orphaned BlockAddID.
|
||||||
|
None if track.mvc_params.is_some() => {
|
||||||
|
let (s, p) = track.mvc_params.as_ref().unwrap();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"MVC track: could not build MVCDecoderConfigurationRecord from the \
|
"MVC track: could not build MVCDecoderConfigurationRecord from the \
|
||||||
dependent view's parameter sets (subset_sps={} B, pps={} B); \
|
dependent view's parameter sets (subset_sps={} B, pps={} B); \
|
||||||
emitting no mvcC mapping — the 3D pairing will not be signalled.",
|
emitting no mvcC mapping — the 3D pairing will not be signalled.",
|
||||||
subset_sps.len(),
|
s.len(),
|
||||||
pps.len(),
|
p.len(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||||
@@ -1117,7 +1186,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
primary_video_track: tracks
|
primary_video_track: tracks
|
||||||
.iter()
|
.iter()
|
||||||
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
||||||
track_has_mvc_mapping: tracks.iter().map(|t| t.mvc_params.is_some()).collect(),
|
track_has_mvc_mapping,
|
||||||
continuity: TimelineContinuity::new(),
|
continuity: TimelineContinuity::new(),
|
||||||
cues: Vec::new(),
|
cues: Vec::new(),
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
@@ -3450,7 +3519,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn track_vint_encodes_one_and_two_byte_forms() {
|
fn track_vint_encodes_one_two_and_three_byte_forms() {
|
||||||
// 1-byte form for track numbers < 0x80, high bit set.
|
// 1-byte form for track numbers < 0x80, high bit set.
|
||||||
let (b, n) = track_vint(1);
|
let (b, n) = track_vint(1);
|
||||||
assert_eq!(&b[..n], &[0x81]);
|
assert_eq!(&b[..n], &[0x81]);
|
||||||
@@ -3461,6 +3530,12 @@ mod tests {
|
|||||||
assert_eq!(&b[..n], &[0x40, 0x80]);
|
assert_eq!(&b[..n], &[0x40, 0x80]);
|
||||||
let (b, n) = track_vint(0x3FFF);
|
let (b, n) = track_vint(0x3FFF);
|
||||||
assert_eq!(&b[..n], &[0x7F, 0xFF]);
|
assert_eq!(&b[..n], &[0x7F, 0xFF]);
|
||||||
|
// 3-byte form at/above 0x4000, 0x20 length marker in the top byte —
|
||||||
|
// handled in RELEASE (no silent marker-bit clobber), not just debug.
|
||||||
|
let (b, n) = track_vint(0x4000);
|
||||||
|
assert_eq!(&b[..n], &[0x20, 0x40, 0x00]);
|
||||||
|
let (b, n) = track_vint(0x1F_FFFF);
|
||||||
|
assert_eq!(&b[..n], &[0x3F, 0xFF, 0xFF]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -4600,6 +4675,60 @@ mod tests {
|
|||||||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mvc_codec_private_appends_mvcc_extension_block() {
|
||||||
|
// avcC ‖ u32be(4 + record.len()) ‖ "mvcC" ‖ record (Matroska Codec Spec §4.3.9).
|
||||||
|
let avcc = vec![0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0xAA];
|
||||||
|
let record = vec![0x01, 0x80, 0x00, 0x33, 0xBF, 0x01, 0xCC]; // 7 bytes
|
||||||
|
let out = mvc_codec_private(&avcc, &record);
|
||||||
|
assert_eq!(&out[..avcc.len()], &avcc[..], "avcC preserved verbatim");
|
||||||
|
// size field = 4 ("mvcC") + 7 (record) = 11 = extension block size minus 4.
|
||||||
|
assert_eq!(&out[avcc.len()..avcc.len() + 4], &11u32.to_be_bytes());
|
||||||
|
assert_eq!(&out[avcc.len() + 4..avcc.len() + 8], b"mvcC");
|
||||||
|
assert_eq!(
|
||||||
|
&out[avcc.len() + 8..],
|
||||||
|
&record[..],
|
||||||
|
"record after the mvcC fourcc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
|
||||||
|
// An MVC base track's CodecPrivate must be the base avcC followed by the
|
||||||
|
// mvcC extension — the track-level signal mediainfo/decoders read.
|
||||||
|
let avcc = vec![
|
||||||
|
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
|
||||||
|
];
|
||||||
|
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
|
||||||
|
let pps = vec![0x68, 0xEE, 0x3C];
|
||||||
|
let mut v = make_video_track();
|
||||||
|
v.codec_private = Some(avcc.clone());
|
||||||
|
v.mvc_params = Some((subset_sps.clone(), pps.clone()));
|
||||||
|
let data = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[])
|
||||||
|
.unwrap()
|
||||||
|
.writer
|
||||||
|
.into_inner();
|
||||||
|
let record = mvc_decoder_config_record(&subset_sps, &pps).unwrap();
|
||||||
|
let expected = mvc_codec_private(&avcc, &record);
|
||||||
|
assert!(
|
||||||
|
data.windows(expected.len())
|
||||||
|
.any(|w| w == expected.as_slice()),
|
||||||
|
"emitted CodecPrivate must be avcC + mvcC extension"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A non-MVC (2D) track writes its avcC VERBATIM — no mvcC appended.
|
||||||
|
let mut v2 = make_video_track();
|
||||||
|
v2.codec_private = Some(avcc.clone());
|
||||||
|
let d2 = MkvMuxer::new(Cursor::new(Vec::new()), &[v2], None, 0.0, &[])
|
||||||
|
.unwrap()
|
||||||
|
.writer
|
||||||
|
.into_inner();
|
||||||
|
assert!(
|
||||||
|
!d2.windows(4).any(|w| w == b"mvcC"),
|
||||||
|
"2D track CodecPrivate must not carry an mvcC extension"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
||||||
//
|
//
|
||||||
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
||||||
|
|||||||
@@ -1016,7 +1016,11 @@ fn parse_track(
|
|||||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||||
match aid {
|
match aid {
|
||||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
||||||
ebml::CHANNELS => ch = read_uint_bounded(r, as_)? as u8,
|
// Clamp instead of `as u8`: a foreign/corrupt MKV with a
|
||||||
|
// CHANNELS value that is a multiple of 256 would truncate to
|
||||||
|
// 0 (an invalid channel count) on a bare cast. Saturate to
|
||||||
|
// u8::MAX so an absurd count degrades to "many", never to 0.
|
||||||
|
ebml::CHANNELS => ch = read_uint_bounded(r, as_)?.min(u8::MAX as u64) as u8,
|
||||||
_ => {
|
_ => {
|
||||||
skip_bytes(r, as_)?;
|
skip_bytes(r, as_)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ pub use network::NetworkStream;
|
|||||||
pub use null::NullStream;
|
pub use null::NullStream;
|
||||||
pub use pipelined_stream::PipelinedPesStream;
|
pub use pipelined_stream::PipelinedPesStream;
|
||||||
pub use resolve::build_iso_pipeline;
|
pub use resolve::build_iso_pipeline;
|
||||||
|
pub use resolve::resolve_mux_key_map;
|
||||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||||
pub use stdio::StdioStream;
|
pub use stdio::StdioStream;
|
||||||
|
|
||||||
|
|||||||
@@ -56,14 +56,6 @@ pub struct PipelinedPesStream {
|
|||||||
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
||||||
/// per-poll reads it replaces were needless hot-path overhead.
|
/// per-poll reads it replaces were needless hot-path overhead.
|
||||||
skip_parse: bool,
|
skip_parse: bool,
|
||||||
/// Cumulative bytes of scrambled AACS units the producer's decrypt step
|
|
||||||
/// could not decrypt — silent decrypt loss the demux drops without a sync.
|
|
||||||
/// Shared with the producer thread's [`DecryptingSectorSource`]
|
|
||||||
/// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through
|
|
||||||
/// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial
|
|
||||||
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
|
|
||||||
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
|
|
||||||
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
|
|
||||||
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
|
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
|
||||||
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
||||||
/// and summarised once at EOF.
|
/// and summarised once at EOF.
|
||||||
@@ -134,7 +126,6 @@ impl PipelinedPesStream {
|
|||||||
pending_frames: std::collections::VecDeque::new(),
|
pending_frames: std::collections::VecDeque::new(),
|
||||||
eof: false,
|
eof: false,
|
||||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||||
decrypt_loss: None,
|
|
||||||
dropped_nav_packets: 0,
|
dropped_nav_packets: 0,
|
||||||
resync,
|
resync,
|
||||||
is_video,
|
is_video,
|
||||||
@@ -142,20 +133,6 @@ impl PipelinedPesStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`]
|
|
||||||
/// reports bytes of scrambled AACS units that could not be decrypted (and
|
|
||||||
/// were therefore silently dropped downstream). Obtained from the
|
|
||||||
/// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved
|
|
||||||
/// into the prefetch thread. The M2TS / no-decrypt pipelines leave this
|
|
||||||
/// unset.
|
|
||||||
pub(crate) fn with_decrypt_loss(
|
|
||||||
mut self,
|
|
||||||
loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
|
||||||
) -> Self {
|
|
||||||
self.decrypt_loss = Some(loss);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull one batch of `PesPacket`s from the demux thread, run
|
/// Pull one batch of `PesPacket`s from the demux thread, run
|
||||||
/// codec parse on each, enqueue resulting `PesFrame`s on
|
/// codec parse on each, enqueue resulting `PesFrame`s on
|
||||||
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
||||||
@@ -464,17 +441,9 @@ impl Stream for PipelinedPesStream {
|
|||||||
.and_then(|(_, parser)| parser.codec_private())
|
.and_then(|(_, parser)| parser.codec_private())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lost_bytes(&self) -> u64 {
|
// `lost_bytes` uses the trait default (0): the file-backed highway has no
|
||||||
// The file-backed highway has no read-error zero-fill term (resolve
|
// read-error zero-fill term (resolve/mapfile tracks physical read loss
|
||||||
// tracks read loss separately), but the producer's decrypt step can
|
// separately) and the decrypt path no longer reports a decrypt-loss term.
|
||||||
// pass scrambled units through undecrypted — silent loss the demux
|
|
||||||
// drops. Surface that so the mux abort gate sees a partial AACS/CSS
|
|
||||||
// decrypt failure rather than reporting a perfect rip.
|
|
||||||
self.decrypt_loss
|
|
||||||
.as_ref()
|
|
||||||
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+388
-40
@@ -358,12 +358,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
// case must NOT raise a false E7023.
|
// case must NOT raise a false E7023.
|
||||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
.map_err(|e| -> io::Error { e.into() })?;
|
||||||
// Upfront FMTS gate, parallel to the unit-key gate above. With
|
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
|
||||||
// BYPASS_FMTS_KEY this is a no-op and a 2.1 disc's forensic units are
|
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
|
||||||
// concealed as ordinary decrypt loss below; without it, a 2.1 disc
|
// the key-fetch closure and can actually attempt resolution. (An older
|
||||||
// lacking segment keys fails here rather than emitting a holed mux.
|
// upfront blanket-reject gate lived here; it predated the resolver and
|
||||||
disc.ensure_forensic_segments_decryptable(opts.raw)
|
// rejected every 2.1 disc before a source could be tried.)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
|
||||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||||
// by probing the first DECRYPTED access units of the chosen title.
|
// by probing the first DECRYPTED access units of the chosen title.
|
||||||
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
||||||
@@ -391,11 +390,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
|||||||
let title = disc.titles[idx].clone();
|
let title = disc.titles[idx].clone();
|
||||||
let format = disc.content_format;
|
let format = disc.content_format;
|
||||||
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
|
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
|
||||||
// sequential read from fast storage, no bad sectors. Measured
|
// sequential read from fast storage, no bad sectors. Empirically
|
||||||
// optimum on the rip1 testbed; bumping to 16384 sectors (32 MiB)
|
// optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
|
||||||
// regressed (more cache pressure, longer per-batch latency starves
|
// pressure, longer per-batch latency starves the consumer between
|
||||||
// the consumer between iterations). Physical drives keep smaller
|
// iterations). Physical drives keep smaller batches for adaptive
|
||||||
// batches for adaptive error handling.
|
// error handling.
|
||||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||||
|
|
||||||
// Pass `DecryptKeys::None` to the decrypt decorator when
|
// Pass `DecryptKeys::None` to the decrypt decorator when
|
||||||
@@ -600,6 +599,350 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
|||||||
(parsers, pid_to_track, ts, ps)
|
(parsers, pid_to_track, ts, ps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title
|
||||||
|
/// before muxing. It decides which held unit key decrypts each of the title's
|
||||||
|
/// LBA ranges and secures any key the pool is missing through the app's
|
||||||
|
/// configured source (`fetch`) up front, never reactively per unit at mux time.
|
||||||
|
///
|
||||||
|
/// This is what ends the key-server storm. The old mux decrypted a unit, checked
|
||||||
|
/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad
|
||||||
|
/// content never reaches that bar — re-asked the key service for a key it already
|
||||||
|
/// held. There is no per-unit byte pattern that separates "correctly decrypted
|
||||||
|
/// but authored-bad" from "still encrypted", so that check is unanswerable. Here
|
||||||
|
/// we answer the answerable question instead: which CPS unit does each LBA range
|
||||||
|
/// belong to, decided by the disc's key structure (validated once against real
|
||||||
|
/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just
|
||||||
|
/// decrypts each unit with its mapped key and trusts it.
|
||||||
|
///
|
||||||
|
/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the
|
||||||
|
/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to
|
||||||
|
/// the key that opens a real sample from it; a bad-content extent no sample can
|
||||||
|
/// classify inherits its predecessor's key (contiguity). FMTS segment mapping
|
||||||
|
/// layers onto the same structure.
|
||||||
|
/// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the
|
||||||
|
/// disc carries `IndividualSegment.tbl` AND a key source is configured; `None`
|
||||||
|
/// otherwise (not FMTS, or no source — the caller's base-Unit-Key path then
|
||||||
|
/// applies, and the forensic units garble and are dropped by the demux).
|
||||||
|
///
|
||||||
|
/// The forensic segments each carry an **index** tag (1..32) selecting one of 32
|
||||||
|
/// **index keys** the base Unit Key cannot open (see [`crate::aacs::segment`]).
|
||||||
|
/// This resolves those keys up front from the configured source — sending, per
|
||||||
|
/// index, a batch of same-index units the service maps to that index's key — adds
|
||||||
|
/// them to the pool, and builds a per-segment LBA→key map. Applying a segment's
|
||||||
|
/// key over its whole range decodes the ~40 units of that index's interleave half
|
||||||
|
/// to clean TS and garbles the other ~40 (the alternate half), which the demux
|
||||||
|
/// then drops, yielding one coherent stream. The base Unit Key covers everything
|
||||||
|
/// outside a segment.
|
||||||
|
fn resolve_fmts_key_map(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
title: &DiscTitle,
|
||||||
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
|
fetch: Option<&crate::sector::KeyFetch>,
|
||||||
|
_format: ContentFormat,
|
||||||
|
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
|
||||||
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
|
use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments};
|
||||||
|
|
||||||
|
// Off by default: while `BYPASS_FMTS_KEY` is set, forensic decode is disabled —
|
||||||
|
// no segment table read, no key-service traffic — and the caller's base-Unit-Key
|
||||||
|
// path applies (the forensic units garble and the demux drops them, the shipped
|
||||||
|
// behaviour). Flip `BYPASS_FMTS_KEY` to false to activate forensic decode once
|
||||||
|
// the index-key resolution is validated end to end.
|
||||||
|
if crate::aacs::segment::BYPASS_FMTS_KEY {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the segment map; absent → not an FMTS disc.
|
||||||
|
let Ok(udf) = crate::udf::read_filesystem(reader) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Ok(tbl) = udf.read_file(reader, "/AACS/IndividualSegment.tbl") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(segments) = parse_individual_segments(&tbl) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if segments.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// This IS an FMTS disc, so the forensic index keys are REQUIRED — exactly like
|
||||||
|
// a Unit Key. Without a configured key source we cannot obtain them, so we
|
||||||
|
// cannot produce a complete rip: fail loud rather than silently drop the
|
||||||
|
// forensic segments. (The caller may still choose `--raw`, which never reaches
|
||||||
|
// this path.)
|
||||||
|
let Some(fetch) = fetch else {
|
||||||
|
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||||
|
};
|
||||||
|
tracing::info!(target: "freemkv::keysource", segments = segments.len(), extents = title.extents.len(), "fmts: begin index-key resolution");
|
||||||
|
|
||||||
|
// Read aligned unit `index` of `seg`: clip byte `start_spn*192 + index*6144`.
|
||||||
|
let read_unit =
|
||||||
|
|reader: &mut dyn SectorSource, seg: &crate::aacs::segment::Segment, index: usize| {
|
||||||
|
let clip_byte = seg.start_spn as u64 * 192 + index as u64 * ALIGNED_UNIT_LEN as u64;
|
||||||
|
let lba = clip_byte_to_lba(&title.extents, clip_byte)?;
|
||||||
|
let mut c = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
|
reader.read_sectors(lba, 3, &mut c, false).ok()?;
|
||||||
|
Some(c)
|
||||||
|
};
|
||||||
|
// ── ONE forensic query. The key service returns ALL forensic index keys for the
|
||||||
|
// disc in a single response, ORDERED by index (array element i = index i+1).
|
||||||
|
// So send one clean single-variant batch (a segment's even-phase units) and
|
||||||
|
// read the whole set back — no per-index probing, no phase measurement, no
|
||||||
|
// decrypt-and-check: the array position IS the index. The first readable
|
||||||
|
// segment whose batch yields the full set wins; a short (e.g. 1-key,
|
||||||
|
// base-UK-shaped) response means that batch wasn't forensic (a wrong
|
||||||
|
// feature-title mapping), so try the next segment.
|
||||||
|
//
|
||||||
|
// ANCHOR RULE: the query MUST sample an INDEX-1 segment. The key service only
|
||||||
|
// returns the full set for the canonical anchor sample (a unit that decrypts
|
||||||
|
// under the index-1 key); a batch from any other forensic index is rejected
|
||||||
|
// (a base-UK-shaped miss). The `index == 1` filter guarantees every batch we
|
||||||
|
// send is an anchor — and the forensic tag cycles 1..32 in file order, so
|
||||||
|
// ~1-in-32 segments qualify (~25 across the feature), leaving ample read-fault
|
||||||
|
// fallback within the `MAX_ANCHOR_ATTEMPTS` budget. ─────────────────────────
|
||||||
|
const N_INDEX: usize = 32;
|
||||||
|
// Each forensic batch carries the server's minimum-samples count (the same
|
||||||
|
// disambiguation floor the online source enforces), drawn as even-phase units
|
||||||
|
// to land one clean variant half.
|
||||||
|
const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS;
|
||||||
|
// Read-fault fallback budget: how many INDEX-1 (anchor) segments to attempt
|
||||||
|
// before giving up. Only matters when the leading anchor segments are
|
||||||
|
// unreadable; each attempt is one server round-trip, so it is bounded.
|
||||||
|
const MAX_ANCHOR_ATTEMPTS: usize = 16;
|
||||||
|
let mut index_keys: Vec<[u8; 16]> = Vec::new();
|
||||||
|
for seg in segments
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.index == 1)
|
||||||
|
.take(MAX_ANCHOR_ATTEMPTS)
|
||||||
|
{
|
||||||
|
let mut batch: Vec<Vec<u8>> = Vec::new();
|
||||||
|
for p in 0..BATCH_UNITS {
|
||||||
|
if let Some(c) = read_unit(reader, seg, p * 2) {
|
||||||
|
batch.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if batch.len() < BATCH_UNITS {
|
||||||
|
continue; // read fault / short tail
|
||||||
|
}
|
||||||
|
let fresh = fetch(&batch);
|
||||||
|
if fresh.len() >= N_INDEX {
|
||||||
|
index_keys = fresh;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(target: "freemkv::keysource", held = index_keys.len(), need = N_INDEX, "fmts: collection done");
|
||||||
|
// The full set is required. Anything short holes the rip — fail loud like a
|
||||||
|
// missing Unit Key rather than emit forensic-holed output.
|
||||||
|
if index_keys.len() < N_INDEX {
|
||||||
|
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map array position → forensic index (element i = index i+1); add each key to
|
||||||
|
// the pool and remember its slot by tag. `base_idx` is the Unit Key (slot 0).
|
||||||
|
let base_idx = 0usize;
|
||||||
|
let mut tag_slot: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
|
||||||
|
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||||
|
for (i, k) in index_keys.iter().take(N_INDEX).enumerate() {
|
||||||
|
let tag = (i + 1) as u16;
|
||||||
|
let slot = match unit_keys.iter().position(|(_, h)| h == k) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
let s = unit_keys.len();
|
||||||
|
// CPS-unit id is cosmetic for the mapped decrypt (it indexes by
|
||||||
|
// slot); use a high, distinct number for the forensic keys.
|
||||||
|
unit_keys.push((1000 + s as u32, *k));
|
||||||
|
s
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tag_slot.insert(tag, slot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build the per-segment LBA ranges directly from the tag. Each segment is
|
||||||
|
// decoded from its TAG half: the map routes the segment's whole span to its
|
||||||
|
// tag's key; the tag key opens the tag half wherever it interleaves, and the
|
||||||
|
// un-served version-B half — decrypted with that (for it, wrong) key —
|
||||||
|
// garbles and the demux drops it, leaving one clean variant per span. No
|
||||||
|
// re-read and no phase needed here: byte-5 `seg.index` selects the key. A
|
||||||
|
// segment whose tag is somehow absent (cannot happen with all 32 held) or
|
||||||
|
// that straddles an extent boundary is left unmapped and tallied. ─────────
|
||||||
|
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(segments.len());
|
||||||
|
let mut unresolved = 0usize;
|
||||||
|
for seg in &segments {
|
||||||
|
let Some(&slot) = tag_slot.get(&seg.index) else {
|
||||||
|
unresolved += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let start_byte = seg.start_spn as u64 * 192;
|
||||||
|
let end_byte = (seg.end_spn as u64 + 1) * 192;
|
||||||
|
let (Some(a), Some(b)) = (
|
||||||
|
clip_byte_to_lba(&title.extents, start_byte),
|
||||||
|
clip_byte_to_lba(&title.extents, end_byte - 1),
|
||||||
|
) else {
|
||||||
|
unresolved += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Only emit a contiguous within-extent range (segments are ~480 KB; a rare
|
||||||
|
// extent-straddle is left unresolved rather than given a wrong span).
|
||||||
|
if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 {
|
||||||
|
ranges.push((a, b + 1, slot));
|
||||||
|
} else {
|
||||||
|
unresolved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every forensic segment must map to an index key. Any that did not is a hole
|
||||||
|
// in the rip — with the full 32-key set in hand this should never happen, so
|
||||||
|
// treat it as a hard failure (a read fault or an unexpected on-disc layout)
|
||||||
|
// rather than silently emitting a segment the base Unit Key only garbles.
|
||||||
|
if unresolved != 0 {
|
||||||
|
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(crate::decrypt::AacsKeyMap::from_ranges(
|
||||||
|
ranges, base_idx,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_mux_key_map(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
title: &DiscTitle,
|
||||||
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
|
fetch: Option<&crate::sector::KeyFetch>,
|
||||||
|
format: ContentFormat,
|
||||||
|
) -> io::Result<crate::decrypt::AacsKeyMap> {
|
||||||
|
use crate::aacs::content::{
|
||||||
|
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool_len = match keys {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(),
|
||||||
|
// CSS / clear: no AACS map (the decorator's map path is AACS-only).
|
||||||
|
_ => return Ok(crate::decrypt::AacsKeyMap::single(0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Secure the disc's key up front from the configured source when the pool is
|
||||||
|
// empty (a genuine "no key yet" — e.g. keydb miss, online-only disc).
|
||||||
|
if pool_len == 0 {
|
||||||
|
if let Some(f) = fetch {
|
||||||
|
let samples = crate::keysource::read_encrypted_units(reader, title, 8);
|
||||||
|
if !samples.is_empty() {
|
||||||
|
let fresh = f(&samples);
|
||||||
|
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||||
|
for k in fresh {
|
||||||
|
if !unit_keys.iter().any(|(_, h)| *h == k) {
|
||||||
|
let i = unit_keys.len() as u32;
|
||||||
|
unit_keys.push((i, k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the pool is STILL empty, this AACS-encrypted title needs a Unit Key we
|
||||||
|
// could not obtain from any source. That is the same situation as any known
|
||||||
|
// key we don't hold — fail loud at resolve time rather than deferring an
|
||||||
|
// opaque decrypt error (or, worse, emitting ciphertext) at mux time.
|
||||||
|
let empty = matches!(
|
||||||
|
keys,
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } if unit_keys.is_empty()
|
||||||
|
);
|
||||||
|
if empty {
|
||||||
|
return Err(crate::error::Error::DecryptFailed.into());
|
||||||
|
}
|
||||||
|
return Ok(crate::decrypt::AacsKeyMap::single(0));
|
||||||
|
}
|
||||||
|
// FMTS (AACS 2.1): if the disc carries `IndividualSegment.tbl`, the forensic
|
||||||
|
// segments need per-index keys the base Unit Key can't open. Resolve them up
|
||||||
|
// front from the configured source and build a per-segment map. Returns `None`
|
||||||
|
// when the disc is not FMTS, or no key source is configured (then the base UK
|
||||||
|
// path below applies and the forensic units garble → demux drops them).
|
||||||
|
if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format)? {
|
||||||
|
return Ok(map);
|
||||||
|
}
|
||||||
|
if pool_len == 1 {
|
||||||
|
// One CPS unit → one key everywhere. No structural walk, no sampling.
|
||||||
|
return Ok(crate::decrypt::AacsKeyMap::single(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-CPS: read a spread of real encrypted units from each extent and pick
|
||||||
|
// the held key that opens one (the `is_clean` proof is sound HERE — samples
|
||||||
|
// are guaranteed real content, not the authored-bad units that trip the mux).
|
||||||
|
let sample_units = |reader: &mut dyn SectorSource, start: u32, sectors: u32| -> Vec<Vec<u8>> {
|
||||||
|
let total_units = sectors / ALIGNED_UNIT_SECTORS;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if total_units == 0 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const PROBES: u32 = 8;
|
||||||
|
for p in 1..=PROBES {
|
||||||
|
let unit = ((total_units as u64 * p as u64) / (PROBES as u64 + 1)) as u32;
|
||||||
|
if unit >= total_units {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let lba = start.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
|
||||||
|
let mut buf = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
|
if reader
|
||||||
|
.read_sectors(lba, ALIGNED_UNIT_SECTORS as u16, &mut buf, false)
|
||||||
|
.is_ok()
|
||||||
|
&& aacs_unit_encrypted(&buf, format)
|
||||||
|
{
|
||||||
|
out.push(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let pick = |samples: &[Vec<u8>], pool: &[(u32, [u8; 16])]| -> Option<usize> {
|
||||||
|
for (i, (_, k)) in pool.iter().enumerate() {
|
||||||
|
if samples.iter().any(|s| {
|
||||||
|
let mut u = s.clone();
|
||||||
|
decrypt_unit(&mut u, k);
|
||||||
|
is_clean(&u, format)
|
||||||
|
}) {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
|
||||||
|
let mut last_idx = 0usize;
|
||||||
|
for ext in &title.extents {
|
||||||
|
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
|
||||||
|
// Snapshot the current pool for the pure `pick` closure.
|
||||||
|
let pool: Vec<(u32, [u8; 16])> = match keys {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(),
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
let mut idx = pick(&samples, &pool);
|
||||||
|
if idx.is_none() {
|
||||||
|
if let Some(f) = fetch {
|
||||||
|
if !samples.is_empty() {
|
||||||
|
let fresh = f(&samples);
|
||||||
|
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||||
|
for k in fresh {
|
||||||
|
if !unit_keys.iter().any(|(_, h)| *h == k) {
|
||||||
|
let i = unit_keys.len() as u32;
|
||||||
|
unit_keys.push((i, k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx = pick(&samples, unit_keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A bad-content extent no sample can classify inherits its predecessor's
|
||||||
|
// key (CPS boundaries are contiguous, so the neighbour is almost always
|
||||||
|
// right); this never storms and never mis-fails a decryptable disc.
|
||||||
|
let idx = idx.unwrap_or(last_idx);
|
||||||
|
last_idx = idx;
|
||||||
|
ranges.push((
|
||||||
|
ext.start_lba,
|
||||||
|
ext.start_lba.saturating_add(ext.sector_count),
|
||||||
|
idx,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(crate::decrypt::AacsKeyMap::from_ranges(ranges, 0))
|
||||||
|
}
|
||||||
|
|
||||||
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
|
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
|
||||||
/// a `FileSectorSource`-backed reader. Returns the resulting
|
/// a `FileSectorSource`-backed reader. Returns the resulting
|
||||||
/// `PipelinedPesStream`.
|
/// `PipelinedPesStream`.
|
||||||
@@ -618,17 +961,16 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
|||||||
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
||||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
/// pipeline stops at the next boundary. `None` disables cancellation.
|
||||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||||
/// - `fetch`: optional fresh-key-on-failure callback (see
|
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
|
||||||
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
|
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
|
||||||
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
|
/// callback: the map decides the key for every LBA before the read loop starts.
|
||||||
/// returns. `None` keeps the prior behaviour (the unit is counted as loss).
|
|
||||||
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
|
||||||
// point; grouping them into a struct would only move the same fields around.
|
// point; grouping them into a struct would only move the same fields around.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||||
reader: S,
|
mut reader: S,
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
keys: crate::decrypt::DecryptKeys,
|
mut keys: crate::decrypt::DecryptKeys,
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
format: ContentFormat,
|
format: ContentFormat,
|
||||||
halt: Option<crate::halt::Halt>,
|
halt: Option<crate::halt::Halt>,
|
||||||
@@ -645,27 +987,30 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
||||||
_ => 1,
|
_ => 1,
|
||||||
};
|
};
|
||||||
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
|
// MUX path: read > decrypt > mux. Resolve the proactive AACS key map UP FRONT
|
||||||
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
|
// — one key per CPS unit / segment, secured from the configured source and
|
||||||
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
|
// recorded against the LBA ranges it covers. The mux then decrypts each unit
|
||||||
// decorator; only this mux pipeline opts in.
|
// with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive
|
||||||
|
// key-fetch, no key-server storm. A unit that decrypts to broken TS is the
|
||||||
|
// muxer's problem, exactly as before. AACS-only; CSS self-cracks per region.
|
||||||
|
let key_map =
|
||||||
|
match &keys {
|
||||||
|
crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
|
||||||
|
resolve_mux_key_map(&mut reader, &title, &mut keys, fetch.as_ref(), format)?,
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let mut decrypting =
|
let mut decrypting =
|
||||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
|
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||||
.tolerate_decrypt_loss();
|
if let Some(map) = key_map {
|
||||||
// Install the fresh-key-on-failure callback (if any) so a unit no held key
|
decrypting = decrypting.with_key_map(map);
|
||||||
// decrypts is re-tried via the application's key source before being counted
|
|
||||||
// as loss. An AACS 2.1 forensic-segment unit that no key opens is just an
|
|
||||||
// undecryptable unit like any other: concealed and counted as decrypt loss —
|
|
||||||
// a loss is a loss, no FMTS special casing.
|
|
||||||
if let Some(cb) = fetch {
|
|
||||||
decrypting = decrypting.with_key_fetch(cb);
|
|
||||||
}
|
}
|
||||||
// Grab the loss counters before the decorator is moved into the producer
|
// Loss-counter handle. The mux does NOT tally decrypt-quality misses: a
|
||||||
// thread. It tracks bytes of scrambled AACS units no key could decrypt —
|
// broken-TS unit is the muxer's concern, and a missing key is an up-front
|
||||||
// silent loss the demux drops; the consuming stream surfaces it through
|
// resolve failure — indistinguishable from bad authoring at this seam, so
|
||||||
// `lost_bytes()` so the mux abort gate sees a partial decrypt failure rather
|
// counting it would false-abort a bad-encoded-but-decryptable disc. A genuine
|
||||||
// than a clean rip. Forensic (2.1) undecryptable units land here too.
|
// can't-decrypt surfaces as `Err`; `lost_bytes()` reflects physical read loss
|
||||||
let decrypt_loss = decrypting.decrypt_loss();
|
// only (there is no decrypt-loss term to fold in).
|
||||||
|
|
||||||
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
|
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
|
||||||
// the reader, probe the feature head through the (plaintext) decrypting
|
// the reader, probe the feature head through the (plaintext) decrypting
|
||||||
@@ -691,10 +1036,13 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
let (demux_thread, demux_rx) =
|
let (demux_thread, demux_rx) =
|
||||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
|
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
|
||||||
.map_err(|e| -> io::Error { e.into() })?;
|
.map_err(|e| -> io::Error { e.into() })?;
|
||||||
Ok(
|
Ok(PipelinedPesStream::new(
|
||||||
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
|
demux_thread,
|
||||||
.with_decrypt_loss(decrypt_loss),
|
demux_rx,
|
||||||
)
|
title,
|
||||||
|
parsers,
|
||||||
|
pid_to_track,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
|
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
|
||||||
|
|||||||
+25
-21
@@ -12,9 +12,11 @@ use crate::consts::TS_PACKET_BYTES;
|
|||||||
|
|
||||||
/// TS sync byte.
|
/// TS sync byte.
|
||||||
const SYNC_BYTE: u8 = 0x47;
|
const SYNC_BYTE: u8 = 0x47;
|
||||||
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
|
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream. The demuxer
|
||||||
/// concealment fill emits null packets on this PID, tagged with an
|
/// still recognises a `0x1FFF` packet with an adaptation-field
|
||||||
/// adaptation-field discontinuity_indicator to signal a concealed gap.
|
/// discontinuity_indicator as a concealed-gap loss signal, but the in-tree WRITER
|
||||||
|
/// that emitted these (the removed NULL-TS concealment fill) is gone — the mux no
|
||||||
|
/// longer conceals; only externally-authored markers reach this path now.
|
||||||
const NULL_PID: u16 = 0x1FFF;
|
const NULL_PID: u16 = 0x1FFF;
|
||||||
|
|
||||||
/// A reassembled PES packet with timestamp info.
|
/// A reassembled PES packet with timestamp info.
|
||||||
@@ -34,8 +36,10 @@ pub struct PesPacket {
|
|||||||
pub source: Option<crate::pes::SourcePos>,
|
pub source: Option<crate::pes::SourcePos>,
|
||||||
/// True when one or more packets for this stream were lost before this PES —
|
/// True when one or more packets for this stream were lost before this PES —
|
||||||
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
|
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
|
||||||
/// a tracked PID, or the CC-independent concealment marker the mux emits when
|
/// a tracked PID, or a CC-independent NULL-TS concealment marker (P3/B1). NOTE:
|
||||||
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
|
/// the mux no longer emits such markers (the concealment writer was removed);
|
||||||
|
/// this now flags only real discontinuities and externally-authored markers.
|
||||||
|
/// This PES is
|
||||||
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
|
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
|
||||||
/// truncated partial and flags the next complete PES; a loss landing on a PES
|
/// truncated partial and flags the next complete PES; a loss landing on a PES
|
||||||
/// boundary flags the PES STARTING after it (never the one just flushed). So
|
/// boundary flags the PES STARTING after it (never the one just flushed). So
|
||||||
@@ -200,7 +204,7 @@ impl PesAssembler {
|
|||||||
/// BD Transport Stream demuxer.
|
/// BD Transport Stream demuxer.
|
||||||
pub struct TsDemuxer {
|
pub struct TsDemuxer {
|
||||||
assemblers: Vec<PesAssembler>,
|
assemblers: Vec<PesAssembler>,
|
||||||
pid_index: Vec<i16>, // PID → index into assemblers, -1 = not tracked
|
pid_index: Vec<i32>, // PID → index into assemblers, -1 = not tracked
|
||||||
remainder: Vec<u8>, // leftover bytes from previous feed() call
|
remainder: Vec<u8>, // leftover bytes from previous feed() call
|
||||||
/// Absolute source byte offset of the NEXT byte to be fed — the running
|
/// Absolute source byte offset of the NEXT byte to be fed — the running
|
||||||
/// base that turns an in-buffer packet offset into a source position.
|
/// base that turns an in-buffer packet offset into a source position.
|
||||||
@@ -224,20 +228,17 @@ impl TsDemuxer {
|
|||||||
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
||||||
/// valid (wholly-unused) table.
|
/// valid (wholly-unused) table.
|
||||||
pub fn new(pids: &[u16]) -> Self {
|
pub fn new(pids: &[u16]) -> Self {
|
||||||
// The PID→assembler index is stored as i16 (-1 = untracked), so a
|
// The PID→assembler index is stored as i32 (-1 = untracked). PIDs are
|
||||||
// 32768th+ tracked PID would truncate to a negative value and be
|
// u16 (≤ 65535) and the assembler index `i` is bounded by the number of
|
||||||
// silently treated as untracked. Callers pass a handful of PIDs
|
// distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can
|
||||||
// (BD-TS has at most ~8192), so this is a programmer-error guard.
|
// never truncate to a negative value and be mis-read as untracked —
|
||||||
debug_assert!(
|
// unlike an i16 table, this is safe in RELEASE, not just under debug.
|
||||||
pids.len() <= i16::MAX as usize,
|
|
||||||
"TsDemuxer: too many PIDs for an i16 index table"
|
|
||||||
);
|
|
||||||
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
||||||
let table_size = (max_pid + 1).max(8192);
|
let table_size = (max_pid + 1).max(8192);
|
||||||
let mut pid_index = vec![-1i16; table_size];
|
let mut pid_index = vec![-1i32; table_size];
|
||||||
let mut assemblers = Vec::with_capacity(pids.len());
|
let mut assemblers = Vec::with_capacity(pids.len());
|
||||||
for (i, &pid) in pids.iter().enumerate() {
|
for (i, &pid) in pids.iter().enumerate() {
|
||||||
pid_index[pid as usize] = i as i16;
|
pid_index[pid as usize] = i as i32;
|
||||||
assemblers.push(PesAssembler::new(pid));
|
assemblers.push(PesAssembler::new(pid));
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
@@ -368,10 +369,13 @@ impl TsDemuxer {
|
|||||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||||
let adaptation = (ts[3] >> 4) & 0x03;
|
let adaptation = (ts[3] >> 4) & 0x03;
|
||||||
|
|
||||||
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
|
// P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an
|
||||||
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
|
// adaptation-field discontinuity_indicator. NOTE: the in-tree writer that
|
||||||
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
|
// laid these down on an undecryptable unit was removed with the pure-decrypt
|
||||||
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
|
// passthrough change (the mux no longer conceals), so this recognition now
|
||||||
|
// only fires on externally-authored markers — a candidate for removal with
|
||||||
|
// the rest of the retired concealment path.
|
||||||
|
// As a loss signal it is CC-INDEPENDENT — unlike a tracked PID's 4-bit
|
||||||
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
|
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
|
||||||
// an exact multiple of 16 packets and a loss at the very start of a PID
|
// an exact multiple of 16 packets and a loss at the very start of a PID
|
||||||
// (no prior CC to diff against). The decrypt layer cannot know which
|
// (no prior CC to diff against). The decrypt layer cannot know which
|
||||||
@@ -1057,7 +1061,7 @@ mod tests {
|
|||||||
|
|
||||||
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
|
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
|
||||||
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
|
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
|
||||||
/// shape `fill_null_ts_unit` writes for every packet of a concealed unit).
|
/// shape of a concealed-unit packet).
|
||||||
fn null_marker_packet() -> Vec<u8> {
|
fn null_marker_packet() -> Vec<u8> {
|
||||||
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
||||||
pkt[4] = SYNC_BYTE; // 0x47
|
pkt[4] = SYNC_BYTE; // 0x47
|
||||||
|
|||||||
+298
-832
File diff suppressed because it is too large
Load Diff
+62
-12
@@ -129,6 +129,10 @@ impl SectorSource for Box<dyn SectorSource> {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
(**self).set_speed(kbs)
|
(**self).set_speed(kbs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_unit_base(&mut self, lba: u32) {
|
||||||
|
(**self).set_unit_base(lba)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SectorSource for &mut (dyn SectorSource + '_) {
|
impl SectorSource for &mut (dyn SectorSource + '_) {
|
||||||
@@ -160,6 +164,10 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
(**self).set_speed(kbs)
|
(**self).set_speed(kbs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_unit_base(&mut self, lba: u32) {
|
||||||
|
(**self).set_unit_base(lba)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write 2048-byte sectors to a disc image or composed sink.
|
/// Write 2048-byte sectors to a disc image or composed sink.
|
||||||
@@ -181,7 +189,7 @@ pub trait SectorSink: Send {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub use crate::io::file_sector_source::FileSectorSource;
|
pub use crate::io::file_sector_source::FileSectorSource;
|
||||||
pub use decrypting::{DECRYPT_VERIFY_READ, DecryptingSectorSource, KeyFetch};
|
pub use decrypting::{DecryptingSectorSource, KeyFetch};
|
||||||
pub use file::FileSectorSink;
|
pub use file::FileSectorSink;
|
||||||
pub use prefetched::PrefetchedSectorSource;
|
pub use prefetched::PrefetchedSectorSource;
|
||||||
|
|
||||||
@@ -198,23 +206,33 @@ mod tests {
|
|||||||
capacity: u32,
|
capacity: u32,
|
||||||
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||||
speeds: Arc<Mutex<Vec<u16>>>,
|
speeds: Arc<Mutex<Vec<u16>>>,
|
||||||
|
unit_bases: Arc<Mutex<Vec<u32>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `Spy` under test plus the handles recording its reads and speed sets.
|
/// A `Spy` under test plus the handles recording its reads, speed sets,
|
||||||
type SpyHarness = (Spy, Arc<Mutex<Vec<(u32, u16, bool)>>>, Arc<Mutex<Vec<u16>>>);
|
/// and unit-base sets.
|
||||||
|
type SpyHarness = (
|
||||||
|
Spy,
|
||||||
|
Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||||
|
Arc<Mutex<Vec<u16>>>,
|
||||||
|
Arc<Mutex<Vec<u32>>>,
|
||||||
|
);
|
||||||
|
|
||||||
impl Spy {
|
impl Spy {
|
||||||
fn new(capacity: u32) -> SpyHarness {
|
fn new(capacity: u32) -> SpyHarness {
|
||||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||||
let speeds = Arc::new(Mutex::new(Vec::new()));
|
let speeds = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let unit_bases = Arc::new(Mutex::new(Vec::new()));
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
capacity,
|
capacity,
|
||||||
reads: reads.clone(),
|
reads: reads.clone(),
|
||||||
speeds: speeds.clone(),
|
speeds: speeds.clone(),
|
||||||
|
unit_bases: unit_bases.clone(),
|
||||||
},
|
},
|
||||||
reads,
|
reads,
|
||||||
speeds,
|
speeds,
|
||||||
|
unit_bases,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +256,16 @@ mod tests {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
self.speeds.lock().unwrap().push(kbs);
|
self.speeds.lock().unwrap().push(kbs);
|
||||||
}
|
}
|
||||||
|
fn set_unit_base(&mut self, lba: u32) {
|
||||||
|
self.unit_bases.lock().unwrap().push(lba);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `set_unit_base` through a generic `S: SectorSource` bound — this is
|
||||||
|
/// the path that actually exercises the `Box<dyn>` / `&mut dyn` FORWARDING
|
||||||
|
/// impls (a direct call on a `dyn` value dispatches via the vtable instead).
|
||||||
|
fn set_unit_base_generic<S: SectorSource>(mut s: S, base: u32) {
|
||||||
|
s.set_unit_base(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default `capacity_sectors` is 0 (unknown). Grounding: trait
|
/// The default `capacity_sectors` is 0 (unknown). Grounding: trait
|
||||||
@@ -286,7 +314,7 @@ mod tests {
|
|||||||
/// Box<dyn SectorSource>` forwarding bodies.
|
/// Box<dyn SectorSource>` forwarding bodies.
|
||||||
#[test]
|
#[test]
|
||||||
fn boxed_dyn_forwards_all_methods() {
|
fn boxed_dyn_forwards_all_methods() {
|
||||||
let (spy, reads, speeds) = Spy::new(777);
|
let (spy, reads, speeds, unit_bases) = Spy::new(777);
|
||||||
let mut boxed: Box<dyn SectorSource> = Box::new(spy);
|
let mut boxed: Box<dyn SectorSource> = Box::new(spy);
|
||||||
|
|
||||||
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
|
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
|
||||||
@@ -308,24 +336,46 @@ mod tests {
|
|||||||
vec![5400],
|
vec![5400],
|
||||||
"set_speed must forward"
|
"set_speed must forward"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// set_unit_base through the generic bound exercises the forwarding impl
|
||||||
|
// (a direct `boxed.set_unit_base()` would vtable-dispatch instead). A
|
||||||
|
// missing forwarding body would silently no-op and record nothing.
|
||||||
|
set_unit_base_generic(boxed, 64);
|
||||||
|
assert_eq!(
|
||||||
|
*unit_bases.lock().unwrap(),
|
||||||
|
vec![64],
|
||||||
|
"set_unit_base must forward through Box<dyn>"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `&mut dyn SectorSource` must likewise forward all three methods.
|
/// `&mut dyn SectorSource` must likewise forward every method.
|
||||||
/// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`.
|
/// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`.
|
||||||
#[test]
|
#[test]
|
||||||
fn mut_ref_dyn_forwards_all_methods() {
|
fn mut_ref_dyn_forwards_all_methods() {
|
||||||
let (mut spy, reads, speeds) = Spy::new(123);
|
let (mut spy, reads, speeds, unit_bases) = Spy::new(123);
|
||||||
let r: &mut dyn SectorSource = &mut spy;
|
|
||||||
|
|
||||||
assert_eq!(r.capacity_sectors(), 123);
|
{
|
||||||
|
let r: &mut dyn SectorSource = &mut spy;
|
||||||
|
assert_eq!(r.capacity_sectors(), 123);
|
||||||
|
|
||||||
let mut buf = vec![0u8; 2 * 2048];
|
let mut buf = vec![0u8; 2 * 2048];
|
||||||
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
||||||
assert_eq!(n, 2 * 2048);
|
assert_eq!(n, 2 * 2048);
|
||||||
|
|
||||||
r.set_speed(8800);
|
r.set_speed(8800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass `&mut dyn` as a generic S so the forwarding impl's set_unit_base
|
||||||
|
// is the one under test, not the vtable path.
|
||||||
|
let r2: &mut dyn SectorSource = &mut spy;
|
||||||
|
set_unit_base_generic(r2, 128);
|
||||||
|
|
||||||
assert_eq!(*reads.lock().unwrap(), vec![(7, 2, false)]);
|
assert_eq!(*reads.lock().unwrap(), vec![(7, 2, false)]);
|
||||||
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
|
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
|
||||||
|
assert_eq!(
|
||||||
|
*unit_bases.lock().unwrap(),
|
||||||
|
vec![128],
|
||||||
|
"set_unit_base must forward through &mut dyn"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,11 +252,14 @@ impl PrefetchedSectorSource {
|
|||||||
};
|
};
|
||||||
if bytes <= buf.capacity() {
|
if bytes <= buf.capacity() {
|
||||||
// Re-expose `bytes` without zero-filling pages that
|
// Re-expose `bytes` without zero-filling pages that
|
||||||
// `read_sectors` is about to overwrite. The enclosing
|
// `read_sectors` is about to overwrite. Sound because the
|
||||||
// capacity guard makes the `set_len` provably sound even
|
// enclosing `bytes <= capacity` guard bounds the length,
|
||||||
// if a recycled buffer ever comes back smaller than the
|
// and every byte below `capacity` is physically
|
||||||
// `vec![0u8; batch_bytes]` it was born with.
|
// initialised: buffers are born `vec![0u8; batch_bytes]`
|
||||||
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity");
|
// and only ever grown via `resize(_, 0)`, so a recycled
|
||||||
|
// buffer that came back shorter (consumer `truncate`)
|
||||||
|
// still has initialised backing storage under `set_len`,
|
||||||
|
// which `read_sectors` then overwrites before any read.
|
||||||
unsafe { buf.set_len(bytes) };
|
unsafe { buf.set_len(bytes) };
|
||||||
} else {
|
} else {
|
||||||
buf.resize(bytes, 0);
|
buf.resize(bytes, 0);
|
||||||
|
|||||||
+58
-30
@@ -103,25 +103,30 @@ pub struct RecoverCtx {
|
|||||||
pub prev_dropped: usize,
|
pub prev_dropped: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A recovery: given a read's still-scrambled `buf` and the **generic**
|
/// A recovery: given a read's post-decrypt `target` (pure decrypt leaves the
|
||||||
/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or
|
/// applied-key plaintext), the matching on-disc `ciphertext`, and the **generic**
|
||||||
/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme
|
/// [`DecryptKeys`], make units decrypt (fetch a key into `keys` and retry) and/or
|
||||||
/// — the installed recovery decides what to do with the generic keys, so any
|
/// classify the loss (see [`MissOutcome`]). Decryption itself lives in ONE place
|
||||||
/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different
|
/// (`decrypt_sectors`); a recovery only supplies the missing KEY and re-runs it.
|
||||||
/// [`Recover`] the input stream installs. `FnMut` so per-recovery
|
/// `ciphertext` is separate from `target` because a pure decrypt overwrites the
|
||||||
/// state (the AACS dedup set / call budget) lives in the closure's captures with
|
/// target with plaintext — the key server still needs the original on-disc bytes,
|
||||||
/// no lock; `Send` so it can ride the mux highway's producer thread.
|
/// and the retry re-decrypts from them. The type names NO encryption scheme; any
|
||||||
pub type Recover = Box<dyn FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
|
/// scheme is just a different [`Recover`] the input stream installs. `FnMut` so
|
||||||
|
/// per-recovery state (dedup set / call budget) lives in the closure's captures;
|
||||||
|
/// `Send` so it can ride the mux highway's producer thread.
|
||||||
|
pub type Recover =
|
||||||
|
Box<dyn FnMut(&mut [u8], &[u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
|
||||||
|
|
||||||
/// The AACS key-fetch step used by [`key_fetch`]: gather the
|
/// The AACS key-fetch step used by [`key_fetch`]: gather the units the pool did
|
||||||
/// still-scrambled units, ask `fetch` for keys, add any new ones to the pool and
|
/// NOT open, ask `fetch` for keys, add any new ones to the pool and re-decrypt.
|
||||||
/// re-decrypt. `dry` / `calls` are the caller-owned dedup set and call budget.
|
/// `dry` / `calls` are the caller-owned dedup set and call budget. Returns the
|
||||||
/// Returns the post-retry dropped-byte count.
|
/// post-retry unverified-byte count.
|
||||||
fn aacs_fetch_step(
|
fn aacs_fetch_step(
|
||||||
dry: &mut HashSet<u64>,
|
dry: &mut HashSet<u64>,
|
||||||
calls: &mut usize,
|
calls: &mut usize,
|
||||||
fetch: &KeyFetch,
|
fetch: &KeyFetch,
|
||||||
buf: &mut [u8],
|
target: &mut [u8],
|
||||||
|
ciphertext: &[u8],
|
||||||
keys: &mut DecryptKeys,
|
keys: &mut DecryptKeys,
|
||||||
ctx: &RecoverCtx,
|
ctx: &RecoverCtx,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
@@ -130,14 +135,25 @@ fn aacs_fetch_step(
|
|||||||
return prev_dropped;
|
return prev_dropped;
|
||||||
}
|
}
|
||||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact
|
// Container of this disc's content — travels with the keys; drives the
|
||||||
// on-disc ciphertext no held key could open. A trailing partial unit
|
// encrypted-flag / structure check below (TS vs PS).
|
||||||
|
let format = match &*keys {
|
||||||
|
DecryptKeys::Aacs { format, .. } => *format,
|
||||||
|
_ => crate::disc::ContentFormat::BdTs,
|
||||||
|
};
|
||||||
|
// Gather up to MAX_FETCH_SAMPLES units the current pool did NOT open. Detect
|
||||||
|
// them on the post-decrypt TARGET (a failed unit stays TS-destroyed; an opened
|
||||||
|
// one is now clean TS and is skipped), but SAMPLE the matching on-disc
|
||||||
|
// `ciphertext` — the exact bytes the key server needs. A trailing partial unit
|
||||||
// (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is
|
// (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is
|
||||||
// correct.
|
// correct.
|
||||||
let mut samples: Vec<Vec<u8>> = Vec::new();
|
let mut samples: Vec<Vec<u8>> = Vec::new();
|
||||||
for chunk in buf.chunks_exact(unit_len) {
|
for (t, c) in target
|
||||||
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
|
.chunks_exact(unit_len)
|
||||||
samples.push(chunk.to_vec());
|
.zip(ciphertext.chunks_exact(unit_len))
|
||||||
|
{
|
||||||
|
if crate::aacs::content::aacs_unit_needs_decrypt(t, format) {
|
||||||
|
samples.push(c.to_vec());
|
||||||
if samples.len() >= MAX_FETCH_SAMPLES {
|
if samples.len() >= MAX_FETCH_SAMPLES {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -172,10 +188,13 @@ fn aacs_fetch_step(
|
|||||||
dry.extend(fps);
|
dry.extend(fps);
|
||||||
return prev_dropped;
|
return prev_dropped;
|
||||||
}
|
}
|
||||||
// Retry now that the pool has grown; a unit that still won't decrypt is
|
// Retry now that the pool has grown. Reset the target to the on-disc
|
||||||
// genuine loss. A retry error must not mask the original count.
|
// ciphertext first (a pure decrypt already overwrote it with the failed
|
||||||
|
// plaintext), then re-run the ONE decrypt. A unit that still won't reach clean
|
||||||
|
// TS stays unverified; a retry error must not mask the original count.
|
||||||
|
target.copy_from_slice(ciphertext);
|
||||||
redecrypt(
|
redecrypt(
|
||||||
buf,
|
target,
|
||||||
keys,
|
keys,
|
||||||
ctx.unit_key_idx,
|
ctx.unit_key_idx,
|
||||||
ctx.lba,
|
ctx.lba,
|
||||||
@@ -187,7 +206,7 @@ fn aacs_fetch_step(
|
|||||||
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
|
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
|
||||||
/// caller that wants an explicit "give up" recovery has one.
|
/// caller that wants an explicit "give up" recovery has one.
|
||||||
pub fn none() -> Recover {
|
pub fn none() -> Recover {
|
||||||
Box::new(|_buf, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
|
Box::new(|_target, _ciphertext, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
|
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
|
||||||
@@ -195,9 +214,9 @@ pub fn none() -> Recover {
|
|||||||
pub fn key_fetch(fetch: KeyFetch) -> Recover {
|
pub fn key_fetch(fetch: KeyFetch) -> Recover {
|
||||||
let mut dry: HashSet<u64> = HashSet::new();
|
let mut dry: HashSet<u64> = HashSet::new();
|
||||||
let mut calls: usize = 0;
|
let mut calls: usize = 0;
|
||||||
Box::new(move |buf, keys, ctx| {
|
Box::new(move |target, ciphertext, keys, ctx| {
|
||||||
MissOutcome::loss(aacs_fetch_step(
|
MissOutcome::loss(aacs_fetch_step(
|
||||||
&mut dry, &mut calls, &fetch, buf, keys, ctx,
|
&mut dry, &mut calls, &fetch, target, ciphertext, keys, ctx,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -243,8 +262,10 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let out = r(&mut buf, &mut keys, &ctx(0, 6144));
|
let cipher = buf.clone();
|
||||||
|
let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144));
|
||||||
assert_eq!(out.dropped, 6144);
|
assert_eq!(out.dropped, 6144);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +286,10 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
let cipher = buf.clone();
|
||||||
|
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||||
assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once");
|
assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once");
|
||||||
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
|
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
@@ -289,11 +312,14 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut buf = scrambled_unit(0x44);
|
let mut buf = scrambled_unit(0x44);
|
||||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
let cipher = buf.clone();
|
||||||
|
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||||
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
|
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
|
||||||
r(&mut buf2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
let cipher2 = buf2.clone();
|
||||||
|
r(&mut buf2, &cipher2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
calls.load(Ordering::SeqCst),
|
calls.load(Ordering::SeqCst),
|
||||||
1,
|
1,
|
||||||
@@ -313,13 +339,15 @@ mod tests {
|
|||||||
let mut keys = DecryptKeys::Aacs {
|
let mut keys = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
// Distinct ciphertext each time so the dry-set never short-circuits; only
|
// Distinct ciphertext each time so the dry-set never short-circuits; only
|
||||||
// the internal call budget should stop the fetch. The closure self-limits,
|
// the internal call budget should stop the fetch. The closure self-limits,
|
||||||
// so the decorator can call it unconditionally.
|
// so the decorator can call it unconditionally.
|
||||||
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
|
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
|
||||||
let mut buf = scrambled_unit(i);
|
let mut buf = scrambled_unit(i);
|
||||||
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
let cipher = buf.clone();
|
||||||
|
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
calls.load(Ordering::SeqCst),
|
calls.load(Ordering::SeqCst),
|
||||||
|
|||||||
+51
@@ -1790,6 +1790,57 @@ mod tests {
|
|||||||
assert!(inf[2048..].iter().all(|&b| b == 0xBB));
|
assert!(inf[2048..].iter().all(|&b| b == 0xBB));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_aacs_inputs_falls_through_to_hddvd_any_dir() {
|
||||||
|
// HD DVD keeps its AACS material under /ANY!/ (VTKF000.AACS title-key
|
||||||
|
// file + MKBROM.AACS), NOT /AACS/Unit_Key_RO.inf + /AACS/MKB_RO.inf. The
|
||||||
|
// role-based candidate lists must fall through to the /ANY!/ files with
|
||||||
|
// NO disc-type branch, so the online keyserver POST carries the HD DVD
|
||||||
|
// title-key file (magic "DVD_HD_V_TKF") as inf_b64 + MKBROM as mkb_b64 —
|
||||||
|
// the server then classifies the disc as HD DVD by that magic.
|
||||||
|
let any = DirEntry {
|
||||||
|
name: "ANY!".to_string(),
|
||||||
|
is_dir: true,
|
||||||
|
meta_lba: 0,
|
||||||
|
size: 0,
|
||||||
|
entries: vec![
|
||||||
|
file_entry("VTKF000.AACS", 5, 2048),
|
||||||
|
file_entry("MKBROM.AACS", 7, 2048),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let root = DirEntry {
|
||||||
|
name: String::new(),
|
||||||
|
is_dir: true,
|
||||||
|
meta_lba: 0,
|
||||||
|
size: 0,
|
||||||
|
entries: vec![any], // deliberately NO /AACS/ dir
|
||||||
|
};
|
||||||
|
let mut reader = MapReader::new();
|
||||||
|
// VTKF000.AACS: one extent whose content opens with the HD DVD magic.
|
||||||
|
let mut vtkf = [0u8; 2048];
|
||||||
|
vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF");
|
||||||
|
reader.put(5, build_efe_long(2048, &[(0, 2048, 10)]));
|
||||||
|
reader.put(10, vtkf);
|
||||||
|
// MKBROM.AACS: one extent with a type-0x10 AACS-1.0 (HD DVD) version record.
|
||||||
|
let mut mkb = [0u8; 2048];
|
||||||
|
mkb[..12].copy_from_slice(&[
|
||||||
|
0x10, 0x00, 0x00, 0x0C, 0x00, 0x04, 0x10, 0x03, 0x00, 0x00, 0x00, 0x03,
|
||||||
|
]);
|
||||||
|
reader.put(7, build_efe_long(2048, &[(0, 2048, 50)]));
|
||||||
|
reader.put(50, mkb);
|
||||||
|
|
||||||
|
let fs = fs_with(0, 0, root);
|
||||||
|
let (inf, _mkb, _version) =
|
||||||
|
crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
|
||||||
|
.expect("read_aacs_inputs must source the HD DVD /ANY!/ files");
|
||||||
|
assert_eq!(
|
||||||
|
&inf[..12],
|
||||||
|
b"DVD_HD_V_TKF",
|
||||||
|
"inf must be the HD DVD VTKF (its magic), sourced from /ANY!/ via the \
|
||||||
|
candidate fall-through — not /AACS/Unit_Key_RO.inf"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_ranges_saturates_near_u32_max() {
|
fn merge_ranges_saturates_near_u32_max() {
|
||||||
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.
|
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.
|
||||||
|
|||||||
+67
-61
@@ -131,16 +131,15 @@ fn aacs_decrypt_unit_roundtrip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify it looks encrypted (body TS syncs scrambled)
|
// Verify it looks encrypted (body TS syncs scrambled)
|
||||||
assert!(aacs::content::ts_sync_destroyed(&plain));
|
assert!(!aacs::content::is_clean(
|
||||||
|
&plain,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Now decrypt
|
// Now decrypt
|
||||||
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
|
aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||||
assert!(
|
assert!(
|
||||||
result,
|
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"decrypt_unit should return true on valid encrypted unit"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!aacs::content::ts_sync_destroyed(&plain),
|
|
||||||
"decrypted unit should read as clear (TS syncs restored)"
|
"decrypted unit should read as clear (TS syncs restored)"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -258,9 +257,9 @@ fn aacs_vuk_derivation_roundtrip() {
|
|||||||
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: aacs_ts_sync_destroyed detects scrambled units via the raw TS syncs.
|
/// Test: `is_clean` distinguishes clean vs scrambled units via the TS proof floor.
|
||||||
#[test]
|
#[test]
|
||||||
fn aacs_ts_sync_destroyed_detection() {
|
fn aacs_is_clean_detection() {
|
||||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||||
// scrambled. (Flag bits play no role.)
|
// scrambled. (Flag bits play no role.)
|
||||||
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
@@ -270,7 +269,7 @@ fn aacs_ts_sync_destroyed_detection() {
|
|||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&clear),
|
aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"clear unit (syncs intact) must not be scrambled"
|
"clear unit (syncs intact) must not be scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -279,32 +278,35 @@ fn aacs_ts_sync_destroyed_detection() {
|
|||||||
flagged[0] = 0xC0; // copy-control bits
|
flagged[0] = 0xC0; // copy-control bits
|
||||||
flagged[7] = 0xC0; // TSC bits
|
flagged[7] = 0xC0; // TSC bits
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&flagged),
|
aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"flag bits must not be read as encryption"
|
"flag bits must not be read as encryption"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A scrambled body (syncs destroyed) → scrambled.
|
// A scrambled body (syncs destroyed) → scrambled.
|
||||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&scrambled),
|
!aacs::content::is_clean(&scrambled, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"unit with no intact TS syncs must read as scrambled"
|
"unit with no intact TS syncs must read as scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Too short
|
// Too short
|
||||||
let short = vec![0xFFu8; 100];
|
let short = vec![0xFFu8; 100];
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&short),
|
aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"short buffer should not be detected"
|
"short buffer should not be detected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: aacs_decrypt_unit_unencrypted_passthrough
|
/// Test: aacs_clear_unit_reports_not_encrypted
|
||||||
///
|
///
|
||||||
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
|
/// `decrypt_unit` is now PURE (applies the key unconditionally). The "leave a
|
||||||
|
/// clear unit untouched" policy lives at the caller's gate `aacs_unit_encrypted`:
|
||||||
|
/// a CPI-clear unit reports not-encrypted, so the caller never hands it to
|
||||||
|
/// decrypt_unit.
|
||||||
#[test]
|
#[test]
|
||||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
fn aacs_clear_unit_reports_not_encrypted() {
|
||||||
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
|
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
|
// Intact TS syncs every 192 bytes → not scrambled.
|
||||||
let mut off = 4;
|
let mut off = 4;
|
||||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||||
unit[off] = 0x47;
|
unit[off] = 0x47;
|
||||||
@@ -312,13 +314,15 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
|
|||||||
}
|
}
|
||||||
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
||||||
unit[0] &= 0x3F;
|
unit[0] &= 0x3F;
|
||||||
let original = unit.clone();
|
|
||||||
let key = [0xAA; 16];
|
|
||||||
|
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
let result = aacs::content::decrypt_unit(&mut unit, &key);
|
&unit,
|
||||||
assert!(result, "clear unit should return true");
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
));
|
||||||
|
assert!(
|
||||||
|
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
|
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AACS cross-validation with independent AES implementation ──────────────
|
// ── AACS cross-validation with independent AES implementation ──────────────
|
||||||
@@ -413,11 +417,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// -- Decrypt with the library --
|
// -- Decrypt with the library --
|
||||||
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||||
assert!(
|
|
||||||
ok,
|
|
||||||
"decrypt_unit returned false (TS sync verification failed)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -458,44 +458,14 @@ fn aacs_cross_validation_alternate_key() {
|
|||||||
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
|
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(aacs::content::decrypt_unit(&mut plaintext, &unit_key));
|
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||||
|
|
||||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||||
assert_eq!(&plaintext[..], &expected[..]);
|
assert_eq!(&plaintext[..], &expected[..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
// (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate
|
||||||
/// per-sector to bytes 16..2048 (bus encryption layer).
|
// in `aacs::content`'s unit tests, not here.)
|
||||||
#[test]
|
|
||||||
fn aacs_bus_decrypt_cross_validation() {
|
|
||||||
let read_data_key: [u8; 16] = [
|
|
||||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
|
||||||
0x00,
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
for i in 0..aacs::content::ALIGNED_UNIT_LEN {
|
|
||||||
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
|
|
||||||
}
|
|
||||||
let expected = plaintext.clone();
|
|
||||||
|
|
||||||
// Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector
|
|
||||||
for sector_start in (0..aacs::content::ALIGNED_UNIT_LEN).step_by(2048) {
|
|
||||||
ref_aes_cbc_encrypt(
|
|
||||||
&read_data_key,
|
|
||||||
&CROSS_AACS_IV,
|
|
||||||
&mut plaintext[sector_start + 16..sector_start + 2048],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_ne!(&plaintext[16..32], &expected[16..32]);
|
|
||||||
|
|
||||||
aacs::content::decrypt_bus(&mut plaintext, &read_data_key);
|
|
||||||
assert_eq!(
|
|
||||||
plaintext, expected,
|
|
||||||
"bus decrypt did not recover original plaintext"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
|
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -663,6 +633,28 @@ fn css_stevenson_attack_validates_cracked_key() {
|
|||||||
This is expected: synthetic sectors lack the TAB1 output encoding \
|
This is expected: synthetic sectors lack the TAB1 output encoding \
|
||||||
present in real CSS-encrypted DVD sectors."
|
present in real CSS-encrypted DVD sectors."
|
||||||
);
|
);
|
||||||
|
// Never let this test pass vacuously: when the attack can't converge on
|
||||||
|
// synthetic data, still assert always-true properties of the CSS keystream
|
||||||
|
// so a real regression is caught on every run — descramble_sector is
|
||||||
|
// DETERMINISTIC (same key/seed/data → same output) and NON-TRIVIAL (it
|
||||||
|
// actually transforms the payload, not a silent no-op).
|
||||||
|
for (key, seed) in candidates {
|
||||||
|
let mut base = vec![0x00u8; 2048];
|
||||||
|
base[0x14] = 0x30;
|
||||||
|
base[0x54..0x59].copy_from_slice(seed);
|
||||||
|
base[0x80..0x8A]
|
||||||
|
.copy_from_slice(&[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21]);
|
||||||
|
let mut a = base.clone();
|
||||||
|
let mut b = base.clone();
|
||||||
|
css::lfsr::descramble_sector(key, &mut a);
|
||||||
|
css::lfsr::descramble_sector(key, &mut b);
|
||||||
|
assert_eq!(a, b, "descramble must be deterministic for key={key:02X?}");
|
||||||
|
assert_ne!(
|
||||||
|
&a[0x80..2048],
|
||||||
|
&base[0x80..2048],
|
||||||
|
"descramble must transform the payload for key={key:02X?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +699,20 @@ fn css_recover_title_key_with_exact_plaintext() {
|
|||||||
The LFSR0 recovery phase may not converge for this combination.",
|
The LFSR0 recovery phase may not converge for this combination.",
|
||||||
title_key, seed
|
title_key, seed
|
||||||
);
|
);
|
||||||
|
// Never pass vacuously: when LFSR0 recovery can't converge on this
|
||||||
|
// synthetic sector, still assert always-true properties of the cipher so
|
||||||
|
// a real regression is caught on every run — descramble_sector is
|
||||||
|
// DETERMINISTIC and NON-TRIVIAL (actually transforms the payload).
|
||||||
|
let mut a = original.clone();
|
||||||
|
let mut b = original.clone();
|
||||||
|
css::lfsr::descramble_sector(&title_key, &mut a);
|
||||||
|
css::lfsr::descramble_sector(&title_key, &mut b);
|
||||||
|
assert_eq!(a, b, "descramble must be deterministic");
|
||||||
|
assert_ne!(
|
||||||
|
&a[0x80..2048],
|
||||||
|
&original[0x80..2048],
|
||||||
|
"descramble must transform the payload"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-12
@@ -27,21 +27,39 @@ fn decrypt_sectors_with_aacs_keys_works() {
|
|||||||
|
|
||||||
let unit_key: [u8; 16] = [0xAAu8; 16];
|
let unit_key: [u8; 16] = [0xAAu8; 16];
|
||||||
|
|
||||||
// Encrypt the unit using AACS algorithm
|
// Apply the key to the pattern to produce ciphertext-shaped bytes for the
|
||||||
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
|
// call below. (decrypt_unit is now PURE — it applies the key unconditionally,
|
||||||
|
// so it is NOT idempotent; never call it twice on the same unit.)
|
||||||
|
aacs::content::decrypt_unit(&mut unit, &unit_key);
|
||||||
|
// (byte 0 keeps its CPI bits set from above, so `decrypt_sectors` recognises
|
||||||
|
// this as encrypted content and actually applies the key.)
|
||||||
|
|
||||||
// Now we have encrypted data - create DecryptKeys with actual keys
|
let mut aacs_keys = DecryptKeys::Aacs {
|
||||||
let mut keys = DecryptKeys::Aacs {
|
|
||||||
unit_keys: vec![(0u32, unit_key)],
|
unit_keys: vec![(0u32, unit_key)],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
|
let mut none_keys = DecryptKeys::None;
|
||||||
|
|
||||||
// decrypt_sectors should handle this without error
|
// The regression this guards is passing `DecryptKeys::None` where AACS keys
|
||||||
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0);
|
// were meant. Prove the two DIVERGE: AACS applies the key (bytes change), None
|
||||||
|
// leaves the unit byte-for-byte untouched. is_ok alone can't catch that —
|
||||||
|
// both variants return Ok.
|
||||||
|
let mut with_aacs = unit.clone();
|
||||||
|
let mut with_none = unit.clone();
|
||||||
|
libfreemkv::decrypt::decrypt_sectors(&mut with_aacs, &mut aacs_keys, 0)
|
||||||
|
.expect("AACS decrypt must not error");
|
||||||
|
libfreemkv::decrypt::decrypt_sectors(&mut with_none, &mut none_keys, 0)
|
||||||
|
.expect("None decrypt must not error");
|
||||||
|
|
||||||
assert!(
|
assert_ne!(
|
||||||
result.is_ok(),
|
with_aacs, unit,
|
||||||
"decrypt_sectors with AACS keys should not error"
|
"AACS keys must actually transform the unit"
|
||||||
|
);
|
||||||
|
assert_eq!(with_none, unit, "None keys must leave the unit untouched");
|
||||||
|
assert_ne!(
|
||||||
|
with_aacs, with_none,
|
||||||
|
"AACS decrypt must differ from the None no-op (the None-vs-Aacs regression)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,16 +108,25 @@ fn aacs_encryption_flag_detection() {
|
|||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
|
&unit,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Flag bits on a synced unit do not make it look encrypted.
|
// Flag bits on a synced unit do not make it look encrypted.
|
||||||
unit[0] = 0xC0;
|
unit[0] = 0xC0;
|
||||||
unit[7] = 0xC0;
|
unit[7] = 0xC0;
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
|
&unit,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Scrambled body (syncs gone) → encrypted.
|
// Scrambled body (syncs gone) → encrypted.
|
||||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
assert!(aacs::content::ts_sync_destroyed(&scrambled));
|
assert!(!aacs::content::is_clean(
|
||||||
|
&scrambled,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||||
@@ -111,6 +138,7 @@ fn decrypt_keys_is_encrypted_variants() {
|
|||||||
let aacs = DecryptKeys::Aacs {
|
let aacs = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
assert!(aacs.is_encrypted());
|
assert!(aacs.is_encrypted());
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
|
|||||||
progress: None,
|
progress: None,
|
||||||
halt: Some(halt.clone()),
|
halt: Some(halt.clone()),
|
||||||
key_fetch: None,
|
key_fetch: None,
|
||||||
fast_capture: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
||||||
|
|||||||
+187
-29
@@ -232,8 +232,6 @@ struct Golden {
|
|||||||
bytes_unreadable: u64,
|
bytes_unreadable: u64,
|
||||||
/// `bytes_pending` (NonTrimmed) at end.
|
/// `bytes_pending` (NonTrimmed) at end.
|
||||||
bytes_pending: u64,
|
bytes_pending: u64,
|
||||||
/// Did the pass exit via wedge-detection?
|
|
||||||
wedged_exit: bool,
|
|
||||||
/// Sanity bound on trace length — patch makes a finite number of
|
/// Sanity bound on trace length — patch makes a finite number of
|
||||||
/// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus
|
/// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus
|
||||||
/// retries. Asserted as an UPPER bound only (so any reduction in
|
/// retries. Asserted as an UPPER bound only (so any reduction in
|
||||||
@@ -319,7 +317,6 @@ fn profile_01_clean_all_recoverable() {
|
|||||||
bytes_good: capacity_sectors as u64 * 2048,
|
bytes_good: capacity_sectors as u64 * 2048,
|
||||||
bytes_unreadable: 0,
|
bytes_unreadable: 0,
|
||||||
bytes_pending: 0,
|
bytes_pending: 0,
|
||||||
wedged_exit: false,
|
|
||||||
max_reads: 8, // adaptive batch=32 reads finishes 16 sectors in 1 read; allow up to 8.
|
max_reads: 8, // adaptive batch=32 reads finishes 16 sectors in 1 read; allow up to 8.
|
||||||
};
|
};
|
||||||
assert_eq!(stats.bytes_good, expected.bytes_good, "01_clean bytes_good");
|
assert_eq!(stats.bytes_good, expected.bytes_good, "01_clean bytes_good");
|
||||||
@@ -796,29 +793,190 @@ fn profile_08_batch_fail_singles_ok() {
|
|||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Suppressed for now: NOT_READY-then-recover, HARDWARE_ERROR (wedge),
|
// Sense-family error paths in `Disc::patch`: NOT_READY-then-recover,
|
||||||
// ILLEGAL_REQUEST (wedge), and ABORTED_COMMAND profiles. Each would
|
// HARDWARE_ERROR, ILLEGAL_REQUEST, and ABORTED_COMMAND.
|
||||||
// trigger long real-time sleeps inside `handle_read_failure`:
|
|
||||||
//
|
//
|
||||||
// - NOT_READY (sense_key=0x02, asc=0x02/0x03/0x04): 15 s pause per
|
// These drive `disc.patch(...)` DIRECTLY rather than through `run_profile`
|
||||||
// occurrence (`patch_not_ready_pause`), and retries the same LBA
|
// (which drives `Disc::copy`, whose SWEEP path really sleeps on NOT_READY /
|
||||||
// in-place. Even one NOT_READY costs the test 15 s wall-time.
|
// wedge cooldowns via `sleep_secs_or_halt`). The patch handler chain itself
|
||||||
|
// uses an injectable deadline clock (`Instant::now` in production) and never
|
||||||
|
// `thread::sleep`s, so these paths run at full speed with no wall-time cost —
|
||||||
|
// the earlier "sleeps aren't injectable" suppression only ever applied to the
|
||||||
|
// copy/sweep driver, not to patch.
|
||||||
//
|
//
|
||||||
// - HARDWARE_ERROR / ILLEGAL_REQUEST: 30 s per occurrence
|
// The load-bearing invariant asserted across every PERSISTENT failure sense is
|
||||||
// (`WEDGE_FAMILY_COOLDOWN_SECS`), bounded by
|
// the recovery contract: a patch pass NEVER promotes a sector to Unreadable
|
||||||
// `WEDGE_ABORT_THRESHOLD=16` before wedged-exit. Worst case ~8
|
// (the orchestrator does that only after the final pass) and NEVER silently
|
||||||
// minutes per profile.
|
// drops bytes — a still-bad sector stays NonTrimmed (pending), so
|
||||||
//
|
// good + pending always conserves the total. Exact good/pending splits are
|
||||||
// The sleeps are not injectable. Adding them would require either a
|
// left loose so wedge-skip tuning can't spuriously fail these.
|
||||||
// `now()` / `sleep()` trait injection (out of scope for the unification
|
|
||||||
// task) or a "test mode" compile-time flag (architectural smell). The
|
/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192)
|
||||||
// behavioural contracts for those paths are captured in
|
/// range) patch pass with the given failure step and return the final map
|
||||||
// `read_error.rs`'s in-module tests instead — they exercise the
|
/// stats. 256-sector synthetic disc; everything outside the range is Finished.
|
||||||
// classifier without invoking the patch loop's sleep side-effects.
|
fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats {
|
||||||
//
|
let capacity_sectors: u32 = 256;
|
||||||
// If the unification ever proceeds, the next step is to add a clock
|
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
|
||||||
// injection point in `handle_read_failure` and extend this fixture
|
reader.always(130, step);
|
||||||
// with the wedge/NOT_READY profiles too.
|
|
||||||
|
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||||
|
let disc = synthetic_disc(capacity_sectors);
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let iso_path = tmp.path().to_path_buf();
|
||||||
|
drop(tmp);
|
||||||
|
let nontrimmed = [(128 * 2048, 64 * 2048)];
|
||||||
|
let finished = [
|
||||||
|
(0, 128 * 2048),
|
||||||
|
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
|
||||||
|
];
|
||||||
|
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
||||||
|
|
||||||
|
let opts = libfreemkv::disc::PatchOptions {
|
||||||
|
decrypt: false,
|
||||||
|
block_sectors: Some(32),
|
||||||
|
full_recovery: true,
|
||||||
|
reverse: true,
|
||||||
|
wedged_threshold: 50,
|
||||||
|
progress: None,
|
||||||
|
halt: None,
|
||||||
|
key_fetch: None,
|
||||||
|
};
|
||||||
|
disc.patch(&mut reader, &iso_path, &opts)
|
||||||
|
.expect("patch must not error on a per-sector failure sense");
|
||||||
|
|
||||||
|
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||||
|
let stats = Mapfile::load(&map_path).unwrap().stats();
|
||||||
|
let _ = std::fs::remove_file(&iso_path);
|
||||||
|
let _ = std::fs::remove_file(&map_path);
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A persistent sense that never clears must obey the pass contract: nothing
|
||||||
|
/// Unreadable, nothing lost (good + pending == total), and at least the dead
|
||||||
|
/// sector left pending.
|
||||||
|
fn assert_persistent_sense_contract(step: ScriptStep, label: &str) {
|
||||||
|
let stats = single_dead_sector_patch_stats(step);
|
||||||
|
let total = 256u64 * 2048;
|
||||||
|
assert_eq!(
|
||||||
|
stats.bytes_unreadable, 0,
|
||||||
|
"{label}: a patch pass must NEVER mark Unreadable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stats.bytes_good + stats.bytes_pending,
|
||||||
|
total,
|
||||||
|
"{label}: conservation — no byte may be silently dropped"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stats.bytes_pending >= 2048,
|
||||||
|
"{label}: the always-dead sector must remain pending (NonTrimmed)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_persistent_hardware_error_conserves_and_never_unreadable() {
|
||||||
|
// HARDWARE_ERROR (sense_key=0x04) — wedge family.
|
||||||
|
assert_persistent_sense_contract(
|
||||||
|
ScriptStep::Err {
|
||||||
|
sense_key: 0x04,
|
||||||
|
asc: 0x11,
|
||||||
|
ascq: 0x00,
|
||||||
|
},
|
||||||
|
"HARDWARE_ERROR",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_persistent_illegal_request_conserves_and_never_unreadable() {
|
||||||
|
// ILLEGAL_REQUEST (sense_key=0x05) — wedge family.
|
||||||
|
assert_persistent_sense_contract(
|
||||||
|
ScriptStep::Err {
|
||||||
|
sense_key: 0x05,
|
||||||
|
asc: 0x21,
|
||||||
|
ascq: 0x00,
|
||||||
|
},
|
||||||
|
"ILLEGAL_REQUEST",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_persistent_aborted_command_conserves_and_never_unreadable() {
|
||||||
|
// ABORTED_COMMAND (sense_key=0x0B).
|
||||||
|
assert_persistent_sense_contract(
|
||||||
|
ScriptStep::Err {
|
||||||
|
sense_key: 0x0B,
|
||||||
|
asc: 0x00,
|
||||||
|
ascq: 0x00,
|
||||||
|
},
|
||||||
|
"ABORTED_COMMAND",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patch_not_ready_then_recovers_fully() {
|
||||||
|
// NOT_READY (sense_key=0x02, asc=0x04) that clears after two attempts must
|
||||||
|
// recover the sector in-pass — no residual loss, no Unreadable, no hang.
|
||||||
|
let capacity_sectors: u32 = 256;
|
||||||
|
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
|
||||||
|
reader.sequence(
|
||||||
|
130,
|
||||||
|
vec![
|
||||||
|
ScriptStep::Err {
|
||||||
|
sense_key: 0x02,
|
||||||
|
asc: 0x04,
|
||||||
|
ascq: 0x00,
|
||||||
|
},
|
||||||
|
ScriptStep::Err {
|
||||||
|
sense_key: 0x02,
|
||||||
|
asc: 0x04,
|
||||||
|
ascq: 0x00,
|
||||||
|
},
|
||||||
|
ScriptStep::Ok,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||||
|
let disc = synthetic_disc(capacity_sectors);
|
||||||
|
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
let iso_path = tmp.path().to_path_buf();
|
||||||
|
drop(tmp);
|
||||||
|
let nontrimmed = [(128 * 2048, 64 * 2048)];
|
||||||
|
let finished = [
|
||||||
|
(0, 128 * 2048),
|
||||||
|
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
|
||||||
|
];
|
||||||
|
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
||||||
|
|
||||||
|
let opts = libfreemkv::disc::PatchOptions {
|
||||||
|
decrypt: false,
|
||||||
|
block_sectors: Some(32),
|
||||||
|
full_recovery: true,
|
||||||
|
reverse: true,
|
||||||
|
wedged_threshold: 50,
|
||||||
|
progress: None,
|
||||||
|
halt: None,
|
||||||
|
key_fetch: None,
|
||||||
|
};
|
||||||
|
disc.patch(&mut reader, &iso_path, &opts)
|
||||||
|
.expect("patch must not error on a transient NOT_READY");
|
||||||
|
|
||||||
|
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||||
|
let stats = Mapfile::load(&map_path).unwrap().stats();
|
||||||
|
assert_eq!(
|
||||||
|
stats.bytes_unreadable, 0,
|
||||||
|
"NOT_READY recovery must not mark Unreadable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stats.bytes_pending, 0,
|
||||||
|
"a NOT_READY that clears must leave nothing pending"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stats.bytes_good,
|
||||||
|
capacity_sectors as u64 * 2048,
|
||||||
|
"every sector recovers once NOT_READY clears"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&iso_path);
|
||||||
|
let _ = std::fs::remove_file(&map_path);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────── Handler chain recovers re-readable sectors inside a bad block ────────
|
// ──────── Handler chain recovers re-readable sectors inside a bad block ────────
|
||||||
//
|
//
|
||||||
@@ -826,9 +984,10 @@ fn profile_08_batch_fail_singles_ok() {
|
|||||||
// handler chain's linear pass narrows a failed batch to per-sector reads, so it
|
// handler chain's linear pass narrows a failed batch to per-sector reads, so it
|
||||||
// recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed —
|
// recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed —
|
||||||
// strictly better than the old fast-capture path, which left the whole failed
|
// strictly better than the old fast-capture path, which left the whole failed
|
||||||
// 32-block untouched. (`fast_capture` is now inert: the chain supersedes it. The
|
// 32-block untouched. (The old `fast_capture` knob was removed: the handler
|
||||||
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is
|
// chain supersedes it. The breadth-first "fast on all ranges, then escalate"
|
||||||
// a scheduling concern for the handler scheduler, tracked separately.)
|
// ORDERING it once provided is a scheduling concern for the handler scheduler,
|
||||||
|
// tracked separately.)
|
||||||
//
|
//
|
||||||
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
|
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
|
||||||
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
|
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
|
||||||
@@ -869,10 +1028,9 @@ fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() {
|
|||||||
progress: None,
|
progress: None,
|
||||||
halt: None,
|
halt: None,
|
||||||
key_fetch: None,
|
key_fetch: None,
|
||||||
fast_capture: true,
|
|
||||||
};
|
};
|
||||||
disc.patch(&mut reader, &iso_path, &opts)
|
disc.patch(&mut reader, &iso_path, &opts)
|
||||||
.expect("fast-capture patch must not error");
|
.expect("handler-chain patch must not error");
|
||||||
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
||||||
let stats = Mapfile::load(&map_path).unwrap().stats();
|
let stats = Mapfile::load(&map_path).unwrap().stats();
|
||||||
|
|||||||
Reference in New Issue
Block a user