mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a2493429 | ||
|
|
0e73650440 | ||
|
|
bcfd5eb213 | ||
|
|
8ce0a66bee | ||
|
|
7d4d9a3e84 | ||
|
|
4dec71a867 | ||
|
|
a9b29c7060 | ||
|
|
0cb525517b | ||
|
|
5b4a3f32dc | ||
|
|
66b0bc385c | ||
|
|
aaccd83750 | ||
|
|
40f5b01a12 | ||
|
|
82d42b1930 | ||
|
|
913b01b79f | ||
|
|
af7ef59f3e | ||
|
|
ef8e11b6f3 | ||
|
|
e1f897fb32 | ||
|
|
602e44fde9 | ||
|
|
622008650c | ||
|
|
c508484451 | ||
|
|
cf16f66e07 | ||
|
|
adcab75409 | ||
|
|
61e4095727 | ||
|
|
540f84c7d0 | ||
|
|
82c4e018d5 | ||
|
|
08e962694f | ||
|
|
37e563b8ef | ||
|
|
e659613ab8 | ||
|
|
eaa7ddc683 | ||
|
|
723c7edf45 | ||
|
|
d9881c57c0 | ||
|
|
61b4486ae9 | ||
|
|
72d39544ac | ||
|
|
1e394eaeb8 | ||
|
|
4d1aa6bbf7 | ||
|
|
9ce03b4d1d | ||
|
|
f099a4a31a | ||
|
|
e606b5e3a2 | ||
|
|
de809189ec | ||
|
|
253b0924e3 | ||
|
|
98a7892c34 | ||
|
|
b82d21756a | ||
|
|
02c584a3d9 | ||
|
|
564e66fd24 | ||
|
|
57d40e81a5 | ||
|
|
674819d937 | ||
|
|
c5e8421f9f |
+179
-6
@@ -1,16 +1,189 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.5] — 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FMTS (AACS 2.1) forensic discs now mux to a clean, single-variant stream.** A
|
||||
forensic segment interleaves the local device group's variant with a foreign
|
||||
group's at the aligned-unit level. The mux decrypted only our half but left the
|
||||
foreign half in the buffer as ciphertext, on the assumption that the demuxer
|
||||
"drops untouched ciphertext cleanly." It does not — a foreign unit's bytes hit a
|
||||
tracked PID at the 192-byte stride, mis-parse, and trip the demux's concealed-gap
|
||||
keyframe resync, which discards good frames of ours around every segment (visible
|
||||
playback glitches). `AacsKeyMap::read_plan` now turns the map into the title's
|
||||
read plan: every default / CPS unit, plus inside a forensic segment **only our
|
||||
phase's units**. The foreign half is never read, decrypted, or handed to the
|
||||
demux. On a retail 4K UHD title this took concealed-gap resyncs from **349 → 0**
|
||||
and recovered ~2 GB of previously-dropped frames. Wired into **both** mux paths —
|
||||
the file-backed highway (`build_iso_pipeline`) and the inline live-drive
|
||||
`DiscStream` (`with_key_map`) — so single- and multi-pass FMTS rips are both clean.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Key-bearing types redact their `Debug` output.** Every type that carries key
|
||||
material (device keys, processing keys, unit keys, media keys, VUKs, resolved
|
||||
chains, CSS/AACS state, …) now prints a `<redacted>` marker instead of the bytes,
|
||||
so no key can reach a log or panic message. Each is covered by a test asserting no
|
||||
key byte appears.
|
||||
- **Hex parsing is centralized and case-insensitive.** A single set of canonical
|
||||
`0x`/`0X`-tolerant hex→integer parsers replaces scattered ad-hoc parsing (this is
|
||||
what silently dropped keydb device keys written with an uppercase `0X` prefix).
|
||||
- **Internal-only public surface narrowed to `pub(crate)`, and duplicate
|
||||
`foo_with_X` methods collapsed to one** — no behavioral change, smaller API.
|
||||
|
||||
## [1.4.4] — 2026-07-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Online key requests are no longer silently dropped on discs that yield few
|
||||
sample units.** The online key source refuses any request carrying fewer than
|
||||
`MIN_SAMPLE_UNITS` (8) encrypted-content samples — too few can match an
|
||||
incidental unit rather than the one asked about (a false positive, most acute on
|
||||
AACS 2.1 forensic-variant content). autorip gathered only 4, so every online
|
||||
lookup was skipped before it ever reached the key service and surfaced to the
|
||||
user as "key service down." autorip's sample count is now tied to
|
||||
`MIN_SAMPLE_UNITS` with a **compile-time floor**, so it can never regress below
|
||||
the minimum again.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The online request is assembled from a proven-sufficient sample set.** New
|
||||
`DecodeSampleSet` (`libfreemkv::keysource`) wraps the content-unit samples and
|
||||
can only be constructed with at least `MIN_SAMPLE_UNITS` of them — so an online
|
||||
key request cannot be built from too few samples. The minimum is validated once,
|
||||
at construction, rather than by a runtime check a caller could forget.
|
||||
|
||||
## [1.4.3] — 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- **`MIN_SAMPLE_UNITS` moved to the base crate.** The minimum sample count an
|
||||
online key request must carry now has a single definition in
|
||||
`libfreemkv::keysource`; `freemkv-keysources` re-exports it, so the online source
|
||||
and libfreemkv's own forensic query size their requests from one shared value.
|
||||
- **The online unit-key reply is parsed as a list.** A response carries either a
|
||||
single Unit Key (ordinary disc) or the full ordered set (an AACS 2.1
|
||||
forensic-variant disc); the client accepts both and maps array position to
|
||||
forensic index.
|
||||
|
||||
### Added
|
||||
|
||||
- **Forensic-variant online query samples the anchor segment.** On an AACS 2.1
|
||||
forensic-variant disc the online key query draws its sample from the first
|
||||
forensic segment (index 1) — one canonical, deterministic sample — instead of an
|
||||
arbitrary segment.
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.1 (FMTS) variant-decode foundation.** `UnitKey` gains a
|
||||
`variant_number` field (`0` = ordinary content, `1..=32` = a forensic
|
||||
variant) with `UnitKey::new` / `UnitKey::variant` constructors, and a new
|
||||
`aacs::variant_select` module resolves a disc's single forensic variant and
|
||||
classifies each aligned unit — decrypt with the default key, decrypt with the
|
||||
variant key, drop a foreign variant, or conceal a keyless forensic unit. This
|
||||
is the groundwork for selecting one variant's segments and dropping the other
|
||||
31; the decrypt-pipeline wiring lands with the variant key source.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`IndividualSegment.tbl`: the per-record field is the variant, not a segment
|
||||
number.** `Segment.number` → `Segment.variant`. Verified against a retail 2.1
|
||||
disc, the field cycles `1..=32` across the table (a per-variant tag) rather
|
||||
than counting up, so variant selection routes on the correct value.
|
||||
|
||||
## [1.3.1] — 2026-07-10
|
||||
|
||||
### Licensing
|
||||
|
||||
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
|
||||
including 1.3.0 remain under AGPL-3.0). All code is an independent Rust
|
||||
implementation of the public/standard disc formats and cryptographic
|
||||
algorithms; comments that cross-referenced GPL/LGPL C projects (libdvdcss,
|
||||
libaacs, libbluray, libdvdread, libdvdnav) were dropped, and the CSS content
|
||||
cipher and the Stevenson title-key attack are attributed to their published
|
||||
cryptanalysis rather than any particular software.
|
||||
including 1.3.0 remain under AGPL-3.0).
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.3.1"
|
||||
version = "1.4.5"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "MIT"
|
||||
@@ -28,7 +28,7 @@ cbc = "0.1"
|
||||
# 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
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.3.1" }
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.5" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
|
||||
+603
-726
File diff suppressed because it is too large
Load Diff
+43
-8
@@ -463,9 +463,9 @@ pub enum KeyCandidate {
|
||||
/// 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
|
||||
/// 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`.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct ResolvedChain {
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub vuk: Option<Vuk>,
|
||||
@@ -475,6 +475,21 @@ pub struct ResolvedChain {
|
||||
pub dk: Option<DeviceKey>,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `unit_keys` holds raw title-key bytes, never printed. The
|
||||
// other rungs are `types` newtypes that self-redact. Guarded by
|
||||
// `resolved_chain_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ResolvedChain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ResolvedChain")
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.field("vuk", &self.vuk)
|
||||
.field("mk", &self.mk)
|
||||
.field("pk", &self.pk)
|
||||
.field("dk", &self.dk)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
|
||||
///
|
||||
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
|
||||
@@ -487,7 +502,7 @@ pub struct ResolvedChain {
|
||||
///
|
||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
||||
/// `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
|
||||
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||
@@ -505,7 +520,8 @@ pub fn resolve_candidate(
|
||||
let version = mkb_type(mkb)
|
||||
.map(|t| t.generation())
|
||||
.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() {
|
||||
return None;
|
||||
}
|
||||
@@ -567,6 +583,28 @@ mod resolve_candidate_tests {
|
||||
use super::*;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
|
||||
/// `ResolvedChain.unit_keys` holds raw title-key bytes (the other rungs are
|
||||
/// self-redacting `types` newtypes). `Debug` must not leak the title keys.
|
||||
#[test]
|
||||
fn resolved_chain_debug_is_redacted() {
|
||||
let c = ResolvedChain {
|
||||
unit_keys: vec![(1, [0xD5; 16])],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
};
|
||||
let dbg = format!("{c:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"ResolvedChain leaked unit keys: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("unit_keys_len"),
|
||||
"ResolvedChain missing redaction: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||
@@ -612,10 +650,7 @@ mod resolve_candidate_tests {
|
||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_is_itself() {
|
||||
let uk = UnitKey {
|
||||
idx: 2,
|
||||
key: [0x9u8; 16],
|
||||
};
|
||||
let uk = UnitKey::new(2, [0x9u8; 16]);
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
|
||||
//!
|
||||
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
|
||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
|
||||
//! [`super::segment`]); the decode keeps the segments matching our index,
|
||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
||||
//! (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
|
||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||
//!
|
||||
//! Where the resolved index comes from is a separate concern
|
||||
//! ([`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
|
||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
||||
|
||||
use super::segment::{Segment, segment_for_unit};
|
||||
use super::types::UnitKey;
|
||||
|
||||
/// What the decode should do with one AACS aligned unit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnitDisposition {
|
||||
/// Outside every forensic segment: ordinary content, decrypt with the
|
||||
/// default (index-0) unit key.
|
||||
Default,
|
||||
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
|
||||
/// that index's key.
|
||||
Index(u8),
|
||||
/// Inside a forensic segment tagged with a DIFFERENT index: not our
|
||||
/// watermark, so it is not part of our output — drop it.
|
||||
DropForeignIndex(u8),
|
||||
/// 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
|
||||
/// as loss. Carries the segment's index for diagnostics.
|
||||
ForensicNoKey(u8),
|
||||
}
|
||||
|
||||
/// Resolve the disc's single forensic index from the keys we hold.
|
||||
///
|
||||
/// Scans for an index key (`index_number` in `1..=32`) and returns its
|
||||
/// index. `None` when only default (index-0) keys are held — i.e. no
|
||||
/// index source answered, so forensic segments are not decodable. A disc has
|
||||
/// exactly one index, so the first non-zero key decides; if several distinct
|
||||
/// index keys were somehow supplied the lowest wins (deterministic), which is
|
||||
/// only a defensive tiebreak — the probe/derivation yields one.
|
||||
pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||
unit_keys
|
||||
.iter()
|
||||
.map(|k| k.index_number)
|
||||
.filter(|&v| v != 0)
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
||||
/// the forensic segment map and the disc's resolved index (`None` if no
|
||||
/// index key is held).
|
||||
pub fn unit_disposition(
|
||||
unit_offset: u64,
|
||||
segments: &[Segment],
|
||||
disc_index: Option<u8>,
|
||||
) -> UnitDisposition {
|
||||
match segment_for_unit(segments, unit_offset) {
|
||||
// Not in any forensic segment → ordinary content.
|
||||
None => UnitDisposition::Default,
|
||||
// In a forensic segment → decide by whether it is our index.
|
||||
Some(seg) => {
|
||||
let seg_index = seg.index as u8;
|
||||
match disc_index {
|
||||
Some(v) if v == seg_index => UnitDisposition::Index(v),
|
||||
Some(_) => UnitDisposition::DropForeignIndex(seg_index),
|
||||
None => UnitDisposition::ForensicNoKey(seg_index),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||
|
||||
/// Build a one-record segment table (index, start_spn, end_spn).
|
||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(&16u16.to_be_bytes());
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes());
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
parse_individual_segments(&v).expect("parse")
|
||||
}
|
||||
|
||||
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||
if index == 0 {
|
||||
UnitKey::new(idx, [0u8; 16])
|
||||
} else {
|
||||
UnitKey::forensic(idx, [index; 16], index)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_picks_the_single_index_key() {
|
||||
// Default keys only → no index resolved.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_index(&[]), None);
|
||||
// One index key among defaults → that index.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct indexes (deterministic).
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_segments_is_default() {
|
||||
let segs = tbl(&[(1, 343680, 346239)]);
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
// With no segments at all (1.0 / 2.0), everything is Default.
|
||||
assert_eq!(
|
||||
unit_disposition(off, &[], Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_our_index_decrypts() {
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(7)),
|
||||
UnitDisposition::Index(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_foreign_index_drops() {
|
||||
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(3)),
|
||||
UnitDisposition::DropForeignIndex(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forensic_unit_with_no_key_is_concealed() {
|
||||
// A forensic segment but we never resolved an index → conceal as loss.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, None),
|
||||
UnitDisposition::ForensicNoKey(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straddling_unit_still_classified_as_its_segment() {
|
||||
// A unit whose 32-packet span only tails into the segment still routes
|
||||
// to the segment (matches segment_for_unit's span test).
|
||||
let segs = tbl(&[(5, 100, 200)]);
|
||||
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.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(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.
|
||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||
|
||||
@@ -283,3 +364,94 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+60
-11
@@ -29,6 +29,7 @@ pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
pub mod host_certs;
|
||||
pub mod index_select;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod provider;
|
||||
@@ -39,10 +40,18 @@ pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
|
||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
||||
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
|
||||
/// or changing a fallback in one place can then never silently diverge the
|
||||
/// On-disc UDF paths to the AACS key-input files.
|
||||
///
|
||||
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps 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.
|
||||
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";
|
||||
@@ -50,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_CONTENT_CERT: &str = "/AACS/Content000.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
|
||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||
@@ -60,7 +109,7 @@ pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||
// content-decrypt entry points that downstream key-source crates import through
|
||||
// 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.
|
||||
pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys};
|
||||
pub use content::ALIGNED_UNIT_LEN;
|
||||
pub use derive::derive_vuk;
|
||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||
|
||||
@@ -70,7 +119,7 @@ mod tests {
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! 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::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
@@ -98,15 +147,15 @@ mod tests {
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// 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 _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey {
|
||||
idx: 0,
|
||||
key: [0u8; 16],
|
||||
}),
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
|
||||
+42
-4
@@ -13,7 +13,6 @@ use super::mkb::*;
|
||||
// ── Full VUK resolution chain ───────────────────────────────────────────────
|
||||
|
||||
/// Result of resolving a disc's VUK.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedKeys {
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf)
|
||||
pub disc_hash: [u8; 20],
|
||||
@@ -34,6 +33,23 @@ pub struct ResolvedKeys {
|
||||
pub key_source: u8,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `vuk` and `unit_keys` are raw key bytes, never printed.
|
||||
// `disc_hash` is the public per-disc identifier (SHA-1 of the .inf), not secret.
|
||||
// Guarded by `resolved_keys_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ResolvedKeys {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ResolvedKeys")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.field("title_cps_unit", &self.title_cps_unit)
|
||||
.field("version", &self.version)
|
||||
.field("bus_encryption", &self.bus_encryption)
|
||||
.field("key_source", &self.key_source)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inputs shared by every classical-path resolver. References only —
|
||||
/// callers retain ownership of all buffers.
|
||||
pub struct ResolveContext<'a> {
|
||||
@@ -156,7 +172,7 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
/// equivalent of path 2 — there's no host-side PK derivation against a
|
||||
/// Variant MKB.)
|
||||
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 bus_encryption = ctx
|
||||
.content_cert
|
||||
@@ -280,8 +296,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
.map(|cc| cc.bus_encryption)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Parse Unit_Key_RO.inf at the version-appropriate stride.
|
||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
|
||||
// Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the
|
||||
// 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 has_vid = *ctx.volume_id != [0u8; 16];
|
||||
@@ -466,6 +483,27 @@ mod tests {
|
||||
use super::super::types::*;
|
||||
use super::*;
|
||||
|
||||
/// `ResolvedKeys` carries the disc's VUK and unit keys raw; `Debug` must not
|
||||
/// leak them. Sentinel 213 (0xD5); non-secret fields are not 213.
|
||||
#[test]
|
||||
fn resolved_keys_debug_is_redacted() {
|
||||
let rk = ResolvedKeys {
|
||||
disc_hash: [0u8; 20],
|
||||
vuk: Some([0xD5; 16]),
|
||||
unit_keys: vec![(1, [0xD5; 16])],
|
||||
title_cps_unit: vec![0],
|
||||
version: AacsVersion::V21,
|
||||
bus_encryption: true,
|
||||
key_source: 1,
|
||||
};
|
||||
let dbg = format!("{rk:?}");
|
||||
assert!(!dbg.contains("213"), "ResolvedKeys leaked keys: {dbg}");
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"ResolvedKeys missing marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Audit #5: the `major` / `from_major` mapping is load-bearing for the
|
||||
/// Unit_Key_RO stride, so pin it as a table. V10 ↔ BD; V20/V21 → UHD; any
|
||||
/// non-BD major selects the V20/V21 64-byte stride (V10 is the only 48-byte).
|
||||
|
||||
+207
-57
@@ -1,55 +1,55 @@
|
||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||
//!
|
||||
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
|
||||
//! forensic-watermark mechanism. The same frames are authored as several
|
||||
//! slightly different variants; each variant is encrypted under its own SEGMENT
|
||||
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
|
||||
//! 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).
|
||||
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
|
||||
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
|
||||
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
|
||||
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
|
||||
//!
|
||||
//! This table says WHERE the variant segments live so a decoder can decrypt
|
||||
//! them with segment keys and select one coherent variant instead of muxing
|
||||
//! Terminology (see the project AACS reference): the **index** here is NOT the
|
||||
//! 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.
|
||||
//!
|
||||
//! Format (validated against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||
//! record[count] (16 bytes each):
|
||||
//! u32 marker (= 0x01000000) | u16 segment_number | u16 flag (= 1)
|
||||
//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
//! Source-packet numbers are the 192-byte BDAV packet index: byte offset =
|
||||
//! `spn * 192`. Observed on one disc: 792 segments, each ~2560 packets
|
||||
//! (~480 KB), spread across the entire 54 GB feature (one roughly every 67 MB).
|
||||
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
|
||||
//! on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across records in
|
||||
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
|
||||
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
|
||||
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
|
||||
//! 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.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||
|
||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
|
||||
///
|
||||
/// `true` (today): the forensic variant segments are skipped as expected loss
|
||||
/// 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
|
||||
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
|
||||
/// ([`Error::FmtsKeyMissing`]) — the same policy as a missing unit key, so a
|
||||
/// forensic-holed rip is refused rather than produced. No segment-key source
|
||||
/// exists yet, so `true` is the only value under which a 2.1 disc rips at all;
|
||||
/// flip to `false` once segment keys can be sourced and a partial rip should be
|
||||
/// refused. Hardcoded on purpose — not a user setting.
|
||||
///
|
||||
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
|
||||
pub const BYPASS_FMTS_KEY: bool = true;
|
||||
|
||||
/// One forensic variant segment: the inclusive source-packet range it occupies
|
||||
/// in the FMTS clip.
|
||||
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||
/// FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
/// 1-based segment number (table order).
|
||||
pub number: u16,
|
||||
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
|
||||
/// table rather than counting up — it selects WHICH of the 32 index keys
|
||||
/// decrypts this range. (`0` is not used here; the default/non-forensic
|
||||
/// content carries no segment record at all.)
|
||||
pub index: u16,
|
||||
/// First source packet of the segment (inclusive).
|
||||
pub start_spn: u32,
|
||||
/// Last source packet of the segment (inclusive).
|
||||
@@ -103,8 +103,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
/// unit's clip-relative byte offset.
|
||||
///
|
||||
/// 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
|
||||
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
|
||||
/// overlaps a forensic segment must be opened with that segment's **index key**
|
||||
/// (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
|
||||
/// 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
|
||||
@@ -112,16 +112,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
|
||||
/// 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.
|
||||
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 first = (unit_offset / 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))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
||||
@@ -141,12 +141,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
let mut segments = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = 8 + i * record_size;
|
||||
// o+4..o+8 = segment_number (u16) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let number = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
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 end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||
segments.push(Segment {
|
||||
number,
|
||||
index,
|
||||
start_spn,
|
||||
end_spn,
|
||||
});
|
||||
@@ -154,12 +154,74 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||
/// records. `recs` are `(segment_number, start_spn, end_spn)`.
|
||||
/// records. `recs` are `(index, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
@@ -175,9 +237,75 @@ mod tests {
|
||||
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]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on a retail 2.1 disc: 2560-packet segments.
|
||||
// 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
|
||||
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
@@ -185,7 +313,9 @@ mod tests {
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].number, 1);
|
||||
assert_eq!(segs[0].index, 1);
|
||||
assert_eq!(segs[1].index, 2);
|
||||
assert_eq!(segs[2].index, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
@@ -224,13 +354,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_variant() {
|
||||
fn unit_inside_segment_routes_to_index() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.number, 1);
|
||||
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_field_cycles_one_to_thirty_two() {
|
||||
// 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.
|
||||
let mut recs = Vec::new();
|
||||
let mut spn = 1000u32;
|
||||
for row in 0..2 {
|
||||
for v in 1..=32u16 {
|
||||
recs.push((v, spn, spn + 2559));
|
||||
spn += 50_000; // ~one segment every ~67 MB
|
||||
}
|
||||
let _ = row;
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -238,29 +388,29 @@ mod tests {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(variant_segment_for_unit(&segs, off).is_none());
|
||||
assert!(segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[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
|
||||
// packets straddle the boundary) must still route to the variant key,
|
||||
// because part of its ciphertext is variant-encrypted.
|
||||
// packets straddle the boundary) must still route to the index key,
|
||||
// because part of its ciphertext is forensic-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.number, 7);
|
||||
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.index, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
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]
|
||||
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.
|
||||
assert!(variant_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(0)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+198
-8
@@ -6,7 +6,7 @@
|
||||
//! owns only the crypto and these value types that flow through it.
|
||||
|
||||
/// A device key for MKB subset-difference tree processing.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceKey {
|
||||
pub key: [u8; 16],
|
||||
pub node: u16,
|
||||
@@ -15,7 +15,7 @@ pub struct DeviceKey {
|
||||
}
|
||||
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||
pub private_key: [u8; 20],
|
||||
@@ -28,22 +28,22 @@ pub struct HostCert {
|
||||
}
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||
/// device-key walk derives at its matching SD node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
@@ -53,14 +53,49 @@ pub struct ProcessingKey(pub [u8; 16]);
|
||||
/// area). The CPS-unit *number* association is a higher-level concern owned by
|
||||
/// [`super::inf::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
|
||||
///
|
||||
/// `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 forensic index key that
|
||||
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
|
||||
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
|
||||
/// (the 65536-value device selector), which is a separate MKB-layer concern.
|
||||
pub index_number: u8,
|
||||
}
|
||||
|
||||
impl UnitKey {
|
||||
/// 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.
|
||||
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
index_number: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forensic index key: `index_number` in `1..=32`, decrypting the
|
||||
/// `IndividualSegment.tbl` segments tagged with that index.
|
||||
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
index_number,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
|
||||
pub const fn is_default_index(&self) -> bool {
|
||||
self.index_number == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct DiscEntry {
|
||||
/// Disc hash (20 bytes, hex)
|
||||
pub disc_hash: String,
|
||||
@@ -75,3 +110,158 @@ pub struct DiscEntry {
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
// ── Redacting `Debug` impls ──────────────────────────────────────────────────
|
||||
//
|
||||
// Every type above carries AACS secret material (device keys, host PRIVATE keys,
|
||||
// media/volume/processing/unit keys). `#[derive(Debug)]` would print those bytes
|
||||
// verbatim, so a stray `debug!("{:?}", …)` or a panic message would leak the
|
||||
// keys. These hand-written impls print only NON-secret shape (presence, lengths,
|
||||
// tree coordinates, indices) — never key bytes. `decrypt::DecryptKeys` follows
|
||||
// the same policy by omitting `Debug` entirely; here we keep `Debug` because
|
||||
// these are `PartialEq`/`Eq` value types used in `assert_eq!` and nested inside
|
||||
// other `#[derive(Debug)]` structs, so the trait must exist — just not leak.
|
||||
// Guarded by `redaction_tests` below.
|
||||
|
||||
impl std::fmt::Debug for DeviceKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeviceKey")
|
||||
.field("key", &"<redacted>")
|
||||
.field("node", &self.node)
|
||||
.field("uv", &self.uv)
|
||||
.field("u_mask_shift", &self.u_mask_shift)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HostCert {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HostCert")
|
||||
.field("private_key", &"<redacted>")
|
||||
.field("certificate_len", &self.certificate.len())
|
||||
.field("private_key_v2", &self.private_key_v2.map(|_| "<redacted>"))
|
||||
.field(
|
||||
"certificate_v2_len",
|
||||
&self.certificate_v2.as_ref().map(|c| c.len()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vid(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MediaKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("MediaKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vuk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vuk(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProcessingKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("ProcessingKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UnitKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UnitKey")
|
||||
.field("idx", &self.idx)
|
||||
.field("key", &"<redacted>")
|
||||
.field("index_number", &self.index_number)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DiscEntry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DiscEntry")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("title", &self.title)
|
||||
.field("media_key", &self.media_key.map(|_| "<redacted>"))
|
||||
.field("disc_id", &self.disc_id.map(|_| "<redacted>"))
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod redaction_tests {
|
||||
use super::*;
|
||||
|
||||
// Sentinel key byte 0xD5 = decimal 213. A derived `Debug` prints `[u8;N]`
|
||||
// as decimal, so a leaked key surfaces the substring "213"; the redacting
|
||||
// impls must not. No non-secret field below is 213, so "213" appearing means
|
||||
// key bytes leaked. Each type must also carry a "redacted" marker (or omit
|
||||
// the secret entirely) so re-adding `#[derive(Debug)]` fails this test.
|
||||
const S: u8 = 0xD5;
|
||||
|
||||
fn assert_redacted(what: &str, dbg: &str) {
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"{what}: Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_debug_is_redacted() {
|
||||
let d = DeviceKey {
|
||||
key: [S; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert_redacted("DeviceKey", &format!("{d:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cert_debug_is_redacted() {
|
||||
let h = HostCert {
|
||||
private_key: [S; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
private_key_v2: Some([S; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
assert_redacted("HostCert", &format!("{h:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_keys_debug_is_redacted() {
|
||||
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
|
||||
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
|
||||
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
|
||||
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_debug_is_redacted() {
|
||||
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_debug_is_redacted() {
|
||||
let e = DiscEntry {
|
||||
disc_hash: "0xAA".into(),
|
||||
title: "T".into(),
|
||||
media_key: Some([S; 16]),
|
||||
disc_id: Some([S; 16]),
|
||||
vuk: Some([S; 16]),
|
||||
unit_keys: vec![(1, [S; 16])],
|
||||
};
|
||||
assert_redacted("DiscEntry", &format!("{e:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -146,7 +146,7 @@ use super::derive::{calc_pk_from_dk, calc_v_mask};
|
||||
/// Outcome of a subset-difference walk against an MKB. Carries the
|
||||
/// processing key and the matching `uv` slot — both needed as inputs
|
||||
/// to the variant chain.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ProcessingKeyMatch {
|
||||
/// Processing Key.
|
||||
pub kp: [u8; 16],
|
||||
@@ -158,6 +158,20 @@ pub struct ProcessingKeyMatch {
|
||||
pub cvalue_index: usize,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `kp` (a Processing Key) and `cvalue` are secret, never
|
||||
// printed. `uv` / `cvalue_index` are non-secret coordinates. Guarded by
|
||||
// `processing_key_match_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ProcessingKeyMatch {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProcessingKeyMatch")
|
||||
.field("kp", &"<redacted>")
|
||||
.field("uv", &self.uv)
|
||||
.field("cvalue", &"<redacted>")
|
||||
.field("cvalue_index", &self.cvalue_index)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records.iter().find(|r| {
|
||||
(r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
|
||||
@@ -635,6 +649,27 @@ mod tests {
|
||||
use super::super::crypto::aesg3;
|
||||
use super::super::derive::calc_pk_from_dk;
|
||||
|
||||
/// `ProcessingKeyMatch` carries the Processing Key (`kp`) and `cvalue` raw;
|
||||
/// `Debug` must redact both. Non-secret `uv`/`cvalue_index` are not 213.
|
||||
#[test]
|
||||
fn processing_key_match_debug_is_redacted() {
|
||||
let m = ProcessingKeyMatch {
|
||||
kp: [0xD5; 16],
|
||||
uv: 1,
|
||||
cvalue: [0xD5; 16],
|
||||
cvalue_index: 2,
|
||||
};
|
||||
let dbg = format!("{m:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"ProcessingKeyMatch leaked kp/cvalue: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"ProcessingKeyMatch missing marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
|
||||
// Regression for the unbounded-loop hang: pick a (dev_key_v_mask,
|
||||
|
||||
@@ -66,6 +66,10 @@ pub mod coding_type {
|
||||
pub const MPEG2_VIDEO: u8 = 0x02;
|
||||
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
|
||||
pub const H264: u8 = 0x1B;
|
||||
/// H.264 / MVC dependent view (Blu-ray 3D right-eye substream). Carried in
|
||||
/// the SSIF interleaved stream under its own PID; the base view is [`H264`].
|
||||
/// ISO/IEC 13818-1 stream_type 0x20 (MVC video sub-bitstream).
|
||||
pub const H264_MVC: u8 = 0x20;
|
||||
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
|
||||
pub const HEVC: u8 = 0x24;
|
||||
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
|
||||
|
||||
+46
-21
@@ -29,7 +29,7 @@ use crate::sector::SectorSource;
|
||||
const CSS_LOCKED_BAIL: u32 = 64;
|
||||
|
||||
/// CSS decryption state for a DVD title.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct CssState {
|
||||
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
|
||||
pub title_key: [u8; 5],
|
||||
@@ -43,6 +43,18 @@ pub struct CssState {
|
||||
pub crack_span: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `CssState` is reachable via the public `Disc.css` field, so
|
||||
// a `{:?}` on a `Disc` would otherwise print the raw CSS title key. Print only
|
||||
// the (non-secret) crack span. Guarded by `css_state_debug_is_redacted`.
|
||||
impl std::fmt::Debug for CssState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CssState")
|
||||
.field("title_key", &"<redacted>")
|
||||
.field("crack_span", &self.crack_span)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
|
||||
///
|
||||
@@ -54,12 +66,16 @@ pub struct CssState {
|
||||
/// extents and return the first sector that yields a key — no player keys, no
|
||||
/// disc-key crack. Works on a live drive (after bus-auth unlocks reads) and on
|
||||
/// disc images alike.
|
||||
/// This convenience form runs to completion (no cancellation) and returns just
|
||||
/// the key; callers needing an operator-Stop / watchdog cancel, or the three-way
|
||||
/// [`CrackOutcome`] (to distinguish "unencrypted" from "encrypted-but-uncracked"),
|
||||
/// use [`crack_key_outcome`], which takes a `halt` token.
|
||||
pub fn crack_key(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
) -> Option<CssState> {
|
||||
crack_key_halt(reader, extents, batch_sectors, None)
|
||||
crack_key_scan(reader, extents, batch_sectors, None, false).into_state()
|
||||
}
|
||||
|
||||
/// Outcome of a CSS crack scan that distinguishes the THREE cases the bare
|
||||
@@ -105,6 +121,12 @@ impl CrackOutcome {
|
||||
/// ScrambledUncracked) so callers can distinguish "genuinely unencrypted" from
|
||||
/// "encrypted but uncrackable" — the latter must become a hard error, never a
|
||||
/// silent fall-through to plaintext.
|
||||
///
|
||||
/// Takes an optional cooperative-cancellation token. "No silent hangs": the
|
||||
/// crack scans up to 50_000 sectors, which on a live drive hitting bad sectors
|
||||
/// can take a long time, so it polls `halt` once per batch (the same cadence
|
||||
/// sweep/patch use) and emits a `freemkv::heartbeat` beat ("css_crack") each
|
||||
/// batch so a stuck scan is visible in the log.
|
||||
pub fn crack_key_outcome(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
@@ -114,27 +136,10 @@ pub fn crack_key_outcome(
|
||||
crack_key_scan(reader, extents, batch_sectors, halt, true)
|
||||
}
|
||||
|
||||
/// [`crack_key`] with an optional cooperative-cancellation token.
|
||||
///
|
||||
/// "No silent hangs": the crack scans up to 50_000 sectors, which on a live
|
||||
/// drive hitting bad sectors can take a long time. This variant polls `halt`
|
||||
/// once per batch (the same cadence sweep/patch use) so an operator Stop or a
|
||||
/// scan-level watchdog can interrupt the scan, and emits a
|
||||
/// `freemkv::heartbeat` beat ("css_crack") each batch so a stuck scan is
|
||||
/// visible in the log.
|
||||
pub fn crack_key_halt(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> Option<CssState> {
|
||||
crack_key_scan(reader, extents, batch_sectors, halt, false).into_state()
|
||||
}
|
||||
|
||||
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
|
||||
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
|
||||
/// from a genuinely-unencrypted one (the [`crack_key`] / [`crack_key_halt`]
|
||||
/// `Option` wrappers collapse both to `None`).
|
||||
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
|
||||
/// collapses both to `None` via [`CrackOutcome::into_state`]).
|
||||
fn crack_key_scan(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
@@ -364,6 +369,26 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// `CssState` is reachable via the public `Disc.css` field, so a `{:?}` on a
|
||||
/// `Disc` must not print the raw CSS title key. Sentinel byte 213 (0xD5);
|
||||
/// `crack_span` is non-secret and none of its values are 213.
|
||||
#[test]
|
||||
fn css_state_debug_is_redacted() {
|
||||
let s = CssState {
|
||||
title_key: [0xD5; 5],
|
||||
crack_span: Some((10, 20)),
|
||||
};
|
||||
let dbg = format!("{s:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"CssState Debug leaked the title key: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"CssState Debug missing marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── is_scrambled ───────────────────────────────────────────────────────
|
||||
|
||||
/// is_scrambled returns false for any buffer shorter than one sector,
|
||||
|
||||
+698
-80
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -336,7 +336,7 @@ fn frame_record(track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> V
|
||||
/// `track_number` is the 1-based MKV track number; `track` is the built
|
||||
/// [`crate::mux::mkv::MkvTrack`] whose fields map one-to-one onto the emitted
|
||||
/// elements (see `MkvMuxer::new`). No-op unless the diag target is on.
|
||||
pub fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
pub(crate) fn dump_mkv_track(track_number: u64, track: &crate::mux::mkv::MkvTrack) {
|
||||
if !diag_enabled() {
|
||||
return;
|
||||
}
|
||||
@@ -530,7 +530,7 @@ fn dump_aacs(disc: &Disc) {
|
||||
a.bus_encryption,
|
||||
a.mkb_version,
|
||||
a.disc_hash,
|
||||
a.key_source.name(),
|
||||
a.key_source,
|
||||
a.vuk.is_some(),
|
||||
a.unit_keys.len(),
|
||||
a.uk_ro.len(),
|
||||
|
||||
+59
-5
@@ -72,6 +72,11 @@ impl Disc {
|
||||
// Parse each clip for size, duration, and sector extents
|
||||
let mut extents = Vec::new();
|
||||
let mut total_size: u64 = 0;
|
||||
// Set when any clip resolves to a STREAM/SSIF/<clip>.ssif — a Blu-ray 3D
|
||||
// interleaved stream carrying both the base (left) and MVC dependent
|
||||
// (right) views. Drives reading the SSIF for both eyes and adding the
|
||||
// dependent-view stream below.
|
||||
let mut is_3d = false;
|
||||
let mut clips = Vec::with_capacity(parsed.play_items.len());
|
||||
// BD playlists legally reference the same .m2ts clip_id from
|
||||
// multiple PlayItems (multi-angle, seamless splits, looped
|
||||
@@ -110,10 +115,23 @@ impl Disc {
|
||||
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
|
||||
// clip is unchanged — the fallback only runs when `.m2ts`
|
||||
// is absent, which is exactly when `file_extents` errors.
|
||||
let file_exts = CLIP_STREAM_EXTS.iter().find_map(|ext| {
|
||||
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
|
||||
udf_fs.file_extents(reader, &path).ok()
|
||||
});
|
||||
// 3D discs interleave the left (base) and right (MVC
|
||||
// dependent) views in STREAM/SSIF/<clip>.ssif — note the
|
||||
// SSIF/ subdir. Prefer it when present: the SSIF is one
|
||||
// transport stream carrying BOTH eyes on distinct PIDs,
|
||||
// so muxing it captures the full 3D. 2D clips fall back to
|
||||
// the base .m2ts / .fmts as before.
|
||||
let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id);
|
||||
let file_exts = match udf_fs.file_extents(reader, &ssif) {
|
||||
Ok(exts) => {
|
||||
is_3d = true;
|
||||
Some(exts)
|
||||
}
|
||||
Err(_) => CLIP_STREAM_EXTS.iter().find_map(|ext| {
|
||||
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
|
||||
udf_fs.file_extents(reader, &path).ok()
|
||||
}),
|
||||
};
|
||||
if let Some(file_exts) = file_exts {
|
||||
for (lba, sectors) in file_exts {
|
||||
if sectors > 0 && lba > 0 {
|
||||
@@ -138,7 +156,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
// Build streams from STN table
|
||||
let streams: Vec<Stream> = parsed
|
||||
let mut streams: Vec<Stream> = parsed
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
@@ -232,6 +250,42 @@ impl Disc {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3D: add the MVC dependent (right-eye) video stream. The base STN table
|
||||
// lists only the left-eye video; the dependent view is a second video
|
||||
// PID (stream_type 0x20) carried in the SSIF. The on-disc PAT/PMT are
|
||||
// AACS-encrypted (unreadable pre-key) and the base STN omits the
|
||||
// dependent view (it lives in the MPLS STN_table_SS), so we use the
|
||||
// BD-3D PID convention: dependent = base-view video PID + 1
|
||||
// (e.g. 0x1011 -> 0x1012). Reading the SSIF (above) provides its packets.
|
||||
//
|
||||
// Limitation: `is_3d` latches per PLAYLIST, not per clip. A playlist that
|
||||
// mixed a 3D clip (has an SSIF) with a 2D clip (no SSIF) would tag the
|
||||
// whole title 3D; the 2D clip's frames then mux as plain Blocks (no
|
||||
// dependent PID → no BlockAdditional) under a track that still advertises
|
||||
// the mvcC mapping. That output is valid (per-frame BlockAdditional is
|
||||
// optional) but over-claims 3D for those frames. Real 3D main-feature
|
||||
// playlists are single-clip or uniformly 3D, so this is not exercised;
|
||||
// per-clip 3D would need per-clip stream sets (a larger change).
|
||||
if is_3d {
|
||||
if let Some(base) = streams.iter().find_map(|s| match s {
|
||||
Stream::Video(v) => Some(v.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
let dep_pid = base.pid.wrapping_add(1);
|
||||
let have_dep = streams
|
||||
.iter()
|
||||
.any(|s| matches!(s, Stream::Video(v) if v.pid == dep_pid));
|
||||
if !have_dep {
|
||||
streams.push(Stream::Video(VideoStream {
|
||||
pid: dep_pid,
|
||||
secondary: true,
|
||||
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
|
||||
..base
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert marks to chapters. mark_type == 1 is an entry-mark
|
||||
// (chapter); type 2 is a link point and type 0 is reserved, so
|
||||
// neither is a chapter.
|
||||
|
||||
+38
-9
@@ -7,7 +7,6 @@ use crate::udf;
|
||||
|
||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||
/// Only available when scanning from a real drive, not ISO images.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct HandshakeResult {
|
||||
pub volume_id: [u8; 16],
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
@@ -29,6 +28,19 @@ pub(super) struct HandshakeResult {
|
||||
pub drive_unlocked: bool,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `volume_id` and `read_data_key` (the AACS 2.0 bus key) are
|
||||
// secret; print only shape. Guarded by `handshake_result_debug_is_redacted`.
|
||||
impl std::fmt::Debug for HandshakeResult {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HandshakeResult")
|
||||
.field("volume_id", &"<redacted>")
|
||||
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
|
||||
.field("read_data_key_err", &self.read_data_key_err)
|
||||
.field("drive_unlocked", &self.drive_unlocked)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Single source of truth for "is AACS bus encryption gone for this scan?". The
|
||||
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
|
||||
/// encryption is gone when ANY of these holds:
|
||||
@@ -311,15 +323,11 @@ impl Disc {
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs;
|
||||
|
||||
let uk_ro_data = udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let uk_ro_data =
|
||||
aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
|
||||
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||
|
||||
let cc = udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
||||
let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::inf::parse_content_cert);
|
||||
@@ -432,6 +440,27 @@ mod tests {
|
||||
use crate::sector::SectorSource;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `HandshakeResult` carries the Volume ID and the AACS 2.0 bus (read-data)
|
||||
/// key; `Debug` must redact both. Sentinel 213 (0xD5).
|
||||
#[test]
|
||||
fn handshake_result_debug_is_redacted() {
|
||||
let hs = HandshakeResult {
|
||||
volume_id: [0xD5; 16],
|
||||
read_data_key: Some([0xD5; 16]),
|
||||
read_data_key_err: None,
|
||||
drive_unlocked: false,
|
||||
};
|
||||
let d = format!("{hs:?}");
|
||||
assert!(
|
||||
!d.contains("213"),
|
||||
"HandshakeResult leaked VID/bus key: {d}"
|
||||
);
|
||||
assert!(
|
||||
d.contains("redacted"),
|
||||
"HandshakeResult missing marker: {d}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// In-memory disc + minimal UDF image with a single physical
|
||||
// partition (metadata_start == partition_start). Offsets cited
|
||||
@@ -980,7 +1009,7 @@ mod tests {
|
||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||
struct CertSource(Vec<aacs::types::HostCert>);
|
||||
impl crate::KeySource for CertSource {
|
||||
fn get_uk(
|
||||
fn get_unit_keys(
|
||||
&self,
|
||||
_ctx: &dyn crate::keysource::ResolveCtx,
|
||||
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||
|
||||
@@ -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
|
||||
/// inner source for its lifetime while the caller keeps the underlying
|
||||
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
|
||||
|
||||
+3
-1
@@ -522,8 +522,10 @@ fn compose_xpl_titles(
|
||||
})
|
||||
.collect();
|
||||
titles.push(DiscTitle {
|
||||
// Language-neutral identifier (no user-facing English in the library):
|
||||
// matches the UDF `TITLE_*` volume-label style. Apps localize display.
|
||||
playlist: if t.name.is_empty() {
|
||||
format!("Title {}", t.number)
|
||||
format!("TITLE_{}", t.number)
|
||||
} else {
|
||||
t.name.clone()
|
||||
},
|
||||
|
||||
+2
-2
@@ -77,7 +77,7 @@ impl SectorStatus {
|
||||
|
||||
/// One contiguous range of bytes with a status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MapEntry {
|
||||
pub(crate) struct MapEntry {
|
||||
pub pos: u64,
|
||||
pub size: u64,
|
||||
pub status: SectorStatus,
|
||||
@@ -498,7 +498,7 @@ impl Mapfile {
|
||||
|
||||
/// All map entries, sorted ascending by `pos` and (after load)
|
||||
/// guaranteed disjoint and non-overflowing.
|
||||
pub fn entries(&self) -> &[MapEntry] {
|
||||
pub(crate) fn entries(&self) -> &[MapEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
|
||||
+210
-196
@@ -10,7 +10,7 @@
|
||||
|
||||
mod bluray;
|
||||
mod dvd;
|
||||
pub mod dvd_audio_probe;
|
||||
pub(crate) mod dvd_audio_probe;
|
||||
mod encrypt;
|
||||
mod extract;
|
||||
mod hddvd;
|
||||
@@ -19,7 +19,6 @@ mod patch;
|
||||
pub mod read_error;
|
||||
mod section_recover;
|
||||
mod sweep;
|
||||
pub mod verify;
|
||||
|
||||
use crate::drive::{Drive, extract_scsi_context};
|
||||
use crate::error::{Error, Result};
|
||||
@@ -85,7 +84,8 @@ pub struct Disc {
|
||||
pub enum ContentFormat {
|
||||
/// Blu-ray BD Transport Stream (192-byte packets)
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -216,6 +216,22 @@ pub struct VideoStream {
|
||||
pub measured_cicp: Option<MeasuredCicp>,
|
||||
}
|
||||
|
||||
/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
|
||||
/// view** — the paired substream of the AVC base view. Set by the BD scan
|
||||
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
|
||||
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
|
||||
/// per-frame `BlockAdditional`). Single source of truth for the contract.
|
||||
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";
|
||||
|
||||
impl VideoStream {
|
||||
/// Whether this video stream is the MVC dependent (right-eye) view — the
|
||||
/// 3D substream that the muxer merges into the base track as a per-frame
|
||||
/// `BlockAdditional` rather than emitting as an independent track.
|
||||
pub fn is_mvc_dependent(&self) -> bool {
|
||||
self.label == MVC_DEPENDENT_LABEL
|
||||
}
|
||||
}
|
||||
|
||||
/// Measured CICP colour signalling read directly from a video elementary stream
|
||||
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -441,7 +457,7 @@ pub(crate) fn chapter_name(i: usize) -> String {
|
||||
}
|
||||
|
||||
/// A contiguous range of sectors on disc.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Extent {
|
||||
pub start_lba: u32,
|
||||
pub sector_count: u32,
|
||||
@@ -667,7 +683,10 @@ fn range_chapter(lba: u32, title: &DiscTitle) -> (Option<u32>, Option<f64>) {
|
||||
/// This is the single place range→chapter/time annotation happens; autorip used
|
||||
/// to own it and read the mapfile to do so. Now the library computes it from
|
||||
/// its in-memory mapfile + title, and the client renders the result verbatim.
|
||||
pub fn locate_ranges(raw: &[(u64, u64)], title: &DiscTitle) -> crate::progress::LocatedProgress {
|
||||
pub(crate) fn locate_ranges(
|
||||
raw: &[(u64, u64)],
|
||||
title: &DiscTitle,
|
||||
) -> crate::progress::LocatedProgress {
|
||||
use crate::consts::{MILLIS_PER_SEC, SECTOR_BYTES_U64};
|
||||
use crate::progress::{LocatedProgress, LocatedRange};
|
||||
const MAX_LOCATED: usize = 50;
|
||||
@@ -811,7 +830,11 @@ impl Codec {
|
||||
use crate::consts::coding_type as c;
|
||||
match ct {
|
||||
c::HEVC => Codec::Hevc,
|
||||
c::H264 => Codec::H264,
|
||||
// 0x1B base-view AVC and 0x20 MVC dependent-view (Blu-ray 3D right
|
||||
// eye) are both H.264. Mapping 0x20 to video is what makes the PMT
|
||||
// scan enumerate the dependent view as a second H.264 stream (its
|
||||
// own PID in the SSIF) instead of dropping it — the basis of 3D.
|
||||
c::H264 | c::H264_MVC => Codec::H264,
|
||||
c::VC1 => Codec::Vc1,
|
||||
c::MPEG2_VIDEO => Codec::Mpeg2,
|
||||
c::TRUEHD => Codec::TrueHd,
|
||||
@@ -1343,7 +1366,6 @@ impl DiscTitle {
|
||||
// ─── Encryption ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// AACS decryption state for a disc.
|
||||
#[derive(Debug)]
|
||||
pub struct AacsState {
|
||||
/// AACS version (1 or 2)
|
||||
pub version: u8,
|
||||
@@ -1374,6 +1396,28 @@ pub struct AacsState {
|
||||
pub mkb: Vec<u8>,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `AacsState` is crate-root re-exported and reachable via the
|
||||
// public `Disc.aacs` field; it carries VUK / unit keys / read-data (bus) key /
|
||||
// volume id / raw .inf + MKB. Print only non-secret shape; redact every
|
||||
// key/secret field. Guarded by `aacs_state_and_key_debug_are_redacted`.
|
||||
impl std::fmt::Debug for AacsState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AacsState")
|
||||
.field("version", &self.version)
|
||||
.field("bus_encryption", &self.bus_encryption)
|
||||
.field("mkb_version", &self.mkb_version)
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("key_source", &self.key_source)
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
|
||||
.field("volume_id", &"<redacted>")
|
||||
.field("uk_ro_len", &self.uk_ro.len())
|
||||
.field("mkb_len", &self.mkb.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// How AACS keys were resolved. Variants are ordered root-of-trust →
|
||||
/// per-disc-leaf, matching the resolver's path-try order: the resolver
|
||||
/// attempts derivation from the strongest input it has first and falls
|
||||
@@ -1396,18 +1440,9 @@ pub enum KeyOrigin {
|
||||
ExternalUk,
|
||||
}
|
||||
|
||||
impl KeyOrigin {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
KeyOrigin::DeviceKey => "MKB + device key",
|
||||
KeyOrigin::ProcessingKey => "MKB + processing key",
|
||||
KeyOrigin::KeyDbDerived => "KEYDB (derived)",
|
||||
KeyOrigin::KeyDb => "KEYDB",
|
||||
KeyOrigin::KeyDbUnitKeys => "KEYDB (unit keys)",
|
||||
KeyOrigin::ExternalUk => "external UK",
|
||||
}
|
||||
}
|
||||
}
|
||||
// No `KeyOrigin::name()`: the library holds ZERO user-facing English (CLAUDE.md).
|
||||
// `KeyOrigin` is a typed enum; applications map its variants to display text
|
||||
// (see freemkv's `disc_info::key_origin_label`).
|
||||
|
||||
// ─── Disc scanning ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1603,12 +1638,11 @@ impl Disc {
|
||||
// detection needs the read, the read needs auth, auth needs detection.
|
||||
// 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
|
||||
// in the clear. This block is DVD-only (MPEG-PS); BD/UHD (MPEG-TS) goes
|
||||
// through the AACS handshake above and never reaches here.
|
||||
if disc.css.is_none()
|
||||
&& disc.content_format == ContentFormat::MpegPs
|
||||
&& !disc.titles.is_empty()
|
||||
{
|
||||
// in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
|
||||
// `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
|
||||
// not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
|
||||
// through the AACS path above). BD/UHD are MPEG-TS and never reach here.
|
||||
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
|
||||
// carries a non-zero key. Menu / VMG / logo cells (often the
|
||||
// low-LBA first extent) return a ZERO title key over REPORT KEY —
|
||||
@@ -1724,10 +1758,13 @@ impl Disc {
|
||||
// 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
|
||||
// clear. AACS images go through KEYDB VUK lookup, not here.
|
||||
if disc.css.is_none()
|
||||
&& disc.content_format == ContentFormat::MpegPs
|
||||
&& !disc.titles.is_empty()
|
||||
{
|
||||
//
|
||||
// Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
|
||||
// `.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
|
||||
.titles
|
||||
.iter()
|
||||
@@ -1779,10 +1816,9 @@ impl Disc {
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
|
||||
let inf = udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| {
|
||||
udf_fs.read_file(reader, p)
|
||||
})?;
|
||||
let mkb = Self::read_mkb_content(reader, udf_fs)?;
|
||||
let version = Self::read_aacs_version(reader, udf_fs);
|
||||
Ok((inf, mkb, version))
|
||||
@@ -1800,12 +1836,12 @@ impl Disc {
|
||||
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
|
||||
/// not quietly pick the V10 stride for a UHD disc.
|
||||
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
|
||||
match udf_fs
|
||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(crate::aacs::inf::parse_content_cert)
|
||||
match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| {
|
||||
udf_fs.read_file(reader, p)
|
||||
})
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(crate::aacs::inf::parse_content_cert)
|
||||
{
|
||||
Some(c) => c.version.major(),
|
||||
None => {
|
||||
@@ -1836,10 +1872,9 @@ impl Disc {
|
||||
const MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
let mut want = START_BYTES;
|
||||
loop {
|
||||
let buf = udf_fs
|
||||
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
|
||||
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| {
|
||||
udf_fs.read_file_prefix(reader, p, want)
|
||||
})?;
|
||||
let n = crate::aacs::mkb::mkb_content_len(&buf);
|
||||
// `n` strictly inside `buf` => the record walk reached the padding
|
||||
// boundary (full content captured). `buf` shorter than `want` =>
|
||||
@@ -2224,7 +2259,7 @@ impl Disc {
|
||||
/// point at one level of that chain; [`Disc::decrypt_with`] derives down from
|
||||
/// it to the per-CPS unit keys. New levels can be added without breaking
|
||||
/// callers.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum Key {
|
||||
/// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB
|
||||
@@ -2253,6 +2288,22 @@ pub enum Key {
|
||||
Unit(Vec<(u32, [u8; 16])>),
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `Key` is crate-root re-exported and is the key-transport
|
||||
// type crossing `Disc::decrypt_with`; every variant carries raw key material.
|
||||
// Print only the variant name and count — never bytes. Guarded by
|
||||
// `aacs_state_and_key_debug_are_redacted`.
|
||||
impl std::fmt::Debug for Key {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Key::Device(v) => write!(f, "Key::Device(<{} redacted>)", v.len()),
|
||||
Key::Processing(v) => write!(f, "Key::Processing(<{} redacted>)", v.len()),
|
||||
Key::Media(v) => write!(f, "Key::Media(<{} redacted>)", v.len()),
|
||||
Key::Volume(_) => f.write_str("Key::Volume(<redacted>)"),
|
||||
Key::Unit(v) => write!(f, "Key::Unit(<{} redacted>)", v.len()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `unit_keys` covers EVERY supplied scrambled content `sample` — the
|
||||
/// validation gate for [`Disc::decrypt_with`]. Conservative: a sample that is
|
||||
/// not AACS-scrambled proves nothing, and with no scrambled sample at all there
|
||||
@@ -2277,12 +2328,15 @@ fn aligned_unit_keys_validate(
|
||||
unit_keys: &[(u32, [u8; 16])],
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
samples: &[Vec<u8>],
|
||||
format: ContentFormat,
|
||||
) -> 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
|
||||
.iter()
|
||||
.map(|s| s.as_slice())
|
||||
.filter(|s| aacs_unit_needs_decrypt(s))
|
||||
.filter(|s| aacs_unit_needs_decrypt(s, format))
|
||||
.collect();
|
||||
if scrambled.is_empty() {
|
||||
return true; // nothing to disprove against — accept
|
||||
@@ -2304,7 +2358,13 @@ fn aligned_unit_keys_validate(
|
||||
hb.tick_cpu(tried, total);
|
||||
tried += 1;
|
||||
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;
|
||||
break;
|
||||
}
|
||||
@@ -2331,6 +2391,7 @@ impl Disc {
|
||||
crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: aacs.unit_keys.clone(),
|
||||
read_data_key: aacs.read_data_key,
|
||||
format: self.content_format,
|
||||
}
|
||||
} else if let Some(ref css) = self.css {
|
||||
crate::decrypt::DecryptKeys::Css {
|
||||
@@ -2357,7 +2418,7 @@ impl Disc {
|
||||
///
|
||||
/// Empty when the disc has no parsed titles (CSS / unencrypted / unscanned);
|
||||
/// callers treat an empty map as "no content gate" and fall back accordingly.
|
||||
pub fn encrypted_content_ranges(&self) -> Vec<(u32, u32)> {
|
||||
pub(crate) fn encrypted_content_ranges(&self) -> Vec<(u32, u32)> {
|
||||
merged_extents(self.titles.iter().flat_map(|t| &t.extents))
|
||||
}
|
||||
|
||||
@@ -2365,10 +2426,10 @@ impl Disc {
|
||||
/// empty when this disc has no captured AACS state. Used to name the disc in
|
||||
/// a [`Error::NoDiscKey`] so the application can tell the user which disc to
|
||||
/// add to the keydb.
|
||||
pub fn aacs_disc_hash(&self) -> String {
|
||||
pub(crate) fn aacs_disc_hash(&self) -> String {
|
||||
self.aacs
|
||||
.as_ref()
|
||||
.map(|a| a.disc_hash.trim_start_matches("0x").to_string())
|
||||
.map(|a| crate::hex::strip_hex_prefix(&a.disc_hash).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -2505,35 +2566,25 @@ impl Disc {
|
||||
///
|
||||
/// `batch_sectors` sizes the crack's batched reads (file-safe value for
|
||||
/// an ISO; `detect_max_batch_sectors` for a live drive).
|
||||
pub fn decrypt_keys_for_title(
|
||||
&self,
|
||||
idx: usize,
|
||||
reader: &mut dyn SectorSource,
|
||||
batch_sectors: u16,
|
||||
) -> crate::decrypt::DecryptKeys {
|
||||
self.decrypt_keys_for_title_checked(idx, reader, batch_sectors)
|
||||
.0
|
||||
}
|
||||
|
||||
/// [`Self::decrypt_keys_for_title`] plus the per-title encryption verdict the
|
||||
/// gate needs to AVOID A FALSE ERROR on a genuinely-clear extra title.
|
||||
/// Also returns the per-title encryption verdict the gate needs to AVOID A
|
||||
/// FALSE ERROR on a genuinely-clear extra title.
|
||||
///
|
||||
/// On a multi-VTS CSS DVD that ALSO carries a clear, unencrypted stub title
|
||||
/// (a 0.5 s menu loop, an FBI-warning nav title) living in its own VTS, the
|
||||
/// re-crack over that stub's extents finds NO scrambled sector and recovers
|
||||
/// no key. The bare `decrypt_keys_for_title` collapses that to
|
||||
/// no key. Returning ONLY the keys would collapse that to
|
||||
/// `DecryptKeys::None`, indistinguishable from "scrambled but uncrackable",
|
||||
/// so [`Self::ensure_decryptable_keys`] (which fails whenever `css.is_some()`
|
||||
/// and the key is `None`) wrongly raised `E7023` for a title that needs no
|
||||
/// key at all. That is the false error the multi-title mux must never emit.
|
||||
/// and the key is `None`) would wrongly raise `E7023` for a title that needs
|
||||
/// no key at all — the false error the multi-title mux must never emit.
|
||||
///
|
||||
/// This variant runs the re-crack via [`crate::css::crack_key_outcome`] and
|
||||
/// So the re-crack runs via [`crate::css::crack_key_outcome`] and ALSO
|
||||
/// returns `title_is_clear == true` when the title's own extents showed NO
|
||||
/// scrambling (`CrackOutcome::Unencrypted`) — the gate then treats that title
|
||||
/// as needing no key and passes it cleanly. A title that genuinely IS
|
||||
/// scrambled but uncrackable returns `(None, false)` and still hard-fails.
|
||||
/// The returned bool pairs with [`Self::ensure_title_decryptable`].
|
||||
pub fn decrypt_keys_for_title_checked(
|
||||
pub fn decrypt_keys_for_title(
|
||||
&self,
|
||||
idx: usize,
|
||||
reader: &mut dyn SectorSource,
|
||||
@@ -2597,7 +2648,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
/// Per-title decrypt gate that honours the `title_is_clear` verdict from
|
||||
/// [`Self::decrypt_keys_for_title_checked`].
|
||||
/// [`Self::decrypt_keys_for_title`].
|
||||
///
|
||||
/// Identical to [`Self::ensure_decryptable_keys`] EXCEPT it does not raise
|
||||
/// `E7023` when the chosen title proved genuinely clear (`title_is_clear`):
|
||||
@@ -2625,28 +2676,6 @@ impl Disc {
|
||||
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
|
||||
/// / 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
|
||||
@@ -2664,7 +2693,7 @@ impl Disc {
|
||||
/// file-backed mux. Without this, a keyed disc swept without a keydb would
|
||||
/// recover its UK yet still report E8005 (no usable `decrypt_keys`) at
|
||||
/// remux. No-op for an unencrypted or CSS (DVD) disc.
|
||||
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
|
||||
pub(crate) fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
|
||||
if let Some(aacs) = self.aacs.as_mut() {
|
||||
aacs.unit_keys = keys;
|
||||
aacs.key_source = KeyOrigin::ExternalUk;
|
||||
@@ -2820,7 +2849,12 @@ impl Disc {
|
||||
// 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
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -3053,10 +3087,6 @@ impl Disc {
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.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)?;
|
||||
tracing::info!(
|
||||
@@ -3111,27 +3141,15 @@ impl Disc {
|
||||
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
|
||||
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
|
||||
//
|
||||
// A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the
|
||||
// autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we
|
||||
// still resolve the keys and VERIFY each unit on a scratch copy: a unit
|
||||
// that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like
|
||||
// a SCSI error, and flows into the SAME read-error recovery (skip /
|
||||
// NonTrimmed / patch). This is the one spot that makes "a read succeeded"
|
||||
// mean "read AND decrypts" — everything downstream is unchanged. With no
|
||||
// usable AACS keys (no keydb) it degrades to a plain pass-through.
|
||||
//
|
||||
// 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.
|
||||
// Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and
|
||||
// plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a
|
||||
// pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI
|
||||
// read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure.
|
||||
// (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute
|
||||
// unit grid against clip-file-anchored AACS units and false-failed good
|
||||
// clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
|
||||
// verify and no post-sweep clip-anchored pass; decryptability is proven at
|
||||
// mux time, not at capture time.)
|
||||
let keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
@@ -3157,22 +3175,6 @@ impl Disc {
|
||||
};
|
||||
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.
|
||||
let mapfile_path = self.mapfile_for(path);
|
||||
// covers_disc reconciliation. A resume against a mapfile whose total
|
||||
@@ -3463,18 +3465,6 @@ impl Disc {
|
||||
// The consumer thread sees decrypted bytes; the
|
||||
// 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
|
||||
// owned Vec. The producer's `buf` is reused
|
||||
// for the next read.
|
||||
@@ -3483,26 +3473,6 @@ impl Disc {
|
||||
producer_err = Some(consumer_gone());
|
||||
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);
|
||||
pos += block_bytes;
|
||||
}
|
||||
@@ -3989,18 +3959,6 @@ pub struct PatchOptions<'a> {
|
||||
/// 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.
|
||||
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`].
|
||||
@@ -4251,6 +4209,40 @@ pub fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `AacsState` (public via `Disc.aacs`) and `Key` (the key-transport enum)
|
||||
/// must never print raw key bytes on `{:?}`. Sentinel 213 (0xD5); non-secret
|
||||
/// fields below are not 213.
|
||||
#[test]
|
||||
fn aacs_state_and_key_debug_are_redacted() {
|
||||
let st = AacsState {
|
||||
version: 2,
|
||||
bus_encryption: true,
|
||||
mkb_version: Some(77),
|
||||
disc_hash: "0xAA".into(),
|
||||
key_source: KeyOrigin::ExternalUk,
|
||||
vuk: Some([0xD5; 16]),
|
||||
unit_keys: vec![(1, [0xD5; 16])],
|
||||
read_data_key: Some([0xD5; 16]),
|
||||
volume_id: [0xD5; 16],
|
||||
uk_ro: vec![1, 2, 3],
|
||||
mkb: vec![4, 5, 6],
|
||||
};
|
||||
let d = format!("{st:?}");
|
||||
assert!(!d.contains("213"), "AacsState leaked key bytes: {d}");
|
||||
assert!(d.contains("redacted"), "AacsState missing marker: {d}");
|
||||
|
||||
for k in [
|
||||
Key::Unit(vec![(1, [0xD5; 16])]),
|
||||
Key::Volume([0xD5; 16]),
|
||||
Key::Processing(vec![[0xD5; 16]]),
|
||||
Key::Media(vec![[0xD5; 16]]),
|
||||
] {
|
||||
let d = format!("{k:?}");
|
||||
assert!(!d.contains("213"), "Key leaked bytes: {d}");
|
||||
assert!(d.contains("redacted"), "Key missing marker: {d}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── encrypted-content map (`merged_extents` core) ────────────────────────
|
||||
|
||||
fn ext(start_lba: u32, sector_count: u32) -> Extent {
|
||||
@@ -4847,6 +4839,7 @@ mod tests {
|
||||
crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys,
|
||||
read_data_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");
|
||||
@@ -5125,8 +5118,8 @@ mod tests {
|
||||
// ── Fix 2/3: a genuinely-clear extra title on a CSS disc never E7023s ──────
|
||||
|
||||
/// Reader that serves clear (unscrambled) sectors for one extent range and
|
||||
/// CSS-locked errors elsewhere — enough to drive `decrypt_keys_for_title_
|
||||
/// checked`'s per-title re-crack to `Unencrypted` for a clear stub.
|
||||
/// CSS-locked errors elsewhere — enough to drive `decrypt_keys_for_title`'s
|
||||
/// per-title re-crack to `Unencrypted` for a clear stub.
|
||||
struct ClearStubReader {
|
||||
clear_range: (u32, u32),
|
||||
}
|
||||
@@ -5186,7 +5179,7 @@ mod tests {
|
||||
let mut reader = ClearStubReader {
|
||||
clear_range: (0, 100_000),
|
||||
};
|
||||
let (keys, title_is_clear) = disc.decrypt_keys_for_title_checked(stub_idx, &mut reader, 8);
|
||||
let (keys, title_is_clear) = disc.decrypt_keys_for_title(stub_idx, &mut reader, 8);
|
||||
assert!(
|
||||
!keys.is_encrypted(),
|
||||
"a clear stub needs no key (got encrypted keys)"
|
||||
@@ -5366,14 +5359,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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
|
||||
// like resume / mapfile must be unaffected).
|
||||
assert!(super::aligned_unit_keys_validate(
|
||||
&[(0, [0x11u8; 16])],
|
||||
None,
|
||||
&[]
|
||||
&[],
|
||||
ContentFormat::BdTs
|
||||
));
|
||||
|
||||
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
|
||||
@@ -5384,18 +5378,22 @@ mod tests {
|
||||
clear[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
assert!(!ts_sync_destroyed(&clear));
|
||||
assert!(crate::aacs::content::is_clean(
|
||||
&clear,
|
||||
crate::disc::ContentFormat::BdTs
|
||||
));
|
||||
assert!(super::aligned_unit_keys_validate(
|
||||
&[(0, [0x11u8; 16])],
|
||||
None,
|
||||
&[clear.clone()]
|
||||
&[clear.clone()],
|
||||
ContentFormat::BdTs
|
||||
));
|
||||
|
||||
// A genuinely scrambled unit the RIGHT key restores to clear TS.
|
||||
let uk = [0x5au8; 16];
|
||||
let enc = encrypt_unit_for_test(&clear, &uk);
|
||||
assert!(
|
||||
ts_sync_destroyed(&enc),
|
||||
!crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs),
|
||||
"encrypted unit must read scrambled"
|
||||
);
|
||||
|
||||
@@ -5403,16 +5401,23 @@ mod tests {
|
||||
assert!(super::aligned_unit_keys_validate(
|
||||
&[(7, uk)],
|
||||
None,
|
||||
&[enc.clone()]
|
||||
&[enc.clone()],
|
||||
ContentFormat::BdTs
|
||||
));
|
||||
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
|
||||
assert!(!super::aligned_unit_keys_validate(
|
||||
&[(7, [0x00u8; 16])],
|
||||
None,
|
||||
&[enc.clone()]
|
||||
&[enc.clone()],
|
||||
ContentFormat::BdTs
|
||||
));
|
||||
// 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]
|
||||
@@ -5424,7 +5429,7 @@ mod tests {
|
||||
// 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
|
||||
// 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 off = 4;
|
||||
@@ -5437,8 +5442,14 @@ mod tests {
|
||||
let uk1 = [0x22u8; 16];
|
||||
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
|
||||
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
|
||||
assert!(ts_sync_destroyed(&sample0));
|
||||
assert!(ts_sync_destroyed(&sample1));
|
||||
assert!(!crate::aacs::content::is_clean(
|
||||
&sample0,
|
||||
crate::disc::ContentFormat::BdTs
|
||||
));
|
||||
assert!(!crate::aacs::content::is_clean(
|
||||
&sample1,
|
||||
crate::disc::ContentFormat::BdTs
|
||||
));
|
||||
|
||||
let samples = vec![sample0.clone(), sample1.clone()];
|
||||
|
||||
@@ -5447,21 +5458,24 @@ mod tests {
|
||||
assert!(!super::aligned_unit_keys_validate(
|
||||
&[(0, uk0)],
|
||||
None,
|
||||
&samples
|
||||
&samples,
|
||||
ContentFormat::BdTs
|
||||
));
|
||||
|
||||
// Complete key set (both CPS units) -> accept.
|
||||
assert!(super::aligned_unit_keys_validate(
|
||||
&[(0, uk0), (1, uk1)],
|
||||
None,
|
||||
&samples
|
||||
&samples,
|
||||
ContentFormat::BdTs
|
||||
));
|
||||
|
||||
// Order-independent: covering key present anywhere in the set is fine.
|
||||
assert!(super::aligned_unit_keys_validate(
|
||||
&[(1, uk1), (0, uk0)],
|
||||
None,
|
||||
&samples
|
||||
&samples,
|
||||
ContentFormat::BdTs
|
||||
));
|
||||
}
|
||||
|
||||
@@ -5587,7 +5601,7 @@ mod tests {
|
||||
let mut src = RecordingSource {
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
};
|
||||
match disc.decrypt_keys_for_title(0, &mut src, 16) {
|
||||
match disc.decrypt_keys_for_title(0, &mut src, 16).0 {
|
||||
crate::decrypt::DecryptKeys::Css { title_key } => {
|
||||
assert_eq!(title_key, [0xAB; 5], "same-VTS title reuses cracked key");
|
||||
}
|
||||
@@ -5612,7 +5626,7 @@ mod tests {
|
||||
let mut src = RecordingSource {
|
||||
reads: std::cell::RefCell::new(Vec::new()),
|
||||
};
|
||||
let keys = disc.decrypt_keys_for_title(1, &mut src, 16);
|
||||
let keys = disc.decrypt_keys_for_title(1, &mut src, 16).0;
|
||||
assert!(
|
||||
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||
"a re-crack miss in a provably-different VTS must be a hard failure (None), \
|
||||
|
||||
+32
-86
@@ -405,9 +405,15 @@ pub(super) fn compute_initial_state(
|
||||
bad_ranges.reverse();
|
||||
}
|
||||
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)
|
||||
.map(|m| m.file_type().is_file())
|
||||
.unwrap_or(false);
|
||||
.unwrap_or(true);
|
||||
Ok((
|
||||
map,
|
||||
initial_stats,
|
||||
@@ -1247,7 +1253,24 @@ impl Disc {
|
||||
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
|
||||
let map = match mapfile::Mapfile::load(mapfile_path) {
|
||||
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(&[
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
@@ -1299,33 +1322,13 @@ impl Disc {
|
||||
let bytes_good_before = initial_stats.bytes_good;
|
||||
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
|
||||
// (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting
|
||||
// patch (the multipass / `--raw --multipass` path) resolves the keys and
|
||||
// VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt
|
||||
// fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the
|
||||
// retry loop keeps re-reading it "until it decrypts or retries exhaust"
|
||||
// exactly as for a SCSI read error — and a unit that DOES decrypt on a
|
||||
// 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.
|
||||
// (`opts.decrypt`) decrypts in place (plaintext ISO); a NON-decrypting
|
||||
// patch (the multipass / `--raw --multipass` path) copies ciphertext
|
||||
// verbatim (keys = `None` → pass-through). Bad sectors are found by
|
||||
// PHYSICAL read success, not by decrypt structure: a re-read that returns
|
||||
// good bytes recovers the range; a read that errors leaves it NonTrimmed
|
||||
// for the next pass. (The old decrypt-VERIFY read gate was removed.)
|
||||
let keys = if opts.decrypt {
|
||||
self.decrypt_keys()
|
||||
} else {
|
||||
@@ -1435,64 +1438,7 @@ impl Disc {
|
||||
// sink's summary. `close` failing on a regular-file sync_all is
|
||||
// surfaced here as `Error::IoError`, matching pre-split
|
||||
// behaviour.
|
||||
let mut 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 summary = pipe.finish()?;
|
||||
|
||||
let outcome = build_outcome(
|
||||
&state,
|
||||
|
||||
@@ -66,14 +66,6 @@ pub(super) enum WorkItem {
|
||||
/// tell them apart without parsing a flag.
|
||||
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
|
||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||
@@ -182,12 +174,6 @@ impl Sink<WorkItem> for SweepSink {
|
||||
}
|
||||
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 => {
|
||||
let stats = self.map.stats();
|
||||
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
|
||||
|
||||
-1022
File diff suppressed because it is too large
Load Diff
+46
-4
@@ -16,7 +16,7 @@
|
||||
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
|
||||
/// non-hex byte, or an odd length, yields `None`.
|
||||
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let body = strip_hex_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
|
||||
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
|
||||
@@ -34,7 +34,7 @@ pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
||||
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
|
||||
/// non-hex byte or a length mismatch.
|
||||
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
let body = strip_prefix(s.trim());
|
||||
let body = strip_hex_prefix(s.trim());
|
||||
let bytes = body.as_bytes();
|
||||
if bytes.len() != 2 * N {
|
||||
return None;
|
||||
@@ -46,8 +46,33 @@ pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
|
||||
fn strip_prefix(s: &str) -> &str {
|
||||
/// Parse a hex string into a `u16`. Accepts an optional `0x`/`0X` prefix
|
||||
/// (case-insensitive) via the same [`strip_hex_prefix`] the byte parsers use.
|
||||
/// `None` on any non-hex content or overflow.
|
||||
///
|
||||
/// Exists so callers never hand-roll `from_str_radix(s.trim_start_matches("0x"), 16)`
|
||||
/// — a **case-sensitive** strip that silently dropped an uppercase-`0X` value.
|
||||
/// (That reintroduced-in-keydb bug is exactly what this module was built to kill;
|
||||
/// the integer fields now share the one prefix rule.)
|
||||
pub fn parse_hex_u16(s: &str) -> Option<u16> {
|
||||
u16::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a hex string into a `u32`. See [`parse_hex_u16`].
|
||||
pub fn parse_hex_u32(s: &str) -> Option<u32> {
|
||||
u32::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Parse a hex string into a `u8`. See [`parse_hex_u16`].
|
||||
pub fn parse_hex_u8(s: &str) -> Option<u8> {
|
||||
u8::from_str_radix(strip_hex_prefix(s.trim()), 16).ok()
|
||||
}
|
||||
|
||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive). Public so
|
||||
/// callers that only need the prefix rule (e.g. normalizing a disc hash) reuse
|
||||
/// the one definition instead of hand-rolling a case-sensitive
|
||||
/// `trim_start_matches("0x")`.
|
||||
pub fn strip_hex_prefix(s: &str) -> &str {
|
||||
s.strip_prefix("0x")
|
||||
.or_else(|| s.strip_prefix("0X"))
|
||||
.unwrap_or(s)
|
||||
@@ -95,6 +120,23 @@ mod tests {
|
||||
assert_eq!(parse_hex_fixed::<16>(&s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_ints_accept_both_prefix_cases_and_bare() {
|
||||
// The regression the keydb device-key bug hit: uppercase `0X` must parse
|
||||
// identically to `0x` and to a bare value.
|
||||
assert_eq!(parse_hex_u16("0x0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16("0X0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16("0001"), Some(1));
|
||||
assert_eq!(parse_hex_u16(" 0XABCD "), Some(0xABCD));
|
||||
assert_eq!(parse_hex_u32("0X00000002"), Some(2));
|
||||
assert_eq!(parse_hex_u32("deadbeef"), Some(0xDEAD_BEEF));
|
||||
assert_eq!(parse_hex_u8("0X03"), Some(3));
|
||||
assert_eq!(parse_hex_u8("ff"), Some(0xFF));
|
||||
// Overflow / non-hex → None.
|
||||
assert_eq!(parse_hex_u8("0x1FF"), None);
|
||||
assert_eq!(parse_hex_u16("0xzz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_variable_length_and_odd_rejected() {
|
||||
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
|
||||
|
||||
@@ -78,8 +78,8 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
|
||||
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||
/// cache stays bounded the same way the write side does.
|
||||
///
|
||||
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
||||
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
||||
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
|
||||
/// smaller windows (8 / 16 MiB) shorten the
|
||||
/// kernel-readahead overlap and slow the producer; larger windows
|
||||
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||
|
||||
+300
-48
@@ -23,6 +23,64 @@ use crate::aacs::types::{UnitKey, Vid};
|
||||
use crate::disc::Key;
|
||||
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
|
||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||
/// structures a source or key server may key on.
|
||||
@@ -57,7 +115,7 @@ pub struct DiscInputs {
|
||||
pub volume_label: Option<String>,
|
||||
}
|
||||
|
||||
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_uk`] so a
|
||||
/// A lazy view of a disc's AACS material, handed to [`KeySource::get_unit_keys`] so a
|
||||
/// source can drive the derivation chain without holding the disc reader.
|
||||
///
|
||||
/// "Lazy" by contract: each accessor returns only what the source asks for, so a
|
||||
@@ -175,10 +233,34 @@ impl ResolveCtx for DiscInputsCtx<'_> {
|
||||
/// ([`resolve_and_apply`]) tries each source in order and validates the returned
|
||||
/// keys against real ciphertext before committing them, so a wrong key from one
|
||||
/// source transparently falls through to the next.
|
||||
///
|
||||
/// Two explicit resolve operations, one per key kind — never one overloaded call
|
||||
/// whose meaning depends on how many keys came back:
|
||||
/// * [`get_unit_keys`](Self::get_unit_keys) — the disc's base per-CPS-unit Unit
|
||||
/// Keys (index space = CPS-unit number). The common path for every disc.
|
||||
/// * [`get_fmts_indexes`](Self::get_fmts_indexes) — the AACS 2.1 forensic index
|
||||
/// keys (index space = forensic index 1..N). Defaults to empty: a source with
|
||||
/// no forensic material opts out, and only an FMTS disc ever asks.
|
||||
///
|
||||
/// What each source must do to answer is the source's own business: a keydb keys
|
||||
/// on `disc_hash` and reads no samples; the online source submits the ctx's
|
||||
/// content samples (a base batch for `get_unit_keys`, an index-1 anchor batch for
|
||||
/// `get_fmts_indexes`) to the key service.
|
||||
pub trait KeySource {
|
||||
/// Resolve this disc's terminal Unit Keys from this source. An empty `Vec`
|
||||
/// is a genuine "no key here"; `Err` is a source failure.
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
|
||||
/// Resolve this disc's base per-CPS-unit Unit Keys from this source. An empty
|
||||
/// `Vec` is a genuine "no key here"; `Err` is a source failure.
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
|
||||
|
||||
/// Resolve this disc's AACS 2.1 forensic index keys — the per-index keys the
|
||||
/// base Unit Key cannot open (see [`crate::aacs::segment`]) — ordered by
|
||||
/// forensic index (element `i` carries `UnitKey.idx == i`, forensic index
|
||||
/// `i + 1`). The source hands back the COMPLETE set it holds; the caller
|
||||
/// trusts any non-empty result as all of them and never assumes a fixed count.
|
||||
/// Defaults to empty: a source with no forensic material (a plain keydb, the
|
||||
/// mapfile) opts out, and only an FMTS disc's mux ever calls this.
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// The AACS host certificate(s) this source can supply for the live-drive
|
||||
/// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the
|
||||
@@ -215,7 +297,7 @@ pub fn resolve_and_apply(
|
||||
/// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for
|
||||
/// applications to render. ZERO English; the trace is typed enums only.
|
||||
///
|
||||
/// One-shot per source: each source's [`KeySource::get_uk`] is called exactly
|
||||
/// One-shot per source: each source's [`KeySource::get_unit_keys`] is called exactly
|
||||
/// once with a [`DiscInputsCtx`] over `inputs`. Non-empty Unit Keys are mapped
|
||||
/// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`],
|
||||
/// which validates them against `inputs.samples` and only mutates the disc on
|
||||
@@ -243,7 +325,7 @@ pub fn resolve_and_apply_traced(
|
||||
for source in sources {
|
||||
// `who` is the source's own stable identifier — no enum to map back to.
|
||||
let who = source.label().to_string();
|
||||
match source.get_uk(&ctx) {
|
||||
match source.get_unit_keys(&ctx) {
|
||||
Ok(uks) if !uks.is_empty() => {
|
||||
// Positional index → canonical CPS-unit number (position + 1).
|
||||
let unit_keys: Vec<(u32, [u8; 16])> = uks
|
||||
@@ -294,7 +376,7 @@ pub fn resolve_and_apply_traced(
|
||||
/// decorator re-decrypts with the returned keys, which is the validation.
|
||||
pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||
for source in sources {
|
||||
if let Ok(uks) = source.get_uk(ctx) {
|
||||
if let Ok(uks) = source.get_unit_keys(ctx) {
|
||||
if !uks.is_empty() {
|
||||
return uks;
|
||||
}
|
||||
@@ -303,35 +385,92 @@ pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) ->
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Build the read-time key-fetch closure from the disc's public AACS inputs and
|
||||
/// a way to (re)build the application's key sources. The decorator calls it with
|
||||
/// the still-scrambled unit ciphertext when no held key opens that unit; it runs
|
||||
/// [`fetch_unit_keys`] with those bytes as `samples` and returns any keys.
|
||||
/// The forensic counterpart to [`fetch_unit_keys`]: drive `sources` in order and
|
||||
/// return the first source's non-empty AACS 2.1 forensic index set. `ctx` carries
|
||||
/// the index-1 anchor batch (the mux, which owns disc geometry, gathers it and
|
||||
/// injects it as the ctx's samples); a source that needs no samples (a keydb
|
||||
/// keying on `disc_hash`) ignores them. Whatever the winning source returns —
|
||||
/// ≥ 1 key — is trusted as the COMPLETE ordered set; no fixed count is assumed.
|
||||
pub fn fetch_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||
for source in sources {
|
||||
if let Ok(uks) = source.get_fmts_indexes(ctx) {
|
||||
if !uks.is_empty() {
|
||||
return uks;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS
|
||||
/// inputs and a way to (re)build the application's key sources. The returned
|
||||
/// resolver has the two explicit operations the mux and recovery decorator call:
|
||||
/// [`unit_keys`](crate::sector::KeyFetch::unit_keys) drives [`fetch_unit_keys`]
|
||||
/// (base per-CPS-unit keys), [`fmts_indexes`](crate::sector::KeyFetch::fmts_indexes)
|
||||
/// drives [`fetch_fmts_indexes`] (the AACS 2.1 forensic set). Each is handed the
|
||||
/// caller's sample batch as the ctx's `samples`, so a source pulls whatever
|
||||
/// material it needs.
|
||||
///
|
||||
/// One builder, used by every read path (sweep / patch / mux) and by every
|
||||
/// consumer (CLI, autorip) — neither application contains the fetch logic, only
|
||||
/// its key-source config. Returns a **shared, stateless** [`crate::sector::KeyFetch`]
|
||||
/// (`Arc<Fn>`): build it once, clone it into each read path. `make_sources` is
|
||||
/// invoked per fetch (the cold path, ~once per CPS unit) so the closure stays
|
||||
/// One builder, used by every read path (sweep / patch / mux) and every consumer
|
||||
/// (CLI, autorip) — neither application contains the fetch logic, only its
|
||||
/// key-source config. Cheap to clone; build once, clone into each read path.
|
||||
/// `make_sources` is invoked per fetch (the cold path) so the resolver stays
|
||||
/// `Send + Sync` without requiring `KeySource: Send`.
|
||||
pub fn key_fetch(
|
||||
inputs: DiscInputs,
|
||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||
) -> crate::sector::KeyFetch {
|
||||
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
||||
let sources = make_sources();
|
||||
let mut di = inputs.clone();
|
||||
di.samples = samples.to_vec();
|
||||
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
|
||||
// an online /decode reply that returns a VUK (not a terminal UK) then
|
||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
|
||||
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||
let ctx = DiscInputsCtx::new(&di);
|
||||
fetch_unit_keys(&sources, &ctx)
|
||||
.into_iter()
|
||||
.map(|u| u.key)
|
||||
.collect()
|
||||
})
|
||||
// One driver behind both operations: rebuild the sources, inject `samples`
|
||||
// as the ctx's content samples, run `drive` (the per-kind fetch), map the
|
||||
// resolved UnitKeys to raw keys. Memoized by the fingerprint of the sample
|
||||
// batch: the resolved keys are disc-level (a clip's index / CPS keys are
|
||||
// identical for every title that references it), so the first batch resolves
|
||||
// over the network and every repeat is answered from the cache with no
|
||||
// request. Empty replies are cached too — a key the service lacks for a batch
|
||||
// won't appear on a re-ask, so re-hitting the network buys nothing. Each
|
||||
// operation gets its OWN cache: a base batch and a forensic anchor never
|
||||
// collide, and the same bytes could legitimately resolve differently per op.
|
||||
// The per-kind driver: `fetch_unit_keys` or `fetch_fmts_indexes`.
|
||||
type FetchDriver = fn(&[Box<dyn KeySource>], &dyn ResolveCtx) -> Vec<UnitKey>;
|
||||
fn make_op(
|
||||
inputs: DiscInputs,
|
||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||
drive: FetchDriver,
|
||||
) -> crate::sector::KeyFetchFn {
|
||||
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]> {
|
||||
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 mut di = inputs.clone();
|
||||
di.samples = samples.to_vec();
|
||||
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
|
||||
// an online /decode reply that returns a VUK (not a terminal UK) then
|
||||
// derives unit keys from `enc_title_keys`, which a V10 disc parses at
|
||||
// the 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||
let ctx = DiscInputsCtx::new(&di);
|
||||
let keys: Vec<[u8; 16]> = drive(&sources, &ctx).into_iter().map(|u| u.key).collect();
|
||||
cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(fp, keys.clone());
|
||||
keys
|
||||
})
|
||||
}
|
||||
let unit = make_op(inputs.clone(), make_sources.clone(), fetch_unit_keys);
|
||||
let fmts = make_op(inputs, make_sources, fetch_fmts_indexes);
|
||||
crate::sector::KeyFetch::new(unit, fmts)
|
||||
}
|
||||
|
||||
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
|
||||
@@ -407,7 +546,7 @@ pub fn read_encrypted_units(
|
||||
break;
|
||||
}
|
||||
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());
|
||||
if out.len() >= n {
|
||||
return out;
|
||||
@@ -425,6 +564,46 @@ mod tests {
|
||||
use crate::aacs::types::UnitKey;
|
||||
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::host_certs() defaults to empty regardless of the MKB argument.
|
||||
@@ -436,7 +615,7 @@ mod tests {
|
||||
fn key_source_host_certs_defaults_to_empty() {
|
||||
struct MinimalSource;
|
||||
impl KeySource for MinimalSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
@@ -493,7 +672,7 @@ mod tests {
|
||||
fn trace_who_is_the_source_label_verbatim() {
|
||||
struct LabeledSource(&'static str);
|
||||
impl KeySource for LabeledSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
@@ -550,23 +729,20 @@ mod tests {
|
||||
|
||||
struct EmptySource;
|
||||
impl KeySource for EmptySource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
struct ErroringSource;
|
||||
impl KeySource for ErroringSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Err(Error::AacsNoKeys)
|
||||
}
|
||||
}
|
||||
struct HasKey([u8; 16]);
|
||||
impl KeySource for HasKey {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey {
|
||||
idx: 0,
|
||||
key: self.0,
|
||||
}])
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, self.0)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,14 +784,11 @@ mod tests {
|
||||
seen: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
}
|
||||
impl KeySource for Probe {
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
if let Ok(s) = ctx.samples(8) {
|
||||
self.seen.lock().unwrap().extend(s);
|
||||
}
|
||||
Ok(vec![UnitKey {
|
||||
idx: 0,
|
||||
key: self.key,
|
||||
}])
|
||||
Ok(vec![UnitKey::new(0, self.key)])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +804,7 @@ mod tests {
|
||||
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
|
||||
let got = cb(&samples);
|
||||
let got = cb.unit_keys(&samples);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![key],
|
||||
@@ -645,6 +818,85 @@ mod tests {
|
||||
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
|
||||
}
|
||||
|
||||
/// The two `KeyFetch` operations route to the two DISTINCT trait methods:
|
||||
/// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
|
||||
/// `get_fmts_indexes`. A source that returns different keys per method proves
|
||||
/// the seam no longer collapses "1 base key" and "the forensic set" into one
|
||||
/// overloaded call — the operation, not the return length, decides which.
|
||||
#[test]
|
||||
fn key_fetch_routes_unit_and_fmts_to_distinct_source_methods() {
|
||||
const BASE: [u8; 16] = [0xB0; 16];
|
||||
const F1: [u8; 16] = [0xF1; 16];
|
||||
const F2: [u8; 16] = [0xF2; 16];
|
||||
|
||||
struct TwoOp;
|
||||
impl KeySource for TwoOp {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, BASE)])
|
||||
}
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, F1), UnitKey::new(1, F2)])
|
||||
}
|
||||
}
|
||||
|
||||
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> =
|
||||
Arc::new(|| vec![Box::new(TwoOp) as Box<dyn KeySource>]);
|
||||
let cb = key_fetch(empty_inputs(), make);
|
||||
let samples = vec![vec![0x01u8; 4]];
|
||||
|
||||
assert_eq!(
|
||||
cb.unit_keys(&samples),
|
||||
vec![BASE],
|
||||
"unit_keys resolves the base Unit Key via get_unit_keys"
|
||||
);
|
||||
assert_eq!(
|
||||
cb.fmts_indexes(&samples),
|
||||
vec![F1, F2],
|
||||
"fmts_indexes resolves the forensic set (any length) via get_fmts_indexes"
|
||||
);
|
||||
}
|
||||
|
||||
/// `KeyFetch::unit_only` serves base keys but NEVER a forensic set — the
|
||||
/// contract the sweep/patch recovery decorator relies on (it resolves CPS
|
||||
/// units only). Its `fmts_indexes` is unconditionally empty.
|
||||
#[test]
|
||||
fn key_fetch_unit_only_never_serves_forensic() {
|
||||
let f = crate::sector::KeyFetch::unit_only(std::sync::Arc::new(|_| vec![[0xAA; 16]]));
|
||||
assert_eq!(f.unit_keys(&[vec![0u8; 4]]), vec![[0xAA; 16]]);
|
||||
assert!(
|
||||
f.fmts_indexes(&[vec![0u8; 4]]).is_empty(),
|
||||
"unit_only resolver yields no forensic keys"
|
||||
);
|
||||
}
|
||||
|
||||
/// `get_fmts_indexes` defaults to empty, so a base-only source (a keydb) opts
|
||||
/// out of the forensic path without implementing it. `fetch_fmts_indexes` then
|
||||
/// falls through to the next source, exactly like the unit-key driver.
|
||||
#[test]
|
||||
fn fetch_fmts_indexes_skips_default_optout_source() {
|
||||
struct BaseOnly; // uses the default (empty) get_fmts_indexes
|
||||
impl KeySource for BaseOnly {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, [0x11; 16])])
|
||||
}
|
||||
}
|
||||
struct Forensic;
|
||||
impl KeySource for Forensic {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
Ok(vec![UnitKey::new(0, [0x77; 16])])
|
||||
}
|
||||
}
|
||||
let inputs = empty_inputs();
|
||||
let ctx = DiscInputsCtx::new(&inputs);
|
||||
let sources: Vec<Box<dyn KeySource>> = vec![Box::new(BaseOnly), Box::new(Forensic)];
|
||||
let got = fetch_fmts_indexes(&sources, &ctx);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].key, [0x77; 16], "the base-only source is skipped");
|
||||
}
|
||||
|
||||
/// #4 regression: encrypted content NOT at the extent midpoint (a late-
|
||||
/// starting feature, or a midpoint landing in clear nav) must still be
|
||||
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
|
||||
@@ -723,7 +975,7 @@ mod tests {
|
||||
);
|
||||
for s in &samples {
|
||||
assert!(
|
||||
aacs_unit_encrypted(s),
|
||||
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
|
||||
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
|
||||
);
|
||||
}
|
||||
@@ -806,7 +1058,7 @@ mod tests {
|
||||
);
|
||||
for s in &samples {
|
||||
assert!(
|
||||
aacs_unit_encrypted(s),
|
||||
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
|
||||
"only CPI-flagged units are selected"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
+6
-2
@@ -98,7 +98,7 @@ pub const VERSION_LABEL: &str = concat!(env!("FREEMKV_VERSION"), env!("GIT_SUFFI
|
||||
|
||||
/// The muxing/writing-application string written into MKV output
|
||||
/// (`"freemkv <version> (g<hash>)"`).
|
||||
pub const MUX_APP: &str = concat!("freemkv ", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX"));
|
||||
pub(crate) const MUX_APP: &str = concat!("freemkv ", env!("FREEMKV_VERSION"), env!("GIT_SUFFIX"));
|
||||
|
||||
pub mod aacs;
|
||||
pub(crate) mod clpi;
|
||||
@@ -187,7 +187,10 @@ pub use identity::DriveId;
|
||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||
// 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 ─────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -248,6 +251,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||
// `SectorSource` to get plaintext sectors out.
|
||||
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 sector::{
|
||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
||||
|
||||
+114
-3
@@ -56,6 +56,13 @@ pub struct H264Parser {
|
||||
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
|
||||
/// the BD/UHD transport path, which carries a per-frame PTS.
|
||||
reorder: Option<super::reorder::SparsePtsReorder>,
|
||||
/// MVC dependent-view (Blu-ray 3D right-eye) passthrough mode. When set, the
|
||||
/// parser does NOT strip SPS/PPS (nor re-assert at keyframes): every NAL —
|
||||
/// subset SPS (type 15), prefix (14), coded-slice-extension (20), PPS (8) —
|
||||
/// is length-prefixed in-band, so each emitted frame is a self-contained
|
||||
/// dependent access unit suitable for a Matroska `BlockAdditional`. The base
|
||||
/// view's avcC/param-set stripping is unchanged (separate parser instance).
|
||||
mvc_passthrough: bool,
|
||||
}
|
||||
|
||||
impl Default for H264Parser {
|
||||
@@ -73,6 +80,7 @@ impl H264Parser {
|
||||
cur_sps: None,
|
||||
cur_pps: None,
|
||||
reorder: None,
|
||||
mvc_passthrough: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +93,15 @@ impl H264Parser {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable MVC dependent-view passthrough (see the `mvc_passthrough` field):
|
||||
/// keep every parameter set in-band so each frame is a self-contained
|
||||
/// dependent access unit for a Matroska `BlockAdditional`. Used only for the
|
||||
/// Blu-ray 3D dependent (right-eye) stream.
|
||||
pub(crate) fn with_mvc_passthrough(mut self, enabled: bool) -> Self {
|
||||
self.mvc_passthrough = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Route a finished frame through the PTS reorderer when enabled, else emit
|
||||
/// it directly (unchanged transport-stream behaviour).
|
||||
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
|
||||
@@ -192,17 +209,23 @@ impl CodecParser for H264Parser {
|
||||
// frame in the mux hot path (mirrors the HEVC parser).
|
||||
let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
|
||||
|
||||
// MVC dependent-view passthrough: keep ALL param sets in-band (the frame
|
||||
// is a self-contained BlockAdditional access unit), never strip/re-assert.
|
||||
let mvc = self.mvc_passthrough;
|
||||
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
|
||||
match nal_type {
|
||||
// Param sets: seed avcC, strip if unchanged vs the active set,
|
||||
// emit in-band on any change (incl. reverting to the avcC copy).
|
||||
NAL_SPS => {
|
||||
// In MVC passthrough these fall through to the default arm so the
|
||||
// subset SPS / PPS stay in-band (self-contained dependent AU).
|
||||
NAL_SPS if !mvc => {
|
||||
emitted_sps |=
|
||||
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
|
||||
}
|
||||
NAL_PPS => {
|
||||
NAL_PPS if !mvc => {
|
||||
emitted_pps |=
|
||||
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
|
||||
}
|
||||
@@ -250,7 +273,7 @@ impl CodecParser for H264Parser {
|
||||
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder
|
||||
// that dropped the set at a reset recovers, and a stale avcC re-apply
|
||||
// can't revert it. Skipped per-type only when this AU already carried it.
|
||||
if keyframe {
|
||||
if keyframe && !mvc {
|
||||
let mut prefix = Vec::new();
|
||||
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
|
||||
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
|
||||
@@ -651,6 +674,94 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_passthrough_keeps_param_sets_inband() {
|
||||
// A dependent-view access unit: subset SPS (NAL 15) + PPS (NAL 8) +
|
||||
// coded-slice-extension (NAL 20). No IDR (type 5), so keyframe stays
|
||||
// false and there is no keyframe re-assertion.
|
||||
let au = || {
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x6F, &[0x80, 0x00, 0x33, 0xAA])); // subset SPS (15)
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
|
||||
d
|
||||
};
|
||||
let nal_types =
|
||||
|f: &Frame| -> Vec<u8> { h264_nals_in(&f.data).iter().map(|n| n[0] & 0x1F).collect() };
|
||||
|
||||
// Normal parser strips the PPS from a non-keyframe AU (it is captured for
|
||||
// the avcC and, without an IDR, never re-asserted in-band).
|
||||
let mut normal = H264Parser::new();
|
||||
let f = normal.parse(&make_pes(au(), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert!(
|
||||
!nal_types(&f[0]).contains(&8),
|
||||
"normal parser strips PPS from a non-keyframe AU: {:?}",
|
||||
nal_types(&f[0])
|
||||
);
|
||||
|
||||
// Passthrough keeps EVERY parameter set in-band, so each dependent frame
|
||||
// is a self-contained access unit for a BlockAdditional.
|
||||
let mut pt = H264Parser::new().with_mvc_passthrough(true);
|
||||
let f = pt.parse(&make_pes(au(), Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types = nal_types(&f[0]);
|
||||
assert!(types.contains(&15), "subset SPS kept in-band: {types:?}");
|
||||
assert!(
|
||||
types.contains(&8),
|
||||
"PPS kept in-band under passthrough: {types:?}"
|
||||
);
|
||||
assert!(types.contains(&20), "slice kept: {types:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_for_mvc_dependent_h264_is_passthrough() {
|
||||
// The dependent-view stream must get a passthrough parser: a PPS in a
|
||||
// non-keyframe AU is kept in-band, not stripped like the base parser.
|
||||
let mut p = crate::mux::codec::parser_for_mvc_dependent(crate::disc::Codec::H264, false);
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
|
||||
let f = p.parse(&make_pes(d, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types: Vec<u8> = h264_nals_in(&f[0].data)
|
||||
.iter()
|
||||
.map(|n| n[0] & 0x1F)
|
||||
.collect();
|
||||
assert!(
|
||||
types.contains(&8),
|
||||
"dependent parser keeps PPS in-band (passthrough): {types:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_passthrough_with_idr_does_not_reassert_param_sets() {
|
||||
// With an IDR present (keyframe=true), passthrough must NOT re-assert the
|
||||
// param sets (the `keyframe && !mvc` guard), so SPS/PPS appear exactly
|
||||
// once — a duplicate would corrupt the dependent BlockAdditional.
|
||||
let mut p = H264Parser::new().with_mvc_passthrough(true);
|
||||
let mut d = Vec::new();
|
||||
d.extend_from_slice(&h264_nal(0x67, &[0x42, 0x00, 0x1E, 0x01])); // SPS (7)
|
||||
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||
d.extend_from_slice(&h264_nal(0x65, &[0x88, 0x00])); // IDR slice (5)
|
||||
let f = p.parse(&make_pes(d, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
let types: Vec<u8> = h264_nals_in(&f[0].data)
|
||||
.iter()
|
||||
.map(|n| n[0] & 0x1F)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
types.iter().filter(|&&t| t == 7).count(),
|
||||
1,
|
||||
"exactly one SPS, no keyframe re-assert under passthrough: {types:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
types.iter().filter(|&&t| t == 8).count(),
|
||||
1,
|
||||
"exactly one PPS, no keyframe re-assert under passthrough: {types:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h264_populates_measured_coding_type_and_source() {
|
||||
use super::super::coding::CodingType;
|
||||
|
||||
@@ -207,6 +207,22 @@ pub fn parser_for_codec(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the codec parser for a Blu-ray 3D **MVC dependent (right-eye)** video
|
||||
/// stream. Same codec space as the base view (H.264), but in param-set
|
||||
/// passthrough mode so each emitted frame is a self-contained dependent access
|
||||
/// unit for a Matroska `BlockAdditional`. Non-H.264 (unexpected) falls back to
|
||||
/// the ordinary parser.
|
||||
pub fn parser_for_mvc_dependent(codec: Codec, is_dvd_ps: bool) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(
|
||||
h264::H264Parser::new()
|
||||
.with_ps_reorder(is_dvd_ps)
|
||||
.with_mvc_passthrough(true),
|
||||
),
|
||||
_ => parser_for_codec(codec, None, is_dvd_ps),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+24
-5
@@ -31,6 +31,10 @@ use std::io::{self, BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Filename-naming strategy for the per-track files.
|
||||
// `allow(dead_code)`: the sink honours all variants, but only the `#[default]` is
|
||||
// constructed today (`output()` builds `DemuxOptions::default()`). The alternates
|
||||
// are a staged option surface awaiting the CLI `--naming` flag (not yet wired).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Naming {
|
||||
/// `<base> <track> <lang> <codec> [DELAY <n>ms].<ext>` — human-readable.
|
||||
@@ -55,6 +59,9 @@ pub enum DelayMode {
|
||||
}
|
||||
|
||||
/// Chapter export format.
|
||||
// `allow(dead_code)`: only the `#[default]` XML variant is constructed today (via
|
||||
// `DemuxOptions::default()`); OGM/Both await the CLI `--chapters` flag.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ChaptersFmt {
|
||||
/// mkvmerge chapter XML.
|
||||
@@ -241,11 +248,23 @@ impl EsWriter for AnnexBWriter {
|
||||
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
||||
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
||||
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
||||
match codec {
|
||||
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
|
||||
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
let converted = match codec {
|
||||
Codec::Hevc => hvcc_to_annex_b(record),
|
||||
Codec::H264 => avcc_to_annex_b(record),
|
||||
_ => 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.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
||||
//! already runs on a producer thread; the *consumer* (main) thread
|
||||
//! 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
|
||||
//! enough that pipelining it with parse pays for itself.
|
||||
//!
|
||||
|
||||
+73
-24
@@ -108,11 +108,6 @@ pub struct DiscStream {
|
||||
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
||||
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
||||
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,
|
||||
/// Mirror of the keys handed in at construction. The decorator
|
||||
/// owns the cryptographic state; this field is kept for
|
||||
@@ -144,14 +139,14 @@ pub struct DiscStream {
|
||||
// Adaptive batch sizer — preferred comes from the caller
|
||||
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
|
||||
adaptive: AdaptiveBatch,
|
||||
pub errors: u64,
|
||||
errors: u64,
|
||||
/// Cumulative bytes actually skipped (zero-filled) on read error.
|
||||
/// Distinct from `errors`, which counts skip *events*: one event can
|
||||
/// cover a whole AACS unit (`unit_align` sectors = 6144 bytes), so
|
||||
/// `errors * 2048` understates real loss by the alignment factor.
|
||||
/// Consumers estimating lost video time must scale by this, not by
|
||||
/// the event count.
|
||||
pub lost_bytes: u64,
|
||||
lost_bytes: u64,
|
||||
pub skip_errors: bool,
|
||||
/// When set and the token is cancelled, fill_extents returns Err(Halted)
|
||||
/// at the next retry boundary. Unlike skip_errors, this propagates the
|
||||
@@ -237,12 +232,12 @@ impl DiscStream {
|
||||
|
||||
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
||||
// 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
|
||||
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
|
||||
// decode/mux stream (live-drive single-pass / direct), never the
|
||||
// ciphertext-preserving sweep, so concealment is always correct here.
|
||||
let mut reader =
|
||||
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
|
||||
// MUX path (read > decrypt > mux): decrypt every unit in place and pass the
|
||||
// bytes to the muxer; a unit that decrypts to broken TS is the muxer's
|
||||
// concern, never conceal / re-fetch / count as loss (fail loud only on a
|
||||
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
|
||||
// single-pass / direct), never the ciphertext-preserving sweep.
|
||||
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
|
||||
|
||||
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
||||
// 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
|
||||
// advanced so the first fill_extents read starts cleanly.
|
||||
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
|
||||
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
||||
@@ -311,7 +303,6 @@ impl DiscStream {
|
||||
|
||||
Self {
|
||||
reader,
|
||||
decrypt_loss,
|
||||
title,
|
||||
decrypt_keys,
|
||||
unit_align,
|
||||
@@ -360,6 +351,27 @@ impl DiscStream {
|
||||
self
|
||||
}
|
||||
|
||||
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) on the inline
|
||||
/// live-drive path — the counterpart to what
|
||||
/// [`build_iso_pipeline`](crate::mux::resolve::build_iso_pipeline) does for the
|
||||
/// file-backed highway. The map is the title's read plan: it decides which unit
|
||||
/// each LBA is and, for an FMTS forensic segment, which phase is ours. The
|
||||
/// extent walk is rewritten to the read plan so **only our-phase units are read
|
||||
/// off the drive** (the alternate device-group units are never fetched,
|
||||
/// decrypted, or muxed), and the map is installed so each unit decrypts with its
|
||||
/// mapped key. A non-forensic map returns the extents unchanged, so a plain
|
||||
/// single/multi-CPS disc reads exactly as before.
|
||||
pub fn with_key_map(mut self, map: std::sync::Arc<crate::decrypt::AacsKeyMap>) -> Self {
|
||||
self.extents = map.read_plan(&self.extents, self.unit_align.max(1) as u32);
|
||||
self.bytes_total_extents = self
|
||||
.extents
|
||||
.iter()
|
||||
.map(|e| e.sector_count as u64 * 2048)
|
||||
.sum();
|
||||
self.reader.set_key_map(map);
|
||||
self
|
||||
}
|
||||
|
||||
fn is_halted(&self) -> bool {
|
||||
self.halt
|
||||
.as_ref()
|
||||
@@ -1003,14 +1015,12 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
|
||||
fn lost_bytes(&self) -> u64 {
|
||||
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
|
||||
// loss — bytes of scrambled AACS units the decorator could not decrypt
|
||||
// and passed through still encrypted (the TS assembler silently drops
|
||||
// them). Both are real missing content the abort gate must see; without
|
||||
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
|
||||
// missing segments passes even under abort_on_lost_secs=0.
|
||||
// Read-error zero-fill loss (counted in fill_extents) — real missing
|
||||
// content the abort gate must see. There is no decrypt-loss term: the
|
||||
// decrypt path passes bad-encoded/undecryptable units through (a broken-TS
|
||||
// unit is the muxer's concern, and a missing key is indistinguishable from
|
||||
// bad authoring here), so only physical read loss is reported.
|
||||
self.lost_bytes
|
||||
.saturating_add(self.decrypt_loss.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,6 +1139,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `with_key_map` on the inline live-drive path applies the same FMTS read plan
|
||||
/// the file-backed highway uses: within a forensic segment only our-phase units
|
||||
/// survive the extent walk, so the alternate device-group units are never read.
|
||||
#[test]
|
||||
fn with_key_map_reads_only_our_phase_units() {
|
||||
use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase};
|
||||
// AACS keys → unit_align = 3, so a unit is 3 sectors and the phase filter
|
||||
// engages. Key contents are irrelevant to the read plan.
|
||||
let aacs = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0u8; 16]), (1, [1u8; 16])],
|
||||
read_data_key: None,
|
||||
format: ContentFormat::BdTs,
|
||||
};
|
||||
// 100 units (300 sectors). A 10-unit Even forensic segment at LBA [30,60):
|
||||
// even units (30,36,42,48,54) are ours; odd (33,39,45,51,57) are dropped.
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(30, 60, 1, Phase::Even)], 0);
|
||||
let stream = DiscStream::new(
|
||||
Box::new(ZeroReader { capacity: 300 }),
|
||||
synthetic_title(300),
|
||||
aacs,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
)
|
||||
.with_key_map(std::sync::Arc::new(map));
|
||||
let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum();
|
||||
assert_eq!(
|
||||
total,
|
||||
300 - 5 * 3,
|
||||
"exactly the 5 alternate-phase units (15 sectors) are dropped from the read walk"
|
||||
);
|
||||
assert!(
|
||||
stream.extents.len() > 1,
|
||||
"the forensic segment split the single extent into our-phase-only runs"
|
||||
);
|
||||
// The progress denominator tracks the reduced read set.
|
||||
assert_eq!(stream.bytes_total_extents, total as u64 * 2048);
|
||||
}
|
||||
|
||||
/// Recording `SectorSource`: logs every `(lba, count)` request and
|
||||
/// returns `Err` whenever the requested range covers `bad_sector`.
|
||||
/// Successful reads return zeroed sectors (which are NOT
|
||||
@@ -1489,6 +1537,7 @@ mod tests {
|
||||
let keys = crate::decrypt::DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0u8; 16])],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
||||
stream.skip_errors = true;
|
||||
|
||||
@@ -94,6 +94,31 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete EBML signed-integer element (two's-complement, big-endian,
|
||||
/// minimal width). Used for `ReferenceBlock` (0xFB), whose value is a signed
|
||||
/// tick offset relative to the current block's timestamp.
|
||||
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
// Minimal two's-complement width: shrink while the top byte is pure sign
|
||||
// extension of the next byte's MSB.
|
||||
let be = val.to_be_bytes();
|
||||
let mut start = 0usize;
|
||||
while start < 7 {
|
||||
let sign_ext = if be[start + 1] & 0x80 != 0 {
|
||||
0xFF
|
||||
} else {
|
||||
0x00
|
||||
};
|
||||
if be[start] != sign_ext {
|
||||
break;
|
||||
}
|
||||
start += 1;
|
||||
}
|
||||
let bytes = &be[start..];
|
||||
write_size(w, bytes.len() as u64)?;
|
||||
w.write_all(bytes)
|
||||
}
|
||||
|
||||
/// Write a complete EBML float element (8-byte double).
|
||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
@@ -481,6 +506,25 @@ pub const LUMINANCE_MIN: u32 = 0x55DA;
|
||||
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
|
||||
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
|
||||
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
|
||||
/// BlockAddIDValue (RFC 9559) — the value a per-frame `BlockAddID` references
|
||||
/// to select this BlockAdditionMapping. Values ≥ 2 (1 is the default plain
|
||||
/// BlockAdditional). Used by the MVC (`mvcC`) mapping for Blu-ray 3D.
|
||||
pub const BLOCK_ADD_ID_VALUE: u32 = 0x41F0;
|
||||
|
||||
// Block additions carried inside a BlockGroup — per-frame side data. For
|
||||
// Blu-ray 3D (MVC) the dependent (right-eye) view NAL units for an access unit
|
||||
// ride here as a BlockAdditional under the track's `mvcC` mapping (RFC 9559
|
||||
// §5.1.4.1.4; Matroska Codec Specifications §4.1.5).
|
||||
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
|
||||
pub const BLOCK_MORE: u32 = 0xA6;
|
||||
pub const BLOCK_ADDITIONAL: u32 = 0xA5;
|
||||
pub const BLOCK_ADD_ID: u32 = 0xEE;
|
||||
/// ReferenceBlock (RFC 9559 element 0xFB, child of BlockGroup) — signed
|
||||
/// timestamp (in TimestampScale ticks) of a block this one references, relative
|
||||
/// to this block's own timestamp. Its PRESENCE marks the Block as non-keyframe
|
||||
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
|
||||
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
|
||||
pub const REFERENCE_BLOCK: u32 = 0xFB;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
@@ -960,6 +1004,31 @@ mod tests {
|
||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_int_minimal_two_complement_width() {
|
||||
// ReferenceBlock (0xFB) signed offsets, minimal two's-complement width.
|
||||
let enc = |v: i64| {
|
||||
let mut b = Vec::new();
|
||||
write_int(&mut b, REFERENCE_BLOCK, v).unwrap();
|
||||
b
|
||||
};
|
||||
assert_eq!(enc(0), [0xFB, 0x81, 0x00], "0 -> 1 byte 0x00");
|
||||
assert_eq!(enc(-1), [0xFB, 0x81, 0xFF], "-1 -> 1 byte 0xFF");
|
||||
assert_eq!(enc(127), [0xFB, 0x81, 0x7F], "127 -> 1 byte");
|
||||
assert_eq!(
|
||||
enc(128),
|
||||
[0xFB, 0x82, 0x00, 0x80],
|
||||
"128 needs 2 bytes (0x80 alone is -128)"
|
||||
);
|
||||
assert_eq!(enc(-128), [0xFB, 0x81, 0x80], "-128 -> 1 byte 0x80");
|
||||
assert_eq!(enc(-129), [0xFB, 0x82, 0xFF, 0x7F], "-129 needs 2 bytes");
|
||||
// i64::MIN is the widest: 8 bytes, size 0x88.
|
||||
let mn = enc(i64::MIN);
|
||||
assert_eq!(mn[0], 0xFB);
|
||||
assert_eq!(mn[1], 0x88);
|
||||
assert_eq!(&mn[2..], &i64::MIN.to_be_bytes());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// write_float — EBML floats here are always 8-byte IEEE-754 doubles,
|
||||
// big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88.
|
||||
|
||||
+7
-19
@@ -157,31 +157,19 @@ impl FviSink {
|
||||
///
|
||||
/// `source_path` / `source_title` record where the index was built from
|
||||
/// (the input URL path + the 0-based title index); they are carried into the
|
||||
/// header's `source` object. The medium defaults to `file` — callers with a
|
||||
/// known medium / playlist / volume use [`FviSink::create_with_source`].
|
||||
/// header's `source` object. The remaining provenance (medium, playlist,
|
||||
/// volume) takes its `SourceInfo` defaults — no caller needs to override them.
|
||||
pub fn create(
|
||||
path: &Path,
|
||||
title: &DiscTitle,
|
||||
source_path: String,
|
||||
source_title: usize,
|
||||
) -> io::Result<Self> {
|
||||
Self::create_with_source(
|
||||
path,
|
||||
title,
|
||||
SourceInfo {
|
||||
path: source_path,
|
||||
title: source_title,
|
||||
..SourceInfo::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Create the sink with a fully-specified [`SourceInfo`] provenance root.
|
||||
pub fn create_with_source(
|
||||
path: &Path,
|
||||
title: &DiscTitle,
|
||||
source: SourceInfo,
|
||||
) -> io::Result<Self> {
|
||||
let source = SourceInfo {
|
||||
path: source_path,
|
||||
title: source_title,
|
||||
..SourceInfo::default()
|
||||
};
|
||||
let file = File::create(path)?;
|
||||
|
||||
let video_track = title
|
||||
|
||||
+536
-41
@@ -68,6 +68,82 @@ const COLOUR_RANGE_LIMITED: u8 = 1;
|
||||
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
|
||||
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
|
||||
|
||||
/// BlockAddIDType "mvcC" — the MVCDecoderConfigurationRecord fourcc, big-endian
|
||||
/// ASCII 'm''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a
|
||||
/// Blu-ray 3D MVC configuration (RFC 9559 + Matroska Codec Specifications
|
||||
/// §4.1.5; equals ISO/IEC 14496-15 MVCConfigurationBox('mvcC')). The per-frame
|
||||
/// dependent (right-eye) view rides as a BlockAdditional under this mapping.
|
||||
const BLOCK_ADD_ID_TYPE_MVCC: u64 = 0x6D76_6343;
|
||||
|
||||
/// BlockAddIDValue for the MVC mapping — the value each per-frame `BlockAddID`
|
||||
/// references (RFC 9559 requires ≥ 2; 1 is the default plain BlockAdditional).
|
||||
const BLOCK_ADD_ID_VALUE_MVC: u64 = 2;
|
||||
|
||||
/// Build an `MVCDecoderConfigurationRecord` (ISO/IEC 14496-15:2013 §7.6.2) from
|
||||
/// the dependent view's subset-SPS (NAL type 15) and PPS NAL units. This is the
|
||||
/// `BlockAddIDExtraData` for the `mvcC` BlockAdditionMapping (and, in the ISO
|
||||
/// container, the `MVCConfigurationBox('mvcC')` payload).
|
||||
///
|
||||
/// Layout mirrors the AVCDecoderConfigurationRecord except byte[4] repurposes
|
||||
/// the AVC record's `bit(6) reserved` as
|
||||
/// `complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`.
|
||||
/// `profile`/`compat`/`level` describe the WHOLE MVC stream and come from the
|
||||
/// subset SPS (bytes 1..=3). `length_size_minus_one` MUST match the base avcC
|
||||
/// (freemkv always emits 4-byte length prefixes → 3). SPSs precede subset SPSs
|
||||
/// in the array; here the array carries the dependent view's parameter sets.
|
||||
///
|
||||
/// Returns `None` if either param set is absent, too short, or exceeds the
|
||||
/// 16-bit length field (a param set > 65535 bytes is non-conforming and would
|
||||
/// mis-frame the record).
|
||||
fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
|
||||
if subset_sps.len() < 4 || subset_sps.len() > 0xFFFF || pps.is_empty() || pps.len() > 0xFFFF {
|
||||
return None;
|
||||
}
|
||||
let mut record = vec![
|
||||
1, // configurationVersion
|
||||
subset_sps[1], // AVCProfileIndication (whole MVC stream, from subset SPS)
|
||||
subset_sps[2], // profile_compatibility
|
||||
subset_sps[3], // AVCLevelIndication
|
||||
// complete_representation(1)=1 | explicit_au_track(1)=0 |
|
||||
// reserved '1111'(4) | lengthSizeMinusOne(2)=3(11) → 1_0_1111_11 = 0xBF.
|
||||
0xBF,
|
||||
// reserved '0'(1) | numOfSequenceParameterSets(7)=1 → 0_0000001 = 0x01.
|
||||
// NB: distinct from the AVC record's byte[5] (reserved(3)|numSPS(5)).
|
||||
0x01,
|
||||
(subset_sps.len() >> 8) as u8,
|
||||
subset_sps.len() as u8,
|
||||
];
|
||||
record.extend_from_slice(subset_sps);
|
||||
record.push(1); // numOfPictureParameterSets
|
||||
record.push((pps.len() >> 8) as u8);
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
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,
|
||||
/// 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
|
||||
@@ -176,6 +252,16 @@ pub struct MkvTrack {
|
||||
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
|
||||
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
|
||||
pub hdr10: Option<crate::mux::codec::Hdr10Metadata>,
|
||||
/// Blu-ray 3D (MVC): the dependent (right-eye) view's `(subset_sps, pps)`
|
||||
/// NAL units, from which the serializer builds the `mvcC`
|
||||
/// MVCDecoderConfigurationRecord (ISO/IEC 14496-15 §7.6.2) for the track's
|
||||
/// BlockAdditionMapping. `None` for non-3D tracks. Set at muxer activation
|
||||
/// from the dependent stream's parameter sets — the same deferred path
|
||||
/// `hdr10`/FieldOrder use — never at construction. When `Some`, the per-frame
|
||||
/// dependent view rides as a `BlockAdditional`. (No `StereoMode` is written:
|
||||
/// RFC 9559 assigns no StereoMode value to MVC-in-BlockAdditional; the mvcC
|
||||
/// mapping is the 3D signal.)
|
||||
pub mvc_params: Option<(Vec<u8>, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
|
||||
@@ -380,6 +466,7 @@ impl MkvTrack {
|
||||
// coded picture's PictureInfo before the header is written (the same
|
||||
// deferred path FieldOrder uses). `None` here → omitted unless seen.
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +535,7 @@ impl MkvTrack {
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +573,7 @@ impl MkvTrack {
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,6 +619,12 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
/// the primary video. `None` when the title has no video track (no track
|
||||
/// drives epochs).
|
||||
primary_video_track: Option<usize>,
|
||||
/// Per-track: whether the track declared an `mvcC` BlockAdditionMapping (its
|
||||
/// `mvc_params` was set at activation). A `BlockAdditional` with BlockAddID=2
|
||||
/// is only conforming when the track carries the matching mapping, so
|
||||
/// `write_frame`'s additional is dropped for a track without it (e.g. the
|
||||
/// dependent view's parameter sets were never captured before activation).
|
||||
track_has_mvc_mapping: Vec<bool>,
|
||||
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
|
||||
continuity: TimelineContinuity,
|
||||
cues: Vec<CuePoint>,
|
||||
@@ -565,6 +660,11 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
/// Highest block timestamp (TimestampScale ticks) written across all tracks —
|
||||
/// the muxed runtime, used to back-patch the DURATION placeholder.
|
||||
max_block_ticks: i64,
|
||||
/// Timestamp (TimestampScale ticks) of the last video keyframe written on the
|
||||
/// primary video track. A non-keyframe MVC base frame lives in a BlockGroup
|
||||
/// (to carry its dependent-view BlockAdditional) and needs a `ReferenceBlock`
|
||||
/// so players don't mistake it for a keyframe; it references this keyframe.
|
||||
last_video_keyframe_ticks: Option<i64>,
|
||||
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
|
||||
/// is unreliable, so the channel count written in the track header is
|
||||
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
|
||||
@@ -685,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,
|
||||
/// 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;
|
||||
/// no heap allocation, called once per block on the mux hot path.
|
||||
/// a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers `< 0x4000`,
|
||||
/// 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`
|
||||
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and
|
||||
/// OR-ing the 0x40 length marker would clobber it, corrupting the track
|
||||
/// number. Not reachable today (track numbers are `i+1` over a few streams),
|
||||
/// so this documents the bound rather than handling 3-byte VINTs.
|
||||
fn track_vint(track_num: usize) -> ([u8; 2], usize) {
|
||||
/// Each width uses a marker bit that must NOT collide with the payload's top
|
||||
/// byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40
|
||||
/// (14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all
|
||||
/// three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range
|
||||
/// track number can never silently clobber the marker bit and corrupt the
|
||||
/// 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 {
|
||||
([(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 {
|
||||
debug_assert!(
|
||||
track_num < 0x4000,
|
||||
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range"
|
||||
track_num < 0x20_0000,
|
||||
"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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,9 +917,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
||||
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
||||
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() {
|
||||
let track_uid = (i + 1) as u64 | 0x100_0000;
|
||||
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)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
||||
@@ -827,7 +953,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
}
|
||||
|
||||
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
|
||||
// (placeholder reserve + later seek-back fill via
|
||||
@@ -923,6 +1062,40 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, vid_pos)?;
|
||||
}
|
||||
|
||||
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
|
||||
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
||||
// mediainfo recognise the dependent (right-eye) view that rides as a
|
||||
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
||||
match mvc_record.as_ref() {
|
||||
Some(record) => {
|
||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
ebml::BLOCK_ADD_ID_VALUE,
|
||||
BLOCK_ADD_ID_VALUE_MVC,
|
||||
)?;
|
||||
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::end_master(&mut writer, map_pos)?;
|
||||
}
|
||||
// `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!(
|
||||
target: "mux",
|
||||
"MVC track: could not build MVCDecoderConfigurationRecord from the \
|
||||
dependent view's parameter sets (subset_sps={} B, pps={} B); \
|
||||
emitting no mvcC mapping — the 3D pairing will not be signalled.",
|
||||
s.len(),
|
||||
p.len(),
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
||||
// mediainfo recognise the track as Dolby Vision.
|
||||
@@ -1013,6 +1186,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
primary_video_track: tracks
|
||||
.iter()
|
||||
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
||||
track_has_mvc_mapping,
|
||||
continuity: TimelineContinuity::new(),
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
@@ -1027,6 +1201,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
duration_secs,
|
||||
duration_patch_pos,
|
||||
max_block_ticks: 0,
|
||||
last_video_keyframe_ticks: None,
|
||||
ac3_channel_fixups,
|
||||
opening_capture: None,
|
||||
})
|
||||
@@ -1058,6 +1233,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// (`None`) leaves the written value untouched: an interlaced track keeps
|
||||
/// its guess rather than being cleared via a multi-element change. The byte
|
||||
/// width is fixed (FieldOrder is 0..=14), so the in-place rewrite is valid.
|
||||
///
|
||||
/// `block_additional`, when `Some`, is attached to the frame as a Matroska
|
||||
/// `BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the
|
||||
/// Block and the dependent (right-eye) access unit rides as the
|
||||
/// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a
|
||||
/// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not
|
||||
/// a keyframe. `None` for every non-3D frame.
|
||||
pub fn write_frame(
|
||||
&mut self,
|
||||
track_idx: usize,
|
||||
@@ -1065,6 +1247,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
keyframe: bool,
|
||||
data: &[u8],
|
||||
duration_ns: Option<u64>,
|
||||
block_additional: Option<&[u8]>,
|
||||
) -> io::Result<()> {
|
||||
// --log-level 3: capture the first ~100 coded frames per track to the
|
||||
// side file BEFORE any timeline mangling, with the codec parser's own
|
||||
@@ -1226,15 +1409,72 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
|
||||
|
||||
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
|
||||
match duration_ns {
|
||||
Some(dur_ns) => {
|
||||
// BlockDuration is in TimestampScale ticks, floored at 1.
|
||||
let duration_ticks = (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64;
|
||||
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ticks)?;
|
||||
let duration_ticks =
|
||||
duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
|
||||
// A BlockAdditional with BlockAddID=2 is only conforming when the track
|
||||
// declared the matching mvcC BlockAdditionMapping. If it did not (the
|
||||
// dependent view's parameter sets were never captured before the header
|
||||
// was written), drop the additional and emit a plain block rather than a
|
||||
// non-conforming file with an orphaned BlockAddID.
|
||||
let block_additional = match block_additional {
|
||||
Some(a)
|
||||
if self
|
||||
.track_has_mvc_mapping
|
||||
.get(track_idx)
|
||||
.copied()
|
||||
.unwrap_or(false) =>
|
||||
{
|
||||
Some(a)
|
||||
}
|
||||
None => {
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
Some(_) => None,
|
||||
None => None,
|
||||
};
|
||||
match block_additional {
|
||||
// MVC: base view Block + dependent-view BlockAdditional, always a
|
||||
// BlockGroup. Non-keyframe base frames get a ReferenceBlock to the
|
||||
// last keyframe (a keyframe carries none), so a player never treats a
|
||||
// P/B frame as a seek point.
|
||||
Some(additional) => {
|
||||
let reference = if keyframe {
|
||||
None
|
||||
} else {
|
||||
// Offset (ticks) of the referenced keyframe relative to this
|
||||
// block. A non-keyframe MUST carry a ReferenceBlock or a reader
|
||||
// treats it as a seek point; fall back to 0 (self-relative) in
|
||||
// the pre-first-keyframe corner (unreachable in practice — such
|
||||
// frames are dropped before a cluster opens) so the marker is
|
||||
// never absent.
|
||||
Some(
|
||||
self.last_video_keyframe_ticks
|
||||
.map(|kf| kf - pts_ticks)
|
||||
.unwrap_or(0),
|
||||
)
|
||||
};
|
||||
self.write_block_group_mvc(
|
||||
track_idx + 1,
|
||||
relative_ts,
|
||||
data,
|
||||
additional,
|
||||
reference,
|
||||
duration_ticks,
|
||||
)?;
|
||||
}
|
||||
None => match duration_ticks {
|
||||
// BlockDuration present (PGS subtitles) → BlockGroup.
|
||||
Some(dt) => {
|
||||
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, dt)?;
|
||||
}
|
||||
None => {
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
}
|
||||
},
|
||||
}
|
||||
// Remember the last PRIMARY-video keyframe's tick so a later non-keyframe
|
||||
// MVC base frame references a keyframe on its OWN track (see the
|
||||
// block_additional path above). Gating to the primary video track avoids a
|
||||
// secondary video track's keyframe becoming a cross-track reference target.
|
||||
if keyframe && Some(track_idx) == self.primary_video_track {
|
||||
self.last_video_keyframe_ticks = Some(pts_ticks);
|
||||
}
|
||||
self.frame_count += 1;
|
||||
|
||||
@@ -1519,6 +1759,52 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut self.writer, bg_pos)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a BlockGroup carrying the base view Block plus the MVC dependent
|
||||
/// (right-eye) access unit as a `BlockAdditional` (BlockAddID=2), per the
|
||||
/// track's `mvcC` BlockAdditionMapping. A non-keyframe frame gets a
|
||||
/// `ReferenceBlock` (`reference` = referenced keyframe offset in ticks) so it
|
||||
/// is not mistaken for a seek point; `BlockDuration` is written when known.
|
||||
fn write_block_group_mvc(
|
||||
&mut self,
|
||||
track_num: usize,
|
||||
relative_ts: i16,
|
||||
data: &[u8],
|
||||
additional: &[u8],
|
||||
reference: Option<i64>,
|
||||
duration_ticks: Option<u64>,
|
||||
) -> io::Result<()> {
|
||||
let (tv, tv_len) = track_vint(track_num);
|
||||
let track_vint = &tv[..tv_len];
|
||||
// The 0x80 Keyframe flag is SimpleBlock-only; inside a BlockGroup Block
|
||||
// it is reserved and MUST be 0 — keyframe-ness is signalled by the
|
||||
// presence/absence of ReferenceBlock.
|
||||
let flags: u8 = 0x00;
|
||||
let block_size = track_vint.len() + 2 + 1 + data.len();
|
||||
|
||||
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
|
||||
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
|
||||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||||
self.writer.write_all(track_vint)?;
|
||||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||||
self.writer.write_all(&[flags])?;
|
||||
self.writer.write_all(data)?;
|
||||
if let Some(dt) = duration_ticks {
|
||||
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, dt)?;
|
||||
}
|
||||
if let Some(ref_off) = reference {
|
||||
ebml::write_int(&mut self.writer, ebml::REFERENCE_BLOCK, ref_off)?;
|
||||
}
|
||||
// BlockAdditions → BlockMore { BlockAddID=2, BlockAdditional=dependent AU }.
|
||||
let adds_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_ADDITIONS)?;
|
||||
let more_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_MORE)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::BLOCK_ADD_ID, BLOCK_ADD_ID_VALUE_MVC)?;
|
||||
ebml::write_binary(&mut self.writer, ebml::BLOCK_ADDITIONAL, additional)?;
|
||||
ebml::end_master(&mut self.writer, more_pos)?;
|
||||
ebml::end_master(&mut self.writer, adds_pos)?;
|
||||
ebml::end_master(&mut self.writer, bg_pos)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -1702,6 +1988,7 @@ mod tests {
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1731,6 +2018,7 @@ mod tests {
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
hdr10: None,
|
||||
mvc_params: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1814,7 +2102,7 @@ mod tests {
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None)
|
||||
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None, None)
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
@@ -1834,7 +2122,7 @@ mod tests {
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
|
||||
.unwrap();
|
||||
muxer.finish().unwrap();
|
||||
|
||||
@@ -1860,7 +2148,7 @@ mod tests {
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
|
||||
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
|
||||
.unwrap();
|
||||
// Force the zero-cue branch: drop every cue before finalizing.
|
||||
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
|
||||
@@ -2111,16 +2399,16 @@ mod tests {
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
|
||||
// Write frames to both tracks
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None)
|
||||
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None)
|
||||
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None)
|
||||
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None)
|
||||
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None, None)
|
||||
.unwrap();
|
||||
// Should not panic
|
||||
let data = muxer.writer.into_inner();
|
||||
@@ -2135,11 +2423,11 @@ mod tests {
|
||||
|
||||
// Record position before first frame
|
||||
let pos_before_kf = muxer.writer.position();
|
||||
muxer.write_frame(0, 0, true, &[0xAA], None).unwrap();
|
||||
muxer.write_frame(0, 0, true, &[0xAA], None, None).unwrap();
|
||||
let pos_after_kf = muxer.writer.position();
|
||||
|
||||
muxer
|
||||
.write_frame(0, 1_000_000, false, &[0xBB], None)
|
||||
.write_frame(0, 1_000_000, false, &[0xBB], None, None)
|
||||
.unwrap();
|
||||
let pos_after_nkf = muxer.writer.position();
|
||||
|
||||
@@ -2385,7 +2673,7 @@ mod tests {
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
|
||||
for (t, pts, kf, data) in frames {
|
||||
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
|
||||
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
|
||||
}
|
||||
let frame_count = muxer.frame_count;
|
||||
muxer.finish().unwrap();
|
||||
@@ -2747,7 +3035,7 @@ mod tests {
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||
for f in &frames {
|
||||
muxer
|
||||
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns)
|
||||
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns, None)
|
||||
.unwrap();
|
||||
}
|
||||
muxer.finish().unwrap();
|
||||
@@ -3136,12 +3424,14 @@ mod tests {
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
|
||||
muxer.write_frame(1, 0, true, &[0xAA; 8], None).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 10_000_000, false, &[0xBB; 8], None)
|
||||
.write_frame(1, 0, true, &[0xAA; 8], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 20_000_000, true, &[0xCC; 8], None)
|
||||
.write_frame(0, 10_000_000, false, &[0xBB; 8], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 20_000_000, true, &[0xCC; 8], None, None)
|
||||
.unwrap();
|
||||
let err = muxer.finish().unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
@@ -3216,7 +3506,7 @@ mod tests {
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
|
||||
for (t, pts, kf, data) in &frames_in_order {
|
||||
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
|
||||
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
|
||||
}
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
@@ -3229,7 +3519,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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.
|
||||
let (b, n) = track_vint(1);
|
||||
assert_eq!(&b[..n], &[0x81]);
|
||||
@@ -3240,6 +3530,12 @@ mod tests {
|
||||
assert_eq!(&b[..n], &[0x40, 0x80]);
|
||||
let (b, n) = track_vint(0x3FFF);
|
||||
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]);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -3286,7 +3582,7 @@ mod tests {
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, &[]).unwrap();
|
||||
for (t, pts, kf, data, dur) in frames {
|
||||
muxer.write_frame(*t, *pts, *kf, data, *dur).unwrap();
|
||||
muxer.write_frame(*t, *pts, *kf, data, *dur, None).unwrap();
|
||||
}
|
||||
muxer.finish().unwrap();
|
||||
shared.lock().unwrap().clone().into_inner()
|
||||
@@ -4135,10 +4431,10 @@ mod tests {
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
|
||||
// Video keyframe 1000 bytes; audio frame 500 bytes.
|
||||
muxer
|
||||
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
|
||||
.write_frame(0, 0, true, &vec![0xABu8; 1000], None, None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
|
||||
.write_frame(1, 0, false, &vec![0xCDu8; 500], None, None)
|
||||
.unwrap();
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
@@ -4188,8 +4484,10 @@ mod tests {
|
||||
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
|
||||
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
|
||||
// Open a cluster with a video keyframe first (cluster invariant).
|
||||
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
|
||||
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x01, 0x02], None, None)
|
||||
.unwrap();
|
||||
muxer.write_frame(1, 0, false, &ac3, None, None).unwrap();
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
|
||||
@@ -4234,6 +4532,203 @@ mod tests {
|
||||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_decoder_config_record_matches_iso_14496_15_layout() {
|
||||
// subset SPS (NAL type 15): [nal_hdr, profile, compat, level, ...].
|
||||
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
|
||||
let pps = vec![0x68, 0xEE, 0x3C];
|
||||
let rec = mvc_decoder_config_record(&subset_sps, &pps).expect("record builds");
|
||||
|
||||
// ISO/IEC 14496-15:2013 §7.6.2 byte layout, verbatim:
|
||||
let expected: Vec<u8> = [
|
||||
vec![
|
||||
1, // configurationVersion
|
||||
0x80, // AVCProfileIndication = subset_sps[1]
|
||||
0x00, // profile_compatibility = subset_sps[2]
|
||||
0x33, // AVCLevelIndication = subset_sps[3]
|
||||
0xBF, // complete_rep(1) explicit_au(0) reserved'1111' lengthSizeMinusOne=3
|
||||
0x01, // reserved'0'(1) numOfSequenceParameterSets(7)=1
|
||||
0x00, 0x06, // sequenceParameterSetLength = 6
|
||||
],
|
||||
subset_sps.clone(),
|
||||
vec![
|
||||
1, // numOfPictureParameterSets
|
||||
0x00, 0x03, // pictureParameterSetLength = 3
|
||||
],
|
||||
pps.clone(),
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(rec, expected, "MVCDecoderConfigurationRecord byte layout");
|
||||
|
||||
// Guards: too-short SPS and empty PPS both refuse (no corrupt record).
|
||||
assert!(mvc_decoder_config_record(&[0x6F, 0x80, 0x00], &pps).is_none());
|
||||
assert!(mvc_decoder_config_record(&subset_sps, &[]).is_none());
|
||||
// Over-length param sets (> 65535) would mis-frame the 16-bit length
|
||||
// field; both must refuse rather than emit a truncated record.
|
||||
let huge = vec![0u8; 0x1_0000];
|
||||
assert!(mvc_decoder_config_record(&huge, &pps).is_none());
|
||||
assert!(mvc_decoder_config_record(&subset_sps, &huge).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_frame_emits_blockgroup_additional_and_reference() {
|
||||
// A track declaring an mvcC mapping: a keyframe base frame carrying a
|
||||
// dependent AU emits BlockGroup > BlockAdditions > BlockMore
|
||||
// {BlockAddID=2, BlockAdditional=dep}; a following non-keyframe adds a
|
||||
// ReferenceBlock so it is not mistaken for a seek point.
|
||||
let mut v = make_video_track();
|
||||
v.mvc_params = Some((
|
||||
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
||||
vec![0x68, 0xEE, 0x3C],
|
||||
));
|
||||
let mut muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
|
||||
let dep_kf = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||
let dep_p = [0xCAu8, 0xFE];
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x65, 0x01, 0x02], None, Some(&dep_kf))
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(0, 40_000_000, false, &[0x41, 0x03], None, Some(&dep_p))
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
assert!(
|
||||
find_id(&data, ebml::BLOCK_ADDITIONS).is_some(),
|
||||
"BlockAdditions (0x75A1) present"
|
||||
);
|
||||
// BlockAddID = 2 (uint): element 0xEE, size 0x81, value 0x02.
|
||||
assert!(
|
||||
data.windows(3).any(|w| w == [0xEE, 0x81, 0x02]),
|
||||
"BlockAddID must be 2"
|
||||
);
|
||||
assert!(
|
||||
data.windows(4).any(|w| w == dep_kf),
|
||||
"keyframe dependent AU present as BlockAdditional"
|
||||
);
|
||||
assert!(
|
||||
data.windows(2).any(|w| w == dep_p),
|
||||
"non-keyframe dependent AU present"
|
||||
);
|
||||
assert!(
|
||||
find_id(&data, ebml::REFERENCE_BLOCK).is_some(),
|
||||
"non-keyframe MVC base frame must carry a ReferenceBlock"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_dropped_when_track_has_no_mvc_mapping() {
|
||||
// If a track did NOT declare an mvcC mapping (mvc_params None), a stray
|
||||
// block_additional must be dropped (no orphaned BlockAddID=2 / no
|
||||
// BlockAdditions) so the file stays conforming.
|
||||
let mut muxer = MkvMuxer::new(
|
||||
Cursor::new(Vec::new()),
|
||||
&[make_video_track()],
|
||||
None,
|
||||
0.0,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0x65, 0x01], None, Some(&[0xDE, 0xAD]))
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
find_id(&data, ebml::BLOCK_ADDITIONS).is_none(),
|
||||
"no BlockAdditions without a declared mvcC mapping"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_track_emits_mvcc_block_addition_mapping() {
|
||||
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
|
||||
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
|
||||
// players / mediainfo recognise the Blu-ray 3D dependent view.
|
||||
let mut v = make_video_track();
|
||||
v.mvc_params = Some((
|
||||
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
||||
vec![0x68, 0xEE, 0x3C],
|
||||
));
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_some(),
|
||||
"MVC track must emit BlockAdditionMapping"
|
||||
);
|
||||
assert!(
|
||||
find_id(&data, ebml::BLOCK_ADD_ID_VALUE).is_some(),
|
||||
"MVC mapping must carry BlockAddIDValue"
|
||||
);
|
||||
assert!(
|
||||
data.windows(4).any(|w| w == [0x6D, 0x76, 0x63, 0x43]),
|
||||
"mvcC fourcc must be present as the BlockAddIDType value"
|
||||
);
|
||||
// Without mvc_params, no mapping.
|
||||
let muxer = MkvMuxer::new(
|
||||
Cursor::new(Vec::new()),
|
||||
&[make_video_track()],
|
||||
None,
|
||||
0.0,
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
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) -------------
|
||||
//
|
||||
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
||||
|
||||
+562
-64
@@ -120,36 +120,253 @@ struct PendingMux {
|
||||
video_track: Option<usize>,
|
||||
/// `--log-level 3` opening-capture side-file path (if any).
|
||||
opening_capture_path: Option<std::path::PathBuf>,
|
||||
/// Frames received before activation, replayed in order once built.
|
||||
buffered: Vec<crate::pes::PesFrame>,
|
||||
/// Frames received before activation, replayed in order once built. Each
|
||||
/// carries an optional MVC dependent-view `BlockAdditional` (present only
|
||||
/// for a 3D base-view frame that was already paired before activation).
|
||||
buffered: Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>,
|
||||
}
|
||||
|
||||
/// Matroska container stream.
|
||||
pub struct MkvStream {
|
||||
disc_title: DiscTitle,
|
||||
mode: Mode,
|
||||
/// Blu-ray 3D (MVC) merge state — present iff the title carries an MVC
|
||||
/// dependent (right-eye) view. Folds the dependent stream's frames into the
|
||||
/// base video track as per-frame `BlockAdditional`, paired by PTS, so the
|
||||
/// output is a single MVC track instead of two independent H.264 tracks.
|
||||
mvc: Option<MvcMerge>,
|
||||
}
|
||||
|
||||
/// Largest number of base frames held awaiting their PTS-matching dependent AU
|
||||
/// before the oldest is flushed unpaired (a plain Block). The SSIF interleaves
|
||||
/// base and dependent access units per unit, so a base's dependent normally
|
||||
/// arrives within one or two frames; this window only bounds memory/latency for
|
||||
/// a stream where the pairing drifts.
|
||||
const MVC_PAIR_WINDOW: usize = 32;
|
||||
|
||||
/// A base-view frame (track already remapped to the muxer's base track index)
|
||||
/// awaiting — or already carrying — its dependent-view `BlockAdditional`.
|
||||
struct PendingBase {
|
||||
frame: crate::pes::PesFrame,
|
||||
additional: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// State for folding the MVC dependent (right-eye) view into the base track.
|
||||
struct MvcMerge {
|
||||
/// `title.streams` index of the base (left-eye) video stream.
|
||||
base_stream_idx: usize,
|
||||
/// `title.streams` index of the dependent (right-eye) video stream.
|
||||
dep_stream_idx: usize,
|
||||
/// Muxer track index of the base view — where the dependent AU is attached
|
||||
/// as a `BlockAdditional` and where `mvc_params` (the `mvcC` mapping) lives.
|
||||
base_track_idx: usize,
|
||||
/// `title.streams` index → muxer track index. The dependent maps to `None`
|
||||
/// (it becomes a BlockAdditional, not a track); every other stream shifts
|
||||
/// down by one if it followed the dependent in stream order.
|
||||
stream_to_track: Vec<Option<usize>>,
|
||||
/// Base frames (decode order) awaiting their dependent or a window flush.
|
||||
pending_base: std::collections::VecDeque<PendingBase>,
|
||||
/// Dependent AU data keyed by PTS, waiting for the matching base.
|
||||
dep_by_pts: std::collections::HashMap<i64, Vec<u8>>,
|
||||
/// `(subset_sps, pps)` from the first dependent AU — builds the `mvcC`
|
||||
/// MVCDecoderConfigurationRecord for the base track's BlockAdditionMapping.
|
||||
captured_params: Option<(Vec<u8>, Vec<u8>)>,
|
||||
/// Count of dependent AUs dropped with no matching base (diagnostic).
|
||||
orphan_deps: u64,
|
||||
}
|
||||
|
||||
impl MvcMerge {
|
||||
/// Ingest one incoming frame; returns `(frame, additional)` pairs ready to
|
||||
/// hand to the muxer, in emit order. Base frames buffer briefly to pair with
|
||||
/// their dependent by PTS; the dependent stream produces no frames of its own
|
||||
/// (it becomes `BlockAdditional`); all other streams pass straight through
|
||||
/// with their track index remapped.
|
||||
fn ingest(
|
||||
&mut self,
|
||||
frame: &crate::pes::PesFrame,
|
||||
) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
|
||||
let mut out = Vec::new();
|
||||
if frame.track == self.dep_stream_idx {
|
||||
if self.captured_params.is_none() {
|
||||
self.captured_params = extract_mvc_params(&frame.data);
|
||||
}
|
||||
// Attach to a waiting base of the same PTS, else stash by PTS.
|
||||
if let Some(pb) = self
|
||||
.pending_base
|
||||
.iter_mut()
|
||||
.find(|pb| pb.frame.pts == frame.pts && pb.additional.is_none())
|
||||
{
|
||||
pb.additional = Some(frame.data.clone());
|
||||
} else {
|
||||
// Bound the orphan map BEFORE inserting: if dependents pile up
|
||||
// unpaired (pairing badly drifted), drop the drifted buffer so it
|
||||
// stays bounded — but keep THIS just-arrived dependent, whose base
|
||||
// frame commonly arrives next. Clearing after the insert would
|
||||
// discard it and overcount orphans by one.
|
||||
if self.dep_by_pts.len() >= MVC_PAIR_WINDOW * 4 {
|
||||
self.orphan_deps += self.dep_by_pts.len() as u64;
|
||||
self.dep_by_pts.clear();
|
||||
}
|
||||
// A duplicate-PTS dependent (e.g. a stale repeat after a stream
|
||||
// discontinuity) displaces the prior one — count it as an orphan
|
||||
// rather than losing it silently.
|
||||
if self
|
||||
.dep_by_pts
|
||||
.insert(frame.pts, frame.data.clone())
|
||||
.is_some()
|
||||
{
|
||||
self.orphan_deps += 1;
|
||||
}
|
||||
}
|
||||
} else if frame.track == self.base_stream_idx {
|
||||
let additional = self.dep_by_pts.remove(&frame.pts);
|
||||
let mut remapped = frame.clone();
|
||||
remapped.track = self.base_track_idx;
|
||||
self.pending_base.push_back(PendingBase {
|
||||
frame: remapped,
|
||||
additional,
|
||||
});
|
||||
} else {
|
||||
// Audio / subtitle / other video: remap the track index and forward.
|
||||
let mut remapped = frame.clone();
|
||||
if let Some(Some(t)) = self.stream_to_track.get(frame.track) {
|
||||
remapped.track = *t;
|
||||
out.push((remapped, None));
|
||||
}
|
||||
}
|
||||
self.drain_ready(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Emit base frames from the FIFO front once each has its dependent attached,
|
||||
/// or flush the oldest unpaired base as a plain Block when the window is full.
|
||||
fn drain_ready(&mut self, out: &mut Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>) {
|
||||
loop {
|
||||
let front_ready = self
|
||||
.pending_base
|
||||
.front()
|
||||
.map(|pb| pb.additional.is_some())
|
||||
.unwrap_or(false);
|
||||
if front_ready || self.pending_base.len() > MVC_PAIR_WINDOW {
|
||||
if let Some(pb) = self.pending_base.pop_front() {
|
||||
out.push((pb.frame, pb.additional));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush every remaining buffered base frame (unpaired → plain Block) at EOF.
|
||||
fn flush(&mut self) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
|
||||
let mut out = Vec::new();
|
||||
for pb in self.pending_base.drain(..) {
|
||||
out.push((pb.frame, pb.additional));
|
||||
}
|
||||
self.orphan_deps += self.dep_by_pts.len() as u64;
|
||||
self.dep_by_pts.clear();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand a frame to the muxer, attaching the MVC dependent view as a
|
||||
/// `BlockAdditional` when `additional` is `Some` (a 3D base frame), else a
|
||||
/// plain block.
|
||||
fn emit_to_muxer(
|
||||
m: &mut MkvMuxer<Box<dyn WriteSeek + Send>>,
|
||||
frame: &crate::pes::PesFrame,
|
||||
additional: Option<&[u8]>,
|
||||
) -> io::Result<()> {
|
||||
m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
additional,
|
||||
)
|
||||
}
|
||||
|
||||
/// Scan a length-prefixed (4-byte big-endian) H.264 NAL stream for the first
|
||||
/// subset SPS (NAL type 15) and first PPS (NAL type 8) — the two parameter sets
|
||||
/// that populate the `mvcC` MVCDecoderConfigurationRecord. Returns
|
||||
/// `Some((subset_sps, pps))` only when BOTH are found; `None` otherwise (the
|
||||
/// serializer then emits no mvcC mapping and logs it).
|
||||
fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
let mut subset_sps: Option<Vec<u8>> = None;
|
||||
let mut pps: Option<Vec<u8>> = None;
|
||||
let mut i = 0usize;
|
||||
while i + 4 <= data.len() {
|
||||
let len = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
|
||||
i += 4;
|
||||
// A zero-length NAL (a stray length prefix) is skipped, not fatal — the
|
||||
// subset SPS / PPS may still follow. A length that runs past the buffer
|
||||
// end IS unrecoverable (the NAL can't be read), so stop there.
|
||||
if len == 0 {
|
||||
continue;
|
||||
}
|
||||
if i + len > data.len() {
|
||||
break;
|
||||
}
|
||||
let nal = &data[i..i + len];
|
||||
i += len;
|
||||
match nal[0] & 0x1F {
|
||||
15 if subset_sps.is_none() => subset_sps = Some(nal.to_vec()),
|
||||
8 if pps.is_none() => pps = Some(nal.to_vec()),
|
||||
_ => {}
|
||||
}
|
||||
if subset_sps.is_some() && pps.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some((subset_sps?, pps?))
|
||||
}
|
||||
|
||||
impl MkvStream {
|
||||
/// Create for writing PES frames → MKV container.
|
||||
/// Codec privates come from title.codec_privates (populated by input stream).
|
||||
pub fn create(writer: Box<dyn WriteSeek + Send>, title: &DiscTitle) -> io::Result<Self> {
|
||||
Self::create_at(writer, title, None)
|
||||
}
|
||||
|
||||
/// As [`create`](Self::create), but `output_path` (when known) enables the
|
||||
/// `--log-level 3` opening-frame capture to `<output>.opening.bin`. A `None`
|
||||
/// path (e.g. an in-memory / stdio sink) silently skips the side-file
|
||||
/// capture; the per-track TrackEntry dump still fires.
|
||||
pub fn create_at(
|
||||
/// Create for writing PES frames → MKV container. Codec privates come from
|
||||
/// `title.codec_privates` (populated by the input stream).
|
||||
///
|
||||
/// `output_path` (when known) enables the `--log-level 3` opening-frame
|
||||
/// capture to `<output>.opening.bin`; `None` (e.g. an in-memory / stdio sink)
|
||||
/// silently skips the side-file capture — the per-track TrackEntry dump still
|
||||
/// fires either way.
|
||||
pub fn create(
|
||||
writer: Box<dyn WriteSeek + Send>,
|
||||
title: &DiscTitle,
|
||||
output_path: Option<&std::path::Path>,
|
||||
) -> io::Result<Self> {
|
||||
// Blu-ray 3D (MVC): a dependent (right-eye) view stream is NOT emitted as
|
||||
// its own track — it is folded into the base track as per-frame
|
||||
// BlockAdditional. Detect it so we skip building a track for it and set up
|
||||
// the merge. `base_stream_idx` is the first video stream.
|
||||
let dep_stream_idx = title
|
||||
.streams
|
||||
.iter()
|
||||
.position(|s| matches!(s, crate::disc::Stream::Video(v) if v.is_mvc_dependent()));
|
||||
// The base is the first NON-dependent video. Excluding the dependent here
|
||||
// means a (malformed / hand-built) title whose only video IS the dependent
|
||||
// yields `base_stream_idx == None` → no merge (the dependent is muxed as an
|
||||
// ordinary track) instead of `base == dep` and a panic on the skipped slot.
|
||||
let base_stream_idx = title
|
||||
.streams
|
||||
.iter()
|
||||
.position(|s| matches!(s, crate::disc::Stream::Video(v) if !v.is_mvc_dependent()));
|
||||
// The merge is only active when BOTH a dependent and a distinct base exist;
|
||||
// only then is the dependent's track skipped/folded.
|
||||
let mvc_active = dep_stream_idx.is_some() && base_stream_idx.is_some();
|
||||
let skip_stream_idx = if mvc_active { dep_stream_idx } else { None };
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
let mut has_default_video = false;
|
||||
let mut has_default_audio = false;
|
||||
// `title.streams` index → muxer track index (`None` = the dependent view,
|
||||
// which has no track). Streams after the dependent shift down by one.
|
||||
let mut stream_to_track: Vec<Option<usize>> = Vec::with_capacity(title.streams.len());
|
||||
for (idx, s) in title.streams.iter().enumerate() {
|
||||
if Some(idx) == skip_stream_idx {
|
||||
stream_to_track.push(None);
|
||||
continue;
|
||||
}
|
||||
let mut track = match s {
|
||||
crate::disc::Stream::Video(v) => MkvTrack::video(v),
|
||||
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
|
||||
@@ -166,9 +383,31 @@ impl MkvStream {
|
||||
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
|
||||
track.codec_private = Some(cp.clone());
|
||||
}
|
||||
stream_to_track.push(Some(tracks.len()));
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
// Assemble the MVC merge only when active — i.e. a dependent AND a
|
||||
// distinct base video both exist (established above). `base_stream_idx`
|
||||
// then always has a built track, so its remap is `Some` (no panic path).
|
||||
let mvc = match (mvc_active, dep_stream_idx, base_stream_idx) {
|
||||
(true, Some(dep_stream_idx), Some(base_stream_idx)) => stream_to_track
|
||||
.get(base_stream_idx)
|
||||
.copied()
|
||||
.flatten()
|
||||
.map(|base_track_idx| MvcMerge {
|
||||
base_stream_idx,
|
||||
dep_stream_idx,
|
||||
base_track_idx,
|
||||
stream_to_track,
|
||||
pending_base: std::collections::VecDeque::new(),
|
||||
dep_by_pts: std::collections::HashMap::new(),
|
||||
captured_params: None,
|
||||
orphan_deps: 0,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Defer muxer construction (and the TrackEntry dump) until the first
|
||||
// coded picture arrives, so the primary video track's FieldOrder is set
|
||||
// from the parser's MEASURED value before the header is written — never
|
||||
@@ -177,6 +416,7 @@ impl MkvStream {
|
||||
|
||||
Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
mvc,
|
||||
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
|
||||
writer,
|
||||
tracks,
|
||||
@@ -209,6 +449,24 @@ impl MkvStream {
|
||||
if let Some(vt) = pending.video_track {
|
||||
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
|
||||
}
|
||||
// Blu-ray 3D: set the base video track's `mvc_params` from the dependent
|
||||
// view's captured subset-SPS/PPS BEFORE the header is written, so the
|
||||
// TrackEntry carries the `mvcC` BlockAdditionMapping. Captured from the
|
||||
// first dependent AU (which arrives right after the first base AU in the
|
||||
// SSIF), so it is available by the time the first base frame activates.
|
||||
if let Some(mvc) = &self.mvc {
|
||||
if let Some(params) = &mvc.captured_params {
|
||||
if let Some(t) = pending.tracks.get_mut(mvc.base_track_idx) {
|
||||
t.mvc_params = Some(params.clone());
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"MVC: no dependent-view subset-SPS/PPS captured before activation; \
|
||||
the base track will carry no mvcC mapping (3D not signalled)."
|
||||
);
|
||||
}
|
||||
}
|
||||
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
|
||||
for (i, track) in pending.tracks.iter().enumerate() {
|
||||
crate::diag::dump_mkv_track((i + 1) as u64, track);
|
||||
@@ -223,18 +481,72 @@ impl MkvStream {
|
||||
if let Some(path) = &pending.opening_capture_path {
|
||||
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
|
||||
}
|
||||
for f in pending.buffered.drain(..) {
|
||||
muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?;
|
||||
for (f, additional) in pending.buffered.drain(..) {
|
||||
muxer.write_frame(
|
||||
f.track,
|
||||
f.pts,
|
||||
f.keyframe,
|
||||
&f.data,
|
||||
f.duration_ns,
|
||||
additional.as_deref(),
|
||||
)?;
|
||||
}
|
||||
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Emit one frame (track index already muxer-relative) with an optional MVC
|
||||
/// dependent-view `BlockAdditional`, honouring the deferred-activation
|
||||
/// machinery: the first video frame triggers muxer construction (its coding
|
||||
/// sets FieldOrder); earlier frames buffer. `additional` is `None` for every
|
||||
/// non-3D frame and for the 3D base frames that had no paired dependent.
|
||||
fn emit(&mut self, frame: &crate::pes::PesFrame, additional: Option<&[u8]>) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
|
||||
Mode::Write(WriteMode::Active(m)) => {
|
||||
return emit_to_muxer(m, frame, additional);
|
||||
}
|
||||
Mode::Write(WriteMode::Building) => return Ok(()),
|
||||
Mode::Write(WriteMode::Pending(_)) => {}
|
||||
}
|
||||
// Pending: the first video frame (or the safety cap) triggers muxer
|
||||
// construction; that frame's coding sets the field order. Other frames
|
||||
// buffer until then.
|
||||
let (activate_now, use_coding) = match &self.mode {
|
||||
Mode::Write(WriteMode::Pending(p)) => {
|
||||
let is_video = match p.video_track {
|
||||
Some(vt) => frame.track == vt,
|
||||
// No video track: nothing to wait for — build on frame one.
|
||||
None => true,
|
||||
};
|
||||
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
|
||||
}
|
||||
_ => unreachable!("guarded above"),
|
||||
};
|
||||
if activate_now {
|
||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
||||
// cap-triggered build never saw the video frame, so nothing measured
|
||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||
return emit_to_muxer(m, frame, additional);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
|
||||
p.buffered
|
||||
.push((frame.clone(), additional.map(|a| a.to_vec())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an MKV file for reading → PES frames.
|
||||
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
|
||||
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
mvc: None,
|
||||
mode: Mode::Read(ReadState {
|
||||
reader: Box::new(reader),
|
||||
cluster_ts_ticks: 0,
|
||||
@@ -425,59 +737,40 @@ impl crate::pes::Stream for MkvStream {
|
||||
}
|
||||
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
// Fast paths.
|
||||
match &mut self.mode {
|
||||
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
|
||||
Mode::Write(WriteMode::Active(m)) => {
|
||||
return m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
);
|
||||
}
|
||||
Mode::Write(WriteMode::Building) => return Ok(()),
|
||||
Mode::Write(WriteMode::Pending(_)) => {}
|
||||
if matches!(self.mode, Mode::Read(_)) {
|
||||
return Err(crate::error::Error::StreamReadOnly.into());
|
||||
}
|
||||
// Pending: the first video frame (or the safety cap) triggers muxer
|
||||
// construction; that frame's coding sets the field order. Other frames
|
||||
// buffer until then.
|
||||
let (activate_now, use_coding) = match &self.mode {
|
||||
Mode::Write(WriteMode::Pending(p)) => {
|
||||
let is_video = match p.video_track {
|
||||
Some(vt) => frame.track == vt,
|
||||
// No video track: nothing to wait for — build on frame one.
|
||||
None => true,
|
||||
};
|
||||
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
|
||||
}
|
||||
_ => unreachable!("guarded above"),
|
||||
};
|
||||
if activate_now {
|
||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
||||
// cap-triggered build never saw the video frame, so nothing measured
|
||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||
return m.write_frame(
|
||||
frame.track,
|
||||
frame.pts,
|
||||
frame.keyframe,
|
||||
&frame.data,
|
||||
frame.duration_ns,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
|
||||
p.buffered.push(frame.clone());
|
||||
}
|
||||
Ok(())
|
||||
// Non-3D fast path: emit the frame directly, no clone, no buffering.
|
||||
if self.mvc.is_none() {
|
||||
return self.emit(frame, None);
|
||||
}
|
||||
// Blu-ray 3D: run the frame through the MVC merge, which remaps track
|
||||
// indices, folds the dependent view into the base as BlockAdditional
|
||||
// (paired by PTS), and yields 0+ frames ready to emit. `ingest` returns
|
||||
// owned pairs so the `self.mvc` borrow is released before `emit`.
|
||||
let emits = self.mvc.as_mut().unwrap().ingest(frame);
|
||||
for (f, additional) in emits {
|
||||
self.emit(&f, additional.as_deref())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> io::Result<()> {
|
||||
// Blu-ray 3D: flush any base frames still awaiting a dependent (emitted
|
||||
// unpaired as plain Blocks) before finalizing.
|
||||
if let Some(mvc) = self.mvc.as_mut() {
|
||||
let tail = mvc.flush();
|
||||
let orphans = mvc.orphan_deps;
|
||||
if orphans > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"MVC: {orphans} dependent-view access units had no matching base frame (dropped)"
|
||||
);
|
||||
}
|
||||
for (f, additional) in tail {
|
||||
self.emit(&f, additional.as_deref())?;
|
||||
}
|
||||
}
|
||||
// A title that produced no frames (or only buffered ones) is still
|
||||
// finalized into a valid MKV: activate now with no measured coding.
|
||||
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
||||
@@ -720,7 +1013,11 @@ fn parse_track(
|
||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||
match aid {
|
||||
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_)?;
|
||||
}
|
||||
@@ -916,6 +1213,207 @@ mod tests {
|
||||
use crate::pes::Stream as _;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Length-prefix (4-byte big-endian) each NAL, as the H.264 parser emits.
|
||||
fn lp(nals: &[&[u8]]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for n in nals {
|
||||
v.extend_from_slice(&(n.len() as u32).to_be_bytes());
|
||||
v.extend_from_slice(n);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn mvc_frame(track: usize, pts: i64, keyframe: bool, data: Vec<u8>) -> crate::pes::PesFrame {
|
||||
crate::pes::PesFrame {
|
||||
track,
|
||||
pts,
|
||||
keyframe,
|
||||
data,
|
||||
duration_ns: None,
|
||||
source: None,
|
||||
coding: None,
|
||||
}
|
||||
}
|
||||
|
||||
// A subset SPS (NAL type 15), a PPS (type 8), and a coded-slice-extension
|
||||
// (type 20) — the shape of a dependent-view access unit.
|
||||
const SUBSET_SPS: [u8; 5] = [0x6F, 0x80, 0x00, 0x33, 0xAA]; // 0x6F & 0x1F = 15
|
||||
const DEP_PPS: [u8; 3] = [0x68, 0xEE, 0x3C]; // 0x68 & 0x1F = 8
|
||||
const DEP_SLICE: [u8; 3] = [0x74, 0x11, 0x22]; // 0x74 & 0x1F = 20
|
||||
|
||||
#[test]
|
||||
fn extract_mvc_params_finds_subset_sps_and_pps() {
|
||||
let data = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
|
||||
let (s, p) = extract_mvc_params(&data).expect("both param sets present");
|
||||
assert_eq!(s, SUBSET_SPS, "subset SPS (NAL 15) captured verbatim");
|
||||
assert_eq!(p, DEP_PPS, "PPS (NAL 8) captured verbatim");
|
||||
// Missing PPS → None (the serializer then emits no mvcC mapping).
|
||||
assert!(extract_mvc_params(&lp(&[&SUBSET_SPS, &DEP_SLICE])).is_none());
|
||||
// Missing subset SPS → None.
|
||||
assert!(extract_mvc_params(&lp(&[&DEP_PPS, &DEP_SLICE])).is_none());
|
||||
}
|
||||
|
||||
fn empty_merge() -> MvcMerge {
|
||||
MvcMerge {
|
||||
base_stream_idx: 0,
|
||||
dep_stream_idx: 2,
|
||||
base_track_idx: 0,
|
||||
stream_to_track: vec![Some(0), Some(1), None],
|
||||
pending_base: std::collections::VecDeque::new(),
|
||||
dep_by_pts: std::collections::HashMap::new(),
|
||||
captured_params: None,
|
||||
orphan_deps: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_pairs_base_and_dependent_by_pts() {
|
||||
let mut m = empty_merge();
|
||||
let dep = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
|
||||
|
||||
// Base arrives first (SSIF order): buffered, nothing emitted yet.
|
||||
let e = m.ingest(&mvc_frame(0, 100, true, lp(&[&[0x65, 1, 2]])));
|
||||
assert!(e.is_empty(), "base held until its dependent arrives");
|
||||
|
||||
// Dependent arrives → base is emitted, remapped to the base track, with
|
||||
// the dependent AU as its BlockAdditional; params are captured.
|
||||
let e = m.ingest(&mvc_frame(2, 100, false, dep.clone()));
|
||||
assert_eq!(e.len(), 1, "the paired base frame is emitted");
|
||||
assert_eq!(e[0].0.track, 0, "remapped to the base muxer track");
|
||||
assert_eq!(
|
||||
e[0].1.as_deref(),
|
||||
Some(dep.as_slice()),
|
||||
"dependent attached"
|
||||
);
|
||||
assert!(m.captured_params.is_some(), "mvcC params captured");
|
||||
|
||||
// Audio passes straight through (remapped, no additional).
|
||||
let e = m.ingest(&mvc_frame(1, 100, true, vec![0xAA]));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].0.track, 1);
|
||||
assert!(e[0].1.is_none());
|
||||
|
||||
// Dependent-before-base (reordered) also pairs.
|
||||
let dep2 = lp(&[&DEP_SLICE]);
|
||||
assert!(m.ingest(&mvc_frame(2, 200, false, dep2.clone())).is_empty());
|
||||
let e = m.ingest(&mvc_frame(0, 200, false, lp(&[&[0x61, 3, 4]])));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1.as_deref(), Some(dep2.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_flushes_unpaired_base_at_eof() {
|
||||
let mut m = empty_merge();
|
||||
// Base with no dependent ever → held, then flushed unpaired at EOF.
|
||||
assert!(
|
||||
m.ingest(&mvc_frame(0, 10, true, vec![0, 0, 0, 1]))
|
||||
.is_empty()
|
||||
);
|
||||
let tail = m.flush();
|
||||
assert_eq!(tail.len(), 1, "unpaired base still emitted");
|
||||
assert!(tail[0].1.is_none(), "no BlockAdditional when unpaired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_mvc_params_no_panic_on_truncated_or_empty() {
|
||||
// Empty, sub-header, zero-length NAL, and a length prefix claiming more
|
||||
// than is present must all return None without panicking (untrusted AU).
|
||||
assert!(extract_mvc_params(&[]).is_none());
|
||||
assert!(extract_mvc_params(&[0, 0, 0]).is_none());
|
||||
assert!(
|
||||
extract_mvc_params(&[0, 0, 0, 0]).is_none(),
|
||||
"lone zero-length NAL yields no params"
|
||||
);
|
||||
assert!(
|
||||
extract_mvc_params(&[0, 0, 0, 10, 0x6F]).is_none(),
|
||||
"length prefix past end breaks, no slice panic"
|
||||
);
|
||||
// A zero-length NAL is SKIPPED, not fatal: valid param sets that follow
|
||||
// are still found (a stray length prefix must not abandon the whole AU).
|
||||
let mut d = vec![0, 0, 0, 0];
|
||||
d.extend_from_slice(&lp(&[&SUBSET_SPS, &DEP_PPS]));
|
||||
let (s, p) = extract_mvc_params(&d).expect("params found past the zero-length NAL");
|
||||
assert_eq!(s, SUBSET_SPS);
|
||||
assert_eq!(p, DEP_PPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_flushes_oldest_base_once_past_window() {
|
||||
let mut m = empty_merge();
|
||||
// Push more unpaired base frames than the window; the excess flush as
|
||||
// plain (unpaired) blocks in FIFO order once len exceeds MVC_PAIR_WINDOW.
|
||||
let n = MVC_PAIR_WINDOW + 8;
|
||||
let mut emitted = 0usize;
|
||||
for pts in 0..n {
|
||||
emitted += m
|
||||
.ingest(&mvc_frame(0, pts as i64, false, vec![0, 0, 0, 1]))
|
||||
.len();
|
||||
}
|
||||
assert_eq!(emitted, 8, "the {n} bases beyond the window flush unpaired");
|
||||
assert_eq!(m.pending_base.len(), MVC_PAIR_WINDOW, "window still held");
|
||||
assert!(m.flush().iter().all(|(_, add)| add.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mvc_merge_dep_overflow_drops_old_keeps_newest() {
|
||||
let mut m = empty_merge();
|
||||
// Fill dep_by_pts to the bound with unpaired dependents (unique PTS).
|
||||
for pts in 0..(MVC_PAIR_WINDOW * 4) {
|
||||
assert!(
|
||||
m.ingest(&mvc_frame(2, pts as i64, false, lp(&[&DEP_SLICE])))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
assert_eq!(m.dep_by_pts.len(), MVC_PAIR_WINDOW * 4);
|
||||
// One more overflows: the drifted buffer is cleared BUT the newest survives
|
||||
// so its (soon-to-arrive) base can still pair.
|
||||
let dep_new = lp(&[&DEP_SLICE]);
|
||||
m.ingest(&mvc_frame(2, 9_999, false, dep_new.clone()));
|
||||
assert_eq!(m.dep_by_pts.len(), 1, "old cleared, newest kept");
|
||||
assert!(m.dep_by_pts.contains_key(&9_999));
|
||||
assert_eq!(
|
||||
m.orphan_deps,
|
||||
(MVC_PAIR_WINDOW * 4) as u64,
|
||||
"old buffer counted once"
|
||||
);
|
||||
// The surviving dependent pairs with its base.
|
||||
let e = m.ingest(&mvc_frame(0, 9_999, false, vec![0x61, 1]));
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].1.as_deref(), Some(dep_new.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_does_not_panic_when_only_video_is_mvc_dependent() {
|
||||
// A (malformed / hand-built) title whose single video IS the dependent
|
||||
// must NOT panic: base_stream_idx is None, so no merge is set up and the
|
||||
// dependent is muxed as an ordinary track.
|
||||
use crate::disc::{
|
||||
Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
|
||||
};
|
||||
let dep = VideoStream {
|
||||
pid: 0x1012,
|
||||
codec: Codec::H264,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: FrameRate::F24,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt709,
|
||||
display_aspect: None,
|
||||
secondary: true,
|
||||
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
|
||||
measured_cicp: None,
|
||||
};
|
||||
let title = DiscTitle {
|
||||
streams: vec![Stream::Video(dep)],
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title, None)
|
||||
.expect("create must succeed, not panic");
|
||||
assert!(
|
||||
s.mvc.is_none(),
|
||||
"no merge when there is no distinct base view"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
|
||||
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
|
||||
|
||||
+4
-2
@@ -108,15 +108,17 @@ pub(crate) mod tsmux;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod videomap;
|
||||
|
||||
pub use demux_sink::{ChaptersFmt, DelayMode, DemuxOptions, DemuxSink, Naming};
|
||||
// `demux://` and `fvi://` sinks are constructed internally by `output()` via the
|
||||
// direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed,
|
||||
// and no consumer names these types, so they are not public API.
|
||||
pub use disc::DiscStream;
|
||||
pub use fvi_sink::FviSink;
|
||||
pub use m2ts::M2tsStream;
|
||||
pub use mkvstream::MkvStream;
|
||||
pub use network::NetworkStream;
|
||||
pub use null::NullStream;
|
||||
pub use pipelined_stream::PipelinedPesStream;
|
||||
pub use resolve::build_iso_pipeline;
|
||||
pub use resolve::resolve_mux_key_map;
|
||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use stdio::StdioStream;
|
||||
|
||||
|
||||
@@ -56,14 +56,6 @@ pub struct PipelinedPesStream {
|
||||
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
||||
/// per-poll reads it replaces were needless hot-path overhead.
|
||||
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
|
||||
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
||||
/// and summarised once at EOF.
|
||||
@@ -134,7 +126,6 @@ impl PipelinedPesStream {
|
||||
pending_frames: std::collections::VecDeque::new(),
|
||||
eof: false,
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
decrypt_loss: None,
|
||||
dropped_nav_packets: 0,
|
||||
resync,
|
||||
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
|
||||
/// codec parse on each, enqueue resulting `PesFrame`s 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())
|
||||
}
|
||||
|
||||
fn lost_bytes(&self) -> u64 {
|
||||
// The file-backed highway has no read-error zero-fill term (resolve
|
||||
// tracks read loss separately), but the producer's decrypt step can
|
||||
// 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)
|
||||
}
|
||||
// `lost_bytes` uses the trait default (0): the file-backed highway has no
|
||||
// read-error zero-fill term (resolve/mapfile tracks physical read loss
|
||||
// separately) and the decrypt path no longer reports a decrypt-loss term.
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+455
-47
@@ -344,9 +344,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
// unchanged (decrypt_keys_for_title short-circuits to decrypt_keys).
|
||||
let (keys, title_is_clear) =
|
||||
match crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
Ok(mut crack_reader) => {
|
||||
disc.decrypt_keys_for_title_checked(idx, &mut crack_reader, 64)
|
||||
}
|
||||
Ok(mut crack_reader) => disc.decrypt_keys_for_title(idx, &mut crack_reader, 64),
|
||||
Err(_) => (disc.decrypt_keys(), false),
|
||||
};
|
||||
// Per-title decrypt gate (parallel to the disc-wide gate above): on
|
||||
@@ -358,12 +356,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
// case must NOT raise a false E7023.
|
||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Upfront FMTS gate, parallel to the unit-key gate above. With
|
||||
// BYPASS_FMTS_KEY this is a no-op and a 2.1 disc's forensic units are
|
||||
// concealed as ordinary decrypt loss below; without it, a 2.1 disc
|
||||
// lacking segment keys fails here rather than emitting a holed mux.
|
||||
disc.ensure_forensic_segments_decryptable(opts.raw)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
|
||||
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
|
||||
// the key-fetch closure and can actually attempt resolution. (An older
|
||||
// upfront blanket-reject gate lived here; it predated the resolver and
|
||||
// rejected every 2.1 disc before a source could be tried.)
|
||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||
// by probing the first DECRYPTED access units of the chosen title.
|
||||
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
||||
@@ -391,11 +388,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
let title = disc.titles[idx].clone();
|
||||
let format = disc.content_format;
|
||||
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
|
||||
// sequential read from fast storage, no bad sectors. Measured
|
||||
// optimum on the rip1 testbed; bumping to 16384 sectors (32 MiB)
|
||||
// regressed (more cache pressure, longer per-batch latency starves
|
||||
// the consumer between iterations). Physical drives keep smaller
|
||||
// batches for adaptive error handling.
|
||||
// sequential read from fast storage, no bad sectors. Empirically
|
||||
// optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
|
||||
// pressure, longer per-batch latency starves the consumer between
|
||||
// iterations). Physical drives keep smaller batches for adaptive
|
||||
// error handling.
|
||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||
|
||||
// Pass `DecryptKeys::None` to the decrypt decorator when
|
||||
@@ -480,7 +477,7 @@ pub fn output(
|
||||
IO_BUF_SIZE,
|
||||
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
|
||||
));
|
||||
Ok(Box::new(MkvStream::create_at(writer, title, Some(path))?))
|
||||
Ok(Box::new(MkvStream::create(writer, title, Some(path))?))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
@@ -532,8 +529,7 @@ pub fn output(
|
||||
// `fvi://` writes the per-picture video index (`docs/FVI_FORMAT.md`).
|
||||
// The bare `output()` arm records the resolver path as the provenance
|
||||
// `source.path` and defaults the title index to 0 (the resolver carries
|
||||
// no title-index context — the CLI follow-up passes the real medium /
|
||||
// title via `FviSink::create_with_source`).
|
||||
// no title-index context).
|
||||
StreamUrl::Fvi { ref path } => {
|
||||
validate_file_path(path, "fvi")?;
|
||||
Ok(Box::new(super::fvi_sink::FviSink::create(
|
||||
@@ -575,7 +571,17 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, idx));
|
||||
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
|
||||
// The Blu-ray 3D MVC dependent (right-eye) view uses a param-set-
|
||||
// passthrough H.264 parser so each frame is a self-contained dependent
|
||||
// access unit for a BlockAdditional; every other stream uses the
|
||||
// ordinary parser for its codec.
|
||||
let parser = match s {
|
||||
crate::disc::Stream::Video(v) if v.is_mvc_dependent() => {
|
||||
super::codec::parser_for_mvc_dependent(codec, is_dvd_ps)
|
||||
}
|
||||
_ => super::codec::parser_for_codec(codec, None, is_dvd_ps),
|
||||
};
|
||||
parsers.push((pid, parser));
|
||||
}
|
||||
let (ts, ps) = match format {
|
||||
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
|
||||
@@ -590,6 +596,391 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||
(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, aacs_unit_encrypted, decrypt_unit, is_clean};
|
||||
use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments};
|
||||
|
||||
// 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)
|
||||
};
|
||||
// ── ANCHOR — fetch the whole 32-key set from ONE index-1 batch. The key
|
||||
// service returns ALL forensic index keys ordered (element i = index i+1)
|
||||
// only for a canonical INDEX-1 sample that decrypts under the index-1 key.
|
||||
// A forensic segment interleaves TWO variants at the aligned-unit level, so
|
||||
// index-1's real content is one PHASE (even or odd units) and the alternate
|
||||
// is a different variant that won't decrypt. We don't know the phase a
|
||||
// priori, so try PHASE A (even) then PHASE B (odd): whichever is index-1's
|
||||
// content comes back with the full set. Both phases failing (across the
|
||||
// read-fault fallback over index-1 segments) ⇒ this disc has no FMTS keys.
|
||||
//
|
||||
// The set's SIZE is whatever the source returns (≥ 1) — never assumed. 32
|
||||
// is all we have seen, but a disc with a different forensic index count is
|
||||
// not ruled out, so the map is sized to the returned `len()`, not a const.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Batch size = the key service's minimum-samples floor (same as the online
|
||||
// source), drawn from ONE phase to land a clean single-variant half.
|
||||
const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS;
|
||||
// Read-fault fallback: how many index-1 segments to attempt if the leading one
|
||||
// is unreadable. The 2 phase requests happen per readable segment.
|
||||
const MAX_ANCHOR_ATTEMPTS: usize = 16;
|
||||
// Even units = p*2; odd units = p*2 + 1.
|
||||
let read_phase_batch = |reader: &mut dyn SectorSource,
|
||||
seg: &crate::aacs::segment::Segment,
|
||||
phase_off: usize|
|
||||
-> Option<Vec<Vec<u8>>> {
|
||||
let mut batch: Vec<Vec<u8>> = Vec::with_capacity(BATCH_UNITS);
|
||||
for p in 0..BATCH_UNITS {
|
||||
batch.push(read_unit(reader, seg, p * 2 + phase_off)?);
|
||||
}
|
||||
Some(batch)
|
||||
};
|
||||
let mut index_keys: Vec<[u8; 16]> = Vec::new();
|
||||
'anchor: for seg in segments
|
||||
.iter()
|
||||
.filter(|s| s.index == 1)
|
||||
.take(MAX_ANCHOR_ATTEMPTS)
|
||||
{
|
||||
for phase_off in [0usize, 1usize] {
|
||||
let Some(batch) = read_phase_batch(reader, seg, phase_off) else {
|
||||
continue; // read fault on this phase — try the other / next segment
|
||||
};
|
||||
let fresh = fetch.fmts_indexes(&batch);
|
||||
// Any non-empty reply is the source's COMPLETE ordered forensic set;
|
||||
// trust it and stop. An empty reply = this phase/segment did not anchor.
|
||||
if !fresh.is_empty() {
|
||||
index_keys = fresh;
|
||||
break 'anchor;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The count is whatever the source returned — not a fixed 32. Sized here, used
|
||||
// everywhere below.
|
||||
let n_index = index_keys.len();
|
||||
tracing::info!(target: "freemkv::keysource", held = n_index, "fmts: collection done");
|
||||
// At least one forensic index key is required. None ⇒ no FMTS key for this
|
||||
// disc from any source — fail loud like a missing Unit Key rather than emit
|
||||
// forensic-holed output.
|
||||
if index_keys.is_empty() {
|
||||
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().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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── PROBE each index's phase. A forensic segment interleaves two variants at
|
||||
// the aligned-unit level; only ONE parity is this index's real content (the
|
||||
// other is the alternate variant — a different key, garbles under ours). For
|
||||
// each index, read a representative tagged segment and count clean decrypts
|
||||
// of its EVEN vs ODD units under that index's key: the clean half is the
|
||||
// index's phase. This is the ONE place `is_clean` runs — "the map must be
|
||||
// right", verified here, once — so the mux decrypt can then trust the map.
|
||||
// Phase is per-index and shared by every segment carrying that index. ──────
|
||||
let mut phase_of_index: std::collections::HashMap<u16, crate::decrypt::Phase> =
|
||||
std::collections::HashMap::new();
|
||||
for (i, k) in index_keys.iter().enumerate() {
|
||||
let tag = (i + 1) as u16;
|
||||
let Some(seg) = segments.iter().find(|s| s.index == tag) else {
|
||||
continue; // no segment carries this index on this feature — skip
|
||||
};
|
||||
let (mut even, mut odd) = (0usize, 0usize);
|
||||
for p in 0..BATCH_UNITS {
|
||||
for (phase_off, counter) in [(0usize, &mut even), (1usize, &mut odd)] {
|
||||
if let Some(mut c) = read_unit(reader, seg, p * 2 + phase_off) {
|
||||
if aacs_unit_encrypted(&c, format) {
|
||||
decrypt_unit(&mut c, k);
|
||||
if is_clean(&c, format) {
|
||||
*counter += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let phase = match even.cmp(&odd) {
|
||||
std::cmp::Ordering::Greater => crate::decrypt::Phase::Even,
|
||||
std::cmp::Ordering::Less => crate::decrypt::Phase::Odd,
|
||||
std::cmp::Ordering::Equal => {
|
||||
// Neither half decrypts clean under this index's key: the map would
|
||||
// be wrong. Fail loud rather than emit a broken segment map.
|
||||
tracing::warn!(target: "freemkv::keysource", index = tag, even, odd, "fmts: no clean phase under index key — refusing broken map");
|
||||
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||
}
|
||||
};
|
||||
phase_of_index.insert(tag, phase);
|
||||
}
|
||||
|
||||
// ── Build the per-segment LBA ranges: each forensic segment → its tag's key
|
||||
// AND its index's phase. The mapped decrypt opens only that half and leaves
|
||||
// the alternate as ciphertext (the muxer drops untouched ciphertext) —
|
||||
// clean by construction, no garble. A segment straddling an extent boundary
|
||||
// is left unmapped and tallied (a hard failure below). ────────────────────
|
||||
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> =
|
||||
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 phase = phase_of_index
|
||||
.get(&seg.index)
|
||||
.copied()
|
||||
.unwrap_or(crate::decrypt::Phase::All);
|
||||
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, phase));
|
||||
} 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 rather than silently emitting a garbled segment.
|
||||
if unresolved != 0 {
|
||||
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||
}
|
||||
|
||||
Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(
|
||||
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.unit_keys(&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.unit_keys(&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
|
||||
/// a `FileSectorSource`-backed reader. Returns the resulting
|
||||
/// `PipelinedPesStream`.
|
||||
@@ -608,17 +999,16 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
|
||||
/// pipeline stops at the next boundary. `None` disables cancellation.
|
||||
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
|
||||
/// - `fetch`: optional fresh-key-on-failure callback (see
|
||||
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
|
||||
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
|
||||
/// returns. `None` keeps the prior behaviour (the unit is counted as loss).
|
||||
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
|
||||
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
|
||||
/// callback: the map decides the key for every LBA before the read loop starts.
|
||||
// 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.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
reader: S,
|
||||
mut reader: S,
|
||||
title: DiscTitle,
|
||||
keys: crate::decrypt::DecryptKeys,
|
||||
mut keys: crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
format: ContentFormat,
|
||||
halt: Option<crate::halt::Halt>,
|
||||
@@ -635,27 +1025,42 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
|
||||
_ => 1,
|
||||
};
|
||||
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
|
||||
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
|
||||
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
|
||||
// decorator; only this mux pipeline opts in.
|
||||
// MUX path: read > decrypt > mux. Resolve the proactive AACS key map UP FRONT
|
||||
// — one key per CPS unit / segment, secured from the configured source and
|
||||
// recorded against the LBA ranges it covers. The mux then decrypts each unit
|
||||
// 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,
|
||||
};
|
||||
// The map IS the title's read plan: it says which CPS unit / forensic segment
|
||||
// each LBA belongs to. Walk ONLY the units it marks as ours — every default /
|
||||
// CPS unit, and inside an FMTS forensic segment only our-phase units. The
|
||||
// alternate-phase units are a different device group's variant; a licensed
|
||||
// player never reads them, and neither do we — they are never fetched,
|
||||
// decrypted, or handed to the demux, so the demux sees one gapless our-variant
|
||||
// stream (no ciphertext to trip a concealed-gap resync). A non-forensic map
|
||||
// returns the extents unchanged, so the common disc reads exactly as before.
|
||||
let extents = match &key_map {
|
||||
Some(map) => map.read_plan(&extents, unit_align as u32),
|
||||
None => extents,
|
||||
};
|
||||
let mut decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
|
||||
.tolerate_decrypt_loss();
|
||||
// Install the fresh-key-on-failure callback (if any) so a unit no held key
|
||||
// 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);
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||
if let Some(map) = key_map {
|
||||
decrypting = decrypting.with_key_map(map);
|
||||
}
|
||||
// Grab the loss counters before the decorator is moved into the producer
|
||||
// thread. It tracks bytes of scrambled AACS units no key could decrypt —
|
||||
// silent loss the demux drops; the consuming stream surfaces it through
|
||||
// `lost_bytes()` so the mux abort gate sees a partial decrypt failure rather
|
||||
// than a clean rip. Forensic (2.1) undecryptable units land here too.
|
||||
let decrypt_loss = decrypting.decrypt_loss();
|
||||
// Loss-counter handle. The mux does NOT tally decrypt-quality misses: a
|
||||
// broken-TS unit is the muxer's concern, and a missing key is an up-front
|
||||
// resolve failure — indistinguishable from bad authoring at this seam, so
|
||||
// counting it would false-abort a bad-encoded-but-decryptable disc. A genuine
|
||||
// can't-decrypt surfaces as `Err`; `lost_bytes()` reflects physical read loss
|
||||
// only (there is no decrypt-loss term to fold in).
|
||||
|
||||
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
|
||||
// the reader, probe the feature head through the (plaintext) decrypting
|
||||
@@ -681,10 +1086,13 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
let (demux_thread, demux_rx) =
|
||||
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
Ok(
|
||||
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
|
||||
.with_decrypt_loss(decrypt_loss),
|
||||
)
|
||||
Ok(PipelinedPesStream::new(
|
||||
demux_thread,
|
||||
demux_rx,
|
||||
title,
|
||||
parsers,
|
||||
pid_to_track,
|
||||
))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
|
||||
/// concealment fill emits null packets on this PID, tagged with an
|
||||
/// adaptation-field discontinuity_indicator to signal a concealed gap.
|
||||
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream. The demuxer
|
||||
/// still recognises a `0x1FFF` packet with an adaptation-field
|
||||
/// 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;
|
||||
|
||||
/// A reassembled PES packet with timestamp info.
|
||||
@@ -34,8 +36,10 @@ pub struct PesPacket {
|
||||
pub source: Option<crate::pes::SourcePos>,
|
||||
/// 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 tracked PID, or the CC-independent concealment marker the mux emits when
|
||||
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
|
||||
/// a tracked PID, or a CC-independent NULL-TS concealment marker (P3/B1). NOTE:
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -200,7 +204,7 @@ impl PesAssembler {
|
||||
/// BD Transport Stream demuxer.
|
||||
pub struct TsDemuxer {
|
||||
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
|
||||
/// 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.
|
||||
@@ -224,20 +228,17 @@ impl TsDemuxer {
|
||||
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
||||
/// valid (wholly-unused) table.
|
||||
pub fn new(pids: &[u16]) -> Self {
|
||||
// The PID→assembler index is stored as i16 (-1 = untracked), so a
|
||||
// 32768th+ tracked PID would truncate to a negative value and be
|
||||
// silently treated as untracked. Callers pass a handful of PIDs
|
||||
// (BD-TS has at most ~8192), so this is a programmer-error guard.
|
||||
debug_assert!(
|
||||
pids.len() <= i16::MAX as usize,
|
||||
"TsDemuxer: too many PIDs for an i16 index table"
|
||||
);
|
||||
// The PID→assembler index is stored as i32 (-1 = untracked). PIDs are
|
||||
// u16 (≤ 65535) and the assembler index `i` is bounded by the number of
|
||||
// distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can
|
||||
// never truncate to a negative value and be mis-read as untracked —
|
||||
// unlike an i16 table, this is safe in RELEASE, not just under debug.
|
||||
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
||||
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());
|
||||
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));
|
||||
}
|
||||
Self {
|
||||
@@ -368,10 +369,13 @@ impl TsDemuxer {
|
||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||
let adaptation = (ts[3] >> 4) & 0x03;
|
||||
|
||||
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
|
||||
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
|
||||
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
|
||||
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
|
||||
// P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an
|
||||
// adaptation-field discontinuity_indicator. NOTE: the in-tree writer that
|
||||
// laid these down on an undecryptable unit was removed with the pure-decrypt
|
||||
// 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
|
||||
// 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
|
||||
@@ -1057,7 +1061,7 @@ mod tests {
|
||||
|
||||
/// 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
|
||||
/// 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> {
|
||||
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
||||
pkt[4] = SYNC_BYTE; // 0x47
|
||||
|
||||
+391
-859
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) {
|
||||
(**self).set_speed(kbs)
|
||||
}
|
||||
|
||||
fn set_unit_base(&mut self, lba: u32) {
|
||||
(**self).set_unit_base(lba)
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorSource for &mut (dyn SectorSource + '_) {
|
||||
@@ -160,6 +164,10 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
(**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.
|
||||
@@ -181,7 +189,7 @@ pub trait SectorSink: Send {
|
||||
}
|
||||
|
||||
pub use crate::io::file_sector_source::FileSectorSource;
|
||||
pub use decrypting::{DECRYPT_VERIFY_READ, DecryptingSectorSource, KeyFetch};
|
||||
pub use decrypting::{DecryptingSectorSource, KeyFetch, KeyFetchFn};
|
||||
pub use file::FileSectorSink;
|
||||
pub use prefetched::PrefetchedSectorSource;
|
||||
|
||||
@@ -198,23 +206,33 @@ mod tests {
|
||||
capacity: u32,
|
||||
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||
speeds: Arc<Mutex<Vec<u16>>>,
|
||||
unit_bases: Arc<Mutex<Vec<u32>>>,
|
||||
}
|
||||
|
||||
/// A `Spy` under test plus the handles recording its reads and speed sets.
|
||||
type SpyHarness = (Spy, Arc<Mutex<Vec<(u32, u16, bool)>>>, Arc<Mutex<Vec<u16>>>);
|
||||
/// A `Spy` under test plus the handles recording its reads, speed sets,
|
||||
/// and unit-base sets.
|
||||
type SpyHarness = (
|
||||
Spy,
|
||||
Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||
Arc<Mutex<Vec<u16>>>,
|
||||
Arc<Mutex<Vec<u32>>>,
|
||||
);
|
||||
|
||||
impl Spy {
|
||||
fn new(capacity: u32) -> SpyHarness {
|
||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||
let speeds = Arc::new(Mutex::new(Vec::new()));
|
||||
let unit_bases = Arc::new(Mutex::new(Vec::new()));
|
||||
(
|
||||
Self {
|
||||
capacity,
|
||||
reads: reads.clone(),
|
||||
speeds: speeds.clone(),
|
||||
unit_bases: unit_bases.clone(),
|
||||
},
|
||||
reads,
|
||||
speeds,
|
||||
unit_bases,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -238,6 +256,16 @@ mod tests {
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
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
|
||||
@@ -286,7 +314,7 @@ mod tests {
|
||||
/// Box<dyn SectorSource>` forwarding bodies.
|
||||
#[test]
|
||||
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);
|
||||
|
||||
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
|
||||
@@ -308,24 +336,46 @@ mod tests {
|
||||
vec![5400],
|
||||
"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 + '_)`.
|
||||
#[test]
|
||||
fn mut_ref_dyn_forwards_all_methods() {
|
||||
let (mut spy, reads, speeds) = Spy::new(123);
|
||||
let r: &mut dyn SectorSource = &mut spy;
|
||||
let (mut spy, reads, speeds, unit_bases) = Spy::new(123);
|
||||
|
||||
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 n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
||||
assert_eq!(n, 2 * 2048);
|
||||
let mut buf = vec![0u8; 2 * 2048];
|
||||
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
|
||||
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!(*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() {
|
||||
// Re-expose `bytes` without zero-filling pages that
|
||||
// `read_sectors` is about to overwrite. The enclosing
|
||||
// capacity guard makes the `set_len` provably sound even
|
||||
// if a recycled buffer ever comes back smaller than the
|
||||
// `vec![0u8; batch_bytes]` it was born with.
|
||||
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity");
|
||||
// `read_sectors` is about to overwrite. Sound because the
|
||||
// enclosing `bytes <= capacity` guard bounds the length,
|
||||
// and every byte below `capacity` is physically
|
||||
// initialised: buffers are born `vec![0u8; batch_bytes]`
|
||||
// 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) };
|
||||
} else {
|
||||
buf.resize(bytes, 0);
|
||||
|
||||
+65
-37
@@ -103,25 +103,30 @@ pub struct RecoverCtx {
|
||||
pub prev_dropped: usize,
|
||||
}
|
||||
|
||||
/// A recovery: given a read's still-scrambled `buf` and the **generic**
|
||||
/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or
|
||||
/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme
|
||||
/// — the installed recovery decides what to do with the generic keys, so any
|
||||
/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different
|
||||
/// [`Recover`] the input stream installs. `FnMut` so per-recovery
|
||||
/// state (the AACS dedup set / call budget) lives in the closure's captures with
|
||||
/// no lock; `Send` so it can ride the mux highway's producer thread.
|
||||
pub type Recover = Box<dyn FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
|
||||
/// A recovery: given a read's post-decrypt `target` (pure decrypt leaves the
|
||||
/// applied-key plaintext), the matching on-disc `ciphertext`, and the **generic**
|
||||
/// [`DecryptKeys`], make units decrypt (fetch a key into `keys` and retry) and/or
|
||||
/// classify the loss (see [`MissOutcome`]). Decryption itself lives in ONE place
|
||||
/// (`decrypt_sectors`); a recovery only supplies the missing KEY and re-runs it.
|
||||
/// `ciphertext` is separate from `target` because a pure decrypt overwrites the
|
||||
/// target with plaintext — the key server still needs the original on-disc bytes,
|
||||
/// and the retry re-decrypts from them. The type names NO encryption scheme; any
|
||||
/// 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
|
||||
/// still-scrambled units, ask `fetch` for keys, add any new ones to the pool and
|
||||
/// re-decrypt. `dry` / `calls` are the caller-owned dedup set and call budget.
|
||||
/// Returns the post-retry dropped-byte count.
|
||||
/// The AACS key-fetch step used by [`key_fetch`]: gather the units the pool did
|
||||
/// NOT open, ask `fetch` for keys, add any new ones to the pool and re-decrypt.
|
||||
/// `dry` / `calls` are the caller-owned dedup set and call budget. Returns the
|
||||
/// post-retry unverified-byte count.
|
||||
fn aacs_fetch_step(
|
||||
dry: &mut HashSet<u64>,
|
||||
calls: &mut usize,
|
||||
fetch: &KeyFetch,
|
||||
buf: &mut [u8],
|
||||
target: &mut [u8],
|
||||
ciphertext: &[u8],
|
||||
keys: &mut DecryptKeys,
|
||||
ctx: &RecoverCtx,
|
||||
) -> usize {
|
||||
@@ -130,14 +135,25 @@ fn aacs_fetch_step(
|
||||
return prev_dropped;
|
||||
}
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact
|
||||
// on-disc ciphertext no held key could open. A trailing partial unit
|
||||
// Container of this disc's content — travels with the keys; drives the
|
||||
// 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
|
||||
// correct.
|
||||
let mut samples: Vec<Vec<u8>> = Vec::new();
|
||||
for chunk in buf.chunks_exact(unit_len) {
|
||||
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
|
||||
samples.push(chunk.to_vec());
|
||||
for (t, c) in target
|
||||
.chunks_exact(unit_len)
|
||||
.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 {
|
||||
break;
|
||||
}
|
||||
@@ -154,7 +170,7 @@ fn aacs_fetch_step(
|
||||
return prev_dropped;
|
||||
}
|
||||
*calls += 1;
|
||||
let fresh = (fetch)(&samples);
|
||||
let fresh = fetch.unit_keys(&samples);
|
||||
// Add only keys we don't already hold (dedup by value).
|
||||
let mut added = 0usize;
|
||||
if let DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||
@@ -172,10 +188,13 @@ fn aacs_fetch_step(
|
||||
dry.extend(fps);
|
||||
return prev_dropped;
|
||||
}
|
||||
// Retry now that the pool has grown; a unit that still won't decrypt is
|
||||
// genuine loss. A retry error must not mask the original count.
|
||||
// Retry now that the pool has grown. Reset the target to the on-disc
|
||||
// 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(
|
||||
buf,
|
||||
target,
|
||||
keys,
|
||||
ctx.unit_key_idx,
|
||||
ctx.lba,
|
||||
@@ -187,7 +206,7 @@ fn aacs_fetch_step(
|
||||
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
|
||||
/// caller that wants an explicit "give up" recovery has one.
|
||||
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
|
||||
@@ -195,9 +214,9 @@ pub fn none() -> Recover {
|
||||
pub fn key_fetch(fetch: KeyFetch) -> Recover {
|
||||
let mut dry: HashSet<u64> = HashSet::new();
|
||||
let mut calls: usize = 0;
|
||||
Box::new(move |buf, keys, ctx| {
|
||||
Box::new(move |target, ciphertext, keys, ctx| {
|
||||
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 {
|
||||
unit_keys: vec![],
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -255,18 +276,20 @@ mod tests {
|
||||
// decorator's integration tests); here we pin the seam's key-plumbing.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = Arc::clone(&calls);
|
||||
let fetch: KeyFetch = Arc::new(move |samples: &[Vec<u8>]| {
|
||||
let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |samples: &[Vec<u8>]| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
assert!(!samples.is_empty(), "failing ciphertext is forwarded");
|
||||
vec![[0xAB; 16]]
|
||||
});
|
||||
}));
|
||||
let mut r = key_fetch(fetch);
|
||||
let mut buf = scrambled_unit(0x33);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
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");
|
||||
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
|
||||
unreachable!()
|
||||
@@ -281,19 +304,22 @@ mod tests {
|
||||
// the SAME ciphertext must not call the fetch again.
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = Arc::clone(&calls);
|
||||
let fetch: KeyFetch = Arc::new(move |_: &[Vec<u8>]| {
|
||||
let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |_: &[Vec<u8>]| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
Vec::new() // never helps
|
||||
});
|
||||
}));
|
||||
let mut r = key_fetch(fetch);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
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
|
||||
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!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
@@ -305,21 +331,23 @@ mod tests {
|
||||
fn key_fetch_call_budget_bounds_fetches() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = Arc::clone(&calls);
|
||||
let fetch: KeyFetch = Arc::new(move |_: &[Vec<u8>]| {
|
||||
let fetch: KeyFetch = KeyFetch::unit_only(Arc::new(move |_: &[Vec<u8>]| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
Vec::new()
|
||||
});
|
||||
}));
|
||||
let mut r = key_fetch(fetch);
|
||||
let mut keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
// Distinct ciphertext each time so the dry-set never short-circuits; only
|
||||
// the internal call budget should stop the fetch. The closure self-limits,
|
||||
// so the decorator can call it unconditionally.
|
||||
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
|
||||
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!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
|
||||
+109
-5
@@ -593,10 +593,27 @@ impl UdfFs {
|
||||
match next_block {
|
||||
Some(cont_lba) => {
|
||||
read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?;
|
||||
// A continuation block is a list of Short ADs from byte 0,
|
||||
// spanning the whole sector.
|
||||
ad_start = 0;
|
||||
ad_bytes = block.len();
|
||||
// A continuation block does NOT begin with allocation
|
||||
// descriptors — it begins with a 24-byte Allocation Extent
|
||||
// Descriptor (ECMA-167 4/14.5, tag id 258): the 16-byte
|
||||
// descriptor tag, then prev_allocation_extent_location
|
||||
// (Uint32 @16) and length_of_allocation_descriptors
|
||||
// (Uint32 @20). The real ADs (same Short/Long/Extended type
|
||||
// as the file) start at offset 24, and their total byte
|
||||
// length is that @20 field.
|
||||
//
|
||||
// Reading from offset 0 parses the AED's own tag header as
|
||||
// allocation descriptors → one garbage extent, then an
|
||||
// unknown extent_type break — silently truncating every
|
||||
// file whose ADs spill into a continuation block (a heavily
|
||||
// fragmented file, e.g. a Blu-ray 3D interleaved base-view
|
||||
// .m2ts with ~1600 fragments). A normal few-extent .m2ts
|
||||
// fits inline and never reaches here, which is why this
|
||||
// stayed hidden.
|
||||
let aed_l_ad =
|
||||
u32::from_le_bytes([block[20], block[21], block[22], block[23]]) as usize;
|
||||
ad_start = 24;
|
||||
ad_bytes = aed_l_ad.min(block.len().saturating_sub(24));
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
@@ -1535,8 +1552,15 @@ mod tests {
|
||||
|
||||
/// A continuation block: a bare list of short ADs from byte 0.
|
||||
fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] {
|
||||
// A continuation block is an Allocation Extent Descriptor (ECMA-167
|
||||
// 4/14.5): a 16-byte descriptor tag, then prev_allocation_extent_location
|
||||
// (Uint32 @16) and length_of_allocation_descriptors (Uint32 @20). The
|
||||
// actual allocation descriptors begin at offset 24. The parser skips the
|
||||
// 24-byte header and reads `l_ad` bytes of ADs from there.
|
||||
let mut s = [0u8; 2048];
|
||||
let mut off = 0usize;
|
||||
let l_ad = (ads.len() * 8) as u32;
|
||||
s[20..24].copy_from_slice(&l_ad.to_le_bytes());
|
||||
let mut off = 24usize;
|
||||
for &(etype, dlen, dlba) in ads {
|
||||
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
|
||||
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
|
||||
@@ -1590,6 +1614,35 @@ mod tests {
|
||||
assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icb_extents_continuation_skips_aed_header_not_read_as_extent() {
|
||||
// Regression (Blu-ray 3D): a continuation block begins with a 24-byte
|
||||
// Allocation Extent Descriptor whose 16-byte descriptor tag holds
|
||||
// NON-zero bytes (tag id 258, CRC, location, ...). Reading allocation
|
||||
// descriptors from offset 0 parses that tag header as an AD → a garbage
|
||||
// extent, then an unknown extent_type break → every fragment past the
|
||||
// first continuation is lost. On a 3D disc that truncated the 25.8 GB
|
||||
// interleaved base-view feature to ~1.8 GB (113 of ~1600 fragments) and
|
||||
// then choked the mux on the bogus, non-unit-aligned extent. The parser
|
||||
// MUST skip the 24-byte AED header and read the real ADs from offset 24.
|
||||
let icb = build_efe(6144, &[(0, 4096, 10), (3, 2048, 50)]);
|
||||
let mut cont = build_cont_block(&[(0, 2048, 20)]);
|
||||
// Stamp a realistic AED descriptor tag (id 258) into the header so a
|
||||
// regression that reads from offset 0 mis-parses it as a bogus extent
|
||||
// instead of finding the real (20, 2048) at offset 24.
|
||||
cont[0..2].copy_from_slice(&258u16.to_le_bytes());
|
||||
let mut reader = MapReader::new();
|
||||
reader.put(5, icb);
|
||||
reader.put(50, cont);
|
||||
let fs = fs_with(0, 0, file_entry("3D", 5, 6144));
|
||||
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
|
||||
assert_eq!(
|
||||
extents,
|
||||
vec![(10, 4096), (20, 2048)],
|
||||
"continuation ADs must be read past the 24-byte AED header, not from offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icb_extents_long_ad_returns_all_extents_not_just_first() {
|
||||
// Regression: BD-ROM large .m2ts files use Long ADs (16-byte
|
||||
@@ -1737,6 +1790,57 @@ mod tests {
|
||||
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]
|
||||
fn merge_ranges_saturates_near_u32_max() {
|
||||
// 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)
|
||||
assert!(aacs::content::ts_sync_destroyed(&plain));
|
||||
assert!(!aacs::content::is_clean(
|
||||
&plain,
|
||||
libfreemkv::disc::ContentFormat::BdTs
|
||||
));
|
||||
|
||||
// Now decrypt
|
||||
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||
aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||
assert!(
|
||||
result,
|
||||
"decrypt_unit should return true on valid encrypted unit"
|
||||
);
|
||||
assert!(
|
||||
!aacs::content::ts_sync_destroyed(&plain),
|
||||
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
|
||||
"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");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn aacs_ts_sync_destroyed_detection() {
|
||||
fn aacs_is_clean_detection() {
|
||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||
// scrambled. (Flag bits play no role.)
|
||||
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
@@ -270,7 +269,7 @@ fn aacs_ts_sync_destroyed_detection() {
|
||||
off += 192;
|
||||
}
|
||||
assert!(
|
||||
!aacs::content::ts_sync_destroyed(&clear),
|
||||
aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs),
|
||||
"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[7] = 0xC0; // TSC bits
|
||||
assert!(
|
||||
!aacs::content::ts_sync_destroyed(&flagged),
|
||||
aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs),
|
||||
"flag bits must not be read as encryption"
|
||||
);
|
||||
|
||||
// A scrambled body (syncs destroyed) → scrambled.
|
||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||
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"
|
||||
);
|
||||
|
||||
// Too short
|
||||
let short = vec![0xFFu8; 100];
|
||||
assert!(
|
||||
!aacs::content::ts_sync_destroyed(&short),
|
||||
aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs),
|
||||
"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]
|
||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
fn aacs_clear_unit_reports_not_encrypted() {
|
||||
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;
|
||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||
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.
|
||||
unit[0] &= 0x3F;
|
||||
let original = unit.clone();
|
||||
let key = [0xAA; 16];
|
||||
|
||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
||||
let result = aacs::content::decrypt_unit(&mut unit, &key);
|
||||
assert!(result, "clear unit should return true");
|
||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
||||
assert!(aacs::content::is_clean(
|
||||
&unit,
|
||||
libfreemkv::disc::ContentFormat::BdTs
|
||||
));
|
||||
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 ──────────────
|
||||
@@ -413,11 +417,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
);
|
||||
|
||||
// -- Decrypt with the library --
|
||||
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||
assert!(
|
||||
ok,
|
||||
"decrypt_unit returned false (TS sync verification failed)"
|
||||
);
|
||||
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
@@ -458,44 +458,14 @@ fn aacs_cross_validation_alternate_key() {
|
||||
&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.
|
||||
assert_eq!(&plaintext[..], &expected[..]);
|
||||
}
|
||||
|
||||
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
||||
/// per-sector to bytes 16..2048 (bus encryption layer).
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
// (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate
|
||||
// in `aacs::content`'s unit tests, not here.)
|
||||
|
||||
// ── 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 \
|
||||
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.",
|
||||
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];
|
||||
|
||||
// Encrypt the unit using AACS algorithm
|
||||
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
|
||||
// Apply the key to the pattern to produce ciphertext-shaped bytes for the
|
||||
// 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 keys = DecryptKeys::Aacs {
|
||||
let mut aacs_keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0u32, unit_key)],
|
||||
read_data_key: None,
|
||||
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let mut none_keys = DecryptKeys::None;
|
||||
|
||||
// decrypt_sectors should handle this without error
|
||||
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0);
|
||||
// The regression this guards is passing `DecryptKeys::None` where AACS keys
|
||||
// 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!(
|
||||
result.is_ok(),
|
||||
"decrypt_sectors with AACS keys should not error"
|
||||
assert_ne!(
|
||||
with_aacs, unit,
|
||||
"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;
|
||||
}
|
||||
// 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.
|
||||
unit[0] = 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.
|
||||
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.
|
||||
@@ -111,6 +138,7 @@ fn decrypt_keys_is_encrypted_variants() {
|
||||
let aacs = DecryptKeys::Aacs {
|
||||
unit_keys: vec![],
|
||||
read_data_key: None,
|
||||
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||
};
|
||||
assert!(aacs.is_encrypted());
|
||||
|
||||
|
||||
@@ -275,7 +275,6 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
|
||||
progress: None,
|
||||
halt: Some(halt.clone()),
|
||||
key_fetch: None,
|
||||
fast_capture: false,
|
||||
};
|
||||
|
||||
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
||||
|
||||
+187
-29
@@ -232,8 +232,6 @@ struct Golden {
|
||||
bytes_unreadable: u64,
|
||||
/// `bytes_pending` (NonTrimmed) at end.
|
||||
bytes_pending: u64,
|
||||
/// Did the pass exit via wedge-detection?
|
||||
wedged_exit: bool,
|
||||
/// Sanity bound on trace length — patch makes a finite number of
|
||||
/// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus
|
||||
/// 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_unreadable: 0,
|
||||
bytes_pending: 0,
|
||||
wedged_exit: false,
|
||||
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");
|
||||
@@ -796,29 +793,190 @@ fn profile_08_batch_fail_singles_ok() {
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Suppressed for now: NOT_READY-then-recover, HARDWARE_ERROR (wedge),
|
||||
// ILLEGAL_REQUEST (wedge), and ABORTED_COMMAND profiles. Each would
|
||||
// trigger long real-time sleeps inside `handle_read_failure`:
|
||||
// Sense-family error paths in `Disc::patch`: NOT_READY-then-recover,
|
||||
// HARDWARE_ERROR, ILLEGAL_REQUEST, and ABORTED_COMMAND.
|
||||
//
|
||||
// - NOT_READY (sense_key=0x02, asc=0x02/0x03/0x04): 15 s pause per
|
||||
// occurrence (`patch_not_ready_pause`), and retries the same LBA
|
||||
// in-place. Even one NOT_READY costs the test 15 s wall-time.
|
||||
// These drive `disc.patch(...)` DIRECTLY rather than through `run_profile`
|
||||
// (which drives `Disc::copy`, whose SWEEP path really sleeps on NOT_READY /
|
||||
// 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
|
||||
// (`WEDGE_FAMILY_COOLDOWN_SECS`), bounded by
|
||||
// `WEDGE_ABORT_THRESHOLD=16` before wedged-exit. Worst case ~8
|
||||
// minutes per profile.
|
||||
//
|
||||
// The sleeps are not injectable. Adding them would require either a
|
||||
// `now()` / `sleep()` trait injection (out of scope for the unification
|
||||
// task) or a "test mode" compile-time flag (architectural smell). The
|
||||
// behavioural contracts for those paths are captured in
|
||||
// `read_error.rs`'s in-module tests instead — they exercise the
|
||||
// classifier without invoking the patch loop's sleep side-effects.
|
||||
//
|
||||
// If the unification ever proceeds, the next step is to add a clock
|
||||
// injection point in `handle_read_failure` and extend this fixture
|
||||
// with the wedge/NOT_READY profiles too.
|
||||
// The load-bearing invariant asserted across every PERSISTENT failure sense is
|
||||
// the recovery contract: a patch pass NEVER promotes a sector to Unreadable
|
||||
// (the orchestrator does that only after the final pass) and NEVER silently
|
||||
// drops bytes — a still-bad sector stays NonTrimmed (pending), so
|
||||
// good + pending always conserves the total. Exact good/pending splits are
|
||||
// left loose so wedge-skip tuning can't spuriously fail these.
|
||||
|
||||
/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192)
|
||||
/// range) patch pass with the given failure step and return the final map
|
||||
/// stats. 256-sector synthetic disc; everything outside the range is Finished.
|
||||
fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats {
|
||||
let capacity_sectors: u32 = 256;
|
||||
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
|
||||
reader.always(130, step);
|
||||
|
||||
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 ────────
|
||||
//
|
||||
@@ -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
|
||||
// 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
|
||||
// 32-block untouched. (`fast_capture` is now inert: the chain supersedes it. The
|
||||
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is
|
||||
// a scheduling concern for the handler scheduler, tracked separately.)
|
||||
// 32-block untouched. (The old `fast_capture` knob was removed: the handler
|
||||
// chain supersedes it. The breadth-first "fast on all ranges, then escalate"
|
||||
// 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
|
||||
// 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,
|
||||
halt: None,
|
||||
key_fetch: None,
|
||||
fast_capture: true,
|
||||
};
|
||||
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 stats = Mapfile::load(&map_path).unwrap().stats();
|
||||
|
||||
+5
-5
@@ -591,7 +591,7 @@ fn meta_all_stream_types() {
|
||||
fn mkvstream_write_finish() {
|
||||
let dt = sample_disc_title();
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
||||
let mut stream = MkvStream::create(writer, &dt).unwrap();
|
||||
let mut stream = MkvStream::create(writer, &dt, None).unwrap();
|
||||
|
||||
// Write some fake PES frames (they won't produce valid MKV content
|
||||
// since there is no real codec data, but it should not panic)
|
||||
@@ -616,7 +616,7 @@ fn mkvstream_write_finish() {
|
||||
fn mkvstream_meta_sets_title() {
|
||||
let dt = sample_disc_title();
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
||||
let stream = MkvStream::create(writer, &dt).unwrap();
|
||||
let stream = MkvStream::create(writer, &dt, None).unwrap();
|
||||
|
||||
let info = PesStream::info(&stream);
|
||||
assert_eq!(info.playlist, "Test Movie");
|
||||
@@ -656,7 +656,7 @@ fn mkvstream_roundtrip_bdts() {
|
||||
};
|
||||
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
||||
let mut stream = MkvStream::create(writer, &dt).unwrap();
|
||||
let mut stream = MkvStream::create(writer, &dt, None).unwrap();
|
||||
|
||||
// Write PES frames targeting the audio track
|
||||
for i in 0..10u8 {
|
||||
@@ -745,7 +745,7 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
};
|
||||
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
||||
let stream = MkvStream::create(writer, &dt).unwrap();
|
||||
let stream = MkvStream::create(writer, &dt, None).unwrap();
|
||||
|
||||
let info = PesStream::info(&stream);
|
||||
assert_eq!(info.streams.len(), 5, "all 5 streams should be preserved");
|
||||
@@ -856,7 +856,7 @@ fn mkvstream_e2e_h264_produces_valid_mkv() {
|
||||
|
||||
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> =
|
||||
Box::new(SharedWriter(output2.clone()));
|
||||
let mut stream2 = MkvStream::create(writer, &dt).unwrap();
|
||||
let mut stream2 = MkvStream::create(writer, &dt, None).unwrap();
|
||||
|
||||
// Write the ES data (SPS+PPS+IDR) as a keyframe PES frame.
|
||||
let frame1 = libfreemkv::pes::PesFrame {
|
||||
|
||||
Reference in New Issue
Block a user