Compare commits
112
Commits
422f2b6bcf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
767d205fd4 | ||
|
|
bbb5a953f1 | ||
|
|
3f238ab3dc | ||
|
|
ec62618bde | ||
|
|
15111d544b | ||
|
|
19b646e012 | ||
|
|
17694af625 | ||
|
|
828f5c0192 | ||
|
|
075223a4bd | ||
|
|
94ac4a442a | ||
|
|
f468237279 | ||
|
|
227545fabc | ||
|
|
05fd7fcbfa | ||
|
|
967d0ac77e | ||
|
|
eb0aa3556e | ||
|
|
7ba43d9c02 | ||
|
|
bf9a69ec80 | ||
|
|
6d6e60fdf8 | ||
|
|
661ab138c6 | ||
|
|
c00384d4df | ||
|
|
e8c151792d | ||
|
|
20b36229a5 | ||
|
|
71aad385c7 | ||
|
|
ec5b10f83a | ||
|
|
5181f6ce19 | ||
|
|
a711ee1d00 | ||
|
|
f2b7cc9bdd | ||
|
|
6f53767e8b | ||
|
|
7ed798e386 | ||
|
|
b9568242df | ||
|
|
8ac18fa631 | ||
|
|
197489fb7c | ||
|
|
dc7dfc6041 | ||
|
|
0acb326079 | ||
|
|
e632874665 | ||
|
|
88e58bfc95 | ||
|
|
279ba0dd7c | ||
|
|
1eb6910bdb | ||
|
|
e380e3b7c8 | ||
|
|
ff349fa61b | ||
|
|
52fd0f733a | ||
|
|
5b03fd8ebc | ||
|
|
c635190b0d | ||
|
|
34c5293704 | ||
|
|
bb59166e48 | ||
|
|
ea047b57d6 | ||
|
|
909fe48628 | ||
|
|
da19280950 | ||
|
|
2274423a6f | ||
|
|
c1f1593003 | ||
|
|
6718c9cdb2 | ||
|
|
9fdd5edb65 | ||
|
|
8a5a26f2a5 | ||
|
|
97ce0b7fab | ||
|
|
e194ef1585 | ||
|
|
4d1b922232 | ||
|
|
3841ae2250 | ||
|
|
2ccb5c9d01 | ||
|
|
b2bd5f8b3e | ||
|
|
6f055394c7 | ||
|
|
98f3dc513f | ||
|
|
5ecfe7c69a | ||
|
|
f255361683 | ||
|
|
c6e6bb9f4b | ||
|
|
f7edd4e6a9 | ||
|
|
a947439171 | ||
|
|
8e6114cd2a | ||
|
|
8f55cb78d2 | ||
|
|
65e14fe3b7 | ||
|
|
1e3610fd75 | ||
|
|
0c9d375548 | ||
|
|
8aff7fe708 | ||
|
|
489545c865 | ||
|
|
281d8baed6 | ||
|
|
6a4ac97a33 | ||
|
|
a8563e9fa3 | ||
|
|
63f6909ff0 | ||
|
|
9f33306a0a | ||
|
|
43cdc1351d | ||
|
|
5728a7c577 | ||
|
|
f85d91a17a | ||
|
|
a688e2c642 | ||
|
|
05fb632d7c | ||
|
|
3cb0a8f41c | ||
|
|
9dbfb70f7e | ||
|
|
9af3f7da7a | ||
|
|
2638c3075e | ||
|
|
3661942bdb | ||
|
|
43c1f9bda0 | ||
|
|
37832ac2dd | ||
|
|
2263d2cc4e | ||
|
|
3546648faa | ||
|
|
98000869b2 | ||
|
|
e308c5b825 | ||
|
|
5e1f880f6e | ||
|
|
ffe8ee8684 | ||
|
|
93571d9181 | ||
|
|
89af9876ae | ||
|
|
0471e0ca40 | ||
|
|
38207d2272 | ||
|
|
add9d8e0cd | ||
|
|
edc60582ec | ||
|
|
ccb7cafc68 | ||
|
|
0183bfb58c | ||
|
|
830d1e360c | ||
|
|
04728d7d94 | ||
|
|
e62ffed2b1 | ||
|
|
9d37043b3e | ||
|
|
6858cd064d | ||
|
|
f99670ceaa | ||
|
|
75b0e68b85 | ||
|
|
4a341331e2 |
+345
@@ -1,5 +1,350 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.6.0] — UNRELEASED
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **High-level orchestration API — a single mux driver and a disc session.**
|
||||||
|
`mux_stream` drives the whole read → decrypt → demux → write pipeline for any
|
||||||
|
source (`MuxInput::Url` / `Iso` / `Session` / `Live`), so consumers stop
|
||||||
|
hand-rolling the frame pump. `DiscSession` hoists drive open + SCSI bring-up +
|
||||||
|
scan + key resolution behind one type; `scan_iso` does the same for a
|
||||||
|
file-backed ISO; `resolve_keys` / `resolve_keys_for` resolve base AACS keys.
|
||||||
|
These let the CLI and autorip shrink to thin front-ends (and back the new
|
||||||
|
`freemkv-engine` crate).
|
||||||
|
- **Per-title stream selection (`StreamSelection`).** A pure primitive that
|
||||||
|
prunes a `DiscTitle`'s audio/subtitle streams to a chosen set of PIDs (video
|
||||||
|
is always kept) before the mux builds its demux state — so track headers,
|
||||||
|
`codec_privates`, and frame routing all follow the pruned list, with no
|
||||||
|
demux-internal filter. Carried on `MuxOptions.selection` /
|
||||||
|
`InputOptions.selection` (both default to keep-everything, a no-op).
|
||||||
|
Languages are the caller's concern; the library speaks PIDs.
|
||||||
|
- `DiscTitle::audio_streams()` / `subtitle_streams()` / `video_streams()` —
|
||||||
|
typed iterators over each stream class.
|
||||||
|
- `MuxOptions` gains a per-call write-pipeline `send_deadline` and derives
|
||||||
|
`Default`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The recovery strategy moved to the new `freemkv-engine` crate.** Sweep,
|
||||||
|
patch, the retry-decision state machine, mapfile bookkeeping, and damage
|
||||||
|
classification are freemkv's specific recovery *philosophy*, not disc-access
|
||||||
|
primitives — they now live in `freemkv-engine`, which composes libfreemkv's
|
||||||
|
public API. The library keeps the raw single-shot read, SCSI-sense-fact
|
||||||
|
translation (`SenseFamily`, now in `scsi`), decrypt, and the mux highway.
|
||||||
|
- Small deliberate `pub` promotions to support the engine as an external
|
||||||
|
consumer: `Disc::resolve_content_key_map` / `encrypted_content_ranges`,
|
||||||
|
`io::WritebackFile`, `drive::extract_scsi_context`, `disc::locate_ranges`.
|
||||||
|
- New typed error classifiers re-exported at the crate root — `is_halt`,
|
||||||
|
`is_skippable_title_stub`, `is_disc_level_no_key` — and a new error variant
|
||||||
|
`SelectionPidUnknown` (E6014).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Mux correctness pass** (the `v1.4.0..HEAD` 10-phase audit): DTS core-header
|
||||||
|
false-drops that dropped good DTS frames; the TrueHD channel-correction probe
|
||||||
|
now runs correctly on AACS discs (7.1/Atmos no longer understated as 5.1);
|
||||||
|
an FMTS phase-probe read fault is distinguished from a wrong key; multi-CPS
|
||||||
|
and orphan-clip keying; the AACS key map is now a *positive* map (a sector
|
||||||
|
with no key passes through rather than failing), with fail-loud on genuinely
|
||||||
|
unresolvable keys; a user Stop mid-read is reported as `completed = false`
|
||||||
|
(a stop is not a failure), not a spurious error.
|
||||||
|
|
||||||
|
## [1.5.2] — 2026-07-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- TrueHD 7.1/Atmos channel correction now works on AACS-encrypted (Blu-ray/UHD)
|
||||||
|
discs. The channel-correction probe was built without an AACS key map, so on
|
||||||
|
every AACS disc its first read failed and the correction was silently skipped —
|
||||||
|
a 7.1/Atmos TrueHD track was muxed with its MPLS-declared channel count (often
|
||||||
|
understated 5.1). The probe now resolves and installs the same key map the mux
|
||||||
|
read uses.
|
||||||
|
- AACS 2.1 (FMTS) discs: a non-forensic title (menu/extras playlist, or any clip
|
||||||
|
that carries no forensic segments) no longer hard-fails the rip. `resolve_fmts_key_map`
|
||||||
|
now filters segments to those addressable within the title and falls back to the
|
||||||
|
base Unit-Key map when none apply — previously the first non-forensic title
|
||||||
|
aborted the whole-disc decrypt and blocked muxing any non-main title. A
|
||||||
|
forensic phase probe whose sampled units are all source-zero padding (an
|
||||||
|
even/odd tie) no longer aborts the rip either.
|
||||||
|
- Multi-CPS AACS `dir://` extraction now decrypts each clip with its own CPS-unit
|
||||||
|
key instead of keying the whole disc with unit key 0 (which silently wrote
|
||||||
|
secondary-CPS files as garbage). A missing key fails loud at resolve. Single-CPS
|
||||||
|
extraction is unchanged (one key opens every unit, orphan clips included).
|
||||||
|
- A trailing partial aligned unit that is inside a mapped range AND flagged
|
||||||
|
encrypted now fails loud (a CBC fragment split across a boundary cannot be
|
||||||
|
decrypted) instead of being emitted as ciphertext-as-clear.
|
||||||
|
- CSS DVDs no longer mux to garbage. Every DVD read path — the file-backed mux
|
||||||
|
highway (`build_iso_pipeline`) and the live-drive single-pass `DiscStream` —
|
||||||
|
now resolves the per-VTS title key at read time through one shared step
|
||||||
|
(`resolve_dvd_title_key`), cracked keylessly in playback order from the title's
|
||||||
|
own extents. An uncrackable title hard-fails (E7023) instead of passing
|
||||||
|
scrambled sectors through as plaintext; `--raw` skips the crack entirely; a
|
||||||
|
user Stop mid-crack surfaces as `Halted`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- DVD scan no longer cracks a title key up front (the key is per-VTS, so a single
|
||||||
|
disc key was meaningless). Scan does only the CSS bus-auth read-unlock — hoisted
|
||||||
|
before the UDF prefetch so scrambled small/menu VOBs no longer cost a rejected
|
||||||
|
read each. Cuts a CSS-DVD scan from ~25s to ~6s.
|
||||||
|
- Unlocker report: the DVD entry is renamed `CSS` → `DVD`.
|
||||||
|
|
||||||
|
## [1.5.1] — 2026-07-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TrueHD audio is no longer silently dropped (and no longer sends decoders out
|
||||||
|
of memory).** The previous release added an MLP major-sync checksum gate to drop
|
||||||
|
genuinely-undecodable audio frames, but the checksum was computed with mismatched
|
||||||
|
byte order — the 16-bit CRC result was folded in one endianness and compared in
|
||||||
|
the other — so it never validated a real major sync. The parser then judged every
|
||||||
|
major sync corrupt and dropped every audio frame from the first one onward,
|
||||||
|
flushing the whole TrueHD track at the end of the file: de-interleaved from the
|
||||||
|
video, which sent players and integrity checkers into an unbounded memory spiral
|
||||||
|
("decoder ran out of memory"). The checksum now matches the reference
|
||||||
|
implementation byte-exact (cross-verified against real 7.1/Atmos and 5.1 discs),
|
||||||
|
so major syncs validate and only genuinely-corrupt frames are dropped; as a
|
||||||
|
safety net, a major sync the parser still can't validate is kept rather than
|
||||||
|
allowed to drop an entire track. TrueHD titles produced after the checksum gate
|
||||||
|
landed need a re-rip.
|
||||||
|
- **HD DVD AACS key files are now found on every disc, not just the common
|
||||||
|
layout.** The AACS directory and title-key filename on HD DVD are chosen by
|
||||||
|
the authoring house, and freemkv previously assumed one fixed spelling
|
||||||
|
(`/ANY!/VTKF000.AACS`). Discs that name their AACS directory differently (e.g.
|
||||||
|
`AAC!` instead of `ANY!`) or ship numbered title-key files (`VTKF090.AACS` /
|
||||||
|
`VTKF100.AACS` rather than `VTKF000.AACS`) are now handled: the AACS directory
|
||||||
|
is located by its contents and every title-key file in it is picked up. Blu-ray
|
||||||
|
and UHD are unaffected.
|
||||||
|
- **HD DVD multi-title decryption reads the right keys.** The HD DVD title-key
|
||||||
|
file (`VTKF*.AACS`) stores its keys in 36-byte records — per the AACS HD DVD
|
||||||
|
specification, and confirmed byte-exact on real discs. freemkv had been reading
|
||||||
|
them at a 32-byte stride, which lands the first key correctly but drifts off
|
||||||
|
every key after it, so only single-title discs decrypted. Discs with more than
|
||||||
|
one protected title now recover every title's key instead of only the first.
|
||||||
|
(Choosing the correct title-key file when a disc carries several playlists
|
||||||
|
still needs verification against an encrypted HD DVD.)
|
||||||
|
- **A dirty disc can no longer "rip clean" but decode with errors.** freemkv now
|
||||||
|
asks the drive to *report* marginal reads instead of silently returning
|
||||||
|
best-effort data as success — on smudged/scratched media a drive can hand back
|
||||||
|
subtly-wrong bytes with a clean status, which used to slip through the rip and
|
||||||
|
surface only as playback/decode errors. A read the drive had to fight for is
|
||||||
|
now distrusted and re-read in the patch pass: a clean re-read wins, and a spot
|
||||||
|
that's genuinely unreadable becomes an honest gap rather than silently-wrong
|
||||||
|
data. Best-effort per drive, and it changes nothing on a clean disc.
|
||||||
|
|
||||||
|
## [1.5.0] — 2026-07-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **MP4 as a source (`mp4://`)** — read a progressive `.mp4` back in and send it to
|
||||||
|
any sink (`mp4:// mkv://`, `mp4:// audio://`, `mp4:// json://`, …). The round-trip
|
||||||
|
is frame-exact.
|
||||||
|
- **Native MP4 output (`mp4://`)** — a disc goes straight to a play-everywhere
|
||||||
|
`.mp4` in one decrypt pass, no ffmpeg. Carries HEVC / H.264 video (with HDR10) and
|
||||||
|
AC-3, E-AC-3, and DTS / DTS-HD audio, and is faststart by default so it plays over
|
||||||
|
HTTP without downloading the end first. It's a **compatibility export, not
|
||||||
|
archival**: MP4 can't hold TrueHD, LPCM, or bitmap (PGS / VobSub) subtitles, so
|
||||||
|
those are **excluded with a loud, itemized report — never a silent drop**
|
||||||
|
(`mkv://` stays the keep-everything path).
|
||||||
|
- **Five extraction sinks — dissect a title, don't just rip it.** New destinations
|
||||||
|
that pull one part of a title out on its own:
|
||||||
|
- **`video://dir/`** — each video track to its own native elementary-stream file.
|
||||||
|
- **`audio://dir/`** — each audio track to its own file in its native container
|
||||||
|
(`.thd`, `.dts`, `.ac3`, `.eac3`, `.aac`, `.flac`; LPCM as `.pcm`).
|
||||||
|
- **`sub://dir/`** — each subtitle track to its own file (PGS `.sup`, VobSub
|
||||||
|
`.idx` + `.sub`, text `.srt`).
|
||||||
|
- **`chapters://file`** — a title's chapter markers as a sidecar (`.xml` / `.txt`
|
||||||
|
/ `.ogm` / `.vtt`).
|
||||||
|
- **`json://file`** — a title's complete structure as JSON.
|
||||||
|
|
||||||
|
`chapters://` and `json://` read nothing of the elementary streams, so they
|
||||||
|
return in seconds.
|
||||||
|
- **Damaged audio frames are dropped instead of shipped as glitches.** When a
|
||||||
|
source disc has a corrupt audio frame, freemkv drops that frame rather than muxing
|
||||||
|
it as a decoder-choking glitch — keeping A/V in sync (a drop is a silence gap,
|
||||||
|
never a shift) and logging every drop. Works across DTS, AC-3 / E-AC-3, FLAC,
|
||||||
|
MP2 / MP3, AAC, and TrueHD, each using the format's own integrity check; a track
|
||||||
|
that is mostly undecodable is dropped whole. This catches structurally-broken
|
||||||
|
frames — corruption of the audio *data* inside an otherwise-valid frame is source
|
||||||
|
damage that can't be told from good data without decoding.
|
||||||
|
- **Forced subtitles detected from the stream.** A PGS subtitle track is flagged
|
||||||
|
forced when its content is entirely forced/narrative subtitles, read from the
|
||||||
|
stream itself rather than the disc's metadata — so it works on discs that carry
|
||||||
|
none. `info -v` reports the same, so `info` and a rip agree.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`json://` emits the complete title model** — every field the scan resolved:
|
||||||
|
video resolution / frame rate / HDR / colour, audio channel layout / sample rate /
|
||||||
|
language / purpose, the subtitle forced flag, plus the clip list and chapter names.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TrueHD: a couple of transient errors no longer discard a whole track.** A
|
||||||
|
corrupt access unit is dropped forward to the next clean sync point, but a short
|
||||||
|
burst of damage no longer trips the whole-track drop, and a corrupt sync point can
|
||||||
|
no longer shift the audio that follows.
|
||||||
|
- **Free-format MP2 / MP3** is a legal, decodable mode and is no longer dropped.
|
||||||
|
|
||||||
|
## [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
|
## [1.3.2] — 2026-07-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "1.3.2"
|
version = "1.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ reach into the others.
|
|||||||
The library exposes flat verbs; the caller drives the multipass loop. Autorip
|
The library exposes flat verbs; the caller drives the multipass loop. Autorip
|
||||||
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
|
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
|
||||||
clean or the configured retry budget is exhausted, then hands the ISO off to
|
clean or the configured retry budget is exhausted, then hands the ISO off to
|
||||||
the mux pipeline. The `freemkv` CLI does the same shape with a
|
the mux pipeline. The `freemkv` CLI does the same shape, but as of 1.6.0 the
|
||||||
terminal-output progress sink. Layer 3 runs inside any consumer of
|
loop itself (including the multi-title rip loop) lives one layer up, in the
|
||||||
`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller
|
shared `freemkv-engine` crate, with a terminal-output progress sink plugged
|
||||||
involvement.
|
into it as the `Sink`. Layer 3 runs inside any consumer of `DiscStream`
|
||||||
|
(direct PES pipeline, ISO playback, etc.) without caller involvement.
|
||||||
|
|
||||||
Three primitives compose the disc-side flow:
|
Three primitives compose the disc-side flow:
|
||||||
|
|
||||||
|
|||||||
+645
-736
File diff suppressed because it is too large
Load Diff
+42
-4
@@ -463,9 +463,9 @@ pub enum KeyCandidate {
|
|||||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||||
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
||||||
/// its declared CPS-unit number); the caller runs
|
/// its declared CPS-unit number); the caller runs
|
||||||
/// [`super::content::unit_key_validates`] to find which one actually opens the
|
/// `decrypt_unit` + `is_clean_ts` to find which one actually opens the
|
||||||
/// disc. Rungs above the candidate are `None`.
|
/// disc. Rungs above the candidate are `None`.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ResolvedChain {
|
pub struct ResolvedChain {
|
||||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||||
pub vuk: Option<Vuk>,
|
pub vuk: Option<Vuk>,
|
||||||
@@ -475,6 +475,21 @@ pub struct ResolvedChain {
|
|||||||
pub dk: Option<DeviceKey>,
|
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.
|
/// 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:
|
/// 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
|
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
|
||||||
/// `unit_keys` against a real encrypted unit with
|
/// `unit_keys` against a real encrypted unit with
|
||||||
/// [`super::content::unit_key_validates`] to prove the candidate opens the disc.
|
/// `decrypt_unit` + `is_clean_ts` to prove the candidate opens the disc.
|
||||||
///
|
///
|
||||||
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
||||||
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||||
@@ -505,7 +520,8 @@ pub fn resolve_candidate(
|
|||||||
let version = mkb_type(mkb)
|
let version = mkb_type(mkb)
|
||||||
.map(|t| t.generation())
|
.map(|t| t.generation())
|
||||||
.unwrap_or(AacsVersion::V10);
|
.unwrap_or(AacsVersion::V10);
|
||||||
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
|
// BD/UHD Unit_Key_RO.inf or HD DVD VTKF000.AACS — dispatched by magic.
|
||||||
|
let ukf = parse_title_keys(unit_key_ro, version)?;
|
||||||
if ukf.encrypted_keys.is_empty() {
|
if ukf.encrypted_keys.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -567,6 +583,28 @@ mod resolve_candidate_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
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
|
/// 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.
|
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||||
|
|||||||
@@ -1,73 +1,79 @@
|
|||||||
//! FMTS variant selection — the pure decode-time decision for a 2.1 disc.
|
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
|
||||||
//!
|
//!
|
||||||
//! A 2.1 disc resolves to exactly one forensic variant (1..=32) for a given
|
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
|
||||||
//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see
|
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
|
||||||
//! [`super::segment`]); the decode keeps the segments matching our variant,
|
//! [`super::segment`]); the decode keeps the segments matching our index,
|
||||||
//! drops the other 31, and treats everything outside a segment as ordinary
|
//! drops the other 31, and treats everything outside a segment as ordinary
|
||||||
//! (variant-0) content. This module owns that classification and nothing else —
|
//! (index-0) content. This module owns that classification and nothing else —
|
||||||
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
|
||||||
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
|
||||||
//!
|
//!
|
||||||
//! Where the resolved variant comes from is a separate concern
|
//! Where the resolved index comes from is a separate concern
|
||||||
//! ([`resolve_disc_variant`]): today it is read off the variant keys the key
|
//! ([`resolve_disc_index`]): today it is read off the index keys the key
|
||||||
//! source handed us; when Processing Keys are available it will come from the
|
//! source handed us; when Processing Keys are available it will come from the
|
||||||
//! VK derivation instead. Either way the disposition logic below is identical.
|
//! VK derivation instead. Either way the disposition logic below is identical.
|
||||||
|
|
||||||
use super::segment::{Segment, variant_segment_for_unit};
|
use super::segment::{Segment, segment_for_unit};
|
||||||
use super::types::UnitKey;
|
use super::types::UnitKey;
|
||||||
|
|
||||||
/// What the decode should do with one AACS aligned unit.
|
/// What the decode should do with one AACS aligned unit.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum UnitDisposition {
|
pub enum UnitDisposition {
|
||||||
/// Outside every forensic segment: ordinary content, decrypt with the
|
/// Outside every forensic segment: ordinary content, decrypt with the
|
||||||
/// default (variant-0) unit key.
|
/// default (index-0) unit key.
|
||||||
Default,
|
Default,
|
||||||
/// Inside a forensic segment tagged with OUR resolved variant: decrypt with
|
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
|
||||||
/// that variant's key.
|
/// that index's key.
|
||||||
Variant(u8),
|
Index(u8),
|
||||||
/// Inside a forensic segment tagged with a DIFFERENT variant: not our
|
/// Inside a forensic segment tagged with a DIFFERENT index: not our
|
||||||
/// watermark, so it is not part of our output — drop it.
|
/// watermark, so it is not part of our output — drop it.
|
||||||
DropForeignVariant(u8),
|
DropForeignIndex(u8),
|
||||||
/// Inside a forensic segment but no variant key is held (the disc's variant
|
/// Inside a forensic segment but no index key is held (the disc's index
|
||||||
/// was never resolved): the segment cannot be decoded, so it is concealed
|
/// was never resolved): the segment cannot be decoded, so it is concealed
|
||||||
/// as loss. Carries the segment's variant for diagnostics.
|
/// as loss. Carries the segment's index for diagnostics.
|
||||||
ForensicNoKey(u8),
|
ForensicNoKey(u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the disc's single forensic variant from the keys we hold.
|
/// Resolve the disc's single forensic index from the keys we hold.
|
||||||
///
|
///
|
||||||
/// Scans for a variant key (`variant_number` in `1..=32`) and returns its
|
/// Scans for an index key (`index_number` in `1..=32`) and returns its
|
||||||
/// variant. `None` when only default (variant-0) keys are held — i.e. no
|
/// index. `None` when only default (index-0) keys are held — i.e. no
|
||||||
/// variant source answered, so forensic segments are not decodable. A disc has
|
/// index source answered, so forensic segments are not decodable. A disc has
|
||||||
/// exactly one variant, so the first non-zero key decides; if several distinct
|
/// exactly one index, so the first non-zero key decides; if several distinct
|
||||||
/// variant keys were somehow supplied the lowest wins (deterministic), which is
|
/// index keys were somehow supplied the lowest wins (deterministic), which is
|
||||||
/// only a defensive tiebreak — the probe/derivation yields one.
|
/// only a defensive tiebreak — the probe/derivation yields one.
|
||||||
pub fn resolve_disc_variant(unit_keys: &[UnitKey]) -> Option<u8> {
|
pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||||
unit_keys
|
unit_keys
|
||||||
.iter()
|
.iter()
|
||||||
.map(|k| k.variant_number)
|
.map(|k| k.index_number)
|
||||||
.filter(|&v| v != 0)
|
.filter(|&v| v != 0)
|
||||||
.min()
|
.min()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
||||||
/// the forensic segment map and the disc's resolved variant (`None` if no
|
/// the forensic segment map and the disc's resolved index (`None` if no
|
||||||
/// variant key is held).
|
/// index key is held).
|
||||||
pub fn unit_disposition(
|
pub fn unit_disposition(
|
||||||
unit_offset: u64,
|
unit_offset: u64,
|
||||||
segments: &[Segment],
|
segments: &[Segment],
|
||||||
disc_variant: Option<u8>,
|
disc_index: Option<u8>,
|
||||||
) -> UnitDisposition {
|
) -> UnitDisposition {
|
||||||
match variant_segment_for_unit(segments, unit_offset) {
|
match segment_for_unit(segments, unit_offset) {
|
||||||
// Not in any forensic segment → ordinary content.
|
// Not in any forensic segment → ordinary content.
|
||||||
None => UnitDisposition::Default,
|
None => UnitDisposition::Default,
|
||||||
// In a forensic segment → decide by whether it is our variant.
|
// In a forensic segment → decide by whether it is our index.
|
||||||
Some(seg) => {
|
Some(seg) => {
|
||||||
let seg_variant = seg.variant as u8;
|
// `seg.index` is an untrusted u16 from IndividualSegment.tbl; a real
|
||||||
match disc_variant {
|
// forensic index is 1..=32. Compare in u16 space so a corrupt/crafted
|
||||||
Some(v) if v == seg_variant => UnitDisposition::Variant(v),
|
// index above 255 can't truncate into a valid u8 and alias our index.
|
||||||
Some(_) => UnitDisposition::DropForeignVariant(seg_variant),
|
// The disposition carries a u8 for diagnostics (saturated — an
|
||||||
None => UnitDisposition::ForensicNoKey(seg_variant),
|
// out-of-range index is never ours anyway).
|
||||||
|
let seg_index = seg.index;
|
||||||
|
let diag = seg_index.min(u8::MAX as u16) as u8;
|
||||||
|
match disc_index {
|
||||||
|
Some(v) if u16::from(v) == seg_index => UnitDisposition::Index(v),
|
||||||
|
Some(_) => UnitDisposition::DropForeignIndex(diag),
|
||||||
|
None => UnitDisposition::ForensicNoKey(diag),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +85,7 @@ mod tests {
|
|||||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||||
|
|
||||||
/// Build a one-record segment table (variant, start_spn, end_spn).
|
/// Build a one-record segment table (index, start_spn, end_spn).
|
||||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||||
@@ -95,23 +101,23 @@ mod tests {
|
|||||||
parse_individual_segments(&v).expect("parse")
|
parse_individual_segments(&v).expect("parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn uk(idx: u32, variant: u8) -> UnitKey {
|
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||||
if variant == 0 {
|
if index == 0 {
|
||||||
UnitKey::new(idx, [0u8; 16])
|
UnitKey::new(idx, [0u8; 16])
|
||||||
} else {
|
} else {
|
||||||
UnitKey::variant(idx, [variant; 16], variant)
|
UnitKey::forensic(idx, [index; 16], index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_picks_the_single_variant_key() {
|
fn resolve_picks_the_single_index_key() {
|
||||||
// Default keys only → no variant resolved.
|
// Default keys only → no index resolved.
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None);
|
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||||
assert_eq!(resolve_disc_variant(&[]), None);
|
assert_eq!(resolve_disc_index(&[]), None);
|
||||||
// One variant key among defaults → that variant.
|
// One index key among defaults → that index.
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7));
|
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||||
// Defensive: lowest of several distinct variants (deterministic).
|
// Defensive: lowest of several distinct indexes (deterministic).
|
||||||
assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3));
|
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -130,29 +136,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_in_our_variant_decrypts() {
|
fn unit_in_our_index_decrypts() {
|
||||||
let segs = tbl(&[(7, 100, 200)]);
|
let segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(7)),
|
unit_disposition(off, &segs, Some(7)),
|
||||||
UnitDisposition::Variant(7)
|
UnitDisposition::Index(7)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_in_foreign_variant_drops() {
|
fn unit_in_foreign_index_drops() {
|
||||||
// Segment tagged variant 7, but our disc variant is 3 → drop it.
|
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||||
let segs = tbl(&[(7, 100, 200)]);
|
let segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(3)),
|
unit_disposition(off, &segs, Some(3)),
|
||||||
UnitDisposition::DropForeignVariant(7)
|
UnitDisposition::DropForeignIndex(7)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forensic_unit_with_no_key_is_concealed() {
|
fn forensic_unit_with_no_key_is_concealed() {
|
||||||
// A forensic segment but we never resolved a variant → conceal as loss.
|
// A forensic segment but we never resolved an index → conceal as loss.
|
||||||
let segs = tbl(&[(7, 100, 200)]);
|
let segs = tbl(&[(7, 100, 200)]);
|
||||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -161,10 +167,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_range_index_does_not_truncate_into_ours() {
|
||||||
|
// A crafted/corrupt segment index of 288 (0x0120) truncates to 32 in a
|
||||||
|
// u8. With our disc index resolved as 32, the old `seg.index as u8`
|
||||||
|
// compare would alias it to OUR index and decrypt with the wrong key.
|
||||||
|
// The u16 compare must instead classify it as foreign.
|
||||||
|
let segs = tbl(&[(288, 100, 200)]);
|
||||||
|
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||||
|
assert_eq!(
|
||||||
|
unit_disposition(off, &segs, Some(32)),
|
||||||
|
UnitDisposition::DropForeignIndex(255)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn straddling_unit_still_classified_as_its_segment() {
|
fn straddling_unit_still_classified_as_its_segment() {
|
||||||
// A unit whose 32-packet span only tails into the segment still routes
|
// A unit whose 32-packet span only tails into the segment still routes
|
||||||
// to the segment (matches variant_segment_for_unit's span test).
|
// to the segment (matches segment_for_unit's span test).
|
||||||
let segs = tbl(&[(5, 100, 200)]);
|
let segs = tbl(&[(5, 100, 200)]);
|
||||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||||
@@ -172,7 +192,7 @@ mod tests {
|
|||||||
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unit_disposition(off, &segs, Some(5)),
|
unit_disposition(off, &segs, Some(5)),
|
||||||
UnitDisposition::Variant(5)
|
UnitDisposition::Index(5)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+215
@@ -161,6 +161,105 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HD DVD Video Title Key File (`VTKF%%%.AACS`) magic — "DVD_HD_V_TKF".
|
||||||
|
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
|
||||||
|
/// Fixed header length before the first Title Key Entry (AACS HD DVD Book,
|
||||||
|
/// Table 3-8).
|
||||||
|
const VTKF_HEADER_LEN: usize = 0x80;
|
||||||
|
/// Title Key Entry stride (Table 3-8): 1-byte `BIFO` + 3 reserved + 16-byte
|
||||||
|
/// encrypted title key + 16-byte binding MAC = 36 bytes.
|
||||||
|
const VTKF_ENTRY_LEN: usize = 0x24;
|
||||||
|
/// Byte offset of the encrypted title key within an entry (after `BIFO` + 3
|
||||||
|
/// reserved).
|
||||||
|
const VTKF_KEY_OFF: usize = 4;
|
||||||
|
/// Number of Title Key Entry slots in a VTKF (Table 3-8): a fixed 64.
|
||||||
|
const VTKF_MAX_ENTRIES: usize = 64;
|
||||||
|
/// `BIFO` bit 7 (`AV_FLG`): set = this slot carries an available title key.
|
||||||
|
const VTKF_AV_FLG: u8 = 0x80;
|
||||||
|
|
||||||
|
/// Parse an HD DVD `VTKF%%%.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 — AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, a fixed
|
||||||
|
/// 2480-byte file, verified byte-exact against real discs (Freedom `VTKF090`,
|
||||||
|
/// Dukes of Hazzard `VTKF000`):
|
||||||
|
/// ```text
|
||||||
|
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
|
||||||
|
/// [0x0C..0x10] BE32 HD_VTKF_SIZE (2480)
|
||||||
|
/// [0x10..0x1C] associated playlist name ("VPLST%%%.XPL")
|
||||||
|
/// [0x1C..0x80] reserved
|
||||||
|
/// [0x80..] 64 entries × 36 bytes:
|
||||||
|
/// BIFO (1) | reserved (3) | ENCRYPTED title key (16) | binding MAC (16)
|
||||||
|
/// BIFO bit 7 (AV_FLG) set = this slot holds a title key
|
||||||
|
/// (pre-recorded discs fill the binding MAC with 0xFF)
|
||||||
|
/// [0x9A0..2480] 16-byte TKF MAC (CMAC keyed by Kvu — NOT a key)
|
||||||
|
/// ```
|
||||||
|
/// The slot index (1-based) is the CPS unit number, so an absent slot is
|
||||||
|
/// SKIPPED (not a terminator) — collapsing gaps would renumber later keys and
|
||||||
|
/// hand the wrong title key to CPS unit N+1. The title→CPS mapping is
|
||||||
|
/// playlist-driven (`VPLST%%%.XPL`) and owned by the HD DVD enumerator, so
|
||||||
|
/// `title_cps_unit` is left empty here.
|
||||||
|
///
|
||||||
|
/// The prior parser used a 32-byte stride (a 12-byte pad instead of the 16-byte
|
||||||
|
/// binding MAC). That reads entry #1 correctly but drifts +4 bytes per entry
|
||||||
|
/// after it, so it only decrypted single-CPS-unit discs; every multi-key VTKF
|
||||||
|
/// (Freedom, Harry Potter) yielded garbage keys for CPS unit ≥2.
|
||||||
|
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();
|
||||||
|
for n in 0..VTKF_MAX_ENTRIES {
|
||||||
|
let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN;
|
||||||
|
if pos + VTKF_ENTRY_LEN > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS
|
||||||
|
// number (do NOT break — a gap must not renumber the keys that follow).
|
||||||
|
if data[pos] & VTKF_AV_FLG == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut key = [0u8; 16];
|
||||||
|
key.copy_from_slice(&data[pos + VTKF_KEY_OFF..pos + VTKF_KEY_OFF + 16]);
|
||||||
|
encrypted_keys.push((n as u32 + 1, key));
|
||||||
|
}
|
||||||
|
if encrypted_keys.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(UnitKeyFile {
|
||||||
|
disc_hash: hash,
|
||||||
|
app_type: 0, // HD DVD VTKF carries no BD-ROM app_type
|
||||||
|
num_bdmv_dir: 0, // BD-only concept
|
||||||
|
use_skb_mkb: false,
|
||||||
|
version: AacsVersion::V10, // HD DVD is always AACS 1.0
|
||||||
|
encrypted_keys,
|
||||||
|
title_cps_unit: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a disc's title-key file, dispatching on the self-describing magic:
|
||||||
|
/// an HD DVD `VTKF000.AACS` (`DVD_HD_V_TKF`) → [`parse_vtkf`]; anything else is a
|
||||||
|
/// BD/UHD `Unit_Key_RO.inf` → [`parse_unit_key_ro`]. Both return the same
|
||||||
|
/// [`UnitKeyFile`], so every downstream AACS derivation stays container-agnostic
|
||||||
|
/// — the single seam where BD-vs-HD-DVD key layout is resolved (mirrors the key
|
||||||
|
/// service, which classifies HD DVD by the very same magic).
|
||||||
|
pub fn parse_title_keys(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||||
|
if data.len() >= 12 && &data[..12] == VTKF_MAGIC {
|
||||||
|
parse_vtkf(data)
|
||||||
|
} else {
|
||||||
|
parse_unit_key_ro(data, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// MKB disc structure format code.
|
/// MKB disc structure format code.
|
||||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||||
|
|
||||||
@@ -283,3 +382,119 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
|||||||
version,
|
version,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod vtkf_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Build a synthetic `VTKF%%%.AACS` matching the real on-disc layout (AACS
|
||||||
|
/// HD DVD Book Table 3-8, verified against Freedom `VTKF090` and Dukes
|
||||||
|
/// `VTKF000`): magic, BE32 size, playlist name, reserved to 0x80, then 64
|
||||||
|
/// entry slots of 36 bytes (the first `keys.len()` present with `AV_FLG`
|
||||||
|
/// set, the rest empty), a reserved gap, and the 16-byte trailing TKF MAC.
|
||||||
|
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||||
|
const FILE_LEN: usize = 2480;
|
||||||
|
let mut v = Vec::new();
|
||||||
|
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||||
|
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
|
||||||
|
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
|
||||||
|
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
|
||||||
|
for n in 0..VTKF_MAX_ENTRIES {
|
||||||
|
if let Some(k) = keys.get(n) {
|
||||||
|
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
|
||||||
|
v.extend_from_slice(&[0, 0, 0]); // reserved
|
||||||
|
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||||
|
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
|
||||||
|
} else {
|
||||||
|
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
|
||||||
|
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
|
||||||
|
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 empty slots and the trailing
|
||||||
|
// 16-byte TKF MAC are NOT mistaken for keys. Critically, k2/k3 are read
|
||||||
|
// at the 36-byte stride (offsets 0xA4, 0xC8); the old 32-byte stride
|
||||||
|
// misread them from inside the previous entry's binding MAC.
|
||||||
|
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
ukf.encrypted_keys[0],
|
||||||
|
(1, k1),
|
||||||
|
"CPS units = 1-based slot index"
|
||||||
|
);
|
||||||
|
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_reads_a_full_64_entry_file() {
|
||||||
|
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
|
||||||
|
// come back, none dropped and none drifted — the regression the 32-byte
|
||||||
|
// stride failed.
|
||||||
|
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
|
||||||
|
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
|
||||||
|
assert_eq!(ukf.encrypted_keys.len(), 64);
|
||||||
|
assert_eq!(
|
||||||
|
ukf.encrypted_keys[63],
|
||||||
|
(64, [63u8; 16]),
|
||||||
|
"entry 64 at 0x{:x}",
|
||||||
|
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+246
-8
@@ -29,6 +29,7 @@ pub mod content;
|
|||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod derive;
|
pub mod derive;
|
||||||
pub mod host_certs;
|
pub mod host_certs;
|
||||||
|
pub mod index_select;
|
||||||
pub mod inf;
|
pub mod inf;
|
||||||
pub mod mkb;
|
pub mod mkb;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
@@ -38,12 +39,26 @@ pub mod segment_key;
|
|||||||
pub mod trace;
|
pub mod trace;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod variant;
|
pub mod variant;
|
||||||
pub mod variant_select;
|
|
||||||
|
|
||||||
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
|
/// On-disc UDF paths to the AACS key-input files, plus HD DVD AACS-directory
|
||||||
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
|
/// discovery.
|
||||||
/// `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
|
/// BD and UHD keep their key material under a fixed `/AACS/…` tree, so those
|
||||||
|
/// paths are constants. HD DVD keeps the equivalents in a reserved root
|
||||||
|
/// directory whose NAME is authoring-house-specific — observed `ANY!` (Dukes
|
||||||
|
/// of Hazzard) and `AAC!` (Freedom / Memory-Tech), each with a `<name>!_BAK`
|
||||||
|
/// mirror — and whose title-key file is NOT always `VTKF000.AACS` (Freedom
|
||||||
|
/// ships `VTKF090.AACS` + `VTKF100.AACS`). So the HD DVD files are DISCOVERED
|
||||||
|
/// from the parsed UDF tree ([`find_hddvd_aacs_dir`] + [`role_paths`]), never
|
||||||
|
/// hardcoded.
|
||||||
|
///
|
||||||
|
/// Each key ROLE ([`AacsRole`]) resolves to an ordered candidate list — the
|
||||||
|
/// BD/UHD constants first, then whatever the HD DVD directory actually holds —
|
||||||
|
/// which every reader walks with [`read_first`], 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 none of them, so it falls through to the discovered
|
||||||
|
/// entries. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
|
||||||
|
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
|
||||||
/// disc_hash / MKB / VID that another reader feeds a key service.
|
/// disc_hash / MKB / VID that another reader feeds a key service.
|
||||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||||||
@@ -52,6 +67,112 @@ pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
|||||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||||
|
|
||||||
|
/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path
|
||||||
|
/// list (BD/UHD constants, then the discovered HD DVD files).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AacsRole {
|
||||||
|
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
|
||||||
|
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
|
||||||
|
UnitKey,
|
||||||
|
/// Media Key Block: BD/UHD `MKB_RO/RW.inf`, HD DVD `MKBROM.AACS`.
|
||||||
|
Mkb,
|
||||||
|
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
|
||||||
|
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
|
||||||
|
ContentCert,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The HD DVD AACS directory in a parsed UDF tree, if present.
|
||||||
|
///
|
||||||
|
/// Identified structurally, NOT by a hardcoded name: the root child directory
|
||||||
|
/// whose name ends in `!` (so the `<name>!_BAK` backup mirror, which also ends
|
||||||
|
/// in a non-`!` char, is not mistaken for it) and which contains `MKBROM.AACS`.
|
||||||
|
/// Observed real names: `ANY!` (Dukes of Hazzard), `AAC!` (Freedom). A BD/UHD
|
||||||
|
/// disc has no such directory → `None`.
|
||||||
|
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
|
||||||
|
udf.root.entries.iter().find(|e| {
|
||||||
|
e.is_dir
|
||||||
|
&& e.name.ends_with('!')
|
||||||
|
&& e.entries
|
||||||
|
.iter()
|
||||||
|
.any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered candidate paths for an AACS key [`AacsRole`]: the fixed BD/UHD
|
||||||
|
/// `/AACS/…` paths first, then the actual HD DVD files discovered in the disc's
|
||||||
|
/// AACS directory (see [`find_hddvd_aacs_dir`]). A disc has only one family, so
|
||||||
|
/// the other family's entries simply never read.
|
||||||
|
///
|
||||||
|
/// For [`AacsRole::UnitKey`] every `VTKF*.AACS` in the directory is appended in
|
||||||
|
/// sorted name order — a disc may carry more than one variant (Freedom:
|
||||||
|
/// `VTKF090` + `VTKF100`), not just `VTKF000`.
|
||||||
|
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
|
||||||
|
let mut v: Vec<String> = match role {
|
||||||
|
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
|
||||||
|
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RW],
|
||||||
|
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
|
||||||
|
}
|
||||||
|
.into_iter()
|
||||||
|
.map(String::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if let Some(dir) = find_hddvd_aacs_dir(udf) {
|
||||||
|
let d = &dir.name;
|
||||||
|
match role {
|
||||||
|
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
|
||||||
|
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
|
||||||
|
AacsRole::UnitKey => {
|
||||||
|
// Glob VTKF*.AACS — the title-key filename is not fixed at
|
||||||
|
// VTKF000 (Freedom ships VTKF090 + VTKF100). Sorted for a
|
||||||
|
// deterministic try order.
|
||||||
|
//
|
||||||
|
// Each VTKF%%%.AACS is bound to ONE playlist (VPLST%%%.XPL): the
|
||||||
|
// TKF's 12-byte PLAYLIST_NAME field (bytes 0x10..0x1C) names the
|
||||||
|
// playlist whose Title Keys it carries, and keys from a TKF whose
|
||||||
|
// name does not match the title's playlist must not be used. The
|
||||||
|
// caller resolves this by trying candidates in sorted order and
|
||||||
|
// decrypting with the one whose keys verify — correct for a
|
||||||
|
// single-playlist disc; a name-matched selection keyed on the
|
||||||
|
// active playlist is the precise form for multi-playlist discs.
|
||||||
|
let mut names: Vec<&str> = dir
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| !e.is_dir)
|
||||||
|
.filter(|e| {
|
||||||
|
let u = e.name.to_ascii_uppercase();
|
||||||
|
u.starts_with("VTKF") && u.ends_with(".AACS")
|
||||||
|
})
|
||||||
|
.map(|e| e.name.as_str())
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
v.extend(names.into_iter().map(|n| format!("/{d}/{n}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk an AACS role's candidate paths (from [`role_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. Generic over the path
|
||||||
|
/// element (`&str` or owned `String`) so it accepts the `Vec<String>` that
|
||||||
|
/// [`role_paths`] builds from the discovered HD DVD directory.
|
||||||
|
pub(crate) fn read_first<S, F>(candidates: &[S], mut read: F) -> crate::error::Result<Vec<u8>>
|
||||||
|
where
|
||||||
|
S: AsRef<str>,
|
||||||
|
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
|
||||||
|
{
|
||||||
|
for path in candidates {
|
||||||
|
if let Ok(buf) = read(path.as_ref()) {
|
||||||
|
return Ok(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(crate::error::Error::AacsNoKeys)
|
||||||
|
}
|
||||||
|
|
||||||
// The module structure IS the public API — consumers import from the owning
|
// The module structure IS the public API — consumers import from the owning
|
||||||
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
|
||||||
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
|
// `aacs::derive::{derive_vuk, resolve_candidate}`, `aacs::resolve::resolve_keys_v2`).
|
||||||
@@ -61,7 +182,7 @@ pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
|||||||
// content-decrypt entry points that downstream key-source crates import through
|
// content-decrypt entry points that downstream key-source crates import through
|
||||||
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
|
||||||
// lets those crates track the module refactor without a lockstep re-pin.
|
// lets those crates track the module refactor without a lockstep re-pin.
|
||||||
pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys};
|
pub use content::ALIGNED_UNIT_LEN;
|
||||||
pub use derive::derive_vuk;
|
pub use derive::derive_vuk;
|
||||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||||
|
|
||||||
@@ -71,7 +192,7 @@ mod tests {
|
|||||||
//! Touching one representative item per module keeps these as a
|
//! Touching one representative item per module keeps these as a
|
||||||
//! compile-time contract that the module paths stay stable.
|
//! compile-time contract that the module paths stay stable.
|
||||||
|
|
||||||
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
|
use super::content::ALIGNED_UNIT_LEN;
|
||||||
use super::inf::{disc_hash, disc_hash_hex};
|
use super::inf::{disc_hash, disc_hash_hex};
|
||||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||||
use super::variant::is_variant_mkb;
|
use super::variant::is_variant_mkb;
|
||||||
@@ -99,7 +220,10 @@ mod tests {
|
|||||||
fn public_helpers_are_callable_by_module_path() {
|
fn public_helpers_are_callable_by_module_path() {
|
||||||
// Touch a representative function from each module so a dropped/renamed
|
// Touch a representative function from each module so a dropped/renamed
|
||||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||||
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
|
let _ = !crate::aacs::content::is_clean(
|
||||||
|
&[0u8; ALIGNED_UNIT_LEN],
|
||||||
|
crate::disc::ContentFormat::BdTs,
|
||||||
|
);
|
||||||
let _ = mkb_content_len(&[]);
|
let _ = mkb_content_len(&[]);
|
||||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||||
@@ -110,4 +234,118 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||||
|
//
|
||||||
|
// The HD DVD AACS dir name and title-key filename are authoring-specific
|
||||||
|
// and were previously hardcoded to `/ANY!/VTKF000.AACS`. These verify the
|
||||||
|
// discovery replacement against both real-disc shapes: Freedom (`AAC!` +
|
||||||
|
// `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||||
|
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||||
|
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let aacs_files = vec![
|
||||||
|
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||||
|
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||||
|
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||||
|
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||||
|
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||||
|
];
|
||||||
|
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![
|
||||||
|
DirSpec {
|
||||||
|
name: "AAC!".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: aacs_files,
|
||||||
|
subdirs: vec![],
|
||||||
|
},
|
||||||
|
DirSpec {
|
||||||
|
name: "AAC!_BAK".to_string(),
|
||||||
|
icb_lba: 30,
|
||||||
|
dir_data_lba: 31,
|
||||||
|
files: bak_files,
|
||||||
|
subdirs: vec![],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
|
||||||
|
// Discovered structurally (ends in '!', holds MKBROM.AACS) — the real
|
||||||
|
// AACS dir, never the `_BAK` mirror.
|
||||||
|
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||||
|
assert_eq!(dir.name, "AAC!");
|
||||||
|
|
||||||
|
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||||
|
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||||
|
vec![
|
||||||
|
super::PATH_UNIT_KEY_RO.to_string(),
|
||||||
|
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||||
|
"/AAC!/VTKF090.AACS".to_string(),
|
||||||
|
"/AAC!/VTKF100.AACS".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||||
|
.last()
|
||||||
|
.unwrap(),
|
||||||
|
"/AAC!/MKBROM.AACS"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||||
|
.last()
|
||||||
|
.unwrap(),
|
||||||
|
"/AAC!/CONTENT_CERT.AACS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||||||
|
// and the candidate list is exactly the static BD/UHD paths.
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "AACS".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: vec![
|
||||||
|
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||||||
|
file("MKB_RO.inf", 101, 5100, 2048, true),
|
||||||
|
],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
|
||||||
|
assert!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||||
|
vec![
|
||||||
|
super::PATH_UNIT_KEY_RO.to_string(),
|
||||||
|
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-4
@@ -13,7 +13,6 @@ use super::mkb::*;
|
|||||||
// ── Full VUK resolution chain ───────────────────────────────────────────────
|
// ── Full VUK resolution chain ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// Result of resolving a disc's VUK.
|
/// Result of resolving a disc's VUK.
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct ResolvedKeys {
|
pub struct ResolvedKeys {
|
||||||
/// Disc hash (SHA1 of Unit_Key_RO.inf)
|
/// Disc hash (SHA1 of Unit_Key_RO.inf)
|
||||||
pub disc_hash: [u8; 20],
|
pub disc_hash: [u8; 20],
|
||||||
@@ -34,6 +33,23 @@ pub struct ResolvedKeys {
|
|||||||
pub key_source: u8,
|
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 —
|
/// Inputs shared by every classical-path resolver. References only —
|
||||||
/// callers retain ownership of all buffers.
|
/// callers retain ownership of all buffers.
|
||||||
pub struct ResolveContext<'a> {
|
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
|
/// equivalent of path 2 — there's no host-side PK derivation against a
|
||||||
/// Variant MKB.)
|
/// Variant MKB.)
|
||||||
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
|
let uk_file = parse_title_keys(ctx.unit_key_ro, AacsVersion::V20)?;
|
||||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
let bus_encryption = ctx
|
let bus_encryption = ctx
|
||||||
.content_cert
|
.content_cert
|
||||||
@@ -280,8 +296,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
|||||||
.map(|cc| cc.bus_encryption)
|
.map(|cc| cc.bus_encryption)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Parse Unit_Key_RO.inf at the version-appropriate stride.
|
// Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the
|
||||||
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
|
// version-appropriate stride, or HD DVD VTKF000.AACS) → common UnitKeyFile.
|
||||||
|
let uk_file = parse_title_keys(ctx.unit_key_ro, version)?;
|
||||||
|
|
||||||
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||||
@@ -466,6 +483,27 @@ mod tests {
|
|||||||
use super::super::types::*;
|
use super::super::types::*;
|
||||||
use super::*;
|
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
|
/// 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
|
/// 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).
|
/// non-BD major selects the V20/V21 64-byte stride (V10 is the only 48-byte).
|
||||||
|
|||||||
+221
-70
@@ -1,61 +1,55 @@
|
|||||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||||
//!
|
//!
|
||||||
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
|
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
|
||||||
//! forensic-watermark mechanism. The same frames are authored as several
|
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
|
||||||
//! slightly different variants; each variant is encrypted under its own SEGMENT
|
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
|
||||||
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
|
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
|
||||||
//! right device keys can decrypt exactly one variant per segment, and which one
|
|
||||||
//! silently identifies the player (traitor tracing). Decrypting a variant
|
|
||||||
//! segment with the Unit Key yields garbage — broken HEVC reference frames
|
|
||||||
//! (empirically: `Could not find ref with POC …` on a plain unit-key rip).
|
|
||||||
//!
|
//!
|
||||||
//! This table says WHERE the variant segments live so a decoder can decrypt
|
//! Terminology (see the project AACS reference): the **index** here is NOT the
|
||||||
//! them with segment keys and select one coherent variant instead of muxing
|
//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the
|
||||||
|
//! MKB that decides *which set* of index keys a device receives, a layer this
|
||||||
|
//! module does not deal with. All the index keys belong to one variant, whose
|
||||||
|
//! number is unknown and irrelevant to the segment map. Decrypting a segment with
|
||||||
|
//! the Unit Key yields garbage — broken HEVC reference frames (empirically:
|
||||||
|
//! `Could not find ref with POC …` on a plain unit-key rip).
|
||||||
|
//!
|
||||||
|
//! This table says WHERE the segments live and which index each carries, so a
|
||||||
|
//! decoder can decrypt them with the matching index key instead of muxing
|
||||||
//! unit-key garbage.
|
//! unit-key garbage.
|
||||||
//!
|
//!
|
||||||
//! Format (validated against a retail AACS 2.1 disc):
|
//! Format (validated against a retail AACS 2.1 disc):
|
||||||
//! ```text
|
//! ```text
|
||||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||||
//! record[count] (16 bytes each):
|
//! record[count] (16 bytes each):
|
||||||
//! u32 marker (= 0x01000000) | u16 variant | u16 flag (= 1)
|
//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1)
|
||||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||||
//! ```
|
//! ```
|
||||||
//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id:
|
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
|
||||||
//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across
|
//! on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across records in
|
||||||
//! records in file order — 24 full cycles of 32 plus a final partial cycle of
|
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
|
||||||
//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index:
|
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
|
||||||
//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread
|
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
|
||||||
//! across the entire 54 GB feature (one roughly every 67 MB).
|
//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a
|
||||||
|
//! segment the 80 units interleave in two stride-2 halves: applying the segment's
|
||||||
|
//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second
|
||||||
|
//! interleaved half, unidentified), which the demux then drops — leaving one
|
||||||
|
//! coherent stream. Confirmed by decoding a retail disc with a full set of 32
|
||||||
|
//! index keys.
|
||||||
|
|
||||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||||
pub const SOURCE_PACKET_LEN: u64 = 192;
|
pub const SOURCE_PACKET_LEN: u64 = 192;
|
||||||
|
|
||||||
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
|
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||||
///
|
/// FMTS clip.
|
||||||
/// `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.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Segment {
|
pub struct Segment {
|
||||||
/// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the
|
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
|
||||||
/// table rather than counting up — it selects WHICH variant this range is,
|
/// table rather than counting up — it selects WHICH of the 32 index keys
|
||||||
/// which is what a variant-keyed decode routes on. (`0` is not used here;
|
/// decrypts this range. (`0` is not used here; the default/non-forensic
|
||||||
/// the default/non-forensic content carries no segment record at all.)
|
/// content carries no segment record at all.)
|
||||||
pub variant: u16,
|
pub index: u16,
|
||||||
/// First source packet of the segment (inclusive).
|
/// First source packet of the segment (inclusive).
|
||||||
pub start_spn: u32,
|
pub start_spn: u32,
|
||||||
/// Last source packet of the segment (inclusive).
|
/// Last source packet of the segment (inclusive).
|
||||||
@@ -109,8 +103,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
|
|||||||
/// unit's clip-relative byte offset.
|
/// unit's clip-relative byte offset.
|
||||||
///
|
///
|
||||||
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
|
||||||
/// overlaps a forensic segment must be opened with that segment's **variant
|
/// overlaps a forensic segment must be opened with that segment's **index key**
|
||||||
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
|
/// (selected by the segment's `index`), not the CPS Unit Key. Opening it with
|
||||||
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
|
||||||
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
/// plain unit-key rip produces. A unit outside every segment is ordinary
|
||||||
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
|
||||||
@@ -118,16 +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
|
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
|
||||||
/// unit that only partly overlaps a segment edge is still classified as
|
/// unit that only partly overlaps a segment edge is still classified as
|
||||||
/// variant; on the observed disc segments are unit-aligned, but the span test
|
/// forensic; on the observed disc segments are unit-aligned, but the span test
|
||||||
/// does not rely on that.
|
/// does not rely on that.
|
||||||
pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
pub fn segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||||
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
||||||
segments.iter().find(|s| s.overlaps_spn(first, last))
|
segments.iter().find(|s| s.overlaps_spn(first, last))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table
|
/// Parse `IndividualSegment.tbl` into its forensic segments, in table
|
||||||
/// order. Returns `None` when the header is malformed, the record size is not
|
/// order. Returns `None` when the header is malformed, the record size is not
|
||||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
/// so a truncated / foreign table degrades to "no segment map" rather than
|
||||||
@@ -147,12 +141,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
|||||||
let mut segments = Vec::with_capacity(count);
|
let mut segments = Vec::with_capacity(count);
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let o = 8 + i * record_size;
|
let o = 8 + i * record_size;
|
||||||
// o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||||
let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
let index = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||||
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
||||||
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||||
segments.push(Segment {
|
segments.push(Segment {
|
||||||
variant,
|
index,
|
||||||
start_spn,
|
start_spn,
|
||||||
end_spn,
|
end_spn,
|
||||||
});
|
});
|
||||||
@@ -160,12 +154,79 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
|||||||
Some(segments)
|
Some(segments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a clip-relative byte offset to the absolute LBA that holds it, by walking
|
||||||
|
/// the title's extents (the `.fmts` clip's sectors in file order). Segment
|
||||||
|
/// offsets in [`Segment`] are clip-relative source-packet numbers, so this is how
|
||||||
|
/// a segment's `spn` range becomes disc LBAs. `None` if the offset is past the
|
||||||
|
/// clip.
|
||||||
|
pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Option<u32> {
|
||||||
|
let mut cum = 0u64;
|
||||||
|
for e in extents {
|
||||||
|
let len = e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64;
|
||||||
|
if clip_byte < cum + len {
|
||||||
|
let sector_in_ext = ((clip_byte - cum) / crate::consts::SECTOR_BYTES as u64) as u32;
|
||||||
|
return Some(e.start_lba.saturating_add(sector_in_ext));
|
||||||
|
}
|
||||||
|
cum += len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map.
|
||||||
|
///
|
||||||
|
/// Each forensic segment's clip-relative source-packet span becomes an absolute
|
||||||
|
/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`,
|
||||||
|
/// e.g. `|i| i as usize` when the pool is `[base, idx1, idx2, …]`). Applying that
|
||||||
|
/// one key across the whole segment decodes the ~40 units of its interleave half
|
||||||
|
/// to clean TS and garbles the other ~40 (the second interleaved half), which the
|
||||||
|
/// demux then drops — yielding one coherent stream. Ranges outside every segment
|
||||||
|
/// are left for the map's default (the ordinary Unit Key). A segment that straddles
|
||||||
|
/// a UDF extent boundary is emitted as one range per whole-sector slice it covers.
|
||||||
|
///
|
||||||
|
/// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges)
|
||||||
|
/// with the Unit-Key index as the default — the same structure the CPS map uses,
|
||||||
|
/// only finer-grained.
|
||||||
|
pub fn fmts_key_ranges(
|
||||||
|
segments: &[Segment],
|
||||||
|
extents: &[crate::disc::Extent],
|
||||||
|
index_to_key_idx: &dyn Fn(u16) -> usize,
|
||||||
|
) -> Vec<(u32, u32, usize)> {
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
for s in segments {
|
||||||
|
// SPNs are untrusted (from IndividualSegment.tbl); an inverted record
|
||||||
|
// (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below.
|
||||||
|
if s.start_spn > s.end_spn {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
|
||||||
|
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
|
||||||
|
// A segment is unit-aligned and contiguous in clip bytes; map its first
|
||||||
|
// and last sector to LBAs. Segments are ~480 KB and extents are GB-sized,
|
||||||
|
// so a segment almost never crosses an extent boundary — but if the two
|
||||||
|
// ends land in different extents (non-contiguous LBAs), skip rather than
|
||||||
|
// emit a wrong span; the units there fall to the Unit Key (garble+drop),
|
||||||
|
// never a mis-decrypt.
|
||||||
|
let (Some(a), Some(b)) = (
|
||||||
|
clip_byte_to_lba(extents, start_byte),
|
||||||
|
clip_byte_to_lba(extents, end_byte - 1),
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if b >= a
|
||||||
|
&& (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64
|
||||||
|
{
|
||||||
|
ranges.push((a, b + 1, index_to_key_idx(s.index)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ranges
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||||
/// records. `recs` are `(variant, start_spn, end_spn)`.
|
/// records. `recs` are `(index, start_spn, end_spn)`.
|
||||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||||
@@ -181,11 +242,101 @@ mod tests {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
// One big clip extent starting at LBA 1000. Clip byte B lives at
|
||||||
|
// LBA 1000 + B/2048.
|
||||||
|
let extents = vec![Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 1_000_000,
|
||||||
|
}];
|
||||||
|
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
|
||||||
|
let segs = vec![
|
||||||
|
Segment {
|
||||||
|
index: 5,
|
||||||
|
start_spn: 100,
|
||||||
|
end_spn: 199,
|
||||||
|
},
|
||||||
|
Segment {
|
||||||
|
index: 7,
|
||||||
|
start_spn: 10_000,
|
||||||
|
end_spn: 10_099,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
|
||||||
|
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||||
|
assert_eq!(ranges.len(), 2, "one LBA range per segment");
|
||||||
|
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
|
||||||
|
// → LBA 1009..1019, key index 5.
|
||||||
|
assert_eq!(ranges[0], (1009, 1019, 5));
|
||||||
|
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
|
||||||
|
// sectors 937..=946 → LBA 1937..1947, key index 7.
|
||||||
|
assert_eq!(ranges[1], (1937, 1947, 7));
|
||||||
|
|
||||||
|
// The ranges drive a positive AacsKeyMap: an LBA in no range has no key.
|
||||||
|
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
|
||||||
|
assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
|
||||||
|
assert_eq!(
|
||||||
|
map.key_idx_for(1012),
|
||||||
|
Some(5),
|
||||||
|
"inside index-5 segment → key 5"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map.key_idx_for(1940),
|
||||||
|
Some(7),
|
||||||
|
"inside index-7 segment → key 7"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
map.key_idx_for(1019),
|
||||||
|
None,
|
||||||
|
"segment end is exclusive → no key"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
let extents = vec![Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 1_000_000,
|
||||||
|
}];
|
||||||
|
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
|
||||||
|
// The record must be skipped rather than panic (debug) / wrap (release).
|
||||||
|
let segs = vec![Segment {
|
||||||
|
index: 5,
|
||||||
|
start_spn: 200,
|
||||||
|
end_spn: 199,
|
||||||
|
}];
|
||||||
|
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||||
|
assert!(ranges.is_empty(), "inverted segment yields no range");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_byte_to_lba_walks_extents() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
let extents = vec![
|
||||||
|
Extent {
|
||||||
|
start_lba: 100,
|
||||||
|
sector_count: 10,
|
||||||
|
}, // clip bytes [0, 20480)
|
||||||
|
Extent {
|
||||||
|
start_lba: 500,
|
||||||
|
sector_count: 10,
|
||||||
|
}, // clip bytes [20480, 40960)
|
||||||
|
];
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
|
||||||
|
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_real_disc_layout() {
|
fn parses_real_disc_layout() {
|
||||||
// First three records observed on retail 2.1 (Zombieland): the variant
|
// First three records observed on retail 2.1 (Zombieland): the variant
|
||||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||||
// `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||||
let tbl = build_tbl(&[
|
let tbl = build_tbl(&[
|
||||||
(1, 343680, 346239),
|
(1, 343680, 346239),
|
||||||
(2, 695616, 698175),
|
(2, 695616, 698175),
|
||||||
@@ -193,9 +344,9 @@ mod tests {
|
|||||||
]);
|
]);
|
||||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||||
assert_eq!(segs.len(), 3);
|
assert_eq!(segs.len(), 3);
|
||||||
assert_eq!(segs[0].variant, 1);
|
assert_eq!(segs[0].index, 1);
|
||||||
assert_eq!(segs[1].variant, 2);
|
assert_eq!(segs[1].index, 2);
|
||||||
assert_eq!(segs[2].variant, 3);
|
assert_eq!(segs[2].index, 3);
|
||||||
assert_eq!(segs[0].start_spn, 343680);
|
assert_eq!(segs[0].start_spn, 343680);
|
||||||
assert_eq!(segs[0].end_spn, 346239);
|
assert_eq!(segs[0].end_spn, 346239);
|
||||||
assert_eq!(segs[0].packet_count(), 2560);
|
assert_eq!(segs[0].packet_count(), 2560);
|
||||||
@@ -234,18 +385,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_inside_segment_routes_to_variant() {
|
fn unit_inside_segment_routes_to_index() {
|
||||||
// A real first-record segment: packets [343680, 346239].
|
// A real first-record segment: packets [343680, 346239].
|
||||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||||
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
|
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||||
assert_eq!(hit.variant, 1);
|
assert_eq!(hit.index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn variant_field_cycles_one_to_thirty_two() {
|
fn index_field_cycles_one_to_thirty_two() {
|
||||||
// Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file
|
// Reality on Zombieland: field@4 is the index, cycling 1..=32 in file
|
||||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||||
let mut recs = Vec::new();
|
let mut recs = Vec::new();
|
||||||
let mut spn = 1000u32;
|
let mut spn = 1000u32;
|
||||||
@@ -258,9 +409,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||||
assert_eq!(segs.len(), 64);
|
assert_eq!(segs.len(), 64);
|
||||||
assert_eq!(segs[31].variant, 32); // end of first cycle
|
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||||
assert_eq!(segs[32].variant, 1); // wraps, does not become 33
|
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.variant)));
|
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -268,29 +419,29 @@ mod tests {
|
|||||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||||
assert!(variant_segment_for_unit(&segs, off).is_none());
|
assert!(segment_for_unit(&segs, off).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unit_straddling_a_segment_edge_counts_as_variant() {
|
fn unit_straddling_a_segment_edge_counts_as_forensic() {
|
||||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||||
// packets straddle the boundary) must still route to the variant key,
|
// packets straddle the boundary) must still route to the index key,
|
||||||
// because part of its ciphertext is variant-encrypted.
|
// because part of its ciphertext is forensic-encrypted.
|
||||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||||
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
|
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||||
assert_eq!(hit.variant, 7);
|
assert_eq!(hit.index, 7);
|
||||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||||
assert!(variant_segment_for_unit(&segs, before).is_none());
|
assert!(segment_for_unit(&segs, before).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_segments_never_routes_to_variant() {
|
fn no_segments_never_routes_to_index() {
|
||||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||||
assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+178
-24
@@ -6,7 +6,7 @@
|
|||||||
//! owns only the crypto and these value types that flow through it.
|
//! owns only the crypto and these value types that flow through it.
|
||||||
|
|
||||||
/// A device key for MKB subset-difference tree processing.
|
/// A device key for MKB subset-difference tree processing.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DeviceKey {
|
pub struct DeviceKey {
|
||||||
pub key: [u8; 16],
|
pub key: [u8; 16],
|
||||||
pub node: u16,
|
pub node: u16,
|
||||||
@@ -15,7 +15,7 @@ pub struct DeviceKey {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Host certificate + private key for AACS SCSI authentication.
|
/// Host certificate + private key for AACS SCSI authentication.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct HostCert {
|
pub struct HostCert {
|
||||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||||
pub private_key: [u8; 20],
|
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.
|
/// 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]);
|
pub struct Vid(pub [u8; 16]);
|
||||||
|
|
||||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
/// 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]);
|
pub struct MediaKey(pub [u8; 16]);
|
||||||
|
|
||||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
/// 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]);
|
pub struct Vuk(pub [u8; 16]);
|
||||||
|
|
||||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
/// 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
|
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||||
/// device-key walk derives at its matching SD node.
|
/// 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]);
|
pub struct ProcessingKey(pub [u8; 16]);
|
||||||
|
|
||||||
/// One decrypted per-CPS-unit AACS title key.
|
/// One decrypted per-CPS-unit AACS title key.
|
||||||
@@ -53,50 +53,49 @@ pub struct ProcessingKey(pub [u8; 16]);
|
|||||||
/// area). The CPS-unit *number* association is a higher-level concern owned by
|
/// 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
|
/// [`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.
|
/// 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 struct UnitKey {
|
||||||
pub idx: u32,
|
pub idx: u32,
|
||||||
pub key: [u8; 16],
|
pub key: [u8; 16],
|
||||||
/// AACS 2.1 (FMTS) forensic-variant tag.
|
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
|
||||||
///
|
///
|
||||||
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
||||||
/// key and for the bulk of a 2.1 title. `1..=32` = a variant key that
|
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
|
||||||
/// decrypts the forensic segments tagged with that same variant in
|
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
|
||||||
/// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at
|
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
|
||||||
/// most one non-zero value is ever in play for a given rip; the decode
|
/// (the 65536-value device selector), which is a separate MKB-layer concern.
|
||||||
/// selects the segments matching it and drops the other variants.
|
pub index_number: u8,
|
||||||
pub variant_number: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UnitKey {
|
impl UnitKey {
|
||||||
/// An ordinary (non-forensic) unit key: `variant_number == 0`. The value
|
/// An ordinary (non-forensic) unit key: `index_number == 0`. The value
|
||||||
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
||||||
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
idx,
|
idx,
|
||||||
key,
|
key,
|
||||||
variant_number: 0,
|
index_number: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A forensic-variant key: `variant_number` in `1..=32`, decrypting the
|
/// A forensic index key: `index_number` in `1..=32`, decrypting the
|
||||||
/// `IndividualSegment.tbl` segments tagged with that variant.
|
/// `IndividualSegment.tbl` segments tagged with that index.
|
||||||
pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self {
|
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
|
||||||
Self {
|
Self {
|
||||||
idx,
|
idx,
|
||||||
key,
|
key,
|
||||||
variant_number,
|
index_number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this key decrypts ordinary (non-forensic) content.
|
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
|
||||||
pub const fn is_default_variant(&self) -> bool {
|
pub const fn is_default_index(&self) -> bool {
|
||||||
self.variant_number == 0
|
self.index_number == 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A per-disc entry from the key database.
|
/// A per-disc entry from the key database.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DiscEntry {
|
pub struct DiscEntry {
|
||||||
/// Disc hash (20 bytes, hex)
|
/// Disc hash (20 bytes, hex)
|
||||||
pub disc_hash: String,
|
pub disc_hash: String,
|
||||||
@@ -111,3 +110,158 @@ pub struct DiscEntry {
|
|||||||
/// Unit keys (title keys) indexed by CPS unit number
|
/// Unit keys (title keys) indexed by CPS unit number
|
||||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
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
|
/// Outcome of a subset-difference walk against an MKB. Carries the
|
||||||
/// processing key and the matching `uv` slot — both needed as inputs
|
/// processing key and the matching `uv` slot — both needed as inputs
|
||||||
/// to the variant chain.
|
/// to the variant chain.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct ProcessingKeyMatch {
|
pub struct ProcessingKeyMatch {
|
||||||
/// Processing Key.
|
/// Processing Key.
|
||||||
pub kp: [u8; 16],
|
pub kp: [u8; 16],
|
||||||
@@ -158,6 +158,20 @@ pub struct ProcessingKeyMatch {
|
|||||||
pub cvalue_index: usize,
|
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]> {
|
fn mkb_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||||
let r = records.iter().find(|r| {
|
let r = records.iter().find(|r| {
|
||||||
(r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
|
(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::crypto::aesg3;
|
||||||
use super::super::derive::calc_pk_from_dk;
|
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]
|
#[test]
|
||||||
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
|
fn calc_pk_from_dk_terminates_on_nonconvergent_mask() {
|
||||||
// Regression for the unbounded-loop hang: pick a (dev_key_v_mask,
|
// Regression for the unbounded-loop hang: pick a (dev_key_v_mask,
|
||||||
|
|||||||
+297
-14
@@ -29,7 +29,7 @@ use crate::sector::SectorSource;
|
|||||||
const CSS_LOCKED_BAIL: u32 = 64;
|
const CSS_LOCKED_BAIL: u32 = 64;
|
||||||
|
|
||||||
/// CSS decryption state for a DVD title.
|
/// CSS decryption state for a DVD title.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CssState {
|
pub struct CssState {
|
||||||
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
|
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
|
||||||
pub title_key: [u8; 5],
|
pub title_key: [u8; 5],
|
||||||
@@ -43,6 +43,18 @@ pub struct CssState {
|
|||||||
pub crack_span: Option<(u32, u32)>,
|
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
|
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||||
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
|
/// 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
|
/// 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-key crack. Works on a live drive (after bus-auth unlocks reads) and on
|
||||||
/// disc images alike.
|
/// 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(
|
pub fn crack_key(
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
extents: &[Extent],
|
extents: &[Extent],
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
) -> Option<CssState> {
|
) -> 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
|
/// 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
|
/// ScrambledUncracked) so callers can distinguish "genuinely unencrypted" from
|
||||||
/// "encrypted but uncrackable" — the latter must become a hard error, never a
|
/// "encrypted but uncrackable" — the latter must become a hard error, never a
|
||||||
/// silent fall-through to plaintext.
|
/// 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(
|
pub fn crack_key_outcome(
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
extents: &[Extent],
|
extents: &[Extent],
|
||||||
@@ -114,27 +136,69 @@ pub fn crack_key_outcome(
|
|||||||
crack_key_scan(reader, extents, batch_sectors, halt, true)
|
crack_key_scan(reader, extents, batch_sectors, halt, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`crack_key`] with an optional cooperative-cancellation token.
|
/// Resolve a DVD title's CSS descramble key from the reader when the caller
|
||||||
|
/// supplied none — the SINGLE place every DVD read path obtains a title key, so
|
||||||
|
/// the file-backed mux highway ([`crate::build_iso_pipeline`]) and the
|
||||||
|
/// live-drive single-pass [`crate::DiscStream`] descramble a DVD identically
|
||||||
|
/// ("reading is reading"). CSS keys are per-VTS and crackable from the scrambled
|
||||||
|
/// data itself, so a `None`/MPEG-PS title cracks its own key here, in playback
|
||||||
|
/// order over `extents`. Everything else is left untouched:
|
||||||
|
/// - AACS keys (HD-DVD `.evo` is also MPEG-PS but arrives as `Aacs`) — no CSS.
|
||||||
|
/// - a title that already carries a key — nothing to resolve.
|
||||||
|
/// - a genuinely clear DVD (no scrambled sector) — stays `None`, a mux no-op.
|
||||||
///
|
///
|
||||||
/// "No silent hangs": the crack scans up to 50_000 sectors, which on a live
|
/// A scrambled-but-uncrackable title is a hard [`crate::error::Error::CssKeyMissing`],
|
||||||
/// drive hitting bad sectors can take a long time. This variant polls `halt`
|
/// never a silent scrambled-passthrough mux.
|
||||||
/// once per batch (the same cadence sweep/patch use) so an operator Stop or a
|
pub(crate) fn resolve_dvd_title_key(
|
||||||
/// 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,
|
reader: &mut dyn SectorSource,
|
||||||
extents: &[Extent],
|
extents: &[Extent],
|
||||||
|
keys: &mut crate::decrypt::DecryptKeys,
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
|
format: crate::disc::ContentFormat,
|
||||||
|
raw: bool,
|
||||||
halt: Option<&crate::halt::Halt>,
|
halt: Option<&crate::halt::Halt>,
|
||||||
) -> Option<CssState> {
|
) -> std::io::Result<()> {
|
||||||
crack_key_scan(reader, extents, batch_sectors, halt, false).into_state()
|
// `--raw` = deliberate ciphertext passthrough: never crack or descramble, and
|
||||||
|
// never hard-fail on scrambled-uncrackable — the user asked for the scrambled
|
||||||
|
// bytes. (In raw mode the caller hands us `None` on purpose; without this
|
||||||
|
// guard we'd install a real key and silently DECRYPT, or abort a raw mux.)
|
||||||
|
if raw {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if matches!(keys, crate::decrypt::DecryptKeys::None)
|
||||||
|
&& format == crate::disc::ContentFormat::MpegPs
|
||||||
|
{
|
||||||
|
// `halt` threads the caller's cancellation token so /api/stop can
|
||||||
|
// interrupt a long crack scan (the old scan-time crack honored it too).
|
||||||
|
let outcome = crack_key_outcome(reader, extents, batch_sectors, halt);
|
||||||
|
// A cancelled crack breaks out early, so its outcome is a TRUNCATED scan
|
||||||
|
// — not a real verdict. Interpreting it would either hard-fail a good disc
|
||||||
|
// as `ScrambledUncracked` (quarantining staging on a Stop) or, worse,
|
||||||
|
// read a half-scanned title as `Unencrypted` and mux scrambled bytes as
|
||||||
|
// plaintext. Surface the cancellation as `Halted` so the caller takes its
|
||||||
|
// graceful-stop path instead of trusting the partial outcome.
|
||||||
|
if halt.map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||||
|
return Err(crate::error::Error::Halted.into());
|
||||||
|
}
|
||||||
|
match outcome {
|
||||||
|
CrackOutcome::Cracked(state) => {
|
||||||
|
*keys = crate::decrypt::DecryptKeys::Css {
|
||||||
|
title_key: state.title_key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
CrackOutcome::ScrambledUncracked => {
|
||||||
|
return Err(crate::error::Error::CssKeyMissing.into());
|
||||||
|
}
|
||||||
|
CrackOutcome::Unencrypted => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
|
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
|
||||||
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
|
/// `saw_scrambled` flag so a scrambled-but-uncracked disc is distinguished
|
||||||
/// from a genuinely-unencrypted one (the [`crack_key`] / [`crack_key_halt`]
|
/// from a genuinely-unencrypted one (the [`crack_key`] `Option` wrapper
|
||||||
/// `Option` wrappers collapse both to `None`).
|
/// collapses both to `None` via [`CrackOutcome::into_state`]).
|
||||||
fn crack_key_scan(
|
fn crack_key_scan(
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
extents: &[Extent],
|
extents: &[Extent],
|
||||||
@@ -364,6 +428,26 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::error::{Error, Result};
|
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 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// is_scrambled returns false for any buffer shorter than one sector,
|
/// is_scrambled returns false for any buffer shorter than one sector,
|
||||||
@@ -868,6 +952,205 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `resolve_dvd_title_key` is the SINGLE shared per-title CSS step both read
|
||||||
|
/// paths (`build_iso_pipeline` multi-pass and `DiscStream::new` single-pass)
|
||||||
|
/// call, so these pin its full contract at the shared boundary.
|
||||||
|
///
|
||||||
|
/// Crack path: a `None`-keyed MPEG-PS title with a crackable scrambled sector
|
||||||
|
/// installs a `Css` key that round-trips the sector.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_cracks_none_mpegps() {
|
||||||
|
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let crackable = crackable_sector(&title_key, &seed, 8);
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.crackable = Some((1003, crackable));
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 50,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("crackable title resolves");
|
||||||
|
match keys {
|
||||||
|
crate::decrypt::DecryptKeys::Css { title_key: got } => {
|
||||||
|
assert_eq!(got, title_key, "installed key must be the cracked key")
|
||||||
|
}
|
||||||
|
_ => panic!("expected Css key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard-fail path: a scrambled-but-uncrackable `None`-keyed MPEG-PS title must
|
||||||
|
/// return `CssKeyMissing`, never leave `keys` as `None` (which would mux
|
||||||
|
/// scrambled bytes as plaintext — the 328k-decode-error corruption).
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_scrambled_uncrackable_hard_fails() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.lock_all = true; // every read CSS-locked → ScrambledUncracked
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
let err = resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect_err("scrambled-uncrackable must hard-fail");
|
||||||
|
// The Error::CssKeyMissing flattens into io::Error carrying its E-code
|
||||||
|
// (7023) in the message — assert that specific code survived.
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains(&format!("E{}", crate::error::E_CSS_KEY_MISSING)),
|
||||||
|
"must surface CssKeyMissing (E{}), got: {err}",
|
||||||
|
crate::error::E_CSS_KEY_MISSING
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||||
|
"keys must stay None on hard-fail (never a scrambled-passthrough key)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `raw` is deliberate ciphertext passthrough: even a scrambled-uncrackable
|
||||||
|
/// title must return `Ok` and leave `keys` untouched (`None`) — no crack, no
|
||||||
|
/// hard-fail. This is the `--raw` guarantee.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_raw_skips_crack_and_never_fails() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.lock_all = true;
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
true, // raw
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("raw must never hard-fail");
|
||||||
|
assert!(
|
||||||
|
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||||
|
"raw must leave keys None (no descramble)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
src.reads.borrow().is_empty(),
|
||||||
|
"raw must not read any sector for a crack"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AACS gate: an MPEG-PS title carrying `Aacs` keys (HD-DVD `.evo`) must be
|
||||||
|
/// left untouched — resolve only fires on `None` keys, never overwriting a
|
||||||
|
/// real key set or cracking AACS ciphertext as CSS.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_leaves_aacs_untouched() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.lock_all = true; // would hard-fail IF it ran the crack
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::Aacs {
|
||||||
|
unit_keys: vec![(0, [0u8; 16])],
|
||||||
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::MpegPs,
|
||||||
|
};
|
||||||
|
resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("AACS title must be left untouched, not cracked");
|
||||||
|
assert!(
|
||||||
|
matches!(keys, crate::decrypt::DecryptKeys::Aacs { .. }),
|
||||||
|
"Aacs keys must survive unchanged"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
src.reads.borrow().is_empty(),
|
||||||
|
"must not read for a crack when keys are already Aacs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear DVD: a `None`-keyed MPEG-PS title with no scrambled sector stays
|
||||||
|
/// `None` (a mux no-op) and returns `Ok` — genuinely-unencrypted DVDs pass.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_clear_dvd_stays_none() {
|
||||||
|
let mut src = MockSource::new(0x00); // all-clear sectors
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("clear DVD passes");
|
||||||
|
assert!(
|
||||||
|
matches!(keys, crate::decrypt::DecryptKeys::None),
|
||||||
|
"a clear DVD must keep None keys"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cancelled crack (user Stop mid-scan) must surface as `Halted`, NOT be
|
||||||
|
/// misread from the truncated scan as `Unencrypted` (→ scrambled passthrough,
|
||||||
|
/// corruption) or `ScrambledUncracked` (→ CssKeyMissing, which quarantines a
|
||||||
|
/// good disc). This pins the halt-outcome fix.
|
||||||
|
#[test]
|
||||||
|
fn resolve_dvd_title_key_halt_surfaces_as_halted_not_a_verdict() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.lock_all = true; // without the halt guard this would be ScrambledUncracked
|
||||||
|
let extents = [Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let halt = crate::halt::Halt::new();
|
||||||
|
halt.cancel(); // Stop already pressed
|
||||||
|
let mut keys = crate::decrypt::DecryptKeys::None;
|
||||||
|
let err = resolve_dvd_title_key(
|
||||||
|
&mut src,
|
||||||
|
&extents,
|
||||||
|
&mut keys,
|
||||||
|
4,
|
||||||
|
crate::disc::ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
Some(&halt),
|
||||||
|
)
|
||||||
|
.expect_err("a cancelled crack must return an error");
|
||||||
|
assert!(
|
||||||
|
err.to_string()
|
||||||
|
.contains(&format!("E{}", crate::error::E_HALTED)),
|
||||||
|
"cancelled crack must surface Halted (E{}), got: {err}",
|
||||||
|
crate::error::E_HALTED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
|
/// CSS_ERROR WIRING (audit §2 / §5 #7): an all-locked synthetic ISO (every
|
||||||
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
|
/// VOB read returns CSS-locked sense `05/6F/03` across MULTIPLE extents, as a
|
||||||
/// real encrypted-but-unauthenticated disc image does) must produce the exact
|
/// real encrypted-but-unauthenticated disc image does) must produce the exact
|
||||||
|
|||||||
+620
-720
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
|
/// `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
|
/// [`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.
|
/// 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() {
|
if !diag_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -530,7 +530,7 @@ fn dump_aacs(disc: &Disc) {
|
|||||||
a.bus_encryption,
|
a.bus_encryption,
|
||||||
a.mkb_version,
|
a.mkb_version,
|
||||||
a.disc_hash,
|
a.disc_hash,
|
||||||
a.key_source.name(),
|
a.key_source,
|
||||||
a.vuk.is_some(),
|
a.vuk.is_some(),
|
||||||
a.unit_keys.len(),
|
a.unit_keys.len(),
|
||||||
a.uk_ro.len(),
|
a.uk_ro.len(),
|
||||||
|
|||||||
+43
-9
@@ -7,7 +7,6 @@ use crate::udf;
|
|||||||
|
|
||||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||||
/// Only available when scanning from a real drive, not ISO images.
|
/// Only available when scanning from a real drive, not ISO images.
|
||||||
#[derive(Debug)]
|
|
||||||
pub(super) struct HandshakeResult {
|
pub(super) struct HandshakeResult {
|
||||||
pub volume_id: [u8; 16],
|
pub volume_id: [u8; 16],
|
||||||
pub read_data_key: Option<[u8; 16]>,
|
pub read_data_key: Option<[u8; 16]>,
|
||||||
@@ -29,6 +28,19 @@ pub(super) struct HandshakeResult {
|
|||||||
pub drive_unlocked: bool,
|
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
|
/// 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
|
/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus
|
||||||
/// encryption is gone when ANY of these holds:
|
/// encryption is gone when ANY of these holds:
|
||||||
@@ -311,15 +323,16 @@ impl Disc {
|
|||||||
) -> Result<AacsState> {
|
) -> Result<AacsState> {
|
||||||
use crate::aacs;
|
use crate::aacs;
|
||||||
|
|
||||||
let uk_ro_data = udf_fs
|
let uk_ro_data =
|
||||||
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
|
aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| {
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
|
udf_fs.read_file(reader, p)
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
})?;
|
||||||
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
let dh = aacs::inf::disc_hash(&uk_ro_data);
|
||||||
|
|
||||||
let cc = udf_fs
|
let cc = aacs::read_first(
|
||||||
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
|
&aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert),
|
||||||
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
|
|p| udf_fs.read_file(reader, p),
|
||||||
|
)
|
||||||
.ok()
|
.ok()
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(aacs::inf::parse_content_cert);
|
.and_then(aacs::inf::parse_content_cert);
|
||||||
@@ -432,6 +445,27 @@ mod tests {
|
|||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
use std::collections::HashMap;
|
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
|
// In-memory disc + minimal UDF image with a single physical
|
||||||
// partition (metadata_start == partition_start). Offsets cited
|
// partition (metadata_start == partition_start). Offsets cited
|
||||||
@@ -980,7 +1014,7 @@ mod tests {
|
|||||||
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
|
||||||
struct CertSource(Vec<aacs::types::HostCert>);
|
struct CertSource(Vec<aacs::types::HostCert>);
|
||||||
impl crate::KeySource for CertSource {
|
impl crate::KeySource for CertSource {
|
||||||
fn get_uk(
|
fn get_unit_keys(
|
||||||
&self,
|
&self,
|
||||||
_ctx: &dyn crate::keysource::ResolveCtx,
|
_ctx: &dyn crate::keysource::ResolveCtx,
|
||||||
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
) -> Result<Vec<crate::aacs::types::UnitKey>> {
|
||||||
|
|||||||
+41
-59
@@ -1,6 +1,7 @@
|
|||||||
//! `Disc::extract_tree` — decrypted file-tree extraction (`dir://`).
|
//! `Disc::extract_tree` — decrypted file-tree extraction (`dir://`).
|
||||||
//!
|
//!
|
||||||
//! Sibling of [`Disc::copy`](super::Disc::copy) (disc → ISO sector dump),
|
//! Sibling of the disc→ISO sector dump (the sweep/patch recovery passes, which
|
||||||
|
//! now live in the `freemkv-engine` crate),
|
||||||
//! specialized to write **per file** rather than a whole image, applying
|
//! specialized to write **per file** rather than a whole image, applying
|
||||||
//! decryption on the way out, and **without** any multipass / recovery
|
//! decryption on the way out, and **without** any multipass / recovery
|
||||||
//! orchestration. 1-shot, decrypt-only.
|
//! orchestration. 1-shot, decrypt-only.
|
||||||
@@ -185,13 +186,51 @@ impl Disc {
|
|||||||
// Per-VTS CSS key map (DVD only): "VTS_xx" -> DecryptKeys. Built lazily
|
// Per-VTS CSS key map (DVD only): "VTS_xx" -> DecryptKeys. Built lazily
|
||||||
// when a scrambled VOB group needs it. AACS / None discs keep the
|
// when a scrambled VOB group needs it. AACS / None discs keep the
|
||||||
// disc-wide keys for every file.
|
// disc-wide keys for every file.
|
||||||
let base_keys = self.decrypt_keys();
|
let mut base_keys = self.decrypt_keys();
|
||||||
|
|
||||||
|
// AACS key map for the extract, chosen by CPS-unit count:
|
||||||
|
//
|
||||||
|
// * SINGLE CPS (the overwhelming majority, incl. every single-key UHD): one
|
||||||
|
// Unit Key opens EVERY encrypted unit on the disc — content in a parsed
|
||||||
|
// title AND an orphan clip that no playlist references. A blanket key-0
|
||||||
|
// map over the whole LBA space is exact and covers orphans; clear
|
||||||
|
// filesystem/nav (encrypted-flag off) passes through untouched.
|
||||||
|
//
|
||||||
|
// * MULTI-CPS: each clip is protected by a different Unit Key, so a blanket
|
||||||
|
// key-0 map would mis-decrypt every secondary-CPS file into garbage
|
||||||
|
// (silently, since Phase::All is trust-only). Build the EXACT per-CPS
|
||||||
|
// content map instead (each title's extents → the CPS key that opens a
|
||||||
|
// real sample from it), up front before the decorator takes the reader. A
|
||||||
|
// content unit whose key the pool lacks fails loud at resolve (extract has
|
||||||
|
// no CPS/forensic fetch source), never emits a wrong-key garble.
|
||||||
|
//
|
||||||
|
// KNOWN LIMITATION (by design): an orphan encrypted clip on a multi-CPS
|
||||||
|
// disc — referenced by no playlist, so in no title extent — is in no range
|
||||||
|
// and passes through as ciphertext. There is no correct key to apply (its
|
||||||
|
// CPS unit is unknown without a playlist reference), and blind trial-decrypt
|
||||||
|
// is exactly what this keymap-only model removes. Single-CPS is unaffected
|
||||||
|
// (the blanket key-0 map above covers orphans).
|
||||||
|
let key_map =
|
||||||
|
match &base_keys {
|
||||||
|
DecryptKeys::Aacs { unit_keys, .. } if unit_keys.len() <= 1 => {
|
||||||
|
Some(std::sync::Arc::new(
|
||||||
|
crate::decrypt::AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
|
||||||
|
self.resolve_content_key_map(reader, &mut base_keys, None, opts.halt.as_ref())?,
|
||||||
|
)),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
// ── Phase 2: stream each file through the decrypting decorator ────
|
// ── Phase 2: stream each file through the decrypting decorator ────
|
||||||
// The decorator owns its inner source for its lifetime. We hand it a
|
// The decorator owns its inner source for its lifetime. We hand it a
|
||||||
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
|
// borrowing wrapper (so the caller keeps `reader`), swap keys per CSS
|
||||||
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
|
// VTS group via `set_keys`; AACS/None keep `base_keys` throughout.
|
||||||
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
|
let mut dec = DecryptingSectorSource::new(Borrowed(reader), base_keys.clone());
|
||||||
|
if let Some(map) = key_map {
|
||||||
|
dec = dec.with_key_map(map);
|
||||||
|
}
|
||||||
|
|
||||||
let mut result = ExtractResult::default();
|
let mut result = ExtractResult::default();
|
||||||
let total_bytes = required;
|
let total_bytes = required;
|
||||||
@@ -288,63 +327,6 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF
|
|
||||||
/// file is clear (nav / playlists / filesystem) and needs no decrypt verify.
|
|
||||||
fn is_aacs_clip(name: &str) -> bool {
|
|
||||||
let lower = name.to_ascii_lowercase();
|
|
||||||
lower.ends_with(".m2ts") || lower.ends_with(".ssif")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enumerate the disc's AACS clip (`.m2ts`/`.ssif`) files as
|
|
||||||
/// [`crate::disc::verify::ClipLayout`]s for the post-read verify gate: each
|
|
||||||
/// clip's declared size plus its absolute disc extents in FILE order. Reads the
|
|
||||||
/// UDF tree through `reader`.
|
|
||||||
///
|
|
||||||
/// FAIL-SAFE: any enumeration error (bad UDF read, name collision, …) yields an
|
|
||||||
/// EMPTY list — the verify gate then covers nothing and the sweep behaves as
|
|
||||||
/// today. Enumeration must never break a rip, so the error is logged, not
|
|
||||||
/// propagated.
|
|
||||||
pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec<crate::disc::verify::ClipLayout> {
|
|
||||||
let result = (|| -> Result<Vec<crate::disc::verify::ClipLayout>> {
|
|
||||||
let fs = udf::read_filesystem(reader)?;
|
|
||||||
let mut planned: Vec<PlannedFile> = Vec::new();
|
|
||||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
|
||||||
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
|
|
||||||
std::collections::HashMap::new();
|
|
||||||
plan_tree(
|
|
||||||
reader,
|
|
||||||
&fs,
|
|
||||||
&fs.root,
|
|
||||||
Path::new(""),
|
|
||||||
"",
|
|
||||||
true,
|
|
||||||
&mut planned,
|
|
||||||
&mut dirs,
|
|
||||||
&mut seen_hosts,
|
|
||||||
)?;
|
|
||||||
Ok(planned
|
|
||||||
.into_iter()
|
|
||||||
.filter(|pf| pf.inline.is_none() && is_aacs_clip(&pf.disc_name))
|
|
||||||
.map(|pf| crate::disc::verify::ClipLayout {
|
|
||||||
size: pf.size,
|
|
||||||
extents: pf.extents,
|
|
||||||
// Every AACS clip we enumerate today is BD-TS (`.m2ts`/`.ssif`).
|
|
||||||
// HD-DVD `.evo` (program stream) maps to `ContainerKind::Ps` here
|
|
||||||
// once `is_aacs_clip` recognises it — the one-line HD-DVD hook.
|
|
||||||
container: crate::disc::verify::ContainerKind::Ts,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
})();
|
|
||||||
result.unwrap_or_else(|e| {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "freemkv::verify",
|
|
||||||
error = %e,
|
|
||||||
"clip enumeration failed; post-read verify disabled for this pass"
|
|
||||||
);
|
|
||||||
Vec::new()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
|
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
|
||||||
/// inner source for its lifetime while the caller keeps the underlying
|
/// inner source for its lifetime while the caller keeps the underlying
|
||||||
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
|
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
|
||||||
|
|||||||
+3
-1
@@ -522,8 +522,10 @@ fn compose_xpl_titles(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
titles.push(DiscTitle {
|
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() {
|
playlist: if t.name.is_empty() {
|
||||||
format!("Title {}", t.number)
|
format!("TITLE_{}", t.number)
|
||||||
} else {
|
} else {
|
||||||
t.name.clone()
|
t.name.clone()
|
||||||
},
|
},
|
||||||
|
|||||||
-1670
File diff suppressed because it is too large
Load Diff
+810
-2814
File diff suppressed because it is too large
Load Diff
-1711
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
|||||||
|
//! Content-based forced-subtitle detection for Blu-ray/UHD PGS tracks.
|
||||||
|
//!
|
||||||
|
//! `freemkv info` and the muxer must agree on which subtitle tracks are forced.
|
||||||
|
//! The muxer derives it from the PGS `forced_on_flag` while muxing a rip; this
|
||||||
|
//! module gives `info` the SAME verdict up front by reading the title's PGS
|
||||||
|
//! streams and feeding them through the one shared classifier
|
||||||
|
//! ([`crate::mux::codec::pgs::ForcedTracker`]) — so the two never diverge.
|
||||||
|
//!
|
||||||
|
//! Cost: a track is only confirmed forced once EVERY display set is seen to be
|
||||||
|
//! forced, so a disc that has a forced track is read through — the
|
||||||
|
//! accuracy-over-speed tradeoff `info` opts into. Full tracks early-exit as soon
|
||||||
|
//! as they show a single non-forced subtitle, and a whole run stops early once
|
||||||
|
//! every track has settled.
|
||||||
|
//!
|
||||||
|
//! Encrypted content: the probe reuses whatever [`SectorSource`] the scan holds.
|
||||||
|
//! With a decrypting source it sees real PGS; without keys it reads ciphertext
|
||||||
|
//! and observes no display sets, in which case it leaves each track's existing
|
||||||
|
//! (vendor-label-derived) forced flag untouched rather than asserting anything.
|
||||||
|
|
||||||
|
use crate::disc::{Codec, DiscTitle, Stream};
|
||||||
|
use crate::mux::codec::CodecParser;
|
||||||
|
use crate::mux::codec::pgs::{ForcedTracker, PgsParser};
|
||||||
|
use crate::mux::ts::TsDemuxer;
|
||||||
|
use crate::sector::SectorSource;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const SECTOR_BYTES: usize = 2048;
|
||||||
|
/// Read the clip in 2 MiB chunks.
|
||||||
|
const CHUNK_SECTORS: u16 = 1024;
|
||||||
|
|
||||||
|
/// Read the title's PGS streams and set `SubtitleStream::forced` from their
|
||||||
|
/// content. Best-effort: any read error ends the probe with whatever verdicts
|
||||||
|
/// have accumulated. Only PGS tracks are touched (DVD VobSub forced comes from
|
||||||
|
/// the IFO/vendor path).
|
||||||
|
pub(crate) fn probe_and_set_forced<S: SectorSource + ?Sized>(
|
||||||
|
reader: &mut S,
|
||||||
|
title: &mut DiscTitle,
|
||||||
|
) {
|
||||||
|
let pg_pids: Vec<u16> = title
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s {
|
||||||
|
Stream::Subtitle(sub) if sub.codec == Codec::Pgs => Some(sub.pid),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if pg_pids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut demux = TsDemuxer::new(&pg_pids);
|
||||||
|
let mut parsers: HashMap<u16, PgsParser> =
|
||||||
|
pg_pids.iter().map(|&p| (p, PgsParser::new())).collect();
|
||||||
|
let mut trackers: HashMap<u16, ForcedTracker> =
|
||||||
|
pg_pids.iter().map(|&p| (p, ForcedTracker::new())).collect();
|
||||||
|
|
||||||
|
let extents = title.extents.clone();
|
||||||
|
let mut buf = vec![0u8; CHUNK_SECTORS as usize * SECTOR_BYTES];
|
||||||
|
'outer: for ext in &extents {
|
||||||
|
let mut lba = ext.start_lba;
|
||||||
|
let mut remaining = ext.sector_count;
|
||||||
|
while remaining > 0 {
|
||||||
|
let count = remaining.min(CHUNK_SECTORS as u32) as u16;
|
||||||
|
let want = count as usize * SECTOR_BYTES;
|
||||||
|
let n = match reader.read_sectors(lba, count, &mut buf[..want], false) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => break 'outer, // best-effort — stop, keep what we have
|
||||||
|
};
|
||||||
|
if n == 0 {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
for pes in demux.feed(&buf[..n]) {
|
||||||
|
if let (Some(parser), Some(tracker)) =
|
||||||
|
(parsers.get_mut(&pes.pid), trackers.get_mut(&pes.pid))
|
||||||
|
{
|
||||||
|
for frame in parser.parse(&pes) {
|
||||||
|
tracker.observe(&frame.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every track has already shown a non-forced set → nothing left to
|
||||||
|
// learn; stop reading the (huge) clip.
|
||||||
|
if trackers.values().all(ForcedTracker::settled_not_forced) {
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
lba += count as u32;
|
||||||
|
remaining -= count as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain any buffered final display set.
|
||||||
|
for (pid, parser) in parsers.iter_mut() {
|
||||||
|
if let Some(tracker) = trackers.get_mut(pid) {
|
||||||
|
for frame in parser.flush() {
|
||||||
|
tracker.observe(&frame.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply verdicts. Only override a track we actually saw content for — an
|
||||||
|
// undecrypted/unread track keeps its vendor-derived flag.
|
||||||
|
for s in &mut title.streams {
|
||||||
|
if let Stream::Subtitle(sub) = s {
|
||||||
|
if sub.codec == Codec::Pgs {
|
||||||
|
if let Some(t) = trackers.get(&sub.pid) {
|
||||||
|
if t.observed() {
|
||||||
|
sub.forced = t.is_forced();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::disc::{ContentFormat, Extent, LabelQualifier, SubtitleStream};
|
||||||
|
|
||||||
|
/// A reader that yields all-zeros (an encrypted / unreadable clip) for a
|
||||||
|
/// bounded span, then EOF.
|
||||||
|
struct ZeroReader {
|
||||||
|
served: u32,
|
||||||
|
cap: u32,
|
||||||
|
}
|
||||||
|
impl SectorSource for ZeroReader {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
_lba: u32,
|
||||||
|
count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> crate::error::Result<usize> {
|
||||||
|
if self.served >= self.cap {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
self.served += count as u32;
|
||||||
|
buf.fill(0);
|
||||||
|
Ok(buf.len())
|
||||||
|
}
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
self.cap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pgs_title(pid: u16, vendor_forced: bool) -> DiscTitle {
|
||||||
|
DiscTitle {
|
||||||
|
playlist: String::new(),
|
||||||
|
playlist_id: 0,
|
||||||
|
duration_secs: 0.0,
|
||||||
|
size_bytes: 0,
|
||||||
|
clips: vec![],
|
||||||
|
streams: vec![Stream::Subtitle(SubtitleStream {
|
||||||
|
pid,
|
||||||
|
codec: Codec::Pgs,
|
||||||
|
language: "eng".into(),
|
||||||
|
forced: vendor_forced,
|
||||||
|
qualifier: LabelQualifier::None,
|
||||||
|
codec_data: None,
|
||||||
|
})],
|
||||||
|
chapters: vec![],
|
||||||
|
extents: vec![Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 4,
|
||||||
|
}],
|
||||||
|
content_format: ContentFormat::BdTs,
|
||||||
|
codec_privates: vec![None],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_observed_content_preserves_vendor_forced() {
|
||||||
|
// An unreadable/encrypted clip yields no PGS display sets — the probe must
|
||||||
|
// leave the existing vendor-derived forced flag untouched, never assert
|
||||||
|
// "not forced" from having seen nothing.
|
||||||
|
let mut reader = ZeroReader { served: 0, cap: 4 };
|
||||||
|
let mut title = pgs_title(0x1200, true);
|
||||||
|
probe_and_set_forced(&mut reader, &mut title);
|
||||||
|
let Stream::Subtitle(s) = &title.streams[0] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert!(s.forced, "no content observed → vendor forced preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reader that serves a fixed BD-TS byte stream once (across sequential
|
||||||
|
/// `read_sectors` calls), then EOF — so the probe's demux→parse→observe→apply
|
||||||
|
/// path runs on real synthetic PGS content.
|
||||||
|
struct TsReader {
|
||||||
|
data: Vec<u8>,
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
impl SectorSource for TsReader {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
_lba: u32,
|
||||||
|
_count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> crate::error::Result<usize> {
|
||||||
|
if self.pos >= self.data.len() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let n = buf.len().min(self.data.len() - self.pos);
|
||||||
|
buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
|
||||||
|
self.pos += n;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
self.data.len().div_ceil(SECTOR_BYTES) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGS PCS layout (matches the private constants in mux::codec::pgs): a
|
||||||
|
// display-set frame begins with a PCS (segment type 0x16); byte 13 is
|
||||||
|
// number_of_composition_objects; byte 17 is the first object's flags, whose
|
||||||
|
// 0x40 bit is forced_on_flag.
|
||||||
|
const PCS_SEG: u8 = 0x16;
|
||||||
|
const PCS_NUM_OBJECTS_OFF: usize = 13;
|
||||||
|
const PCS_FLAGS_OFF: usize = 17;
|
||||||
|
const PCS_FORCED_FLAG: u8 = 0x40;
|
||||||
|
|
||||||
|
/// One PGS display-set elementary payload with a single composition object;
|
||||||
|
/// `forced` sets forced_on_flag.
|
||||||
|
fn pcs_display(forced: bool) -> Vec<u8> {
|
||||||
|
let mut d = vec![0u8; 18];
|
||||||
|
d[0] = PCS_SEG;
|
||||||
|
d[PCS_NUM_OBJECTS_OFF] = 1;
|
||||||
|
d[PCS_FLAGS_OFF] = if forced { PCS_FORCED_FLAG } else { 0 };
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap an elementary payload in one 192-byte BD-TS PES packet (PUSI, PTS
|
||||||
|
/// present) on `pid`. `cc` is the 4-bit continuity counter.
|
||||||
|
fn bd_pes_packet(pid: u16, cc: u8, es: &[u8]) -> Vec<u8> {
|
||||||
|
let mut pkt = vec![0u8; 192];
|
||||||
|
// pkt[0..4] = TP_extra_header (zeros). TS packet starts at pkt[4].
|
||||||
|
pkt[4] = 0x47; // sync
|
||||||
|
pkt[5] = 0x40 | ((pid >> 8) & 0x1F) as u8; // PUSI + PID high 5 bits
|
||||||
|
pkt[6] = (pid & 0xFF) as u8; // PID low 8 bits
|
||||||
|
pkt[7] = 0x10 | (cc & 0x0F); // adaptation=payload-only + continuity counter
|
||||||
|
// PES header (at ts payload = pkt[8..]): 00 00 01 stream_id len flags.
|
||||||
|
let p = 8;
|
||||||
|
pkt[p] = 0x00;
|
||||||
|
pkt[p + 1] = 0x00;
|
||||||
|
pkt[p + 2] = 0x01;
|
||||||
|
pkt[p + 3] = 0xBD; // private_stream_1 (carries the standard PES extension)
|
||||||
|
pkt[p + 4] = 0x00; // PES packet length hi (0 = unbounded; ignored by demux)
|
||||||
|
pkt[p + 5] = 0x00; // PES packet length lo
|
||||||
|
pkt[p + 6] = 0x80; // flags1 ('10' marker)
|
||||||
|
pkt[p + 7] = 0x80; // flags2 → PTS present
|
||||||
|
pkt[p + 8] = 0x05; // PES_header_data_length = 5 (one PTS)
|
||||||
|
// 5-byte PTS with the mandatory marker bits (bytes 0,2,4 low bit = 1).
|
||||||
|
pkt[p + 9] = 0x21;
|
||||||
|
pkt[p + 10] = 0x00;
|
||||||
|
pkt[p + 11] = 0x01;
|
||||||
|
pkt[p + 12] = 0x00;
|
||||||
|
pkt[p + 13] = 0x01;
|
||||||
|
let es_off = p + 14; // ES data follows the 14-byte PES header
|
||||||
|
let n = es.len().min(192 - es_off);
|
||||||
|
pkt[es_off..es_off + n].copy_from_slice(&es[..n]);
|
||||||
|
pkt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two BD-TS PES on `pid`: the FIRST carries `es` (the observed display set);
|
||||||
|
/// the second (a fresh PUSI) exists only to flush the first PES out of the
|
||||||
|
/// demuxer — the probe never calls `TsDemuxer::flush`, so an open PES stays
|
||||||
|
/// buffered until the next PES start arrives.
|
||||||
|
fn ts_stream(pid: u16, es: &[u8]) -> Vec<u8> {
|
||||||
|
let mut s = bd_pes_packet(pid, 0, es);
|
||||||
|
s.extend_from_slice(&bd_pes_packet(pid, 1, &pcs_display(false)));
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forced_display_sets_apply_forced_verdict() {
|
||||||
|
// Feed REAL synthetic PGS bytes through the full demux→parse→observe→apply
|
||||||
|
// path: a forced display set must flip a vendor-not-forced PGS track to
|
||||||
|
// forced. Mutation guard: inverting ForcedTracker::is_forced flips this.
|
||||||
|
let pid = 0x1200u16;
|
||||||
|
let mut reader = TsReader {
|
||||||
|
data: ts_stream(pid, &pcs_display(true)),
|
||||||
|
pos: 0,
|
||||||
|
};
|
||||||
|
let mut title = pgs_title(pid, false); // vendor label says NOT forced
|
||||||
|
probe_and_set_forced(&mut reader, &mut title);
|
||||||
|
let Stream::Subtitle(s) = &title.streams[0] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
s.forced,
|
||||||
|
"an all-forced PGS track → forced verdict applied onto the stream"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonforced_display_sets_clear_forced_verdict() {
|
||||||
|
// A non-forced display set observed on the wire overrides a vendor-forced
|
||||||
|
// label → the track settles as not-forced.
|
||||||
|
let pid = 0x1200u16;
|
||||||
|
let mut reader = TsReader {
|
||||||
|
data: ts_stream(pid, &pcs_display(false)),
|
||||||
|
pos: 0,
|
||||||
|
};
|
||||||
|
let mut title = pgs_title(pid, true); // vendor label says forced
|
||||||
|
probe_and_set_forced(&mut reader, &mut title);
|
||||||
|
let Stream::Subtitle(s) = &title.streams[0] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!s.forced,
|
||||||
|
"a non-forced display set observed → forced verdict cleared"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_pgs_streams_is_noop() {
|
||||||
|
// A title with no PGS subtitle streams is a no-op (the reader is never
|
||||||
|
// touched — a DVD/VobSub or audio-only title).
|
||||||
|
let mut reader = ZeroReader { served: 0, cap: 0 };
|
||||||
|
let mut title = pgs_title(0x1200, false);
|
||||||
|
// Swap the PGS sub for an audio stream so there are no PGS PIDs.
|
||||||
|
title.streams.clear();
|
||||||
|
probe_and_set_forced(&mut reader, &mut title);
|
||||||
|
assert_eq!(reader.served, 0, "no PGS PIDs → no reads");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,232 +0,0 @@
|
|||||||
//! `Disc::sweep`'s consumer-side `Sink<WorkItem>`.
|
|
||||||
//!
|
|
||||||
//! Background: the original sweep loop runs strictly serialised —
|
|
||||||
//! SCSI read → decrypt → seek + write → mapfile.record → next iter.
|
|
||||||
//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and
|
|
||||||
//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync
|
|
||||||
//! 5-15 ms) adds another batch's worth of latency. The drive idles
|
|
||||||
//! during the post-read work; throughput tops out at the *sum* of
|
|
||||||
//! both costs.
|
|
||||||
//!
|
|
||||||
//! A producer/consumer split overlaps the two stages on the generic
|
|
||||||
//! [`crate::io::Pipeline`] + [`crate::io::Sink`] primitive. This module
|
|
||||||
//! is the sweep-specific `Sink` impl; the producer-side state machine
|
|
||||||
//! (read_error context, decrypt, set_speed, halt) stays in
|
|
||||||
//! `Disc::sweep` in `disc/mod.rs`.
|
|
||||||
//!
|
|
||||||
//! Correctness invariants preserved:
|
|
||||||
//! - Mapfile is single-writer (consumer-only). No locking.
|
|
||||||
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
|
||||||
//! - `set_speed` calls happen on the producer thread (same thread that
|
|
||||||
//! owns the `SectorSource`). No new SCSI concurrency.
|
|
||||||
//! - Per-iteration ordering of file-write → mapfile-record is kept
|
|
||||||
//! intact in the consumer (write before record), so the on-disk
|
|
||||||
//! invariant "mapfile only marks Finished what the file has
|
|
||||||
//! received" survives a crash mid-pass.
|
|
||||||
//! - Only one SCSI command is in flight at a time; error-path timing
|
|
||||||
//! is identical and no new retry logic is introduced.
|
|
||||||
|
|
||||||
use std::io::{Seek, SeekFrom, Write};
|
|
||||||
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
|
||||||
|
|
||||||
use crate::error::Error;
|
|
||||||
use crate::io::{Flow, Sink};
|
|
||||||
|
|
||||||
use super::mapfile::{MapStats, Mapfile, SectorStatus};
|
|
||||||
|
|
||||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
|
|
||||||
/// matches the existing zero_gap chunk size used by the pre-split
|
|
||||||
/// sweep loop.
|
|
||||||
const ZERO_CHUNK: usize = 64 * 1024;
|
|
||||||
|
|
||||||
/// Producer → Consumer messages. The consumer applies these in FIFO
|
|
||||||
/// order; ordering of file writes and mapfile records across items is
|
|
||||||
/// preserved.
|
|
||||||
pub(super) enum WorkItem {
|
|
||||||
/// Successful batch read. Producer has already decrypted `buf` if
|
|
||||||
/// `opts.decrypt` was set. Consumer writes `buf` at `pos` and
|
|
||||||
/// records the range as `Finished`.
|
|
||||||
Good { pos: u64, buf: Vec<u8> },
|
|
||||||
|
|
||||||
/// Bisect inner-loop good single sector (already decrypted by the
|
|
||||||
/// producer). 2048 bytes.
|
|
||||||
BisectGood { pos: u64, buf: Box<[u8; 2048]> },
|
|
||||||
|
|
||||||
/// Bisect inner-loop bad single sector. Consumer writes 2048
|
|
||||||
/// zeros at `pos` and records the sector as `NonTrimmed`.
|
|
||||||
BisectBad { pos: u64 },
|
|
||||||
|
|
||||||
/// Whole-batch zero-fill (failed batch on `SkipBlock`, or the
|
|
||||||
/// failed batch portion of `JumpAhead`). Consumer streams zeros
|
|
||||||
/// across `[pos, pos+len)` and records the range as `NonTrimmed`.
|
|
||||||
SkipFill { pos: u64, len: u64 },
|
|
||||||
|
|
||||||
/// Gap fill following a `JumpAhead`. Same effect as `SkipFill`;
|
|
||||||
/// distinguished only so future logging / instrumentation can
|
|
||||||
/// 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
|
|
||||||
/// drained the previous snapshot, the new one is silently
|
|
||||||
/// dropped — the producer's local cache stays current enough.
|
|
||||||
StatsRequest,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Snapshot the consumer sends back to the producer for the progress
|
|
||||||
/// callback.
|
|
||||||
pub(super) struct ProgressSnapshot {
|
|
||||||
pub stats: MapStats,
|
|
||||||
pub bad_ranges: Vec<(u64, u64)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Final summary returned by the consumer thread on shutdown — what
|
|
||||||
/// `SweepSink::close` produces, surfaced to the producer via
|
|
||||||
/// `Pipeline::finish`.
|
|
||||||
pub(super) struct ConsumerSummary {
|
|
||||||
pub stats: MapStats,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drain any pending progress snapshots from the consumer. Returns
|
|
||||||
/// the most recent one, if any. The producer caches it and uses it
|
|
||||||
/// for subsequent progress callbacks until a fresh one arrives.
|
|
||||||
pub(super) fn try_recv_progress(rx: &Receiver<ProgressSnapshot>) -> Option<ProgressSnapshot> {
|
|
||||||
let mut latest = None;
|
|
||||||
while let Ok(snap) = rx.try_recv() {
|
|
||||||
latest = Some(snap);
|
|
||||||
}
|
|
||||||
latest
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Sink<WorkItem>` for sweep. Owns the writeback file + mapfile +
|
|
||||||
/// progress back-channel. `apply` carries the file-write +
|
|
||||||
/// mapfile.record per item; `close` drains the writeback pipeline,
|
|
||||||
/// fsyncs the ISO, and flushes the mapfile.
|
|
||||||
pub(super) struct SweepSink {
|
|
||||||
file: crate::io::WritebackFile,
|
|
||||||
map: Mapfile,
|
|
||||||
/// `sync_all`-on-failure-is-an-error iff the output is a regular
|
|
||||||
/// file. `/dev/null` and pipes always fail `sync_all`; that's not
|
|
||||||
/// a real error.
|
|
||||||
is_regular: bool,
|
|
||||||
/// Back-channel for `StatsRequest` responses. The producer caches
|
|
||||||
/// the latest snapshot and uses it for the progress callback;
|
|
||||||
/// dropped sends on a full channel are by design.
|
|
||||||
prog_tx: SyncSender<ProgressSnapshot>,
|
|
||||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. Held
|
|
||||||
/// in the sink so each apply call doesn't reallocate.
|
|
||||||
zero: Box<[u8; ZERO_CHUNK]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SweepSink {
|
|
||||||
/// Construct a new `SweepSink` plus the matching progress
|
|
||||||
/// receiver. Channel depth on the back-channel is `1` — the
|
|
||||||
/// producer's cache is the source of truth between snapshots.
|
|
||||||
pub(super) fn new(
|
|
||||||
file: crate::io::WritebackFile,
|
|
||||||
map: Mapfile,
|
|
||||||
is_regular: bool,
|
|
||||||
) -> (Self, Receiver<ProgressSnapshot>) {
|
|
||||||
let (prog_tx, prog_rx) = sync_channel::<ProgressSnapshot>(1);
|
|
||||||
let sink = SweepSink {
|
|
||||||
file,
|
|
||||||
map,
|
|
||||||
is_regular,
|
|
||||||
prog_tx,
|
|
||||||
zero: Box::new([0u8; ZERO_CHUNK]),
|
|
||||||
};
|
|
||||||
(sink, prog_rx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sink<WorkItem> for SweepSink {
|
|
||||||
type Output = ConsumerSummary;
|
|
||||||
|
|
||||||
fn apply(&mut self, item: WorkItem) -> Result<Flow, Error> {
|
|
||||||
match item {
|
|
||||||
WorkItem::Good { pos, buf } => {
|
|
||||||
// Decrypt is on the producer; consumer assumes plaintext.
|
|
||||||
let len = buf.len() as u64;
|
|
||||||
self.file.seek(SeekFrom::Start(pos))?;
|
|
||||||
self.file.write_all(&buf)?;
|
|
||||||
self.map.record(pos, len, SectorStatus::Finished)?;
|
|
||||||
}
|
|
||||||
WorkItem::BisectGood { pos, buf } => {
|
|
||||||
self.file.seek(SeekFrom::Start(pos))?;
|
|
||||||
self.file.write_all(&buf[..])?;
|
|
||||||
self.map.record(pos, 2048, SectorStatus::Finished)?;
|
|
||||||
}
|
|
||||||
WorkItem::BisectBad { pos } => {
|
|
||||||
self.file.seek(SeekFrom::Start(pos))?;
|
|
||||||
self.file.write_all(&self.zero[..2048])?;
|
|
||||||
self.map.record(pos, 2048, SectorStatus::NonTrimmed)?;
|
|
||||||
}
|
|
||||||
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
|
|
||||||
self.file.seek(SeekFrom::Start(pos))?;
|
|
||||||
// Subsequent writes are sequential; `WritebackFile`'s
|
|
||||||
// seek-elision keeps them on the writeback pipeline path.
|
|
||||||
let mut filled = 0u64;
|
|
||||||
while filled < len {
|
|
||||||
let chunk = (len - filled).min(self.zero.len() as u64) as usize;
|
|
||||||
self.file.write_all(&self.zero[..chunk])?;
|
|
||||||
filled += chunk as u64;
|
|
||||||
}
|
|
||||||
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
|
|
||||||
// ahead of the sweep head, not damage; including it made the live
|
|
||||||
// located drilldown (at-risk movie time + range count) treat the
|
|
||||||
// whole unread disc as confirmed damage, so at sweep start it
|
|
||||||
// showed ~full-movie at-risk and melted to 0 as the sweep
|
|
||||||
// progressed. Matches the one-shot progress path, which already
|
|
||||||
// excludes NonTried.
|
|
||||||
let bad_ranges = self.map.ranges_with(&[
|
|
||||||
SectorStatus::NonTrimmed,
|
|
||||||
SectorStatus::Unreadable,
|
|
||||||
SectorStatus::NonScraped,
|
|
||||||
]);
|
|
||||||
// Best-effort: drop on backpressure; producer's cache
|
|
||||||
// stays current enough.
|
|
||||||
let _ = self
|
|
||||||
.prog_tx
|
|
||||||
.try_send(ProgressSnapshot { stats, bad_ranges });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Flow::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(mut self) -> Result<Self::Output, Error> {
|
|
||||||
// Drain the writeback pipeline + fsync the ISO, then persist
|
|
||||||
// any pending mapfile state. Same finalisation order as the
|
|
||||||
// pre-Pipeline consumer loop.
|
|
||||||
if let Err(e) = self.file.sync_all() {
|
|
||||||
if self.is_regular {
|
|
||||||
return Err(Error::IoError { source: e });
|
|
||||||
}
|
|
||||||
// Non-regular outputs (/dev/null, pipes) always fail
|
|
||||||
// sync_all; that's not a real error.
|
|
||||||
}
|
|
||||||
self.map.flush()?;
|
|
||||||
|
|
||||||
Ok(ConsumerSummary {
|
|
||||||
stats: self.map.stats(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1022
File diff suppressed because it is too large
Load Diff
+196
-2
@@ -4,7 +4,7 @@
|
|||||||
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
|
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
|
||||||
//! (through [`crate::unlock_bridge`]), and reads sectors.
|
//! (through [`crate::unlock_bridge`]), and reads sectors.
|
||||||
|
|
||||||
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
pub fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
||||||
match e {
|
match e {
|
||||||
Error::ScsiError { status, sense, .. } => (*status, *sense),
|
Error::ScsiError { status, sense, .. } => (*status, *sense),
|
||||||
Error::DiscRead { status, sense, .. } => (status.unwrap_or(0), *sense),
|
Error::DiscRead { status, sense, .. } => (status.unwrap_or(0), *sense),
|
||||||
@@ -61,8 +61,28 @@ const SPIN_UP_SETTLE_SECS: u64 = 10;
|
|||||||
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
|
const SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL: u8 = 0x1E;
|
||||||
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
||||||
const SCSI_MODE_SENSE: u8 = 0x5A;
|
const SCSI_MODE_SENSE: u8 = 0x5A;
|
||||||
|
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||||
const SCSI_REPORT_KEY: u8 = 0xA4;
|
const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||||
|
|
||||||
|
/// SBC/MMC Read-Write Error Recovery mode page (page code 0x01). We flip the
|
||||||
|
/// `PER` bit to make the drive REPORT a recovered read (via CHECK CONDITION +
|
||||||
|
/// sense key RECOVERED ERROR) instead of silently returning best-effort data as
|
||||||
|
/// GOOD status. On marginal/dirty media that silent-GOOD data can be
|
||||||
|
/// mis-corrected — a rip that "passed clean" but decoded with errors. With PER
|
||||||
|
/// on, freemkv sees the marginal read and re-reads it in Pass N (a loud miss,
|
||||||
|
/// never a silent commit). See `build_error_recovery_select_payload`.
|
||||||
|
const MODE_PAGE_ERROR_RECOVERY: u8 = 0x01;
|
||||||
|
/// Bit masks in the Read-Write Error Recovery flags byte (page byte 2).
|
||||||
|
const ERP_FLAG_TB: u8 = 0x20; // Transfer Block: still deliver the recovered data
|
||||||
|
const ERP_FLAG_PER: u8 = 0x04; // Post Error: report recovered errors
|
||||||
|
const ERP_FLAG_DTE: u8 = 0x02; // Data Terminate on Error: MUST be off (we want the data)
|
||||||
|
/// `Parameters Saveable` bit in a mode page's byte 0 — valid only on MODE SENSE;
|
||||||
|
/// must be cleared before echoing the page back in a MODE SELECT.
|
||||||
|
const MODE_PAGE_PS_BIT: u8 = 0x80;
|
||||||
|
/// MODE SENSE(10) parameter header length (bytes), preceding any block
|
||||||
|
/// descriptors and the mode pages.
|
||||||
|
const MODE10_HEADER_LEN: usize = 8;
|
||||||
|
|
||||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||||
pub struct Drive {
|
pub struct Drive {
|
||||||
scsi: Box<dyn ScsiTransport>,
|
scsi: Box<dyn ScsiTransport>,
|
||||||
@@ -463,7 +483,13 @@ impl Drive {
|
|||||||
// the stock riplock). A stock-mode drive with no firmware unlocker still
|
// the stock riplock). A stock-mode drive with no firmware unlocker still
|
||||||
// wants max speed. Best-effort: a failure here must NOT fail the rip.
|
// wants max speed. Best-effort: a failure here must NOT fail the rip.
|
||||||
if r.is_ok() {
|
if r.is_ok() {
|
||||||
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
|
self.set_speed(Self::SPEED_MAX_KBPS);
|
||||||
|
// Ask the drive to REPORT recovered/marginal reads rather than
|
||||||
|
// silently commit best-effort data as GOOD (the dirty-disc
|
||||||
|
// "passed-clean-but-decodes-with-errors" trap). Best-effort: a drive
|
||||||
|
// that doesn't honor it just keeps its defaults — no regression, and
|
||||||
|
// on a clean disc it changes nothing.
|
||||||
|
self.enable_recovered_error_reporting();
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "freemkv::drive",
|
target: "freemkv::drive",
|
||||||
@@ -599,6 +625,53 @@ impl Drive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the drive to REPORT recovered/marginal reads instead of silently
|
||||||
|
/// returning best-effort data as GOOD status. MODE SENSE the Read-Write
|
||||||
|
/// Error Recovery page, flip `PER` (and `TB` on / `DTE` off so we still get
|
||||||
|
/// the data), and MODE SELECT it back — preserving the drive's own retry
|
||||||
|
/// count and other bits.
|
||||||
|
///
|
||||||
|
/// Best-effort: a drive that doesn't support the page, or rejects the SELECT,
|
||||||
|
/// simply keeps its default behaviour — no regression, the rip proceeds. On a
|
||||||
|
/// clean disc this changes nothing (no recovered errors fire); it only
|
||||||
|
/// surfaces the marginal reads that a dirty disc would otherwise commit
|
||||||
|
/// silently. Returns whether the page was successfully written.
|
||||||
|
pub fn enable_recovered_error_reporting(&mut self) -> bool {
|
||||||
|
let Some(sense) = self.mode_sense_page(MODE_PAGE_ERROR_RECOVERY) else {
|
||||||
|
tracing::debug!(target: "freemkv::drive", "MODE SENSE error-recovery page unavailable; leaving drive defaults");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(payload) = build_error_recovery_select_payload(&sense) else {
|
||||||
|
tracing::debug!(target: "freemkv::drive", "error-recovery page malformed/short; leaving drive defaults");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// MODE SELECT(10): PF=1 (page format), parameter list length = payload.
|
||||||
|
let len = payload.len() as u16;
|
||||||
|
let cdb = [
|
||||||
|
SCSI_MODE_SELECT,
|
||||||
|
0x10, // PF=1, SP=0 (don't persist across power cycles)
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
(len >> 8) as u8,
|
||||||
|
len as u8,
|
||||||
|
0x00,
|
||||||
|
];
|
||||||
|
let mut buf = payload;
|
||||||
|
match self.checked_exec(&cdb, crate::scsi::DataDirection::ToDevice, &mut buf, 5_000) {
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!(target: "freemkv::drive", phase = "error_recovery", "recovered-error reporting enabled (PER=1) — marginal reads will surface instead of committing silently");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(target: "freemkv::drive", error = %e, "MODE SELECT error-recovery page rejected; leaving drive defaults");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read vendor-specific READ BUFFER data.
|
/// Read vendor-specific READ BUFFER data.
|
||||||
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
|
pub fn read_buffer(&mut self, mode: u8, buffer_id: u8, length: u16) -> Option<Vec<u8>> {
|
||||||
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
|
let cdb = crate::scsi::build_read_buffer(mode, buffer_id, 0, length as u32);
|
||||||
@@ -878,6 +951,9 @@ impl Drive {
|
|||||||
decode_read_capacity(&buf, result.bytes_transferred)
|
decode_read_capacity(&buf, result.bytes_transferred)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SET CD SPEED "use the drive's maximum" sentinel (0xFFFF KB/s per MMC).
|
||||||
|
pub const SPEED_MAX_KBPS: u16 = 0xFFFF;
|
||||||
|
|
||||||
pub fn set_speed(&mut self, speed_kbs: u16) {
|
pub fn set_speed(&mut self, speed_kbs: u16) {
|
||||||
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
||||||
let mut dummy = [0u8; 0];
|
let mut dummy = [0u8; 0];
|
||||||
@@ -1101,6 +1177,46 @@ fn select_drive_with_media(drives: impl Iterator<Item = Drive>) -> Option<Drive>
|
|||||||
fallback
|
fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turn a MODE SENSE(10) Read-Write Error Recovery page response into the
|
||||||
|
/// payload for a MODE SELECT(10) that enables recovered-error REPORTING —
|
||||||
|
/// preserving every other bit (notably the drive's own read-retry count).
|
||||||
|
///
|
||||||
|
/// Pure so the bit-twiddling is unit-tested without a drive. Steps:
|
||||||
|
/// - locate the page after the 8-byte header + block descriptors (bytes 6-7);
|
||||||
|
/// - verify it is page 0x01 with a flags byte present;
|
||||||
|
/// - in the flags byte: set `PER` (report) and `TB` (still deliver the data),
|
||||||
|
/// clear `DTE` (don't terminate the transfer on the recovered error);
|
||||||
|
/// - clear the page's `PS` bit (valid only on SENSE) and zero the header's
|
||||||
|
/// mode-data-length field (reserved on SELECT).
|
||||||
|
///
|
||||||
|
/// Returns `None` (caller leaves the drive at its defaults) when the response is
|
||||||
|
/// too short or isn't the error-recovery page — never panics on adversarial
|
||||||
|
/// bytes.
|
||||||
|
fn build_error_recovery_select_payload(sense: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
if sense.len() < MODE10_HEADER_LEN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let block_desc_len = u16::from_be_bytes([sense[6], sense[7]]) as usize;
|
||||||
|
let page_off = MODE10_HEADER_LEN.checked_add(block_desc_len)?;
|
||||||
|
// Need page byte 0 (code), byte 1 (length), byte 2 (flags).
|
||||||
|
if page_off.checked_add(3)? > sense.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if sense[page_off] & 0x3F != MODE_PAGE_ERROR_RECOVERY {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut payload = sense.to_vec();
|
||||||
|
// Header: mode-data-length is reserved on SELECT — zero it.
|
||||||
|
payload[0] = 0;
|
||||||
|
payload[1] = 0;
|
||||||
|
// Page byte 0: clear PS (SENSE-only).
|
||||||
|
payload[page_off] &= !MODE_PAGE_PS_BIT;
|
||||||
|
// Flags byte: PER on, TB on, DTE off. Retry count (next byte) untouched.
|
||||||
|
payload[page_off + 2] |= ERP_FLAG_PER | ERP_FLAG_TB;
|
||||||
|
payload[page_off + 2] &= !ERP_FLAG_DTE;
|
||||||
|
Some(payload)
|
||||||
|
}
|
||||||
|
|
||||||
/// Decode a READ CAPACITY (10) response into a sector count.
|
/// Decode a READ CAPACITY (10) response into a sector count.
|
||||||
///
|
///
|
||||||
/// A short transfer (`bytes_transferred < 4`, which would leave the high
|
/// A short transfer (`bytes_transferred < 4`, which would leave the high
|
||||||
@@ -1276,6 +1392,84 @@ mod command_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||||
|
|
||||||
|
/// A minimal MODE SENSE(10) response carrying the Read-Write Error Recovery
|
||||||
|
/// page (0x01) with the given flags byte and retry count, no block
|
||||||
|
/// descriptors. `ps` sets the page's PS bit (SENSE-only), which the SELECT
|
||||||
|
/// payload must clear.
|
||||||
|
fn mode_sense_error_recovery(flags: u8, retry: u8, ps: bool) -> Vec<u8> {
|
||||||
|
let mut v = vec![0u8; MODE10_HEADER_LEN + 12];
|
||||||
|
// Header: nonzero mode-data-length (must be zeroed on SELECT); no block
|
||||||
|
// descriptors.
|
||||||
|
v[0] = 0x00;
|
||||||
|
v[1] = 0x22;
|
||||||
|
v[6] = 0x00;
|
||||||
|
v[7] = 0x00; // block descriptor length = 0
|
||||||
|
let po = MODE10_HEADER_LEN;
|
||||||
|
v[po] = MODE_PAGE_ERROR_RECOVERY | if ps { MODE_PAGE_PS_BIT } else { 0 };
|
||||||
|
v[po + 1] = 0x0A; // page length
|
||||||
|
v[po + 2] = flags; // error-recovery flags
|
||||||
|
v[po + 3] = retry; // read retry count
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_recovery_payload_sets_per_tb_clears_dte_ps_preserves_retry() {
|
||||||
|
// Start with PER off, DTE on, PS on, a specific retry count. The SELECT
|
||||||
|
// payload must flip PER on, TB on, DTE off, clear PS, zero the header
|
||||||
|
// mode-data-length, and leave the retry count untouched.
|
||||||
|
let sense = mode_sense_error_recovery(ERP_FLAG_DTE, 0x2C, true);
|
||||||
|
let out = build_error_recovery_select_payload(&sense).expect("valid page");
|
||||||
|
let po = MODE10_HEADER_LEN;
|
||||||
|
assert_eq!(out[0], 0, "header mode-data-length zeroed for SELECT");
|
||||||
|
assert_eq!(out[1], 0);
|
||||||
|
assert_eq!(out[po] & MODE_PAGE_PS_BIT, 0, "PS cleared for SELECT");
|
||||||
|
assert_eq!(out[po] & 0x3F, MODE_PAGE_ERROR_RECOVERY, "still page 0x01");
|
||||||
|
assert_eq!(out[po + 2] & ERP_FLAG_PER, ERP_FLAG_PER, "PER set");
|
||||||
|
assert_eq!(
|
||||||
|
out[po + 2] & ERP_FLAG_TB,
|
||||||
|
ERP_FLAG_TB,
|
||||||
|
"TB set (still get data)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
out[po + 2] & ERP_FLAG_DTE,
|
||||||
|
0,
|
||||||
|
"DTE cleared (don't terminate)"
|
||||||
|
);
|
||||||
|
assert_eq!(out[po + 3], 0x2C, "read retry count preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_recovery_payload_honors_block_descriptor_offset() {
|
||||||
|
// With an 8-byte block descriptor between header and page, the function
|
||||||
|
// must locate the page at header+desc, not a fixed offset.
|
||||||
|
let mut sense = vec![0u8; MODE10_HEADER_LEN + 8 + 12];
|
||||||
|
sense[7] = 8; // block descriptor length
|
||||||
|
let po = MODE10_HEADER_LEN + 8;
|
||||||
|
sense[po] = MODE_PAGE_ERROR_RECOVERY;
|
||||||
|
sense[po + 1] = 0x0A;
|
||||||
|
sense[po + 2] = 0x00;
|
||||||
|
let out = build_error_recovery_select_payload(&sense).expect("valid");
|
||||||
|
assert_eq!(
|
||||||
|
out[po + 2] & ERP_FLAG_PER,
|
||||||
|
ERP_FLAG_PER,
|
||||||
|
"PER set at the descriptor-offset page"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_recovery_payload_rejects_wrong_or_short_page() {
|
||||||
|
// Wrong page code → None (leave drive at defaults).
|
||||||
|
let mut wrong = mode_sense_error_recovery(0, 0, false);
|
||||||
|
wrong[MODE10_HEADER_LEN] = 0x08; // page 0x08 (caching), not 0x01
|
||||||
|
assert!(build_error_recovery_select_payload(&wrong).is_none());
|
||||||
|
// Too short to hold the header → None, no panic.
|
||||||
|
assert!(build_error_recovery_select_payload(&[0u8; 4]).is_none());
|
||||||
|
// Header claims a block descriptor that runs off the buffer → None.
|
||||||
|
let mut bad = mode_sense_error_recovery(0, 0, false);
|
||||||
|
bad[7] = 0xF0; // descriptor length way past the buffer
|
||||||
|
assert!(build_error_recovery_select_payload(&bad).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
/// Mock transport: returns a fixed data payload (copied into the
|
/// Mock transport: returns a fixed data payload (copied into the
|
||||||
/// caller's buffer, truncated to fit) on every `execute()`.
|
/// caller's buffer, truncated to fit) on every `execute()`.
|
||||||
struct FixedTransport {
|
struct FixedTransport {
|
||||||
|
|||||||
+129
-6
@@ -59,7 +59,9 @@ pub const E_MKV_INVALID: u16 = 6008;
|
|||||||
pub const E_NO_STREAMS: u16 = 6009;
|
pub const E_NO_STREAMS: u16 = 6009;
|
||||||
pub const E_HALTED: u16 = 6010;
|
pub const E_HALTED: u16 = 6010;
|
||||||
pub const E_MAPFILE_INVALID: u16 = 6011;
|
pub const E_MAPFILE_INVALID: u16 = 6011;
|
||||||
|
pub const E_SELECTION_PID_UNKNOWN: u16 = 6014;
|
||||||
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
|
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
|
||||||
|
pub const E_UDF_NOT_FILESYSTEM: u16 = 6013;
|
||||||
|
|
||||||
// AACS (7xxx)
|
// AACS (7xxx)
|
||||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||||
@@ -150,6 +152,14 @@ pub const E_NETWORK_ADDR_BLOCKED: u16 = 9022;
|
|||||||
/// frame dropped before the first keyframe) cannot report success.
|
/// frame dropped before the first keyframe) cannot report success.
|
||||||
pub const E_MUX_EMPTY: u16 = 9023;
|
pub const E_MUX_EMPTY: u16 = 9023;
|
||||||
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
|
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
|
||||||
|
/// `mp4://` output but the title has no (primary) video track to carry.
|
||||||
|
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
|
||||||
|
/// `mp4://` SOURCE file is malformed/truncated (bad box structure, sample table,
|
||||||
|
/// or offsets) — the MP4 demuxer could not parse it.
|
||||||
|
pub const E_MP4_INVALID: u16 = 9049;
|
||||||
|
/// `mp4://` video track is missing its codec-configuration record
|
||||||
|
/// (`hvcC`/`avcC`), without which the sample entry can't be written.
|
||||||
|
pub const E_MP4_MISSING_CODEC_PRIVATE: u16 = 9050;
|
||||||
/// READ CAPACITY returned a short or overflowing transfer.
|
/// READ CAPACITY returned a short or overflowing transfer.
|
||||||
pub const E_DISC_CAPACITY_MALFORMED: u16 = 9047;
|
pub const E_DISC_CAPACITY_MALFORMED: u16 = 9047;
|
||||||
|
|
||||||
@@ -264,6 +274,14 @@ pub enum Error {
|
|||||||
UdfNotFound {
|
UdfNotFound {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
|
/// The reader was addressable but the bytes are structurally NOT a UDF
|
||||||
|
/// filesystem — a deterministic tag/format mismatch (e.g. no Anchor Volume
|
||||||
|
/// Descriptor Pointer at sector 256, no partition descriptor, no File Set
|
||||||
|
/// Descriptor). Distinct from [`Error::DiscRead`] (a transient I/O fault):
|
||||||
|
/// this is a stable property of the media, not something a retry fixes. Lets
|
||||||
|
/// callers (notably FMTS key resolution) treat "not a UDF/FMTS disc" as a
|
||||||
|
/// clean negative while still failing loud on a real read fault.
|
||||||
|
UdfNotFilesystem,
|
||||||
/// A `SectorSource` caller passed a destination buffer smaller than one
|
/// A `SectorSource` caller passed a destination buffer smaller than one
|
||||||
/// 2048-byte sector. A contract violation on the public reader API —
|
/// 2048-byte sector. A contract violation on the public reader API —
|
||||||
/// returned instead of panicking on the slice.
|
/// returned instead of panicking on the slice.
|
||||||
@@ -275,6 +293,12 @@ pub enum Error {
|
|||||||
IfoParse,
|
IfoParse,
|
||||||
MkvInvalid,
|
MkvInvalid,
|
||||||
NoStreams,
|
NoStreams,
|
||||||
|
/// A [`crate::StreamSelection`] listed a PID that does not exist in the
|
||||||
|
/// title's declared streams — a caller bug (e.g. a stale scan), reported
|
||||||
|
/// loudly rather than silently producing an MKV missing a requested track.
|
||||||
|
SelectionPidUnknown {
|
||||||
|
pid: u16,
|
||||||
|
},
|
||||||
/// ddrescue mapfile parse failed. `kind` is a stable, language-neutral
|
/// ddrescue mapfile parse failed. `kind` is a stable, language-neutral
|
||||||
/// identifier (e.g. `"status_char"`, `"hex"`); not a translatable
|
/// identifier (e.g. `"status_char"`, `"hex"`); not a translatable
|
||||||
/// English message.
|
/// English message.
|
||||||
@@ -362,12 +386,10 @@ pub enum Error {
|
|||||||
AacsBusKeyUnavailable,
|
AacsBusKeyUnavailable,
|
||||||
|
|
||||||
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
|
/// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment
|
||||||
/// (variant) key is available to open them, and `BYPASS_FMTS_KEY` is `false`
|
/// (variant) key is available to open them. Raised UPFRONT — before the mux —
|
||||||
/// (strict mode). Raised UPFRONT — before the mux — exactly like a missing
|
/// exactly like a missing unit key, so a 2.1 disc that would rip with holes is
|
||||||
/// unit key, so a 2.1 disc that would rip with holes is refused rather than
|
/// refused rather than silently producing a forensic-holed output. (The mux
|
||||||
/// silently producing a forensic-holed output. When `BYPASS_FMTS_KEY` is
|
/// resolves the full forensic key set up front; a resolution gap fails here.)
|
||||||
/// `true` (the default today) this is never raised: the bulk decodes with the
|
|
||||||
/// unit key and the forensic segments are skipped as expected loss.
|
|
||||||
FmtsKeyMissing,
|
FmtsKeyMissing,
|
||||||
|
|
||||||
// Keydb (8xxx)
|
// Keydb (8xxx)
|
||||||
@@ -420,6 +442,12 @@ pub enum Error {
|
|||||||
/// every frame dropped before the first keyframe — fails loudly. The
|
/// every frame dropped before the first keyframe — fails loudly. The
|
||||||
/// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
|
/// `m2ts://` analogue of [`Error::MkvInvalid`]'s zero-frame guard.
|
||||||
MuxEmpty,
|
MuxEmpty,
|
||||||
|
/// `mp4://` target title has no primary video track to mux.
|
||||||
|
Mp4NoVideoTrack,
|
||||||
|
/// `mp4://` source file is malformed/truncated — the MP4 demuxer failed.
|
||||||
|
Mp4Invalid,
|
||||||
|
/// `mp4://` video track is missing its `hvcC`/`avcC` configuration record.
|
||||||
|
Mp4MissingCodecPrivate,
|
||||||
PesFrameTooLarge {
|
PesFrameTooLarge {
|
||||||
size: usize,
|
size: usize,
|
||||||
},
|
},
|
||||||
@@ -552,11 +580,13 @@ impl Error {
|
|||||||
Error::MplsParse => E_MPLS_PARSE,
|
Error::MplsParse => E_MPLS_PARSE,
|
||||||
Error::ClpiParse => E_CLPI_PARSE,
|
Error::ClpiParse => E_CLPI_PARSE,
|
||||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||||
|
Error::UdfNotFilesystem => E_UDF_NOT_FILESYSTEM,
|
||||||
Error::UdfBufferTooSmall => E_UDF_BUFFER_TOO_SMALL,
|
Error::UdfBufferTooSmall => E_UDF_BUFFER_TOO_SMALL,
|
||||||
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
||||||
Error::IfoParse => E_IFO_PARSE,
|
Error::IfoParse => E_IFO_PARSE,
|
||||||
Error::MkvInvalid => E_MKV_INVALID,
|
Error::MkvInvalid => E_MKV_INVALID,
|
||||||
Error::NoStreams => E_NO_STREAMS,
|
Error::NoStreams => E_NO_STREAMS,
|
||||||
|
Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
|
||||||
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
|
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
|
||||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||||
@@ -599,6 +629,9 @@ impl Error {
|
|||||||
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
|
Error::StreamUrlMissingPort { .. } => E_STREAM_URL_MISSING_PORT,
|
||||||
Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
|
Error::NetworkAddrBlocked { .. } => E_NETWORK_ADDR_BLOCKED,
|
||||||
Error::MuxEmpty => E_MUX_EMPTY,
|
Error::MuxEmpty => E_MUX_EMPTY,
|
||||||
|
Error::Mp4NoVideoTrack => E_MP4_NO_VIDEO_TRACK,
|
||||||
|
Error::Mp4Invalid => E_MP4_INVALID,
|
||||||
|
Error::Mp4MissingCodecPrivate => E_MP4_MISSING_CODEC_PRIVATE,
|
||||||
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
|
Error::PesFrameTooLarge { .. } => E_PES_FRAME_TOO_LARGE,
|
||||||
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
|
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
|
||||||
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
|
Error::PesTrackTooLarge { .. } => E_PES_TRACK_TOO_LARGE,
|
||||||
@@ -761,6 +794,9 @@ impl std::fmt::Display for Error {
|
|||||||
Error::InvalidCdbLength { len, max } => {
|
Error::InvalidCdbLength { len, max } => {
|
||||||
write!(f, "E{}: {}/{}", self.code(), len, max)
|
write!(f, "E{}: {}/{}", self.code(), len, max)
|
||||||
}
|
}
|
||||||
|
Error::SelectionPidUnknown { pid } => {
|
||||||
|
write!(f, "E{}: 0x{:04x}", self.code(), pid)
|
||||||
|
}
|
||||||
_ => write!(f, "E{}", self.code()),
|
_ => write!(f, "E{}", self.code()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -833,6 +869,12 @@ impl From<Error> for std::io::Error {
|
|||||||
// 9023 MuxEmpty: finish() reached with zero frames — the output
|
// 9023 MuxEmpty: finish() reached with zero frames — the output
|
||||||
// would be a header-only container. Treat as invalid output.
|
// would be a header-only container. Treat as invalid output.
|
||||||
E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
|
E_MUX_EMPTY => std::io::ErrorKind::InvalidData,
|
||||||
|
// mp4:// demux errors: a malformed/truncated source file
|
||||||
|
// (E_MP4_INVALID), or a source whose tracks the mux can't use — no
|
||||||
|
// video track / missing codec-private config. All are invalid data.
|
||||||
|
E_MP4_NO_VIDEO_TRACK | E_MP4_INVALID | E_MP4_MISSING_CODEC_PRIVATE => {
|
||||||
|
std::io::ErrorKind::InvalidData
|
||||||
|
}
|
||||||
// 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
|
// 9030 ExtentNotUnitAligned: a malformed/non-AACS-aligned
|
||||||
// extent was handed to the prefetch producer.
|
// extent was handed to the prefetch producer.
|
||||||
9030 => std::io::ErrorKind::InvalidInput,
|
9030 => std::io::ErrorKind::InvalidInput,
|
||||||
@@ -858,6 +900,61 @@ impl From<Error> for std::io::Error {
|
|||||||
/// Convenience alias for `Result<T, Error>`.
|
/// Convenience alias for `Result<T, Error>`.
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// The numeric error code carried by an [`io::Error`](std::io::Error) that was
|
||||||
|
/// produced from an [`Error`], or `None` if it carries none.
|
||||||
|
///
|
||||||
|
/// [`From<Error> for io::Error`] is the ONLY path from a typed [`Error`] to an
|
||||||
|
/// `io::Error` in this crate, and it stringifies (`io::Error::new(kind, msg)`
|
||||||
|
/// where `msg` is the `Error`'s `E<code>[: …]` [`Display`](std::fmt::Display)
|
||||||
|
/// string) rather than boxing the typed value — no code path constructs an
|
||||||
|
/// `io::Error` that still holds a `crate::error::Error` via `get_ref`. So the
|
||||||
|
/// only recognised shape is the round-tripped `E<code>` message prefix.
|
||||||
|
fn io_error_code(e: &std::io::Error) -> Option<u16> {
|
||||||
|
// Round-tripped: `From<Error> for io::Error` stringifies as "E<code>[: …]".
|
||||||
|
let s = e.to_string();
|
||||||
|
let digits = s.strip_prefix('E')?;
|
||||||
|
let end = digits
|
||||||
|
.find(|c: char| !c.is_ascii_digit())
|
||||||
|
.unwrap_or(digits.len());
|
||||||
|
digits.get(..end)?.parse::<u16>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a per-title mux failure is a *skippable title stub* — a
|
||||||
|
/// copy-protected-but-uncrackable title ([`Error::CssKeyMissing`]) or a title
|
||||||
|
/// that produced no muxable frames ([`Error::MkvInvalid`], an empty nav/menu
|
||||||
|
/// PGC stub). An all-titles rip skips such a title and finishes the rest;
|
||||||
|
/// every other error stays fatal.
|
||||||
|
///
|
||||||
|
/// This replaces the CLI's `E7023`/`E6008` string-match with a typed check on
|
||||||
|
/// the [`io::Error`](std::io::Error) `mux_stream` returns.
|
||||||
|
pub fn is_skippable_title_stub(e: &std::io::Error) -> bool {
|
||||||
|
matches!(io_error_code(e), Some(E_MKV_INVALID | E_CSS_KEY_MISSING))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an [`io::Error`](std::io::Error) is a cooperative user stop
|
||||||
|
/// ([`Error::Halted`], code [`E_HALTED`]) — vs a structural failure. A stop is
|
||||||
|
/// resumable, not a rip failure: `mux_stream` maps a mid-run halt to
|
||||||
|
/// `completed = false`, and consumers preserve staging rather than quarantining.
|
||||||
|
/// Typed replacement for the consumers' `E<code>`-leading-token string match.
|
||||||
|
pub fn is_halt(e: &std::io::Error) -> bool {
|
||||||
|
io_error_code(e) == Some(E_HALTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an [`io::Error`](std::io::Error) is a **disc-level** key failure —
|
||||||
|
/// the disc as a whole cannot be decrypted, so EVERY title will fail the same
|
||||||
|
/// way. Distinct from a per-title skippable stub
|
||||||
|
/// ([`is_skippable_title_stub`]): `E_NO_DISC_KEY` (keydb present but no entry
|
||||||
|
/// for this disc), `E_KEYDB_LOAD` (no keydb at all), and `E_AACS_NO_KEYS` (no
|
||||||
|
/// usable AACS key material) are all whole-disc conditions. A multi-title rip
|
||||||
|
/// loop should stop immediately on this (fail-fast) rather than iterate every
|
||||||
|
/// title re-printing the same error.
|
||||||
|
pub fn is_disc_level_no_key(e: &std::io::Error) -> bool {
|
||||||
|
matches!(
|
||||||
|
io_error_code(e),
|
||||||
|
Some(E_NO_DISC_KEY | E_KEYDB_LOAD | E_AACS_NO_KEYS)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
impl Error {
|
impl Error {
|
||||||
/// Borrow the drive-returned SPC-4 sense triple if this error is a
|
/// Borrow the drive-returned SPC-4 sense triple if this error is a
|
||||||
/// [`Error::ScsiError`] carrying sense data. `None` for any other
|
/// [`Error::ScsiError`] carrying sense data. `None` for any other
|
||||||
@@ -952,6 +1049,25 @@ mod tests {
|
|||||||
//! match arms in `code()` / the From impl could silently miscategorize.
|
//! match arms in `code()` / the From impl could silently miscategorize.
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_skippable_title_stub_matches_only_the_two_stub_codes() {
|
||||||
|
// The two skippable per-title stub codes, round-tripped through io::Error
|
||||||
|
// exactly as `mux_stream` returns them.
|
||||||
|
let mkv: std::io::Error = Error::MkvInvalid.into();
|
||||||
|
let css: std::io::Error = Error::CssKeyMissing.into();
|
||||||
|
assert!(is_skippable_title_stub(&mkv));
|
||||||
|
assert!(is_skippable_title_stub(&css));
|
||||||
|
|
||||||
|
// A different coded error is NOT skippable (kills a "match anything with
|
||||||
|
// an E-code" mutant).
|
||||||
|
let nostreams: std::io::Error = Error::NoStreams.into();
|
||||||
|
assert!(!is_skippable_title_stub(&nostreams));
|
||||||
|
|
||||||
|
// A plain io::Error with no E-code prefix is not skippable.
|
||||||
|
let plain = std::io::Error::from(std::io::ErrorKind::BrokenPipe);
|
||||||
|
assert!(!is_skippable_title_stub(&plain));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn new_variants_have_distinct_codes() {
|
fn new_variants_have_distinct_codes() {
|
||||||
let codes = [
|
let codes = [
|
||||||
@@ -1195,6 +1311,7 @@ mod tests {
|
|||||||
E_HALTED,
|
E_HALTED,
|
||||||
E_MAPFILE_INVALID,
|
E_MAPFILE_INVALID,
|
||||||
E_UDF_BUFFER_TOO_SMALL,
|
E_UDF_BUFFER_TOO_SMALL,
|
||||||
|
E_UDF_NOT_FILESYSTEM,
|
||||||
E_AACS_NO_KEYS,
|
E_AACS_NO_KEYS,
|
||||||
E_AACS_CERT_SHORT,
|
E_AACS_CERT_SHORT,
|
||||||
E_AACS_AGID_ALLOC,
|
E_AACS_AGID_ALLOC,
|
||||||
@@ -1236,6 +1353,9 @@ mod tests {
|
|||||||
E_STREAM_URL_MISSING_PORT,
|
E_STREAM_URL_MISSING_PORT,
|
||||||
E_NETWORK_ADDR_BLOCKED,
|
E_NETWORK_ADDR_BLOCKED,
|
||||||
E_MUX_EMPTY,
|
E_MUX_EMPTY,
|
||||||
|
E_MP4_NO_VIDEO_TRACK,
|
||||||
|
E_MP4_INVALID,
|
||||||
|
E_MP4_MISSING_CODEC_PRIVATE,
|
||||||
E_PES_FRAME_TOO_LARGE,
|
E_PES_FRAME_TOO_LARGE,
|
||||||
E_PES_INVALID_MAGIC,
|
E_PES_INVALID_MAGIC,
|
||||||
E_PES_TRACK_TOO_LARGE,
|
E_PES_TRACK_TOO_LARGE,
|
||||||
@@ -1323,6 +1443,9 @@ mod tests {
|
|||||||
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
|
(Error::PipelineConsumerGone, E_PIPELINE_CONSUMER_GONE),
|
||||||
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
|
(Error::DiscCapacityOverflow, E_DISC_CAPACITY_OVERFLOW),
|
||||||
(Error::MuxEmpty, E_MUX_EMPTY),
|
(Error::MuxEmpty, E_MUX_EMPTY),
|
||||||
|
(Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK),
|
||||||
|
(Error::Mp4Invalid, E_MP4_INVALID),
|
||||||
|
(Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),
|
||||||
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
|
(Error::M2tsPacketMalformed, E_M2TS_PACKET_MALFORMED),
|
||||||
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
|
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
|
||||||
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
|
(Error::DiscCapacityMalformed, E_DISC_CAPACITY_MALFORMED),
|
||||||
|
|||||||
+46
-4
@@ -16,7 +16,7 @@
|
|||||||
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
|
/// (case-insensitive), then requires an even run of ASCII hex digits. Any
|
||||||
/// non-hex byte, or an odd length, yields `None`.
|
/// non-hex byte, or an odd length, yields `None`.
|
||||||
pub fn parse_hex_bytes(s: &str) -> Option<Vec<u8>> {
|
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();
|
let bytes = body.as_bytes();
|
||||||
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
|
// Empty → empty Vec (a legitimately-empty variable-length field); odd length
|
||||||
// is malformed. (`parse_hex_fixed` enforces a concrete length separately.)
|
// 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
|
/// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any
|
||||||
/// non-hex byte or a length mismatch.
|
/// non-hex byte or a length mismatch.
|
||||||
pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
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();
|
let bytes = body.as_bytes();
|
||||||
if bytes.len() != 2 * N {
|
if bytes.len() != 2 * N {
|
||||||
return None;
|
return None;
|
||||||
@@ -46,8 +46,33 @@ pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
|
|||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip a single leading `0x` / `0X` if present (case-insensitive).
|
/// Parse a hex string into a `u16`. Accepts an optional `0x`/`0X` prefix
|
||||||
fn strip_prefix(s: &str) -> &str {
|
/// (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")
|
s.strip_prefix("0x")
|
||||||
.or_else(|| s.strip_prefix("0X"))
|
.or_else(|| s.strip_prefix("0X"))
|
||||||
.unwrap_or(s)
|
.unwrap_or(s)
|
||||||
@@ -95,6 +120,23 @@ mod tests {
|
|||||||
assert_eq!(parse_hex_fixed::<16>(&s), None);
|
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]
|
#[test]
|
||||||
fn bytes_variable_length_and_odd_rejected() {
|
fn bytes_variable_length_and_odd_rejected() {
|
||||||
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
|
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
|
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
|
||||||
/// cache stays bounded the same way the write side does.
|
/// cache stays bounded the same way the write side does.
|
||||||
///
|
///
|
||||||
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
|
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
|
||||||
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
|
/// smaller windows (8 / 16 MiB) shorten the
|
||||||
/// kernel-readahead overlap and slow the producer; larger windows
|
/// kernel-readahead overlap and slow the producer; larger windows
|
||||||
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
|
||||||
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
|
||||||
|
|||||||
+2
-3
@@ -40,9 +40,8 @@ pub(crate) mod platform_macos;
|
|||||||
|
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|
||||||
pub(crate) use writeback_file::WritebackFile;
|
pub use writeback_file::WritebackFile;
|
||||||
|
|
||||||
pub use pipeline::{
|
pub use pipeline::{
|
||||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
|
||||||
WRITE_THROUGH_DEPTH,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-6
@@ -173,14 +173,9 @@ fn finish_with_grace<R: Send + 'static>(
|
|||||||
|
|
||||||
/// Default channel depth for callers without a specific reason to
|
/// Default channel depth for callers without a specific reason to
|
||||||
/// pick another value. Kept conservative (4) — most callers should
|
/// pick another value. Kept conservative (4) — most callers should
|
||||||
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
/// use WRITE_PIPELINE_DEPTH instead.
|
||||||
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
|
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
|
||||||
|
|
||||||
/// Read pipeline depth. Larger buffer compensates for drive variability
|
|
||||||
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
|
|
||||||
/// consumer blocks on write.
|
|
||||||
pub const READ_PIPELINE_DEPTH: usize = 32;
|
|
||||||
|
|
||||||
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
||||||
/// sync_file_range blocks; prevents producer from accumulating too much
|
/// sync_file_range blocks; prevents producer from accumulating too much
|
||||||
/// work while consumer waits for NFS to drain.
|
/// work while consumer waits for NFS to drain.
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ fn writeback_chunk_bytes() -> u64 {
|
|||||||
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
|
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct WritebackFile {
|
pub struct WritebackFile {
|
||||||
file: File,
|
file: File,
|
||||||
pipeline: WritebackPipeline,
|
pipeline: WritebackPipeline,
|
||||||
pos: u64,
|
pos: u64,
|
||||||
@@ -115,7 +115,7 @@ impl WritebackFile {
|
|||||||
/// once so the pipeline starts tracking from wherever the file
|
/// once so the pipeline starts tracking from wherever the file
|
||||||
/// already is (typically 0 for fresh files; non-zero for resumed
|
/// already is (typically 0 for fresh files; non-zero for resumed
|
||||||
/// or appended files).
|
/// or appended files).
|
||||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
pub fn new(mut file: File) -> io::Result<Self> {
|
||||||
let pos = file.stream_position()?;
|
let pos = file.stream_position()?;
|
||||||
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
|
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -136,7 +136,7 @@ impl WritebackFile {
|
|||||||
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
|
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
|
||||||
/// extents.
|
/// extents.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) fn create(path: &Path) -> io::Result<Self> {
|
pub fn create(path: &Path) -> io::Result<Self> {
|
||||||
let file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
Self::new(file)
|
Self::new(file)
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ impl WritebackFile {
|
|||||||
/// On platforms without an extent-preallocation primitive this is
|
/// On platforms without an extent-preallocation primitive this is
|
||||||
/// equivalent to `create` — the size hint is dropped after a debug
|
/// equivalent to `create` — the size hint is dropped after a debug
|
||||||
/// log.
|
/// log.
|
||||||
pub(crate) fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
pub fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||||
let file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
platform::preallocate(&file, size_bytes);
|
platform::preallocate(&file, size_bytes);
|
||||||
Self::new(file)
|
Self::new(file)
|
||||||
@@ -163,7 +163,7 @@ impl WritebackFile {
|
|||||||
/// wrap it. Mirrors `File::open` semantics for the writable case
|
/// wrap it. Mirrors `File::open` semantics for the writable case
|
||||||
/// — used by patch / resume paths that mutate an existing ISO in
|
/// — used by patch / resume paths that mutate an existing ISO in
|
||||||
/// place.
|
/// place.
|
||||||
pub(crate) fn open(path: &Path) -> io::Result<Self> {
|
pub fn open(path: &Path) -> io::Result<Self> {
|
||||||
let file = OpenOptions::new().write(true).open(path)?;
|
let file = OpenOptions::new().write(true).open(path)?;
|
||||||
Self::new(file)
|
Self::new(file)
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@ impl WritebackFile {
|
|||||||
/// completed. Callers needing crash-consistency (e.g. mux-finish
|
/// completed. Callers needing crash-consistency (e.g. mux-finish
|
||||||
/// then external commit/DB update) must not treat `Ok(())` as a
|
/// then external commit/DB update) must not treat `Ok(())` as a
|
||||||
/// durability barrier.
|
/// durability barrier.
|
||||||
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
|
pub fn sync_all(&mut self) -> io::Result<()> {
|
||||||
if self.seek_count > 0 {
|
if self.seek_count > 0 {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
|
|||||||
+499
-46
@@ -23,6 +23,64 @@ use crate::aacs::types::{UnitKey, Vid};
|
|||||||
use crate::disc::Key;
|
use crate::disc::Key;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
|
||||||
|
/// Minimum encrypted-content unit samples a single online key request must carry.
|
||||||
|
///
|
||||||
|
/// The key service identifies a key by which of the submitted units it decrypts,
|
||||||
|
/// so too few samples — especially on FMTS, where a segment interleaves several
|
||||||
|
/// variants at the unit level — can return a key that matches an incidental unit
|
||||||
|
/// rather than the one asked about (a false positive). This many distinct units
|
||||||
|
/// make the request unambiguous.
|
||||||
|
///
|
||||||
|
/// Canonical here (the base crate) so BOTH consumers agree on one value: the
|
||||||
|
/// online source in `freemkv-keysources` (which refuses to send an under-sampled
|
||||||
|
/// request) re-exports it, and libfreemkv's own FMTS forensic query
|
||||||
|
/// ([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the
|
||||||
|
/// reverse import (keysources depends on libfreemkv, not vice versa), so the
|
||||||
|
/// value lives at the lower layer both share.
|
||||||
|
pub const MIN_SAMPLE_UNITS: usize = 8;
|
||||||
|
|
||||||
|
/// A set of encrypted content-unit samples PROVEN to carry at least
|
||||||
|
/// [`MIN_SAMPLE_UNITS`] units — the online `/decode` request's proof-of-ownership.
|
||||||
|
///
|
||||||
|
/// "Parse, don't validate": the only constructor, [`DecodeSampleSet::new`], returns
|
||||||
|
/// `None` for an under-sized slice, so an online key request simply *cannot be built*
|
||||||
|
/// from too few samples. The runtime `len() < MIN_SAMPLE_UNITS` check that used to
|
||||||
|
/// live at the request site (and was silently forgotten by an under-sampling caller,
|
||||||
|
/// reading as "key service down") becomes a compile-time obligation: a request builder
|
||||||
|
/// that takes `&DecodeSampleSet` can never receive an unchecked `Vec`.
|
||||||
|
///
|
||||||
|
/// The *count* enforced here is a runtime property of the disc (how many encrypted
|
||||||
|
/// units it yields); the *requested* count is a caller-side compile-time constant that
|
||||||
|
/// callers pin to `MIN_SAMPLE_UNITS` (see e.g. autorip's `SAMPLE_UNITS`). Together the
|
||||||
|
/// two make under-sampling unrepresentable at the request boundary.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DecodeSampleSet(Vec<Vec<u8>>);
|
||||||
|
|
||||||
|
impl DecodeSampleSet {
|
||||||
|
/// Wrap `units` iff it carries at least [`MIN_SAMPLE_UNITS`] samples; `None`
|
||||||
|
/// otherwise (the caller then skips the online source rather than sending an
|
||||||
|
/// ambiguous request). This is the sole way to obtain a `DecodeSampleSet`.
|
||||||
|
pub fn new(units: Vec<Vec<u8>>) -> Option<Self> {
|
||||||
|
(units.len() >= MIN_SAMPLE_UNITS).then_some(Self(units))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The proven-sufficient samples. Guaranteed `>= MIN_SAMPLE_UNITS` in length.
|
||||||
|
pub fn units(&self) -> &[Vec<u8>] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of samples — always `>= MIN_SAMPLE_UNITS`.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Always `false` (a `DecodeSampleSet` never holds fewer than `MIN_SAMPLE_UNITS`);
|
||||||
|
/// provided so the type satisfies the usual `len`/`is_empty` pairing.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
||||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||||
/// structures a source or key server may key on.
|
/// structures a source or key server may key on.
|
||||||
@@ -57,7 +115,7 @@ pub struct DiscInputs {
|
|||||||
pub volume_label: Option<String>,
|
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.
|
/// 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
|
/// "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
|
/// ([`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
|
/// keys against real ciphertext before committing them, so a wrong key from one
|
||||||
/// source transparently falls through to the next.
|
/// 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 {
|
pub trait KeySource {
|
||||||
/// Resolve this disc's terminal Unit Keys from this source. An empty `Vec`
|
/// Resolve this disc's base per-CPS-unit Unit Keys from this source. An empty
|
||||||
/// is a genuine "no key here"; `Err` is a source failure.
|
/// `Vec` is a genuine "no key here"; `Err` is a source failure.
|
||||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
|
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
|
/// 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
|
/// 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
|
/// [`crate::aacs::trace::ResolutionTrace`] recording, per source, what happened — for
|
||||||
/// applications to render. ZERO English; the trace is typed enums only.
|
/// 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
|
/// once with a [`DiscInputsCtx`] over `inputs`. Non-empty Unit Keys are mapped
|
||||||
/// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`],
|
/// to terminal [`Key::Unit`]s and applied via [`crate::Disc::decrypt_with`],
|
||||||
/// which validates them against `inputs.samples` and only mutates the disc on
|
/// 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 {
|
for source in sources {
|
||||||
// `who` is the source's own stable identifier — no enum to map back to.
|
// `who` is the source's own stable identifier — no enum to map back to.
|
||||||
let who = source.label().to_string();
|
let who = source.label().to_string();
|
||||||
match source.get_uk(&ctx) {
|
match source.get_unit_keys(&ctx) {
|
||||||
Ok(uks) if !uks.is_empty() => {
|
Ok(uks) if !uks.is_empty() => {
|
||||||
// Positional index → canonical CPS-unit number (position + 1).
|
// Positional index → canonical CPS-unit number (position + 1).
|
||||||
let unit_keys: Vec<(u32, [u8; 16])> = uks
|
let unit_keys: Vec<(u32, [u8; 16])> = uks
|
||||||
@@ -293,45 +375,156 @@ pub fn resolve_and_apply_traced(
|
|||||||
/// [`resolve_and_apply`] this does not validate/commit to a disc — the read's
|
/// [`resolve_and_apply`] this does not validate/commit to a disc — the read's
|
||||||
/// decorator re-decrypts with the returned keys, which is the validation.
|
/// 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> {
|
pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||||
for source in sources {
|
drive_unit_keys(sources, ctx).keys
|
||||||
if let Ok(uks) = source.get_uk(ctx) {
|
|
||||||
if !uks.is_empty() {
|
|
||||||
return uks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vec::new()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the read-time key-fetch closure from the disc's public AACS inputs and
|
/// Whether a driver run resolved keys, and — when it did NOT — whether the miss
|
||||||
/// a way to (re)build the application's key sources. The decorator calls it with
|
/// was a genuine "no source holds this key" (`errored == false`) or at least one
|
||||||
/// the still-scrambled unit ciphertext when no held key opens that unit; it runs
|
/// source FAILED (`errored == true`, e.g. a network source was unreachable). The
|
||||||
/// [`fetch_unit_keys`] with those bytes as `samples` and returns any keys.
|
/// distinction gates negative-result memoization: an empty-because-absent result
|
||||||
|
/// is safe to cache, an empty-because-a-source-was-down result is transient and
|
||||||
|
/// must NOT be cached (the key may resolve once the source recovers).
|
||||||
|
struct FetchOutcome {
|
||||||
|
keys: Vec<UnitKey>,
|
||||||
|
errored: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`fetch_unit_keys`] plus the error signal: drive `sources` in order, return the
|
||||||
|
/// first source's non-empty Unit Keys, and flag whether any source that failed to
|
||||||
|
/// answer did so with an `Err` (a source failure) rather than an empty `Ok`
|
||||||
|
/// (genuine absence — see [`KeySource::get_unit_keys`]).
|
||||||
|
fn drive_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> FetchOutcome {
|
||||||
|
let mut errored = false;
|
||||||
|
for source in sources {
|
||||||
|
match source.get_unit_keys(ctx) {
|
||||||
|
Ok(uks) if !uks.is_empty() => {
|
||||||
|
return FetchOutcome {
|
||||||
|
keys: uks,
|
||||||
|
errored: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => errored = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FetchOutcome {
|
||||||
|
keys: Vec::new(),
|
||||||
|
errored,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
drive_fmts_indexes(sources, ctx).keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`fetch_fmts_indexes`] plus the error signal (see [`drive_unit_keys`]): the
|
||||||
|
/// forensic counterpart that flags whether any source `Err`ed during the miss.
|
||||||
|
fn drive_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> FetchOutcome {
|
||||||
|
let mut errored = false;
|
||||||
|
for source in sources {
|
||||||
|
match source.get_fmts_indexes(ctx) {
|
||||||
|
Ok(uks) if !uks.is_empty() => {
|
||||||
|
return FetchOutcome {
|
||||||
|
keys: uks,
|
||||||
|
errored: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(_) => errored = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FetchOutcome {
|
||||||
|
keys: Vec::new(),
|
||||||
|
errored,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
/// One builder, used by every read path (sweep / patch / mux) and every consumer
|
||||||
/// consumer (CLI, autorip) — neither application contains the fetch logic, only
|
/// (CLI, autorip) — neither application contains the fetch logic, only its
|
||||||
/// its key-source config. Returns a **shared, stateless** [`crate::sector::KeyFetch`]
|
/// key-source config. Cheap to clone; build once, clone into each read path.
|
||||||
/// (`Arc<Fn>`): build it once, clone it into each read path. `make_sources` is
|
/// `make_sources` is invoked per fetch (the cold path) so the resolver stays
|
||||||
/// invoked per fetch (the cold path, ~once per CPS unit) so the closure stays
|
|
||||||
/// `Send + Sync` without requiring `KeySource: Send`.
|
/// `Send + Sync` without requiring `KeySource: Send`.
|
||||||
pub fn key_fetch(
|
pub fn key_fetch(
|
||||||
inputs: DiscInputs,
|
inputs: DiscInputs,
|
||||||
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
|
||||||
) -> crate::sector::KeyFetch {
|
) -> crate::sector::KeyFetch {
|
||||||
|
// 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. A GENUINELY-empty reply (every source ran and none held the key)
|
||||||
|
// is cached too — the key the service lacks for a batch won't appear on a
|
||||||
|
// re-ask, so re-hitting the network buys nothing. But an empty reply caused
|
||||||
|
// by a source FAILURE (network down, source unreachable) is NOT cached: that
|
||||||
|
// is a transient miss, and caching it would permanently drop a unit that
|
||||||
|
// could be recovered once the source recovers — the `errored` flag on
|
||||||
|
// `FetchOutcome` draws exactly that line. 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: `drive_unit_keys` or `drive_fmts_indexes`.
|
||||||
|
type FetchDriver = fn(&[Box<dyn KeySource>], &dyn ResolveCtx) -> FetchOutcome;
|
||||||
|
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]> {
|
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
|
||||||
|
let fp = {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
samples.len().hash(&mut h);
|
||||||
|
for s in samples {
|
||||||
|
s.hash(&mut h);
|
||||||
|
}
|
||||||
|
h.finish()
|
||||||
|
};
|
||||||
|
if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) {
|
||||||
|
return hit.clone();
|
||||||
|
}
|
||||||
let sources = make_sources();
|
let sources = make_sources();
|
||||||
let mut di = inputs.clone();
|
let mut di = inputs.clone();
|
||||||
di.samples = samples.to_vec();
|
di.samples = samples.to_vec();
|
||||||
// Parse Unit_Key_RO.inf at the disc's OWN stride (carried on `inputs`):
|
// 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
|
// 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
|
// derives unit keys from `enc_title_keys`, which a V10 disc parses at
|
||||||
// 48-byte stride — hardcoding the V20 stride here corrupted them.
|
// the 48-byte stride — hardcoding the V20 stride here corrupted them.
|
||||||
let ctx = DiscInputsCtx::new(&di);
|
let ctx = DiscInputsCtx::new(&di);
|
||||||
fetch_unit_keys(&sources, &ctx)
|
let outcome = drive(&sources, &ctx);
|
||||||
.into_iter()
|
let keys: Vec<[u8; 16]> = outcome.keys.into_iter().map(|u| u.key).collect();
|
||||||
.map(|u| u.key)
|
// Memoize a positive result always; memoize a NEGATIVE (empty) result
|
||||||
.collect()
|
// only when it is a genuine absence, never when a source errored — a
|
||||||
|
// transient outage must not permanently poison this fingerprint.
|
||||||
|
if !keys.is_empty() || !outcome.errored {
|
||||||
|
cache
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.insert(fp, keys.clone());
|
||||||
|
}
|
||||||
|
keys
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
let unit = make_op(inputs.clone(), make_sources.clone(), drive_unit_keys);
|
||||||
|
let fmts = make_op(inputs, make_sources, drive_fmts_indexes);
|
||||||
|
crate::sector::KeyFetch::new(unit, fmts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
|
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
|
||||||
@@ -346,9 +539,9 @@ pub fn key_fetch(
|
|||||||
///
|
///
|
||||||
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
||||||
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
||||||
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
|
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `is_clean` TS-sync
|
||||||
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
|
/// heuristic: a unit lacking clean TS syncs does not imply encryption (an FMTS
|
||||||
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
/// variant frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||||
/// unit sent to a key server yields nothing to validate against — the "0
|
/// unit sent to a key server yields nothing to validate against — the "0
|
||||||
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
|
/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT,
|
||||||
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
|
/// menus) whose CPI is clear; only CPI-flagged content units are collected —
|
||||||
@@ -407,7 +600,7 @@ pub fn read_encrypted_units(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
||||||
if aacs_unit_encrypted(u) {
|
if aacs_unit_encrypted(u, title.content_format) {
|
||||||
out.push(u.to_vec());
|
out.push(u.to_vec());
|
||||||
if out.len() >= n {
|
if out.len() >= n {
|
||||||
return out;
|
return out;
|
||||||
@@ -425,6 +618,46 @@ mod tests {
|
|||||||
use crate::aacs::types::UnitKey;
|
use crate::aacs::types::UnitKey;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn units(n: usize) -> Vec<Vec<u8>> {
|
||||||
|
(0..n).map(|i| vec![i as u8; 4]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DecodeSampleSet: the online request can't be built under-sized ─────────
|
||||||
|
|
||||||
|
/// Fewer than MIN_SAMPLE_UNITS → no set. Mutation: accepting a short slice
|
||||||
|
/// resurrects the exact autorip bug (a 4-sample request silently skipped /
|
||||||
|
/// read as "service down").
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_rejects_under_min() {
|
||||||
|
for n in 0..MIN_SAMPLE_UNITS {
|
||||||
|
assert!(
|
||||||
|
DecodeSampleSet::new(units(n)).is_none(),
|
||||||
|
"{n} samples (< {MIN_SAMPLE_UNITS}) must not build a DecodeSampleSet"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exactly the minimum, and above it, construct — and expose all samples.
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_accepts_min_and_above() {
|
||||||
|
let exact = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS)).expect("min builds");
|
||||||
|
assert_eq!(exact.len(), MIN_SAMPLE_UNITS);
|
||||||
|
assert_eq!(exact.units().len(), MIN_SAMPLE_UNITS);
|
||||||
|
assert!(!exact.is_empty());
|
||||||
|
|
||||||
|
let more = DecodeSampleSet::new(units(MIN_SAMPLE_UNITS + 5)).expect("above min builds");
|
||||||
|
assert_eq!(more.len(), MIN_SAMPLE_UNITS + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wrapped units round-trip byte-for-byte (the request carries exactly what
|
||||||
|
/// was gathered — no reordering/truncation).
|
||||||
|
#[test]
|
||||||
|
fn decode_sample_set_preserves_units() {
|
||||||
|
let raw = units(MIN_SAMPLE_UNITS);
|
||||||
|
let set = DecodeSampleSet::new(raw.clone()).unwrap();
|
||||||
|
assert_eq!(set.units(), raw.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
// ── KeySource default-method behaviour ────────────────────────────────────
|
// ── KeySource default-method behaviour ────────────────────────────────────
|
||||||
|
|
||||||
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
/// KeySource::host_certs() defaults to empty regardless of the MKB argument.
|
||||||
@@ -436,7 +669,7 @@ mod tests {
|
|||||||
fn key_source_host_certs_defaults_to_empty() {
|
fn key_source_host_certs_defaults_to_empty() {
|
||||||
struct MinimalSource;
|
struct MinimalSource;
|
||||||
impl KeySource for 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())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,7 +726,7 @@ mod tests {
|
|||||||
fn trace_who_is_the_source_label_verbatim() {
|
fn trace_who_is_the_source_label_verbatim() {
|
||||||
struct LabeledSource(&'static str);
|
struct LabeledSource(&'static str);
|
||||||
impl KeySource for LabeledSource {
|
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())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
@@ -550,19 +783,19 @@ mod tests {
|
|||||||
|
|
||||||
struct EmptySource;
|
struct EmptySource;
|
||||||
impl KeySource for 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())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct ErroringSource;
|
struct ErroringSource;
|
||||||
impl KeySource for 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)
|
Err(Error::AacsNoKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
struct HasKey([u8; 16]);
|
struct HasKey([u8; 16]);
|
||||||
impl KeySource for HasKey {
|
impl KeySource for HasKey {
|
||||||
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![UnitKey::new(0, self.0)])
|
Ok(vec![UnitKey::new(0, self.0)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -605,7 +838,7 @@ mod tests {
|
|||||||
seen: Arc<Mutex<Vec<Vec<u8>>>>,
|
seen: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||||
}
|
}
|
||||||
impl KeySource for Probe {
|
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) {
|
if let Ok(s) = ctx.samples(8) {
|
||||||
self.seen.lock().unwrap().extend(s);
|
self.seen.lock().unwrap().extend(s);
|
||||||
}
|
}
|
||||||
@@ -625,7 +858,7 @@ mod tests {
|
|||||||
|
|
||||||
let cb = key_fetch(empty_inputs(), make);
|
let cb = key_fetch(empty_inputs(), make);
|
||||||
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
|
let samples = vec![vec![0xEEu8; crate::aacs::content::ALIGNED_UNIT_LEN]];
|
||||||
let got = cb(&samples);
|
let got = cb.unit_keys(&samples);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
got,
|
got,
|
||||||
vec![key],
|
vec![key],
|
||||||
@@ -639,6 +872,225 @@ mod tests {
|
|||||||
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
|
assert_eq!(*builds.lock().unwrap(), 1, "make_sources invoked per fetch");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `key_fetch` memoizes each operation by the fingerprint of the sample batch:
|
||||||
|
/// identical samples reuse the cached keys (no rebuild), different samples miss,
|
||||||
|
/// the two operations keep independent caches, and even an empty reply is cached.
|
||||||
|
#[test]
|
||||||
|
fn key_fetch_memoizes_per_op_by_sample_fingerprint() {
|
||||||
|
let builds = Arc::new(Mutex::new(0usize));
|
||||||
|
let builds_c = Arc::clone(&builds);
|
||||||
|
let key = [0x11u8; 16];
|
||||||
|
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||||
|
*builds_c.lock().unwrap() += 1;
|
||||||
|
vec![Box::new(HasKey(key)) as Box<dyn KeySource>]
|
||||||
|
});
|
||||||
|
let cb = key_fetch(empty_inputs(), make);
|
||||||
|
let a = vec![vec![0xAAu8; 8]];
|
||||||
|
let b = vec![vec![0xBBu8; 8]];
|
||||||
|
|
||||||
|
// First resolve for `a` builds sources; the identical repeat is cached.
|
||||||
|
assert_eq!(cb.unit_keys(&a), vec![key]);
|
||||||
|
assert_eq!(cb.unit_keys(&a), vec![key]);
|
||||||
|
assert_eq!(
|
||||||
|
*builds.lock().unwrap(),
|
||||||
|
1,
|
||||||
|
"identical samples reuse the cache"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A different sample batch is a cache miss → one more build.
|
||||||
|
assert_eq!(cb.unit_keys(&b), vec![key]);
|
||||||
|
assert_eq!(
|
||||||
|
*builds.lock().unwrap(),
|
||||||
|
2,
|
||||||
|
"different samples miss the cache"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The forensic op has its OWN cache (HasKey has no forensic keys → empty),
|
||||||
|
// so `a` builds once more here; its empty reply is then cached too.
|
||||||
|
assert!(cb.fmts_indexes(&a).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
*builds.lock().unwrap(),
|
||||||
|
3,
|
||||||
|
"unit/fmts caches are independent"
|
||||||
|
);
|
||||||
|
assert!(cb.fmts_indexes(&a).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
*builds.lock().unwrap(),
|
||||||
|
3,
|
||||||
|
"an empty reply is cached, not re-asked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transient source outage must NOT be memoized as a permanent "no key":
|
||||||
|
/// a fingerprint whose first fetch failed because the source errored must be
|
||||||
|
/// re-asked, and once the source recovers the key resolves. Regression guard
|
||||||
|
/// for the negative-result memoization fix — caching the errored empty would
|
||||||
|
/// permanently drop a recoverable unit for the rest of the op.
|
||||||
|
#[test]
|
||||||
|
fn errored_empty_is_not_cached_and_retries_when_source_recovers() {
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
let key = [0x77u8; 16];
|
||||||
|
// Shared across every `make_sources()` rebuild: call 0 errors (source
|
||||||
|
// down), every later call succeeds (source recovered).
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
struct Flaky {
|
||||||
|
calls: Arc<AtomicUsize>,
|
||||||
|
key: [u8; 16],
|
||||||
|
}
|
||||||
|
impl KeySource for Flaky {
|
||||||
|
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
|
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||||
|
Err(Error::AacsNoKeys) // first attempt: source unreachable
|
||||||
|
} else {
|
||||||
|
Ok(vec![UnitKey::new(0, self.key)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let calls_c = Arc::clone(&calls);
|
||||||
|
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||||
|
vec![Box::new(Flaky {
|
||||||
|
calls: Arc::clone(&calls_c),
|
||||||
|
key,
|
||||||
|
}) as Box<dyn KeySource>]
|
||||||
|
});
|
||||||
|
|
||||||
|
let cb = key_fetch(empty_inputs(), make);
|
||||||
|
let samples = vec![vec![0xCDu8; 8]];
|
||||||
|
|
||||||
|
// First fetch: the source errors → empty, but the miss must NOT be cached.
|
||||||
|
assert!(
|
||||||
|
cb.unit_keys(&samples).is_empty(),
|
||||||
|
"source down → empty this time"
|
||||||
|
);
|
||||||
|
// Second fetch, SAME samples: not blocked by a cached empty → the now-
|
||||||
|
// recovered source resolves the key.
|
||||||
|
assert_eq!(
|
||||||
|
cb.unit_keys(&samples),
|
||||||
|
vec![key],
|
||||||
|
"recovered source resolves — errored empty was not memoized"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A GENUINE absence (a source that runs and returns an empty `Ok`) is still
|
||||||
|
/// memoized — the benefit the fix preserves. A source counting its calls must
|
||||||
|
/// be asked exactly once for a fingerprint whose first (clean) reply was empty.
|
||||||
|
#[test]
|
||||||
|
fn genuine_empty_is_still_memoized() {
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
struct AlwaysEmpty {
|
||||||
|
calls: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
impl KeySource for AlwaysEmpty {
|
||||||
|
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||||
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(Vec::new()) // ran fine, genuinely holds no key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let calls_c = Arc::clone(&calls);
|
||||||
|
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
|
||||||
|
vec![Box::new(AlwaysEmpty {
|
||||||
|
calls: Arc::clone(&calls_c),
|
||||||
|
}) as Box<dyn KeySource>]
|
||||||
|
});
|
||||||
|
|
||||||
|
let cb = key_fetch(empty_inputs(), make);
|
||||||
|
let samples = vec![vec![0xEFu8; 8]];
|
||||||
|
|
||||||
|
assert!(cb.unit_keys(&samples).is_empty());
|
||||||
|
assert!(cb.unit_keys(&samples).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
calls.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"a clean empty reply is cached — the source is asked only once"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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-
|
/// #4 regression: encrypted content NOT at the extent midpoint (a late-
|
||||||
/// starting feature, or a midpoint landing in clear nav) must still be
|
/// starting feature, or a midpoint landing in clear nav) must still be
|
||||||
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
|
/// sampled — empty samples make `decrypt_with` skip wrong-key validation.
|
||||||
@@ -717,19 +1169,19 @@ mod tests {
|
|||||||
);
|
);
|
||||||
for s in &samples {
|
for s in &samples {
|
||||||
assert!(
|
assert!(
|
||||||
aacs_unit_encrypted(s),
|
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
|
||||||
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
|
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
|
/// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the
|
||||||
/// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but
|
/// TS-sync clarity heuristic. Half the units lack TS syncs but are
|
||||||
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
|
/// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely
|
||||||
/// lack TS syncs; the old sampler collected these and the key server rejected
|
/// lack TS syncs; the old sampler collected these and the key server rejected
|
||||||
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
|
/// the POST as "0 encrypted units". `read_encrypted_units` must skip them and
|
||||||
/// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would
|
/// return ONLY CPI-flagged units. A regression to selecting by TS-sync clarity
|
||||||
/// collect the CPI-clear units too and fail the `& 0xC0` assertion.
|
/// would collect the CPI-clear units too and fail the `& 0xC0` assertion.
|
||||||
#[test]
|
#[test]
|
||||||
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
|
fn read_encrypted_units_selects_by_cpi_not_ts_sync() {
|
||||||
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
|
||||||
@@ -738,7 +1190,8 @@ mod tests {
|
|||||||
|
|
||||||
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
|
// Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47).
|
||||||
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
|
// Odd units: CPI-set (byte0 = 0xC0) with a scrambled body.
|
||||||
// `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd.
|
// Neither has clean TS syncs, so `is_clean` is FALSE for BOTH;
|
||||||
|
// `aacs_unit_encrypted` flags only the odd units.
|
||||||
struct MixSource {
|
struct MixSource {
|
||||||
ext_start: u32,
|
ext_start: u32,
|
||||||
total_units: u32,
|
total_units: u32,
|
||||||
@@ -800,7 +1253,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
for s in &samples {
|
for s in &samples {
|
||||||
assert!(
|
assert!(
|
||||||
aacs_unit_encrypted(s),
|
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
|
||||||
"only CPI-flagged units are selected"
|
"only CPI-flagged units are selected"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+42
-22
@@ -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
|
/// The muxing/writing-application string written into MKV output
|
||||||
/// (`"freemkv <version> (g<hash>)"`).
|
/// (`"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 mod aacs;
|
||||||
pub(crate) mod clpi;
|
pub(crate) mod clpi;
|
||||||
@@ -125,7 +125,7 @@ pub(crate) mod platform;
|
|||||||
pub mod progress;
|
pub mod progress;
|
||||||
pub mod scsi;
|
pub mod scsi;
|
||||||
pub mod sector;
|
pub mod sector;
|
||||||
pub(crate) mod speed;
|
pub mod session;
|
||||||
pub(crate) mod udf;
|
pub(crate) mod udf;
|
||||||
pub(crate) mod unlock_bridge;
|
pub(crate) mod unlock_bridge;
|
||||||
|
|
||||||
@@ -137,38 +137,57 @@ pub(crate) mod unlock_bridge;
|
|||||||
pub use drive::capture::{
|
pub use drive::capture::{
|
||||||
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
|
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
|
||||||
};
|
};
|
||||||
pub use drive::{Drive, DriveStatus, find_drive};
|
pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive};
|
||||||
|
|
||||||
|
// ─── Disc session (drive open + SCSI bring-up hoist) ─────────────────────────
|
||||||
|
//
|
||||||
|
// One entry point that opens a drive and brings the transport up, so consumers
|
||||||
|
// stop hand-rolling `open → wait_ready → init → probe_disc → identify → scan`.
|
||||||
|
// Owns the `Drive` by value; forwards consumer-built key material into
|
||||||
|
// `ScanOptions` (the library derives no certs — see `KeySpec`).
|
||||||
|
pub use session::{
|
||||||
|
DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_iso,
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Errors ─────────────────────────────────────────────────────────────────
|
// ─── Errors ─────────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// All fallible APIs return `Result<T, Error>`. `Error` is a typed enum with a
|
// All fallible APIs return `Result<T, Error>`. `Error` is a typed enum with a
|
||||||
// numeric `code()`; **no English text in the library** — applications map
|
// numeric `code()`; **no English text in the library** — applications map
|
||||||
// codes to localized messages. See `error.rs` for the full taxonomy.
|
// codes to localized messages. See `error.rs` for the full taxonomy.
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result, is_disc_level_no_key, is_halt, is_skippable_title_stub};
|
||||||
|
|
||||||
// ─── Cooperative cancellation ───────────────────────────────────────────────
|
// ─── Cooperative cancellation ───────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// One-bit cooperative cancellation token, shared by every long-running loop
|
// One-bit cooperative cancellation token, shared by every long-running loop —
|
||||||
// in libfreemkv (sweep, patch, mux). Clone it cheaply; pass it by value into
|
// libfreemkv's mux, and the recovery passes (sweep/patch) that now live in the
|
||||||
// each component; poll `is_cancelled()` inside the loop body.
|
// freemkv-engine crate. Clone it cheaply; pass it by value into each component;
|
||||||
|
// poll `is_cancelled()` inside the loop body.
|
||||||
pub use halt::Halt;
|
pub use halt::Halt;
|
||||||
|
|
||||||
// Generic bounded producer/consumer primitive used by sweep, patch, and
|
// Generic bounded producer/consumer primitive used by the mux pipeline (and,
|
||||||
// mux to overlap reads with writes via a dedicated consumer thread.
|
// via this re-export, by the engine's sweep/patch recovery passes) to overlap
|
||||||
|
// reads with writes via a dedicated consumer thread.
|
||||||
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
|
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
|
||||||
// pushes one item with back-pressure; `pipe.finish()` joins the
|
// pushes one item with back-pressure; `pipe.finish()` joins the
|
||||||
// consumer and surfaces its `close()` output. Callers implement `Sink`
|
// consumer and surfaces its `close()` output. Callers implement `Sink`
|
||||||
// to define per-item behaviour and end-of-stream finalisation.
|
// to define per-item behaviour and end-of-stream finalisation.
|
||||||
//
|
//
|
||||||
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
|
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
|
||||||
// most should use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
// most should use WRITE_PIPELINE_DEPTH instead.
|
||||||
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
|
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
|
||||||
// `apply` ends the consumer cleanly (still calls `close()`).
|
// `apply` ends the consumer cleanly (still calls `close()`).
|
||||||
pub use io::pipeline::{
|
pub use io::pipeline::{
|
||||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
|
||||||
WRITE_THROUGH_DEPTH,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Bounded-cache buffered file writer ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Drop-in `std::fs::File` replacement used everywhere the lib writes large
|
||||||
|
// sequential output (mux, extract, sweep, patch) — drains dirty pages
|
||||||
|
// continuously instead of bursting. General I/O infra, not recovery policy;
|
||||||
|
// promoted to `pub` so freemkv-engine's relocated sweep/patch can use it too.
|
||||||
|
pub use io::WritebackFile;
|
||||||
|
|
||||||
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
|
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
|
||||||
pub use event::{BatchSizeReason, Event, EventKind};
|
pub use event::{BatchSizeReason, Event, EventKind};
|
||||||
pub use identity::DriveId;
|
pub use identity::DriveId;
|
||||||
@@ -187,7 +206,7 @@ pub use identity::DriveId;
|
|||||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||||
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
||||||
pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
|
pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
|
||||||
|
|
||||||
// ─── Disc structure ─────────────────────────────────────────────────────────
|
// ─── Disc structure ─────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -201,11 +220,10 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
|
|||||||
// different concepts, the same short name; the trait gets the `Pes`
|
// different concepts, the same short name; the trait gets the `Pes`
|
||||||
// prefix at the crate root to keep both addressable.
|
// prefix at the crate root to keep both addressable.
|
||||||
pub use disc::{
|
pub use disc::{
|
||||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
|
||||||
Disc, DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult,
|
DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult,
|
||||||
FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions,
|
FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, Resolution,
|
||||||
PatchOutcome, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, SweepOptions,
|
SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream,
|
||||||
VideoStream, classify_damage,
|
|
||||||
};
|
};
|
||||||
pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply};
|
pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply};
|
||||||
|
|
||||||
@@ -238,6 +256,7 @@ pub use mux::NullStream;
|
|||||||
pub use mux::StdioStream;
|
pub use mux::StdioStream;
|
||||||
pub use mux::WriteSeek;
|
pub use mux::WriteSeek;
|
||||||
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||||
|
pub use mux::{Mp4FitReport, Mp4SkipReason, mp4_fit_report};
|
||||||
|
|
||||||
// ─── Lower-level surfaces ───────────────────────────────────────────────────
|
// ─── Lower-level surfaces ───────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -248,10 +267,11 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
|||||||
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||||
// `SectorSource` to get plaintext sectors out.
|
// `SectorSource` to get plaintext sectors out.
|
||||||
pub use mux::build_iso_pipeline;
|
pub use mux::build_iso_pipeline;
|
||||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
pub use mux::resolve_mux_key_map;
|
||||||
|
pub use mux::select::{PidFilter, StreamSelection};
|
||||||
|
pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream};
|
||||||
|
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives};
|
||||||
pub use sector::{
|
pub use sector::{
|
||||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
|
DecryptingSectorSource, FileSectorSource, KeyFetch, PrefetchedSectorSource, SectorSource,
|
||||||
SectorSink, SectorSource,
|
|
||||||
};
|
};
|
||||||
pub use speed::DriveSpeed;
|
|
||||||
pub use udf::{UdfFs, read_filesystem};
|
pub use udf::{UdfFs, read_filesystem};
|
||||||
|
|||||||
+241
-48
@@ -41,6 +41,11 @@ pub struct Ac3Parser {
|
|||||||
/// the running per-frame PTS at the point the partial tail was retained.
|
/// the running per-frame PTS at the point the partial tail was retained.
|
||||||
/// Used by `flush()` to time the final buffered frame at EOS.
|
/// Used by `flush()` to time the final buffered frame at EOS.
|
||||||
flush_pts_ns: i64,
|
flush_pts_ns: i64,
|
||||||
|
/// Keep/drop bookkeeping for the CRC decodability gate. A frame that fails
|
||||||
|
/// its native CRC is dropped rather than shipped as a decoder-choking glitch;
|
||||||
|
/// the running PTS is advanced across it (see the emit loop) so the drop is a
|
||||||
|
/// silence gap, never a shift of the following audio.
|
||||||
|
tally: super::dropgate::DropTally,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Ac3Parser {
|
impl Default for Ac3Parser {
|
||||||
@@ -54,8 +59,99 @@ impl Ac3Parser {
|
|||||||
Self {
|
Self {
|
||||||
buf: Vec::with_capacity(4096),
|
buf: Vec::with_capacity(4096),
|
||||||
flush_pts_ns: 0,
|
flush_pts_ns: 0,
|
||||||
|
tally: super::dropgate::DropTally::new("ac3"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Access units dropped as undecodable so far — surfaced to the CLI/mux.
|
||||||
|
pub fn dropped_frames(&self) -> u64 {
|
||||||
|
self.tally.dropped_frames()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total decoded duration (ns) of dropped access units.
|
||||||
|
pub fn dropped_duration_ns(&self) -> u64 {
|
||||||
|
self.tally.dropped_duration_ns()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit the final buffered frame at EOS, through the decodability gate.
|
||||||
|
/// During streaming a final frame may sit in `buf` with no following PES to
|
||||||
|
/// complete it; without this drain the last ~32 ms of audio is lost. Only a
|
||||||
|
/// fully-sized frame at a syncword is considered; a partial/garbage tail is
|
||||||
|
/// discarded, and a corrupt (CRC-failing) final frame is dropped.
|
||||||
|
fn flush_tail(&mut self) -> Vec<Frame> {
|
||||||
|
let buf = std::mem::take(&mut self.buf);
|
||||||
|
let Some(off) = find_ac3_sync(&buf) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let frame_all = &buf[off..];
|
||||||
|
if frame_all.len() < 6 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let bsid = get_bsid(frame_all);
|
||||||
|
let frame_size = if bsid >= 11 {
|
||||||
|
eac3_frame_size(frame_all)
|
||||||
|
} else {
|
||||||
|
ac3_frame_size(frame_all)
|
||||||
|
};
|
||||||
|
if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let frame = &buf[off..off + frame_size];
|
||||||
|
let duration_ns = frame_duration_ns(frame, bsid);
|
||||||
|
if let Some(reason) = ac3_drop_reason(&self.tally, frame, bsid) {
|
||||||
|
self.tally
|
||||||
|
.record_drop(self.flush_pts_ns, duration_ns as i64, frame.len(), reason);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.tally.record_kept();
|
||||||
|
vec![Frame {
|
||||||
|
discontinuity: false,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
pts_ns: self.flush_pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: frame.to_vec(),
|
||||||
|
duration_ns: Some(duration_ns),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use super::crc::crc16_ansi;
|
||||||
|
|
||||||
|
/// Whether a fully-buffered (E-)AC-3 frame passes its native CRC. Per ETSI TS
|
||||||
|
/// 102 366 (ATSC A/52) the frame carries a CRC-16/ANSI (poly 0x8005, init 0,
|
||||||
|
/// non-reflected) over the bytes after the 2-byte syncword — i.e. `crc16_ansi(
|
||||||
|
/// &buf[2..]) == 0` covers `frame_size - 2` bytes; the trailing crc word makes a
|
||||||
|
/// clean frame's residue zero. A nonzero residue is a ~1-in-65536-certain sign
|
||||||
|
/// of payload corruption, so we drop the frame (silence gap) rather than ship a
|
||||||
|
/// glitch. `frame` must be exactly the frame bytes (syncword .. frame_size).
|
||||||
|
fn frame_crc_ok(frame: &[u8]) -> bool {
|
||||||
|
// Need the syncword (2) plus at least one covered byte; the caller only
|
||||||
|
// invokes this on a fully-sized frame, so this is defensive.
|
||||||
|
if frame.len() < 4 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
crc16_ansi(&frame[2..]) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodability verdict for a fully-sized (E-)AC-3 frame: `Some(reason)` when it
|
||||||
|
/// must be dropped, `None` when it decodes. Drops (in order): a poisoned track
|
||||||
|
/// (mostly-undecodable → drop the rest), an out-of-range bitstream id (`bsid >
|
||||||
|
/// 16`; ETSI TS 102 366 defines no bsid above 16), or a failed native frame CRC.
|
||||||
|
fn ac3_drop_reason(
|
||||||
|
tally: &super::dropgate::DropTally,
|
||||||
|
frame: &[u8],
|
||||||
|
bsid: u8,
|
||||||
|
) -> Option<&'static str> {
|
||||||
|
if tally.is_poisoned() {
|
||||||
|
Some("track-poisoned")
|
||||||
|
} else if bsid > 16 {
|
||||||
|
Some("bsid")
|
||||||
|
} else if !frame_crc_ok(frame) {
|
||||||
|
Some("crc")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodecParser for Ac3Parser {
|
impl CodecParser for Ac3Parser {
|
||||||
@@ -91,10 +187,12 @@ impl CodecParser for Ac3Parser {
|
|||||||
// in practice, so this is defense-in-depth.
|
// in practice, so this is defense-in-depth.
|
||||||
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns);
|
let base_pts_ns = pes.pts.map(pts_to_ns).unwrap_or(self.flush_pts_ns);
|
||||||
|
|
||||||
// Prepend leftover from previous PES
|
// Prepend leftover from previous PES, then take the whole buffer into a
|
||||||
|
// local so the emit loop can call `self.tally` (the bytes are no longer
|
||||||
|
// borrowed from `self`). The unconsumed tail is written back at the end.
|
||||||
self.buf.extend_from_slice(&pes.data);
|
self.buf.extend_from_slice(&pes.data);
|
||||||
|
let buf = std::mem::take(&mut self.buf);
|
||||||
let data = &self.buf;
|
let data = &buf;
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
// Running PTS for the next frame to emit in this call.
|
// Running PTS for the next frame to emit in this call.
|
||||||
@@ -134,15 +232,26 @@ impl CodecParser for Ac3Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let duration_ns = frame_duration_ns(remaining, bsid);
|
let duration_ns = frame_duration_ns(remaining, bsid);
|
||||||
|
let frame = &data[start..start + frame_size];
|
||||||
|
// Decodability gate: drop a frame with an out-of-range bsid (> 16)
|
||||||
|
// or whose native CRC fails (payload corruption). `frame_pts_ns` is
|
||||||
|
// advanced BELOW whether or not the frame survives, so a drop is a
|
||||||
|
// silence gap and the following frames keep their true PTS.
|
||||||
|
if let Some(reason) = ac3_drop_reason(&self.tally, frame, bsid) {
|
||||||
|
self.tally
|
||||||
|
.record_drop(frame_pts_ns, duration_ns as i64, frame.len(), reason);
|
||||||
|
} else {
|
||||||
|
self.tally.record_kept();
|
||||||
frames.push(Frame {
|
frames.push(Frame {
|
||||||
discontinuity: false,
|
discontinuity: false,
|
||||||
coding: None,
|
coding: None,
|
||||||
source: None,
|
source: None,
|
||||||
pts_ns: frame_pts_ns,
|
pts_ns: frame_pts_ns,
|
||||||
keyframe: true,
|
keyframe: true,
|
||||||
data: data[start..start + frame_size].to_vec(),
|
data: frame.to_vec(),
|
||||||
duration_ns: Some(duration_ns),
|
duration_ns: Some(duration_ns),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
frame_pts_ns += duration_ns as i64;
|
frame_pts_ns += duration_ns as i64;
|
||||||
pos = start + frame_size;
|
pos = start + frame_size;
|
||||||
}
|
}
|
||||||
@@ -198,38 +307,10 @@ impl CodecParser for Ac3Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn flush(&mut self) -> Vec<Frame> {
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
// End of stream: emit a complete final frame still buffered. During
|
let out = self.flush_tail();
|
||||||
// streaming a final frame may sit in `buf` with no following PES to
|
// Aggregate drop report at end-of-stream (warn-level, always visible).
|
||||||
// complete/confirm it; without this drain the last ~32 ms of audio is
|
self.tally.log_summary();
|
||||||
// dropped at EOS (mirrors dts.rs::flush). Only a fully-sized frame at a
|
out
|
||||||
// syncword is emitted; a partial/garbage tail is discarded.
|
|
||||||
let buf = std::mem::take(&mut self.buf);
|
|
||||||
let Some(off) = find_ac3_sync(&buf) else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
let frame = &buf[off..];
|
|
||||||
if frame.len() < 6 {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let bsid = get_bsid(frame);
|
|
||||||
let frame_size = if bsid >= 11 {
|
|
||||||
eac3_frame_size(frame)
|
|
||||||
} else {
|
|
||||||
ac3_frame_size(frame)
|
|
||||||
};
|
|
||||||
if !(MIN_FRAME_BYTES..=8192).contains(&frame_size) || off + frame_size > buf.len() {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let duration_ns = frame_duration_ns(frame, bsid);
|
|
||||||
vec![Frame {
|
|
||||||
discontinuity: false,
|
|
||||||
coding: None,
|
|
||||||
source: None,
|
|
||||||
pts_ns: self.flush_pts_ns,
|
|
||||||
keyframe: true,
|
|
||||||
data: buf[off..off + frame_size].to_vec(),
|
|
||||||
duration_ns: Some(duration_ns),
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
@@ -304,8 +385,8 @@ const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5];
|
|||||||
///
|
///
|
||||||
/// This is the AUTHORITATIVE channel count for the track header: the DVD IFO
|
/// This is the AUTHORITATIVE channel count for the track header: the DVD IFO
|
||||||
/// `audio_attr_t.channels` nibble is a well-known unreliable/stale field, so
|
/// `audio_attr_t.channels` nibble is a well-known unreliable/stale field, so
|
||||||
/// the muxer prefers this over the IFO-claimed count (mirrors MakeMKV /
|
/// the muxer prefers this over the IFO-claimed count (the bitstream acmod is
|
||||||
/// HandBrake, which never trust the IFO audio nibble). LFE adds one channel
|
/// authoritative; the IFO audio nibble is not trusted). LFE adds one channel
|
||||||
/// (e.g. acmod=7 + lfeon → 6 = 5.1).
|
/// (e.g. acmod=7 + lfeon → 6 = 5.1).
|
||||||
///
|
///
|
||||||
/// Bit layout from the syncword (A/52 §5.3.2 BSI):
|
/// Bit layout from the syncword (A/52 §5.3.2 BSI):
|
||||||
@@ -460,9 +541,24 @@ mod tests {
|
|||||||
frame[1] = 0x77;
|
frame[1] = 0x77;
|
||||||
frame[4] = (fscod << 6) | frmsizecod;
|
frame[4] = (fscod << 6) | frmsizecod;
|
||||||
frame[5] = 0x08 << 3; // bsid = 8 (AC-3)
|
frame[5] = 0x08 << 3; // bsid = 8 (AC-3)
|
||||||
|
finalize_ac3_crc(&mut frame);
|
||||||
frame
|
frame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the trailing CRC word so the whole-frame residue over `[2..]` is zero
|
||||||
|
/// — i.e. the frame passes the decodability gate. Relies on the CRC-16/ANSI
|
||||||
|
/// residue property: appending `crc16([2..n-2])` (big-endian) zeroes the
|
||||||
|
/// register over `[2..n]`. Leaves the crc1 field (bytes 2-3) untouched.
|
||||||
|
fn finalize_ac3_crc(frame: &mut [u8]) {
|
||||||
|
let n = frame.len();
|
||||||
|
if n < 4 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let c = crc16_ansi(&frame[2..n - 2]);
|
||||||
|
frame[n - 2] = (c >> 8) as u8;
|
||||||
|
frame[n - 1] = (c & 0xFF) as u8;
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_empty_pes() {
|
fn parse_empty_pes() {
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
@@ -532,7 +628,7 @@ mod tests {
|
|||||||
// (PES marked discontinuity) carrying a fresh complete frame. The
|
// (PES marked discontinuity) carrying a fresh complete frame. The
|
||||||
// truncated partial must be DROPPED, not spliced — otherwise the parser
|
// truncated partial must be DROPPED, not spliced — otherwise the parser
|
||||||
// emits one corrupt frame built from [stale partial | head of fresh] and
|
// emits one corrupt frame built from [stale partial | head of fresh] and
|
||||||
// strands the tail (FFmpeg: "incomplete frame" / wrong sync).
|
// strands the tail (decoders report "incomplete frame" / wrong sync).
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77
|
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B77
|
||||||
|
|
||||||
@@ -1090,27 +1186,33 @@ mod tests {
|
|||||||
// --- frame acceptance / rejection at the size boundaries ---
|
// --- frame acceptance / rejection at the size boundaries ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eac3_frame_at_min_frame_bytes_is_accepted() {
|
fn eac3_frame_at_min_frame_bytes_passes_sizing_then_crc_gate() {
|
||||||
// The smallest acceptable (E-)AC-3 frame is MIN_FRAME_BYTES = 6.
|
// The smallest frame the SIZING layer accepts is MIN_FRAME_BYTES = 6
|
||||||
// Build an E-AC-3 frame whose frmsiz sizes it to exactly 6 bytes
|
// (frmsiz=2). A synthetic all-zero 6-byte frame passes sizing (so it
|
||||||
// (frmsiz=2). bsid >= 11 selects E-AC-3 sizing. The parser must emit it.
|
// reaches the decodability gate — proven by it being COUNTED as a drop,
|
||||||
|
// not silently size-skipped) but fails the CRC gate and is dropped; the
|
||||||
|
// following real AC-3 frame (valid CRC) is emitted.
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
// 0x0B 0x77 | byte2=0 byte3=2 (frmsiz=2 → 6 bytes) | byte4=0 | byte5 bsid
|
// 0x0B 0x77 | byte2=0 byte3=2 (frmsiz=2 → 6 bytes) | byte4=0 | byte5 bsid
|
||||||
let mut data = vec![0x0B, 0x77, 0x00, 0x02, 0x00, 16 << 3];
|
let mut data = vec![0x0B, 0x77, 0x00, 0x02, 0x00, 16 << 3];
|
||||||
// pad to exactly 6 bytes (already 6). Then a trailing real AC-3 frame so
|
|
||||||
// the 6-byte frame isn't a tail that needs more data.
|
|
||||||
data.truncate(6);
|
data.truncate(6);
|
||||||
data.extend_from_slice(&make_ac3_frame(0, 2));
|
data.extend_from_slice(&make_ac3_frame(0, 2));
|
||||||
let f = parser.parse(&make_eac3_pes(data));
|
let f = parser.parse(&make_eac3_pes(data));
|
||||||
assert_eq!(f.len(), 2, "6-byte E-AC-3 frame accepted + following AC-3");
|
assert_eq!(f.len(), 1, "6-byte frame dropped (CRC), real AC-3 emitted");
|
||||||
assert_eq!(f[0].data.len(), 6);
|
assert_eq!(f[0].data.len(), 160, "the surviving frame is the real AC-3");
|
||||||
|
assert_eq!(
|
||||||
|
parser.dropped_frames(),
|
||||||
|
1,
|
||||||
|
"the 6-byte frame reached the gate"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eac3_max_frmsiz_frame_within_window_accepted() {
|
fn eac3_max_frmsiz_frame_within_window_accepted() {
|
||||||
// E-AC-3 frmsiz is an 11-bit field (3 bits of byte2 + 8 bits of byte3),
|
// E-AC-3 frmsiz is an 11-bit field (3 bits of byte2 + 8 bits of byte3),
|
||||||
// so its maximum value is 0x7FF = 2047 → (2048)*2 = 4096 bytes, which is
|
// so its maximum value is 0x7FF = 2047 → (2048)*2 = 4096 bytes, which is
|
||||||
// inside the MIN_FRAME_BYTES..=8192 accept window and must be emitted.
|
// inside the MIN_FRAME_BYTES..=8192 accept window and, with a valid CRC,
|
||||||
|
// must be emitted.
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
let mut frame = vec![0u8; 4096];
|
let mut frame = vec![0u8; 4096];
|
||||||
frame[0] = 0x0B;
|
frame[0] = 0x0B;
|
||||||
@@ -1118,6 +1220,7 @@ mod tests {
|
|||||||
frame[2] = 0x07; // frmsiz high
|
frame[2] = 0x07; // frmsiz high
|
||||||
frame[3] = 0xFF; // frmsiz low → 0x7FF = 2047 → 4096 bytes
|
frame[3] = 0xFF; // frmsiz low → 0x7FF = 2047 → 4096 bytes
|
||||||
frame[5] = 16 << 3; // bsid 16 (E-AC-3)
|
frame[5] = 16 << 3; // bsid 16 (E-AC-3)
|
||||||
|
finalize_ac3_crc(&mut frame); // pass the decodability gate
|
||||||
let f = parser.parse(&make_eac3_pes(frame));
|
let f = parser.parse(&make_eac3_pes(frame));
|
||||||
assert_eq!(f.len(), 1, "4096-byte E-AC-3 frame within window accepted");
|
assert_eq!(f.len(), 1, "4096-byte E-AC-3 frame within window accepted");
|
||||||
assert_eq!(f[0].data.len(), 4096);
|
assert_eq!(f[0].data.len(), 4096);
|
||||||
@@ -1296,6 +1399,96 @@ mod tests {
|
|||||||
assert_eq!(acmod_channels(&frame), Some(2));
|
assert_eq!(acmod_channels(&frame), Some(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- decodability (CRC) gate: keep clean frames, drop corrupt ones ---
|
||||||
|
|
||||||
|
/// A structurally-valid AC-3 frame with one payload byte corrupted so its
|
||||||
|
/// native CRC fails (header/size intact, so the framer delimits it normally).
|
||||||
|
fn make_corrupt_ac3_frame(fscod: u8, frmsizecod: u8) -> Vec<u8> {
|
||||||
|
let mut f = make_ac3_frame(fscod, frmsizecod);
|
||||||
|
f[20] ^= 0xFF; // flip a payload byte → CRC no longer zero
|
||||||
|
assert!(!frame_crc_ok(&f), "corruption must break the CRC");
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc16_residue_zero_after_finalize_nonzero_after_corruption() {
|
||||||
|
// The CRC-16/ANSI residue property the gate relies on: a finalized frame
|
||||||
|
// has residue 0 over [2..]; flipping any covered byte makes it nonzero.
|
||||||
|
let good = make_ac3_frame(0, 2);
|
||||||
|
assert!(frame_crc_ok(&good));
|
||||||
|
let bad = make_corrupt_ac3_frame(0, 2);
|
||||||
|
assert!(!frame_crc_ok(&bad));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc_fail_frame_is_dropped_survivors_kept() {
|
||||||
|
// good / corrupt / good in one PES: the corrupt middle frame is dropped
|
||||||
|
// (CRC), the two clean frames are emitted, and the drop is counted.
|
||||||
|
let mut parser = Ac3Parser::new();
|
||||||
|
let mut data = make_ac3_frame(0, 2);
|
||||||
|
data.extend_from_slice(&make_corrupt_ac3_frame(0, 2));
|
||||||
|
data.extend_from_slice(&make_ac3_frame(0, 2));
|
||||||
|
let f = parser.parse(&make_eac3_pes(data));
|
||||||
|
// Only two of three survive; flush has nothing (all closed in-call).
|
||||||
|
assert_eq!(f.len(), 2, "corrupt frame dropped, two clean survive");
|
||||||
|
assert_eq!(parser.dropped_frames(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
parser.dropped_duration_ns(),
|
||||||
|
32_000_000,
|
||||||
|
"one 32ms frame of silence"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc_drop_preserves_pts_sync_no_shift() {
|
||||||
|
// THE INVARIANT: dropping a corrupt frame must not shift the audio after
|
||||||
|
// it. good / corrupt / good in one PES — the corrupt frame is dropped but
|
||||||
|
// the trailing clean frame keeps the EXACT PTS it would have had with no
|
||||||
|
// drop (base + 2 frame durations): a silence gap, not a shift.
|
||||||
|
let mut parser = Ac3Parser::new();
|
||||||
|
let mut data = make_ac3_frame(0, 2); // f0
|
||||||
|
data.extend_from_slice(&make_corrupt_ac3_frame(0, 2)); // dropped
|
||||||
|
data.extend_from_slice(&make_ac3_frame(0, 2)); // f2
|
||||||
|
let f = parser.parse(&make_eac3_pes(data));
|
||||||
|
assert_eq!(f.len(), 2);
|
||||||
|
let base = pts_to_ns(90000);
|
||||||
|
let frame_dur = 32_000_000i64; // 1536 @ 48k
|
||||||
|
assert_eq!(f[0].pts_ns, base, "f0 at PES base");
|
||||||
|
assert_eq!(
|
||||||
|
f[1].pts_ns,
|
||||||
|
base + 2 * frame_dur,
|
||||||
|
"surviving frame keeps its true timeline (base + 2 frames) — gap, not shift"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bsid_over_16_is_dropped() {
|
||||||
|
// bsid > 16 is out of range (ETSI TS 102 366 defines no bsid above 16).
|
||||||
|
// A frame with bsid = 17 that still sizes must be dropped, not emitted.
|
||||||
|
let mut frame = vec![0u8; 128];
|
||||||
|
frame[0] = 0x0B;
|
||||||
|
frame[1] = 0x77;
|
||||||
|
frame[3] = 63; // frmsiz = 63 → (63+1)*2 = 128 bytes (E-AC-3 sizing)
|
||||||
|
frame[5] = 17 << 3; // bsid = 17 (> 16)
|
||||||
|
assert_eq!(get_bsid(&frame), 17);
|
||||||
|
let tally = super::super::dropgate::DropTally::new("ac3");
|
||||||
|
assert_eq!(ac3_drop_reason(&tally, &frame, 17), Some("bsid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_stream_drops_nothing() {
|
||||||
|
// A stream of valid frames passes untouched — zero false positives.
|
||||||
|
let mut parser = Ac3Parser::new();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
for _ in 0..5 {
|
||||||
|
data.extend_from_slice(&make_ac3_frame(0, 2));
|
||||||
|
}
|
||||||
|
let mut f = parser.parse(&make_eac3_pes(data));
|
||||||
|
f.extend(parser.flush());
|
||||||
|
assert_eq!(f.len(), 5);
|
||||||
|
assert_eq!(parser.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// helper: PES with a generic pts for E-AC-3 tests
|
// helper: PES with a generic pts for E-AC-3 tests
|
||||||
fn make_eac3_pes(data: Vec<u8>) -> PesPacket {
|
fn make_eac3_pes(data: Vec<u8>) -> PesPacket {
|
||||||
PesPacket {
|
PesPacket {
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//! AAC ADTS decodability gate.
|
||||||
|
//!
|
||||||
|
//! Per the ADTS framing defined in ISO/IEC 13818-7 / ISO/IEC 14496-3, a header
|
||||||
|
//! is structurally invalid in exactly three ways this gate treats as hard
|
||||||
|
//! rejects: syncword != 0xFFF, a reserved `sampling_frequency_index` (the sample
|
||||||
|
//! rate table has 13 valid entries, so index ≥ 13 is reserved), and
|
||||||
|
//! `aac_frame_length < 7` (shorter than the fixed+variable header itself). The
|
||||||
|
//! optional 16-bit ADTS CRC is not verified here — it is simply skipped. So the
|
||||||
|
//! gate enforces those three rejects: a packet that begins with the ADTS sync
|
||||||
|
//! but is otherwise malformed is dropped; a packet with no ADTS sync is raw AAC
|
||||||
|
//! (e.g. from an MP4 container, which carries no ADTS header) or a continuation
|
||||||
|
//! and passes through unchanged — never false-dropped. Raw AAC has no per-frame
|
||||||
|
//! integrity data, so like LPCM it cannot be gated.
|
||||||
|
|
||||||
|
use super::dropgate::DropTally;
|
||||||
|
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||||
|
|
||||||
|
/// ADTS `sampling_frequency_index` table (ISO/IEC 14496-3) — 13 valid entries;
|
||||||
|
/// indices 13/14/15 are 0 (reserved) and constitute a hard reject.
|
||||||
|
const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [
|
||||||
|
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350, 0, 0,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// ADTS header verdict for the packet head.
|
||||||
|
enum AdtsVerdict {
|
||||||
|
/// No 12-bit ADTS sync at the head — not an ADTS frame we can validate.
|
||||||
|
NoSync,
|
||||||
|
/// Sync present and the three structural fields are legal.
|
||||||
|
Valid,
|
||||||
|
/// Sync present but a reserved sample-rate index or a sub-header
|
||||||
|
/// frame-length — structurally invalid per the ADTS spec.
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adts_verdict(data: &[u8]) -> AdtsVerdict {
|
||||||
|
// Need the full 7-byte fixed+variable header to read frame_length.
|
||||||
|
if data.len() < 7 {
|
||||||
|
return AdtsVerdict::NoSync;
|
||||||
|
}
|
||||||
|
// 12-bit syncword 0xFFF: byte0 == 0xFF and top nibble of byte1 == 0xF.
|
||||||
|
if data[0] != 0xFF || (data[1] & 0xF0) != 0xF0 {
|
||||||
|
return AdtsVerdict::NoSync;
|
||||||
|
}
|
||||||
|
// sampling_frequency_index: byte2 bits 5..2.
|
||||||
|
let sr_index = ((data[2] >> 2) & 0x0F) as usize;
|
||||||
|
if ADTS_SAMPLE_RATE_VALID[sr_index] == 0 {
|
||||||
|
return AdtsVerdict::Invalid;
|
||||||
|
}
|
||||||
|
// aac_frame_length: 13 bits = byte3[1:0] | byte4 | byte5[7:5].
|
||||||
|
let frame_length =
|
||||||
|
((u32::from(data[3]) & 0x03) << 11) | (u32::from(data[4]) << 3) | (u32::from(data[5]) >> 5);
|
||||||
|
if frame_length < 7 {
|
||||||
|
return AdtsVerdict::Invalid;
|
||||||
|
}
|
||||||
|
AdtsVerdict::Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AdtsParser {
|
||||||
|
tally: DropTally,
|
||||||
|
/// Last emitted PTS (ns). A PES with no PTS (legal for audio, e.g. a
|
||||||
|
/// post-discontinuity continuation) carries this forward rather than resetting
|
||||||
|
/// the timeline to 0 — matching the AC-3/DTS parsers and preserving A/V sync.
|
||||||
|
last_pts_ns: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AdtsParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdtsParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tally: DropTally::new("aac"),
|
||||||
|
last_pts_ns: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dropped_frames(&self) -> u64 {
|
||||||
|
self.tally.dropped_frames()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dropped_duration_ns(&self) -> u64 {
|
||||||
|
self.tally.dropped_duration_ns()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodecParser for AdtsParser {
|
||||||
|
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||||
|
if pes.data.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let pts_ns = pes
|
||||||
|
.pts
|
||||||
|
.or(pes.dts)
|
||||||
|
.map(pts_to_ns)
|
||||||
|
.unwrap_or(self.last_pts_ns);
|
||||||
|
self.last_pts_ns = pts_ns;
|
||||||
|
|
||||||
|
let drop =
|
||||||
|
self.tally.is_poisoned() || matches!(adts_verdict(&pes.data), AdtsVerdict::Invalid);
|
||||||
|
if drop {
|
||||||
|
let reason = if self.tally.is_poisoned() {
|
||||||
|
"track-poisoned"
|
||||||
|
} else {
|
||||||
|
"header"
|
||||||
|
};
|
||||||
|
self.tally.record_drop(pts_ns, 0, pes.data.len(), reason);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tally.record_kept();
|
||||||
|
vec![Frame {
|
||||||
|
discontinuity: pes.discontinuity,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: pes.data.clone(),
|
||||||
|
duration_ns: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
|
self.tally.log_summary();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||||
|
PesPacket {
|
||||||
|
source: None,
|
||||||
|
pid: 0x1100,
|
||||||
|
pts,
|
||||||
|
dts: None,
|
||||||
|
data,
|
||||||
|
discontinuity: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid ADTS header (AAC-LC, 44.1 kHz, stereo) + payload, with
|
||||||
|
/// aac_frame_length set to the total size.
|
||||||
|
fn adts_frame(payload: usize) -> Vec<u8> {
|
||||||
|
let total = 7 + payload;
|
||||||
|
let mut f = vec![0u8; total];
|
||||||
|
f[0] = 0xFF;
|
||||||
|
f[1] = 0xF1; // sync + MPEG-4 + no CRC (protection_absent=1)
|
||||||
|
f[2] = 0x50; // profile=AAC-LC, sr_index=4 (44.1 kHz)
|
||||||
|
f[3] = 0x80; // channel_config low + start of frame_length
|
||||||
|
// frame_length (13 bits) = total.
|
||||||
|
let fl = total as u32;
|
||||||
|
f[3] = (f[3] & 0xFC) | ((fl >> 11) & 0x03) as u8;
|
||||||
|
f[4] = ((fl >> 3) & 0xFF) as u8;
|
||||||
|
f[5] = (((fl & 0x07) << 5) as u8) | 0x1F; // low 3 bits of len + buffer-fullness bits
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_adts_is_kept() {
|
||||||
|
let mut p = AdtsParser::new();
|
||||||
|
let f = p.parse(&make_pes(adts_frame(400), Some(90000)));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
|
||||||
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pes_without_pts_carries_last_timestamp_not_zero() {
|
||||||
|
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
|
||||||
|
// carry the last known timestamp forward — resetting to 0 would corrupt
|
||||||
|
// A/V sync.
|
||||||
|
let mut p = AdtsParser::new();
|
||||||
|
p.parse(&make_pes(adts_frame(400), Some(90000)));
|
||||||
|
let f = p.parse(&make_pes(adts_frame(400), None));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
f[0].pts_ns,
|
||||||
|
pts_to_ns(90000),
|
||||||
|
"carried forward, not reset to 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_sample_rate_index_is_dropped() {
|
||||||
|
// sr_index = 13 (reserved). byte2 bits5..2 = 1101 → 0x34.
|
||||||
|
let mut p = AdtsParser::new();
|
||||||
|
let mut f = adts_frame(400);
|
||||||
|
f[2] = (f[2] & 0xC3) | (13 << 2); // set sr_index = 13
|
||||||
|
assert!(p.parse(&make_pes(f, Some(0))).is_empty());
|
||||||
|
assert_eq!(p.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subheader_frame_length_is_dropped() {
|
||||||
|
// frame_length < 7 (here 0) is a sub-header length → reject.
|
||||||
|
let mut p = AdtsParser::new();
|
||||||
|
let mut f = adts_frame(400);
|
||||||
|
f[3] &= 0xFC; // clear len high bits
|
||||||
|
f[4] = 0;
|
||||||
|
f[5] &= 0x1F; // clear len low bits → frame_length = 0
|
||||||
|
assert!(p.parse(&make_pes(f, Some(0))).is_empty());
|
||||||
|
assert_eq!(p.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_aac_without_sync_passes_through() {
|
||||||
|
// No ADTS sync (e.g. raw AAC from mp4) → cannot validate → keep.
|
||||||
|
let mut p = AdtsParser::new();
|
||||||
|
let f = p.parse(&make_pes(
|
||||||
|
vec![0x21, 0x00, 0x03, 0x40, 0x00, 0x00, 0x00],
|
||||||
|
Some(0),
|
||||||
|
));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_preserves_sync_via_own_pts() {
|
||||||
|
let mut p = AdtsParser::new();
|
||||||
|
let mut bad = adts_frame(400);
|
||||||
|
bad[2] = (bad[2] & 0xC3) | (14 << 2); // reserved sr_index
|
||||||
|
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
|
||||||
|
let f = p.parse(&make_pes(adts_frame(400), Some(96000)));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
f[0].pts_ns,
|
||||||
|
pts_to_ns(96000),
|
||||||
|
"next frame keeps its own PTS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_packet_passes_through() {
|
||||||
|
let mut p = AdtsParser::new();
|
||||||
|
let f = p.parse(&make_pes(vec![0xFF, 0xF1, 0x50], Some(0)));
|
||||||
|
assert_eq!(f.len(), 1, "too short to validate → kept");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -206,8 +206,8 @@ impl PictureInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Number of field-display periods this picture occupies — the basis for
|
/// Number of field-display periods this picture occupies — the basis for
|
||||||
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10,
|
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10):
|
||||||
/// ffmpeg `nb_fields = repeat_pict + 2`): a field picture occupies 1 field,
|
/// a field picture occupies 1 field,
|
||||||
/// a normal frame 2, a `repeat_first_field` progressive-frame 3 (or 4/6 in a
|
/// a normal frame 2, a `repeat_first_field` progressive-frame 3 (or 4/6 in a
|
||||||
/// progressive sequence); an rff bit on a non-progressive interlaced frame is
|
/// progressive sequence); an rff bit on a non-progressive interlaced frame is
|
||||||
/// spec-forbidden (§6.3.10) and is treated as 2. Codecs without pulldown
|
/// spec-forbidden (§6.3.10) and is treated as 2. Codecs without pulldown
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! Bit-exact CRC helpers shared by the audio codec decodability gates.
|
||||||
|
//!
|
||||||
|
//! Each matches the CRC defined by its format's bitstream specification, so a
|
||||||
|
//! frame these routines flag as a CRC mismatch is exactly the frame a
|
||||||
|
//! spec-conformant decoder would reject. All are MSB-first (non-reflected),
|
||||||
|
//! init 0, no final XOR — the big-endian CRC variants. Each format transmits
|
||||||
|
//! its CRC so that the residue over `data + transmitted_crc` is zero, which is
|
||||||
|
//! exactly how these are used: compute over the whole frame (including its
|
||||||
|
//! trailing CRC) and check `== 0`.
|
||||||
|
|
||||||
|
/// CRC-16/ANSI (a.k.a. CRC-16/BUYPASS): polynomial 0x8005, init 0x0000,
|
||||||
|
/// MSB-first, no reflection, no final XOR. Called by the AC-3/E-AC-3 frame-CRC
|
||||||
|
/// gate (ETSI TS 102 366) and the FLAC frame footer. (The MPEG-audio and
|
||||||
|
/// AAC-ADTS gates validate the header structurally and do not verify their
|
||||||
|
/// optional CRC, so they do not call this.)
|
||||||
|
pub(crate) fn crc16_ansi(data: &[u8]) -> u16 {
|
||||||
|
let mut crc: u16 = 0;
|
||||||
|
for &b in data {
|
||||||
|
crc ^= (b as u16) << 8;
|
||||||
|
for _ in 0..8 {
|
||||||
|
crc = if crc & 0x8000 != 0 {
|
||||||
|
(crc << 1) ^ 0x8005
|
||||||
|
} else {
|
||||||
|
crc << 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CRC-16 with polynomial 0x002D, init 0, MSB-first, used by the MLP / Dolby
|
||||||
|
/// TrueHD major-sync header checksum. NOTE: MLP's checksum is the "reversed"
|
||||||
|
/// scheme — the stored trailer word is the little-endian-read CRC, so this
|
||||||
|
/// standard CRC must be compared against the stored bytes read big-endian.
|
||||||
|
/// The caller handles that comparison (see `truehd::mlp_major_sync_ok`).
|
||||||
|
/// Verified against real MLP/TrueHD bitstreams (225/225 major-sync AUs).
|
||||||
|
pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
|
||||||
|
let mut crc: u16 = 0;
|
||||||
|
for &b in data {
|
||||||
|
crc ^= (b as u16) << 8;
|
||||||
|
for _ in 0..8 {
|
||||||
|
crc = if crc & 0x8000 != 0 {
|
||||||
|
(crc << 1) ^ 0x002D
|
||||||
|
} else {
|
||||||
|
crc << 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CRC-8/ATM (a.k.a. CRC-8/ITU without the final XOR): polynomial 0x07, init 0,
|
||||||
|
/// MSB-first, no reflection — the FLAC frame-header CRC-8 (RFC 9639). Available
|
||||||
|
/// as a primitive; the FLAC gate currently validates only the frame footer CRC-16.
|
||||||
|
pub(crate) fn crc8_atm(data: &[u8]) -> u8 {
|
||||||
|
let mut crc: u8 = 0;
|
||||||
|
for &b in data {
|
||||||
|
crc ^= b;
|
||||||
|
for _ in 0..8 {
|
||||||
|
crc = if crc & 0x80 != 0 {
|
||||||
|
(crc << 1) ^ 0x07
|
||||||
|
} else {
|
||||||
|
crc << 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc16_residue_property_holds() {
|
||||||
|
// Appending the big-endian CRC-16 of a message zeroes the residue over
|
||||||
|
// message+crc — the property every frame gate relies on.
|
||||||
|
let msg = [0x12u8, 0x34, 0x56, 0x78, 0x9A];
|
||||||
|
let c = crc16_ansi(&msg);
|
||||||
|
let mut framed = msg.to_vec();
|
||||||
|
framed.push((c >> 8) as u8);
|
||||||
|
framed.push((c & 0xFF) as u8);
|
||||||
|
assert_eq!(crc16_ansi(&framed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc16_known_vector_check_bytes() {
|
||||||
|
// CRC-16/BUYPASS check value for the ASCII string "123456789" is 0xFEE8
|
||||||
|
// (the standard catalogue check value for poly 0x8005, init 0).
|
||||||
|
assert_eq!(crc16_ansi(b"123456789"), 0xFEE8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc16_mlp_known_vector_check_bytes() {
|
||||||
|
// Independent known-answer for CRC-16 poly 0x002D, init 0, MSB-first over
|
||||||
|
// the catalogue string "123456789" is 0x4FF7 — computed by a separate
|
||||||
|
// reference implementation (NOT by crc16_mlp), so a wrong polynomial or
|
||||||
|
// shift direction here fails this test even though every truehd fixture
|
||||||
|
// (which derives its trailer from crc16_mlp itself) would still pass.
|
||||||
|
assert_eq!(crc16_mlp(b"123456789"), 0x4FF7);
|
||||||
|
assert_eq!(crc16_mlp(&[0x00, 0x01, 0x02, 0x03]), 0x5E26);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc16_mlp_residue_property_holds() {
|
||||||
|
// Appending the big-endian CRC zeroes the residue over message+crc — the
|
||||||
|
// scheme `truehd::mlp_major_sync_ok` relies on.
|
||||||
|
let msg = [0xF8u8, 0x72, 0x6F, 0xBA];
|
||||||
|
let c = crc16_mlp(&msg);
|
||||||
|
let mut framed = msg.to_vec();
|
||||||
|
framed.push((c >> 8) as u8);
|
||||||
|
framed.push((c & 0xFF) as u8);
|
||||||
|
assert_eq!(crc16_mlp(&framed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc8_residue_property_holds() {
|
||||||
|
// Appending the CRC-8 of a message zeroes the residue over message+crc —
|
||||||
|
// how FLAC's header CRC-8 is verified.
|
||||||
|
let msg = [0xDEu8, 0xAD, 0xBE, 0xEF];
|
||||||
|
let c = crc8_atm(&msg);
|
||||||
|
let mut framed = msg.to_vec();
|
||||||
|
framed.push(c);
|
||||||
|
assert_eq!(crc8_atm(&framed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc8_known_vector_check_byte() {
|
||||||
|
// CRC-8/SMBUS (poly 0x07, init 0, no reflection) check value for
|
||||||
|
// "123456789" is 0xF4 — the catalogue check value.
|
||||||
|
assert_eq!(crc8_atm(b"123456789"), 0xF4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! Shared "keep what decodes, drop what doesn't" bookkeeping for the audio
|
||||||
|
//! codec parsers.
|
||||||
|
//!
|
||||||
|
//! The user's rule: a clean mux keeps every frame it can and drops the ones it
|
||||||
|
//! can't — video always survives (it's inter-frame predicted; a per-frame drop
|
||||||
|
//! would cascade, so video resyncs/conceals instead), audio keeps every
|
||||||
|
//! decodable access unit, and a damaged audio AU is dropped rather than shipped
|
||||||
|
//! as a decoder-choking glitch.
|
||||||
|
//!
|
||||||
|
//! The DETECTION is inherently per-codec — each format carries its own
|
||||||
|
//! authoritative corruption check (DTS: the core sync/header parse per ETSI TS
|
||||||
|
//! 102 114; AC-3: the header CRC per ETSI TS 102 366; FLAC: the frame CRC-16; …).
|
||||||
|
//! This type only carries the UNIFORM
|
||||||
|
//! response so every audio parser behaves identically:
|
||||||
|
//!
|
||||||
|
//! 1. **Count** kept vs dropped AUs and the dropped duration.
|
||||||
|
//! 2. **Log** every drop (fail-loud, never silent) — a per-drop trace plus a
|
||||||
|
//! once-per-track aggregate at `warn` so it surfaces without debug logging.
|
||||||
|
//! 3. **Whole-track fallback**: once a track is judged mostly undecodable, latch
|
||||||
|
//! a poison flag so the remainder is dropped too (a track that damaged isn't
|
||||||
|
//! worth muxing).
|
||||||
|
//!
|
||||||
|
//! **Sync preservation is the caller's responsibility**, not this type's: the
|
||||||
|
//! parser must advance its PTS clock across a dropped AU exactly as it would for
|
||||||
|
//! an emitted one, so a drop becomes a silence gap and never a shift of the
|
||||||
|
//! following audio. See `DtsParser`'s `stamp_pts` call ordering for the pattern.
|
||||||
|
|
||||||
|
/// Minimum access units observed before the whole-track drop verdict can fire.
|
||||||
|
/// Below this, a short damaged burst can't poison an otherwise-good track.
|
||||||
|
const TRACK_VERDICT_MIN_AUS: u64 = 200;
|
||||||
|
|
||||||
|
/// Per-track drop bookkeeping shared by the audio codec parsers.
|
||||||
|
pub(crate) struct DropTally {
|
||||||
|
/// Static codec label for log lines (e.g. `"dts"`, `"ac3"`).
|
||||||
|
codec: &'static str,
|
||||||
|
kept: u64,
|
||||||
|
dropped: u64,
|
||||||
|
/// AUs dropped because they were INDIVIDUALLY verified undecodable (a failed
|
||||||
|
/// CRC/header/parity check). Only these feed the whole-track poison verdict.
|
||||||
|
/// Distinct from `dropped`, which also counts *collateral* drops — AUs
|
||||||
|
/// discarded as a consequence of one corruption (TrueHD's resync-forward run,
|
||||||
|
/// or a poisoned track), which must NOT amplify a few real errors into a
|
||||||
|
/// false whole-track loss.
|
||||||
|
verified_dropped: u64,
|
||||||
|
dropped_dur_ns: u64,
|
||||||
|
poisoned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DropTally {
|
||||||
|
pub(crate) fn new(codec: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
codec,
|
||||||
|
kept: 0,
|
||||||
|
dropped: 0,
|
||||||
|
verified_dropped: 0,
|
||||||
|
dropped_dur_ns: 0,
|
||||||
|
poisoned: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the track has been judged too damaged to mux. Once `true`, the
|
||||||
|
/// caller should drop every remaining AU (passing them to [`record_drop`]
|
||||||
|
/// with a poison reason) rather than emit them.
|
||||||
|
pub(crate) fn is_poisoned(&self) -> bool {
|
||||||
|
self.poisoned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access units dropped as undecodable so far — surfaced to the CLI/mux.
|
||||||
|
pub(crate) fn dropped_frames(&self) -> u64 {
|
||||||
|
self.dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total decoded duration (ns) of dropped AUs — the audio silence introduced.
|
||||||
|
pub(crate) fn dropped_duration_ns(&self) -> u64 {
|
||||||
|
self.dropped_dur_ns
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an emitted (decodable) access unit.
|
||||||
|
pub(crate) fn record_kept(&mut self) {
|
||||||
|
self.kept += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a dropped access unit that was INDIVIDUALLY verified undecodable
|
||||||
|
/// (a failed CRC/header/parity check). Counts toward the whole-track poison
|
||||||
|
/// verdict. `reason` is a short static label for the check that failed.
|
||||||
|
pub(crate) fn record_drop(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
|
||||||
|
self.verified_dropped += 1;
|
||||||
|
self.record_drop_common(pts_ns, dur_ns, bytes, reason);
|
||||||
|
self.maybe_poison();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a COLLATERAL drop — an AU discarded as a consequence of another
|
||||||
|
/// corruption rather than being individually undecodable (TrueHD's
|
||||||
|
/// resync-forward run to the next major sync, or an already-poisoned track).
|
||||||
|
/// Counted and logged for the drop report, but deliberately does NOT feed the
|
||||||
|
/// poison verdict, so one corruption event can't amplify into a false
|
||||||
|
/// whole-track loss.
|
||||||
|
pub(crate) fn record_collateral_drop(
|
||||||
|
&mut self,
|
||||||
|
pts_ns: i64,
|
||||||
|
dur_ns: i64,
|
||||||
|
bytes: usize,
|
||||||
|
reason: &str,
|
||||||
|
) {
|
||||||
|
self.record_drop_common(pts_ns, dur_ns, bytes, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_drop_common(&mut self, pts_ns: i64, dur_ns: i64, bytes: usize, reason: &str) {
|
||||||
|
self.dropped += 1;
|
||||||
|
self.dropped_dur_ns += dur_ns.max(0) as u64;
|
||||||
|
tracing::debug!(
|
||||||
|
target: "mux",
|
||||||
|
"{}: dropped undecodable AU #{} pts_ns={} dur_ns={} bytes={} reason={}",
|
||||||
|
self.codec,
|
||||||
|
self.dropped,
|
||||||
|
pts_ns,
|
||||||
|
dur_ns,
|
||||||
|
bytes,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whole-track fallback: after enough AUs to judge, if more than half were
|
||||||
|
/// dropped the track is too damaged to be worth muxing — latch `poisoned`
|
||||||
|
/// and log it loudly once. The minimum-sample gate keeps a short damaged
|
||||||
|
/// burst from poisoning an otherwise-good track.
|
||||||
|
fn maybe_poison(&mut self) {
|
||||||
|
if self.poisoned {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Judge on VERIFIED drops vs all AUs seen: a track is only poisoned when
|
||||||
|
// a majority of its access units are individually undecodable — not when
|
||||||
|
// a couple of corruption events forced long collateral resync runs.
|
||||||
|
let total = self.kept + self.dropped;
|
||||||
|
if total >= TRACK_VERDICT_MIN_AUS && self.verified_dropped * 2 > total {
|
||||||
|
self.poisoned = true;
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
"{}: track too damaged to mux — {}/{} AUs individually undecodable (>50%); dropping the whole track",
|
||||||
|
self.codec,
|
||||||
|
self.verified_dropped,
|
||||||
|
total
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-of-stream aggregate report, logged at `warn` so a track's dropped
|
||||||
|
/// audio is never hidden even without debug logging. No-op if nothing was
|
||||||
|
/// dropped.
|
||||||
|
pub(crate) fn log_summary(&self) {
|
||||||
|
if self.dropped > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
"{}: dropped {} undecodable AU(s) totaling {} ns of audio ({} kept)",
|
||||||
|
self.codec,
|
||||||
|
self.dropped,
|
||||||
|
self.dropped_dur_ns,
|
||||||
|
self.kept
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counts_kept_and_dropped() {
|
||||||
|
let mut t = DropTally::new("test");
|
||||||
|
t.record_kept();
|
||||||
|
t.record_drop(0, 1000, 512, "bad");
|
||||||
|
t.record_kept();
|
||||||
|
assert_eq!(t.dropped_frames(), 1);
|
||||||
|
assert_eq!(t.dropped_duration_ns(), 1000);
|
||||||
|
assert!(!t.is_poisoned());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poisons_after_min_aus_over_half_dropped() {
|
||||||
|
let mut t = DropTally::new("test");
|
||||||
|
// 199 AUs, all dropped: below the min-AU gate, must NOT poison yet.
|
||||||
|
for _ in 0..199 {
|
||||||
|
t.record_drop(0, 1000, 512, "bad");
|
||||||
|
}
|
||||||
|
assert!(!t.is_poisoned(), "below the 200-AU minimum, no verdict");
|
||||||
|
// The 200th drop reaches the minimum with >50% dropped → poison.
|
||||||
|
t.record_drop(0, 1000, 512, "bad");
|
||||||
|
assert!(t.is_poisoned());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_poison_a_mostly_good_track() {
|
||||||
|
let mut t = DropTally::new("test");
|
||||||
|
// 400 AUs, 1 dropped: nowhere near 50%.
|
||||||
|
t.record_drop(0, 1000, 512, "bad");
|
||||||
|
for _ in 0..399 {
|
||||||
|
t.record_kept();
|
||||||
|
}
|
||||||
|
assert!(!t.is_poisoned());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collateral_drops_never_poison_the_track() {
|
||||||
|
// A TrueHD resync-forward run collaterally drops a long burst of AUs, but
|
||||||
|
// none are individually undecodable — the whole-track verdict must stay
|
||||||
|
// clean so one corruption event can't amplify into a false total loss.
|
||||||
|
let mut t = DropTally::new("test");
|
||||||
|
for _ in 0..(TRACK_VERDICT_MIN_AUS * 3) {
|
||||||
|
t.record_collateral_drop(0, 1000, 512, "resync-forward");
|
||||||
|
}
|
||||||
|
assert!(t.dropped_frames() >= TRACK_VERDICT_MIN_AUS, "drops counted");
|
||||||
|
assert!(!t.is_poisoned(), "collateral drops must not poison");
|
||||||
|
}
|
||||||
|
}
|
||||||
+722
-54
@@ -49,6 +49,12 @@ pub struct DtsParser {
|
|||||||
/// running cursor: previous emit + its duration). Only consulted when
|
/// running cursor: previous emit + its duration). Only consulted when
|
||||||
/// `front_pts` is unchanged from `last_front_pts`. `PTS_UNSET` = no base yet.
|
/// `front_pts` is unchanged from `last_front_pts`. `PTS_UNSET` = no base yet.
|
||||||
next_pts_ns: i64,
|
next_pts_ns: i64,
|
||||||
|
/// Keep/drop bookkeeping for the decodability gate: counts, per-drop and
|
||||||
|
/// aggregate logging, and the whole-track poison fallback. A dropped AU is
|
||||||
|
/// NEVER emitted, but the PTS clock is still advanced across it (see
|
||||||
|
/// [`stamp_pts`] usage) so every SURVIVING AU keeps the exact timestamp it
|
||||||
|
/// would have had — a drop becomes a silence gap, never a shift.
|
||||||
|
tally: super::dropgate::DropTally,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DtsParser {
|
impl Default for DtsParser {
|
||||||
@@ -65,6 +71,50 @@ impl DtsParser {
|
|||||||
pts_marks: std::collections::VecDeque::new(),
|
pts_marks: std::collections::VecDeque::new(),
|
||||||
last_front_pts: PTS_UNSET,
|
last_front_pts: PTS_UNSET,
|
||||||
next_pts_ns: PTS_UNSET,
|
next_pts_ns: PTS_UNSET,
|
||||||
|
tally: super::dropgate::DropTally::new("dts"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of access units dropped as undecodable so far. The mux/CLI reads
|
||||||
|
/// this to surface the count ("dropped N damaged DTS frames").
|
||||||
|
pub fn dropped_frames(&self) -> u64 {
|
||||||
|
self.tally.dropped_frames()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total decoded duration (ns) of all dropped access units — the length of
|
||||||
|
/// audio silence introduced by dropping undecodable frames.
|
||||||
|
pub fn dropped_duration_ns(&self) -> u64 {
|
||||||
|
self.tally.dropped_duration_ns()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gate an assembled access unit through the decodability check and either
|
||||||
|
/// push it or drop it. `au_pts`/`dur_ns` are already stamped on the shared
|
||||||
|
/// PTS clock (which the caller advances whether or not the AU survives), so
|
||||||
|
/// a drop leaves the following audio on its true timeline — a gap, not a
|
||||||
|
/// shift. Every drop is logged (fail-loud, never silent).
|
||||||
|
fn emit_or_drop(&mut self, au: Vec<u8>, au_pts: i64, dur_ns: i64, out: &mut Vec<Frame>) {
|
||||||
|
let verdict = if self.tally.is_poisoned() {
|
||||||
|
Err(DropReason::TrackPoisoned)
|
||||||
|
} else {
|
||||||
|
core_header_drop_reason(&au).map_or(Ok(()), Err)
|
||||||
|
};
|
||||||
|
match verdict {
|
||||||
|
Ok(()) => {
|
||||||
|
self.tally.record_kept();
|
||||||
|
out.push(Frame {
|
||||||
|
discontinuity: false,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
pts_ns: au_pts,
|
||||||
|
keyframe: true,
|
||||||
|
data: au,
|
||||||
|
duration_ns: Some(dur_ns as u64),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(reason) => {
|
||||||
|
self.tally
|
||||||
|
.record_drop(au_pts, dur_ns, au.len(), reason.as_str());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +238,7 @@ impl CodecParser for DtsParser {
|
|||||||
if pes.data.is_empty() {
|
if pes.data.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
// A PES with no PTS (rare for audio, but legal — the case OSS demuxers
|
// A PES with no PTS (rare for audio, but legal — the case demuxers
|
||||||
// guard at a post-gap continuation) must NOT reset the timeline to 0;
|
// guard at a post-gap continuation) must NOT reset the timeline to 0;
|
||||||
// continue from the most recent known base. Defense-in-depth: the
|
// continue from the most recent known base. Defense-in-depth: the
|
||||||
// discontinuity-carrying PES is a PUSI with a PTS in practice.
|
// discontinuity-carrying PES is a PUSI with a PTS in practice.
|
||||||
@@ -299,9 +349,18 @@ impl CodecParser for DtsParser {
|
|||||||
// flush is an extension-substream PES, carrying its own later
|
// flush is an extension-substream PES, carrying its own later
|
||||||
// timestamp) must NOT become the next unit's PTS base.
|
// timestamp) must NOT become the next unit's PTS base.
|
||||||
let mut forced = false;
|
let mut forced = false;
|
||||||
let au_end = match next_core_boundary(&self.buf, core_size) {
|
let (au_end, ext_clean) = match next_core_boundary(&self.buf, core_size) {
|
||||||
NextCore::Found(end) => end,
|
NextCore::Found { end, ext_clean } => (end, ext_clean),
|
||||||
NextCore::NeedMore => break, // candidate sync needs more header
|
NextCore::NeedMore if self.buf.len() <= MAX_AU_BYTES => break,
|
||||||
|
NextCore::NeedMore => {
|
||||||
|
// A candidate boundary exists but is not fully buffered. Normally
|
||||||
|
// we wait for more PES; but once the buffer exceeds the AU cap,
|
||||||
|
// apply the same force-flush safety valve as `None` so a crafted
|
||||||
|
// stream that keeps a boundary perpetually incomplete can't grow
|
||||||
|
// `buf` without bound (the `break` above never reaches it).
|
||||||
|
forced = true;
|
||||||
|
(self.buf.len(), true)
|
||||||
|
}
|
||||||
NextCore::None => {
|
NextCore::None => {
|
||||||
// No next core sync buffered yet. The trailing extension
|
// No next core sync buffered yet. The trailing extension
|
||||||
// substream PES packets may still be arriving, so WAIT for
|
// substream PES packets may still be arriving, so WAIT for
|
||||||
@@ -312,27 +371,32 @@ impl CodecParser for DtsParser {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
forced = true;
|
forced = true;
|
||||||
self.buf.len()
|
(self.buf.len(), true)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let au: Vec<u8> = self.buf[..au_end].to_vec();
|
// Damaged source encoding: when the extension boundary was GARBAGE
|
||||||
|
// (not any DTS sync — `ext_clean == false`), the extension bytes for
|
||||||
|
// this AU are corrupt and would make the decoder cascade "DSYNC check
|
||||||
|
// failed" / "Read past end of XLL band data". Emit the clean DTS core
|
||||||
|
// ALONE (a decodable, lossy frame) and still drain past the garbage to
|
||||||
|
// the next core — a perfect mux drops the bad frame's corrupt part
|
||||||
|
// rather than shipping it. A recognized-but-unsizeable extension
|
||||||
|
// (`ext_clean == true`) is preserved in full (lossless).
|
||||||
|
let emit_end = if ext_clean { au_end } else { core_size };
|
||||||
|
let au: Vec<u8> = self.buf[..emit_end].to_vec();
|
||||||
// The AU's own core PES PTS (the PES covering its first byte, even if
|
// The AU's own core PES PTS (the PES covering its first byte, even if
|
||||||
// that PES preceded the one(s) carrying its extensions or the next
|
// that PES preceded the one(s) carrying its extensions or the next
|
||||||
// core), stamped monotonically: honored when it advances past the
|
// core), stamped monotonically: honored when it advances past the
|
||||||
// running clock (UHD one-AU-per-PES), but never allowed to collide
|
// running clock (UHD one-AU-per-PES), but never allowed to collide
|
||||||
// with the previous AU when several cores share ONE PES (DVD).
|
// with the previous AU when several cores share ONE PES (DVD).
|
||||||
let dur_ns = dts_core_duration_ns(&au) as i64;
|
let dur_ns = dts_core_duration_ns(&au) as i64;
|
||||||
|
// Advance the PTS clock for this AU BEFORE the decodability gate, so
|
||||||
|
// a dropped AU still advances the timeline exactly as an emitted one
|
||||||
|
// would: the following AU keeps its true PTS and the drop is a gap,
|
||||||
|
// never a shift. `emit_or_drop` decides whether to actually push it.
|
||||||
let au_pts = self.stamp_pts(self.front_pts(), dur_ns);
|
let au_pts = self.stamp_pts(self.front_pts(), dur_ns);
|
||||||
frames.push(Frame {
|
self.emit_or_drop(au, au_pts, dur_ns, &mut frames);
|
||||||
discontinuity: false,
|
|
||||||
coding: None,
|
|
||||||
source: None,
|
|
||||||
pts_ns: au_pts,
|
|
||||||
keyframe: true,
|
|
||||||
data: au,
|
|
||||||
duration_ns: Some(dur_ns as u64),
|
|
||||||
});
|
|
||||||
self.drain_front(au_end);
|
self.drain_front(au_end);
|
||||||
// After draining, the marker covering the new front (if any) carries
|
// After draining, the marker covering the new front (if any) carries
|
||||||
// the next AU's PTS; `pending_pts` is only the fallback when no
|
// the next AU's PTS; `pending_pts` is only the fallback when no
|
||||||
@@ -359,10 +423,23 @@ impl CodecParser for DtsParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn flush(&mut self) -> Vec<Frame> {
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
// End of stream: emit the final access unit still buffered (the last
|
let out = self.flush_tail();
|
||||||
// core + its extension substreams, which had no following core sync to
|
// Aggregate drop report at end-of-stream (warn-level, always visible).
|
||||||
// close it during streaming). Require a complete core frame; drop a
|
self.tally.log_summary();
|
||||||
// bare partial sync tail.
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DtsParser {
|
||||||
|
/// Emit the final buffered access unit (the last core + its extension
|
||||||
|
/// substreams, which had no following core sync to close it during
|
||||||
|
/// streaming), gated through the decodability check. Require a complete core
|
||||||
|
/// frame; drop a bare partial sync tail.
|
||||||
|
fn flush_tail(&mut self) -> Vec<Frame> {
|
||||||
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < CORE_HEADER_MIN_BYTES
|
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < CORE_HEADER_MIN_BYTES
|
||||||
{
|
{
|
||||||
self.buf.clear();
|
self.buf.clear();
|
||||||
@@ -381,19 +458,9 @@ impl CodecParser for DtsParser {
|
|||||||
let dur_ns = dts_core_duration_ns(&au) as i64;
|
let dur_ns = dts_core_duration_ns(&au) as i64;
|
||||||
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
|
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
|
||||||
self.pts_marks.clear();
|
self.pts_marks.clear();
|
||||||
vec![Frame {
|
let mut out = Vec::new();
|
||||||
discontinuity: false,
|
self.emit_or_drop(au, pts_ns, dur_ns, &mut out);
|
||||||
coding: None,
|
out
|
||||||
source: None,
|
|
||||||
pts_ns,
|
|
||||||
keyframe: true,
|
|
||||||
data: au,
|
|
||||||
duration_ns: Some(dur_ns as u64),
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,7 +474,14 @@ fn find_sync(data: &[u8], pattern: &[u8; 4]) -> Option<usize> {
|
|||||||
/// Result of scanning for the next valid core sync that closes an access unit.
|
/// Result of scanning for the next valid core sync that closes an access unit.
|
||||||
enum NextCore {
|
enum NextCore {
|
||||||
/// A valid next core sync was found; the access unit ends at this offset.
|
/// A valid next core sync was found; the access unit ends at this offset.
|
||||||
Found(usize),
|
/// `ext_clean` is `false` only when the byte at the extension boundary was
|
||||||
|
/// GARBAGE — neither a core sync nor a DTS-HD extension sync — meaning the
|
||||||
|
/// extension region is corrupt (damaged source encoding). The caller then
|
||||||
|
/// emits the clean DTS core alone and drops the garbage, instead of shipping
|
||||||
|
/// a corrupt AU that makes the decoder cascade DSYNC / "Read past end of XLL".
|
||||||
|
/// It stays `true` when the region is a real (if unsizeable) extension sync —
|
||||||
|
/// that path is load-bearing for valid streams and must NOT be dropped.
|
||||||
|
Found { end: usize, ext_clean: bool },
|
||||||
/// A candidate core sync was found but its header isn't fully buffered yet,
|
/// A candidate core sync was found but its header isn't fully buffered yet,
|
||||||
/// so its validity can't be decided — wait for more data.
|
/// so its validity can't be decided — wait for more data.
|
||||||
NeedMore,
|
NeedMore,
|
||||||
@@ -492,8 +566,11 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
|||||||
}
|
}
|
||||||
pos += sz; // skip the whole extension substream precisely
|
pos += sz; // skip the whole extension substream precisely
|
||||||
}
|
}
|
||||||
// Couldn't size it (truncated/garbage header) — heuristic fallback.
|
// A real extension sync we couldn't size (truncated header /
|
||||||
_ => return scan_for_next_core(buf, pos),
|
// unsupported sub-form) — heuristic fallback, but the region IS a
|
||||||
|
// recognized extension, so keep it (ext_clean = true). This path
|
||||||
|
// is load-bearing for valid streams.
|
||||||
|
_ => return scan_for_next_core(buf, pos, true),
|
||||||
}
|
}
|
||||||
} else if buf[pos..].starts_with(&DTS_CORE_SYNC) {
|
} else if buf[pos..].starts_with(&DTS_CORE_SYNC) {
|
||||||
// The bytes right after the precisely-skipped extensions are the next
|
// The bytes right after the precisely-skipped extensions are the next
|
||||||
@@ -503,13 +580,18 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
|||||||
}
|
}
|
||||||
let sz = dts_core_frame_size(&buf[pos..]);
|
let sz = dts_core_frame_size(&buf[pos..]);
|
||||||
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
|
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
|
||||||
return NextCore::Found(pos);
|
return NextCore::Found {
|
||||||
|
end: pos,
|
||||||
|
ext_clean: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return scan_for_next_core(buf, pos); // implausible core here — fall back
|
return scan_for_next_core(buf, pos, true); // implausible core — recognized sync, keep
|
||||||
} else {
|
} else {
|
||||||
// Neither a known extension nor a core sync at the precise boundary
|
// GARBAGE at the extension boundary — neither a core sync nor a
|
||||||
// (padding / junk) — fall back to the heuristic scan.
|
// DTS-HD extension sync. This is damaged source encoding: the
|
||||||
return scan_for_next_core(buf, pos);
|
// extension region is corrupt. Mark ext_clean = false so the caller
|
||||||
|
// emits the clean core alone and drops the garbage.
|
||||||
|
return scan_for_next_core(buf, pos, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,7 +600,7 @@ fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
|||||||
/// syncword whose decoded size is plausible. Used only when precise extension
|
/// syncword whose decoded size is plausible. Used only when precise extension
|
||||||
/// skipping can't proceed; a chance core syncword in extension payload usually
|
/// skipping can't proceed; a chance core syncword in extension payload usually
|
||||||
/// decodes to an implausible size and is skipped.
|
/// decodes to an implausible size and is skipped.
|
||||||
fn scan_for_next_core(buf: &[u8], from: usize) -> NextCore {
|
fn scan_for_next_core(buf: &[u8], from: usize, ext_clean: bool) -> NextCore {
|
||||||
let mut from = from;
|
let mut from = from;
|
||||||
while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) {
|
while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) {
|
||||||
let pos = from + rel;
|
let pos = from + rel;
|
||||||
@@ -527,7 +609,10 @@ fn scan_for_next_core(buf: &[u8], from: usize) -> NextCore {
|
|||||||
}
|
}
|
||||||
let sz = dts_core_frame_size(&buf[pos..]);
|
let sz = dts_core_frame_size(&buf[pos..]);
|
||||||
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
|
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
|
||||||
return NextCore::Found(pos);
|
return NextCore::Found {
|
||||||
|
end: pos,
|
||||||
|
ext_clean,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
from = pos + SYNCWORD_BYTES;
|
from = pos + SYNCWORD_BYTES;
|
||||||
}
|
}
|
||||||
@@ -567,8 +652,8 @@ const DTS_CORE_SAMPLE_RATES: [u32; 16] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the
|
/// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the
|
||||||
/// core-header PCM-sample-block count — the same field ffmpeg's `dca` decoder
|
/// core-header PCM-sample-block count (ETSI TS 102 114) that fixes the frame's
|
||||||
/// uses to timestamp frames. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
|
/// decoded sample count. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
|
||||||
/// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.
|
/// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.
|
||||||
fn dts_core_samples(data: &[u8]) -> u32 {
|
fn dts_core_samples(data: &[u8]) -> u32 {
|
||||||
if data.len() < CORE_HEADER_MIN_BYTES {
|
if data.len() < CORE_HEADER_MIN_BYTES {
|
||||||
@@ -597,6 +682,145 @@ fn dts_core_duration_ns(data: &[u8]) -> u64 {
|
|||||||
(samples * 1_000_000_000 + rate / 2) / rate
|
(samples * 1_000_000_000 + rate / 2) / rate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DTS core-header validity constants (ETSI TS 102 114).
|
||||||
|
/// For a NORMAL frame `deficit_samples` must equal this (`DTS_PCMBLOCK_SAMPLES`)
|
||||||
|
/// — a termination frame may carry fewer; `npcmblocks` must be a multiple of
|
||||||
|
/// `DTS_SUBBAND_SAMPLES`; `audio_mode` must be below `DTS_AMODE_COUNT`;
|
||||||
|
/// `lfe_present == DTS_LFE_FLAG_INVALID` is rejected.
|
||||||
|
const DTS_PCMBLOCK_SAMPLES: u32 = 32;
|
||||||
|
const DTS_SUBBAND_SAMPLES: u32 = 8;
|
||||||
|
/// Number of LEGAL `AMODE` (channel-arrangement) codes. The 6-bit AMODE field
|
||||||
|
/// (ETSI TS 102 114 §5.3.1) has 16 defined channel arrangements, codes 0-15;
|
||||||
|
/// only 16-63 are reserved/user-defined and undecodable. ffmpeg's
|
||||||
|
/// `ff_dca_channels[16] = {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8}` confirms all 16 are
|
||||||
|
/// decodable — codes 10-15 are the 6/7/8-channel layouts. A frame is dropped
|
||||||
|
/// only when `audio_mode >= DTS_AMODE_COUNT` (i.e. a truly reserved 16-63 code);
|
||||||
|
/// dropping a legal 10-15 multichannel core would silence recoverable audio.
|
||||||
|
const DTS_AMODE_COUNT: u32 = 16;
|
||||||
|
const DTS_LFE_FLAG_INVALID: u32 = 3;
|
||||||
|
|
||||||
|
/// Sample rate (Hz) per core `SFREQ` code (ETSI TS 102 114 Table 6-4); a `0`
|
||||||
|
/// entry marks a reserved code that fails header validation as an invalid
|
||||||
|
/// sample rate. Valid entries are locked to the spec by
|
||||||
|
/// `dts_core_sfreq_table_matches_the_dca_spec`; the reserved codes are
|
||||||
|
/// {0, 4, 5, 9, 10}.
|
||||||
|
const DTS_CORE_SR_VALID: [u32; 16] = [
|
||||||
|
0, 8_000, 16_000, 32_000, 0, 0, 11_025, 22_050, 44_100, 0, 0, 12_000, 24_000, 48_000, 96_000,
|
||||||
|
192_000,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Bits per sample per core `PCMR` code (ETSI TS 102 114); a `0` entry marks a
|
||||||
|
/// reserved `PCMR` code that fails header validation as an invalid PCM
|
||||||
|
/// resolution; reserved codes are {4, 7}.
|
||||||
|
const DTS_CORE_PCMR_BITS: [u8; 8] = [16, 16, 20, 20, 0, 24, 24, 0];
|
||||||
|
|
||||||
|
/// Why an access unit was judged undecodable. Each core-header variant is a
|
||||||
|
/// condition under which the DTS core-frame header (ETSI TS 102 114) is invalid
|
||||||
|
/// and a decoder would reject the frame; `TrackPoisoned` is our whole-track drop.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum DropReason {
|
||||||
|
DeficitSamples,
|
||||||
|
PcmBlocks,
|
||||||
|
FrameSize,
|
||||||
|
Amode,
|
||||||
|
SampleRate,
|
||||||
|
LfeFlag,
|
||||||
|
PcmRes,
|
||||||
|
TrackPoisoned,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DropReason {
|
||||||
|
/// Short static label for the drop log (the shared tally logs `&str`).
|
||||||
|
fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DropReason::DeficitSamples => "deficit-samples",
|
||||||
|
DropReason::PcmBlocks => "pcm-blocks",
|
||||||
|
DropReason::FrameSize => "frame-size",
|
||||||
|
DropReason::Amode => "audio-mode",
|
||||||
|
DropReason::SampleRate => "sample-rate",
|
||||||
|
DropReason::LfeFlag => "lfe-flag",
|
||||||
|
DropReason::PcmRes => "pcm-resolution",
|
||||||
|
DropReason::TrackPoisoned => "track-poisoned",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodability gate: the core-frame header validity checks from ETSI TS 102
|
||||||
|
/// 114. Returns `Some(reason)` when the DTS core-frame header is invalid — in
|
||||||
|
/// which case the packet is undecodable ("Invalid data found") and dropping it
|
||||||
|
/// loses nothing a decoder could have used. Returns `None` (keep) for a
|
||||||
|
/// decodable header OR if the header can't be fully read (never false-drop on
|
||||||
|
/// our own buffer underrun; the framer only emits AUs whose core is fully
|
||||||
|
/// buffered and ≥ 96 bytes).
|
||||||
|
///
|
||||||
|
/// The 4-byte core sync is already validated by the framer, so this reads the
|
||||||
|
/// header fields that follow it. The 16-bit CPF header CRC is not verified (we
|
||||||
|
/// skip past it) and the audio-header/side-info CRCs are likewise not checked,
|
||||||
|
/// because decoders treat those bytes as optional/ignored — verifying them
|
||||||
|
/// would drop frames that decode fine (false positives).
|
||||||
|
fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
|
||||||
|
let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?);
|
||||||
|
|
||||||
|
// FTYPE: 1 = NORMAL frame, 0 = TERMINATION frame (the last frame of the
|
||||||
|
// stream). Per ETSI TS 102 114 and both reference decoders — ffmpeg's
|
||||||
|
// `ff_dca_parse_core_frame_header` (`normal_frame && deficit_samples !=
|
||||||
|
// DCA_PCMBLOCK_SAMPLES`) and dcadec's `parse_frame_header` (which branches
|
||||||
|
// on `normal_frame`) — the deficit-sample field must equal 32 ONLY for a
|
||||||
|
// normal frame. A termination frame legitimately carries fewer samples and
|
||||||
|
// is fully decodable; dropping it would silence the last frame of every
|
||||||
|
// stream that ends on one (a guaranteed per-track loss on real discs).
|
||||||
|
let normal_frame = r.read_bit()? == 1;
|
||||||
|
let deficit_samples = r.read_bits(5)? + 1;
|
||||||
|
if normal_frame && deficit_samples != DTS_PCMBLOCK_SAMPLES {
|
||||||
|
return Some(DropReason::DeficitSamples);
|
||||||
|
}
|
||||||
|
let crc_present = r.read_bit()? == 1;
|
||||||
|
let npcmblocks = r.read_bits(7)? + 1;
|
||||||
|
if npcmblocks & (DTS_SUBBAND_SAMPLES - 1) != 0 {
|
||||||
|
return Some(DropReason::PcmBlocks);
|
||||||
|
}
|
||||||
|
let frame_size = r.read_bits(14)? + 1;
|
||||||
|
if frame_size < MIN_CORE_FRAME_BYTES as u32 {
|
||||||
|
return Some(DropReason::FrameSize);
|
||||||
|
}
|
||||||
|
let audio_mode = r.read_bits(6)?;
|
||||||
|
if audio_mode >= DTS_AMODE_COUNT {
|
||||||
|
return Some(DropReason::Amode);
|
||||||
|
}
|
||||||
|
let sr_code = r.read_bits(4)? as usize;
|
||||||
|
if DTS_CORE_SR_VALID[sr_code] == 0 {
|
||||||
|
return Some(DropReason::SampleRate);
|
||||||
|
}
|
||||||
|
let _br_code = r.read_bits(5)?;
|
||||||
|
// Reserved bit. Both reference decoders SKIP this field rather than reject
|
||||||
|
// on it — ffmpeg (`skip_bits1`) and dcadec (`bits_skip1`, comment "Reserved
|
||||||
|
// field"). A frame that sets it is still fully decodable, so rejecting it
|
||||||
|
// was a false-drop that silenced any real stream whose encoder set the bit.
|
||||||
|
// Read past it without gating (never reject a decodable frame).
|
||||||
|
let _reserved = r.read_bit()?;
|
||||||
|
// drc, ts, aux, hdcd (1 each) → ext_audio_type (3) → ext_present, aspf (1 each).
|
||||||
|
r.skip_bits(4)?;
|
||||||
|
r.skip_bits(3)?;
|
||||||
|
r.skip_bits(2)?;
|
||||||
|
let lfe_present = r.read_bits(2)?;
|
||||||
|
if lfe_present == DTS_LFE_FLAG_INVALID {
|
||||||
|
return Some(DropReason::LfeFlag);
|
||||||
|
}
|
||||||
|
let _predictor_history = r.read_bit()?;
|
||||||
|
if crc_present {
|
||||||
|
// Skip past the 16-bit header CRC here — it is not verified.
|
||||||
|
r.skip_bits(16)?;
|
||||||
|
}
|
||||||
|
let _filter_perfect = r.read_bit()?;
|
||||||
|
let _encoder_rev = r.read_bits(4)?;
|
||||||
|
let _copy_hist = r.read_bits(2)?;
|
||||||
|
let pcmr_code = r.read_bits(3)? as usize;
|
||||||
|
if DTS_CORE_PCMR_BITS[pcmr_code] == 0 {
|
||||||
|
return Some(DropReason::PcmRes);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -617,6 +841,12 @@ mod tests {
|
|||||||
let fsize = size - 1;
|
let fsize = size - 1;
|
||||||
let mut data = vec![0u8; size];
|
let mut data = vec![0u8; size];
|
||||||
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
||||||
|
// byte4: FTYPE(1) SHORT(5) CPF(0) NBLKS-high(0). FTYPE = 1 = a NORMAL
|
||||||
|
// frame (the common real-stream case); SHORT = 31 makes deficit_samples
|
||||||
|
// = 32 = DTS_PCMBLOCK_SAMPLES, which the decodability gate (per ETSI TS
|
||||||
|
// 102 114) requires of a normal frame. NBLKS high bit (byte4 bit0) stays
|
||||||
|
// 0 for NBLKS = 15. (0x80 | (31 << 2) = 0xFC.)
|
||||||
|
data[4] = 0x80 | (31u8 << 2);
|
||||||
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
|
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
|
||||||
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
|
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
|
||||||
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
|
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
|
||||||
@@ -664,8 +894,8 @@ mod tests {
|
|||||||
// AU = core(512) + a REAL EXSS substream whose XLL payload embeds a DTS
|
// AU = core(512) + a REAL EXSS substream whose XLL payload embeds a DTS
|
||||||
// core syncword decoding to a plausible size (512). The heuristic-only
|
// core syncword decoding to a plausible size (512). The heuristic-only
|
||||||
// framer would split here and truncate the lossless extension (the
|
// framer would split here and truncate the lossless extension (the
|
||||||
// Dunkirk `dca` "Failed to decode block code(s)" class). Precise EXSS
|
// Dunkirk "Failed to decode block code(s)" decoder-failure class).
|
||||||
// sizing spans the whole extension to the REAL next core.
|
// Precise EXSS sizing spans the whole extension to the REAL next core.
|
||||||
let core = make_dts_core(512);
|
let core = make_dts_core(512);
|
||||||
let exss = make_exss(600, Some(40));
|
let exss = make_exss(600, Some(40));
|
||||||
let next = make_dts_core(512);
|
let next = make_dts_core(512);
|
||||||
@@ -676,12 +906,60 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
next_core_boundary(&buf, core.len()),
|
next_core_boundary(&buf, core.len()),
|
||||||
NextCore::Found(end) if end == core.len() + exss.len()
|
NextCore::Found { end, .. } if end == core.len() + exss.len()
|
||||||
),
|
),
|
||||||
"AU must end at the REAL next core (after the full EXSS), not the false sync inside it"
|
"AU must end at the REAL next core (after the full EXSS), not the false sync inside it"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn garbage_extension_emits_core_only_but_valid_ext_is_kept() {
|
||||||
|
// Damaged source: a valid core, then GARBAGE (no core sync, no extension
|
||||||
|
// sync) where the extension belongs, then the next core. The framer must
|
||||||
|
// mark this boundary ext_clean=false and the parser must emit the clean
|
||||||
|
// 512-byte CORE alone (dropping the garbage), draining to the next core.
|
||||||
|
let core = make_dts_core(512);
|
||||||
|
let garbage = vec![0xE4, 0x3F, 0xE3, 0x90, 0xCC, 0x6C]; // real Bourne head bytes
|
||||||
|
let mut garbage = garbage;
|
||||||
|
garbage.extend(std::iter::repeat(0xAB).take(300));
|
||||||
|
let next = make_dts_core(512);
|
||||||
|
let mut buf = core.clone();
|
||||||
|
buf.extend_from_slice(&garbage);
|
||||||
|
buf.extend_from_slice(&next);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
next_core_boundary(&buf, core.len()),
|
||||||
|
NextCore::Found { end, ext_clean: false } if end == core.len() + garbage.len()
|
||||||
|
),
|
||||||
|
"garbage boundary must be flagged unclean"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let mut frames = parser.parse(&make_pes(buf, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
assert!(!frames.is_empty());
|
||||||
|
for f in &frames {
|
||||||
|
assert_eq!(f.data.len(), 512, "garbage-extension AU emits core only");
|
||||||
|
assert_eq!(&f.data[0..4], &DTS_CORE_SYNC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrast: a REAL extension sync (even if unsizeable) must be KEPT in
|
||||||
|
// full — ext_clean stays true, never downgraded to core-only.
|
||||||
|
let mut buf2 = make_dts_core(512);
|
||||||
|
buf2.extend_from_slice(&make_dts_ext(256));
|
||||||
|
buf2.extend_from_slice(&make_dts_core(512));
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
next_core_boundary(&buf2, 512),
|
||||||
|
NextCore::Found {
|
||||||
|
ext_clean: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"a recognized extension sync is preserved, not dropped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_empty_pes() {
|
fn parse_empty_pes() {
|
||||||
let mut parser = DtsParser::new();
|
let mut parser = DtsParser::new();
|
||||||
@@ -724,9 +1002,9 @@ mod tests {
|
|||||||
// B1: a partial DTS core is buffered, then a concealed gap (PES marked
|
// B1: a partial DTS core is buffered, then a concealed gap (PES marked
|
||||||
// discontinuity) carries a fresh core. The truncated partial must be
|
// discontinuity) carries a fresh core. The truncated partial must be
|
||||||
// DROPPED — splicing it makes the framer emit a corrupt sub-core-length
|
// DROPPED — splicing it makes the framer emit a corrupt sub-core-length
|
||||||
// AU (the Dunkirk `dca` "Failed to decode block code(s)" class) and
|
// AU (the Dunkirk "Failed to decode block code(s)" decoder-failure
|
||||||
// strands the rest. With the fix the post-gap core is the only AU, and it
|
// class) and strands the rest. With the fix the post-gap core is the
|
||||||
// carries the post-gap PTS (not the stale pre-gap one).
|
// only AU, and it carries the post-gap PTS (not the stale pre-gap one).
|
||||||
let mut parser = DtsParser::new();
|
let mut parser = DtsParser::new();
|
||||||
|
|
||||||
// PES 1: first half of a 512-byte core (no boundary marker).
|
// PES 1: first half of a 512-byte core (no boundary marker).
|
||||||
@@ -873,7 +1151,7 @@ mod tests {
|
|||||||
fn dvd_many_cores_one_pes_are_strictly_monotonic() {
|
fn dvd_many_cores_one_pes_are_strictly_monotonic() {
|
||||||
// Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core
|
// Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core
|
||||||
// frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old
|
// frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old
|
||||||
// code stamped every AU with the one PES PTS, which ffmpeg rejected as
|
// code stamped every AU with the one PES PTS, which a muxer rejects as
|
||||||
// "non monotonically increasing dts to muxer: X >= X".
|
// "non monotonically increasing dts to muxer: X >= X".
|
||||||
let mut parser = DtsParser::new();
|
let mut parser = DtsParser::new();
|
||||||
let mut stream = Vec::new();
|
let mut stream = Vec::new();
|
||||||
@@ -920,9 +1198,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dts_core_sfreq_table_matches_the_dca_spec() {
|
fn dts_core_sfreq_table_matches_the_dca_spec() {
|
||||||
// Lock the SFREQ → sample-rate table to ffmpeg's authoritative
|
// Lock the SFREQ → sample-rate table to the authoritative values in
|
||||||
// `avpriv_dca_sample_rates` (ETSI TS 102 114 Table 6-4). The high-rate
|
// ETSI TS 102 114 Table 6-4. The high-rate triad in particular —
|
||||||
// triad in particular — 48 k / 96 k / 192 k at indices 13/14/15 — must not
|
// 48 k / 96 k / 192 k at indices 13/14/15 — must not
|
||||||
// be shifted; a wrong entry would compute an N× frame duration and
|
// be shifted; a wrong entry would compute an N× frame duration and
|
||||||
// reintroduce PTS drift on a 96/192 kHz DTS stream.
|
// reintroduce PTS drift on a 96/192 kHz DTS stream.
|
||||||
let mut core = make_dts_core(512);
|
let mut core = make_dts_core(512);
|
||||||
@@ -1213,6 +1491,51 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn needmore_past_cap_force_flushes_to_bound_buffer() {
|
||||||
|
// A crafted DTS-HD stream whose extension substream declares a size
|
||||||
|
// larger than what is (ever) buffered keeps `next_core_boundary` in a
|
||||||
|
// sustained NeedMore state (a candidate boundary that is never fully
|
||||||
|
// buffered). Once `buf` exceeds MAX_AU_BYTES the NeedMore force-flush
|
||||||
|
// safety valve must fire — mirroring the None arm — so the buffer can't
|
||||||
|
// grow without bound. WITHOUT the guard the parser would `break` and
|
||||||
|
// retain everything, emitting nothing.
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
|
||||||
|
let core = make_dts_core(512);
|
||||||
|
// Short-form EXSS header declaring the maximum 16-bit size (65536 bytes);
|
||||||
|
// we buffer only a truncated prefix of it, so the extension is never
|
||||||
|
// "fully buffered" and the candidate boundary stays NeedMore.
|
||||||
|
let full_ext = make_exss(65536, None);
|
||||||
|
assert_eq!(exss_frame_size(&full_ext), Some(65536));
|
||||||
|
|
||||||
|
// Land the total buffer in (MAX_AU_BYTES, core_size + declared_ext_size):
|
||||||
|
// 65600 > 65536 fires the cap; 65600 < 512 + 65536 = 66048 keeps NeedMore.
|
||||||
|
let total = 65600usize;
|
||||||
|
let mut data = core.clone();
|
||||||
|
data.extend_from_slice(&full_ext[..total - core.len()]);
|
||||||
|
assert!(data.len() > MAX_AU_BYTES, "buffer must exceed the AU cap");
|
||||||
|
assert!(
|
||||||
|
data.len() < core.len() + 65536,
|
||||||
|
"extension must not be fully buffered (sustained NeedMore)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(next_core_boundary(&data, core.len()), NextCore::NeedMore),
|
||||||
|
"the framing decision at this buffer size is NeedMore past the cap"
|
||||||
|
);
|
||||||
|
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
assert_eq!(
|
||||||
|
frames.len(),
|
||||||
|
1,
|
||||||
|
"NeedMore past the AU cap must force-emit, not stall and balloon the buffer"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parser.buf.is_empty(),
|
||||||
|
"the forced flush drains the buffer instead of growing it unbounded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn codec_private_none() {
|
fn codec_private_none() {
|
||||||
let parser = DtsParser::new();
|
let parser = DtsParser::new();
|
||||||
@@ -1478,4 +1801,349 @@ mod tests {
|
|||||||
"sub-floor sync skipped, real 512 core is AU1"
|
"sub-floor sync skipped, real 512 core is AU1"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A structurally-framed but UNDECODABLE core: a valid `make_dts_core` whose
|
||||||
|
/// LFE flag is set to the reserved value 3 (`DTS_LFE_FLAG_INVALID`). It still
|
||||||
|
/// sizes and syncs correctly (so the framer delimits it normally), but the
|
||||||
|
/// core-frame header validity check rejects it as an invalid LFE flag (ETSI
|
||||||
|
/// TS 102 114; dcadec `LFE_FLAG_INVALID`). LFE is byte10 bits2-1, and does
|
||||||
|
/// NOT feed the frame duration (NBLKS + SFREQ only), so a dropped bad core
|
||||||
|
/// still carries the same `DTS_CORE_DUR_NS` as its good peers.
|
||||||
|
fn make_bad_dts_core(size: usize) -> Vec<u8> {
|
||||||
|
let mut d = make_dts_core(size);
|
||||||
|
assert!(
|
||||||
|
core_header_drop_reason(&d).is_none(),
|
||||||
|
"base core is decodable"
|
||||||
|
);
|
||||||
|
d[10] |= 0x06; // LFE flag = 3 (invalid)
|
||||||
|
assert_eq!(
|
||||||
|
core_header_drop_reason(&d),
|
||||||
|
Some(DropReason::LfeFlag),
|
||||||
|
"invalid-LFE core must be judged undecodable"
|
||||||
|
);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_stream_drops_nothing() {
|
||||||
|
// A clean stream of decodable cores must pass the gate untouched — the
|
||||||
|
// detector follows the spec's validity rules exactly, so zero false
|
||||||
|
// positives.
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let mut stream = Vec::new();
|
||||||
|
for _ in 0..5 {
|
||||||
|
stream.extend_from_slice(&make_dts_core(512));
|
||||||
|
}
|
||||||
|
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
assert_eq!(frames.len(), 5, "all five cores emitted");
|
||||||
|
assert_eq!(parser.dropped_frames(), 0, "nothing dropped");
|
||||||
|
assert_eq!(parser.dropped_duration_ns(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undecodable_core_is_dropped_and_counted() {
|
||||||
|
// A single undecodable core between good ones is dropped; the survivors
|
||||||
|
// are emitted and the drop is counted.
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let mut stream = make_dts_core(512);
|
||||||
|
stream.extend_from_slice(&make_bad_dts_core(512));
|
||||||
|
stream.extend_from_slice(&make_dts_core(640));
|
||||||
|
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
assert_eq!(frames.len(), 2, "the bad core is dropped, two survive");
|
||||||
|
assert_eq!(frames[0].data.len(), 512);
|
||||||
|
assert_eq!(frames[1].data.len(), 640);
|
||||||
|
assert_eq!(parser.dropped_frames(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
parser.dropped_duration_ns(),
|
||||||
|
DTS_CORE_DUR_NS as u64,
|
||||||
|
"one frame's worth of audio silence introduced"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_preserves_av_sync_no_shift() {
|
||||||
|
// THE INVARIANT: dropping an undecodable AU must never shift the audio
|
||||||
|
// that follows. A good/bad/good run in ONE PES — the bad middle core is
|
||||||
|
// dropped, but the trailing good core must keep the EXACT PTS it would
|
||||||
|
// have had with no drop (base + 2 frame durations), so the drop is a
|
||||||
|
// silence gap, not a shift.
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let mut stream = make_dts_core(512); // c1: good
|
||||||
|
stream.extend_from_slice(&make_bad_dts_core(512)); // c2: undecodable
|
||||||
|
stream.extend_from_slice(&make_dts_core(640)); // c3: good
|
||||||
|
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 2, "c2 dropped; c1 and c3 survive");
|
||||||
|
let base = pts_to_ns(90000);
|
||||||
|
assert_eq!(frames[0].pts_ns, base, "c1 keeps the PES base PTS");
|
||||||
|
assert_eq!(
|
||||||
|
frames[1].pts_ns,
|
||||||
|
base + 2 * DTS_CORE_DUR_NS,
|
||||||
|
"c3 keeps its TRUE timeline (base + 2 frames) — the drop is a gap, not a shift"
|
||||||
|
);
|
||||||
|
// The gap between the survivors is exactly the dropped frame's duration
|
||||||
|
// beyond the normal one-frame spacing.
|
||||||
|
assert_eq!(
|
||||||
|
frames[1].pts_ns - frames[0].pts_ns,
|
||||||
|
2 * DTS_CORE_DUR_NS,
|
||||||
|
"surviving AUs are spaced by the real timeline including the dropped frame's slot"
|
||||||
|
);
|
||||||
|
assert_eq!(parser.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whole_track_poison_drops_remainder() {
|
||||||
|
// A track dominated by undecodable frames is judged too damaged to mux:
|
||||||
|
// once the >50% verdict fires (after the minimum sample count), the
|
||||||
|
// whole track — including any later good frames — is dropped.
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let mut stream = Vec::new();
|
||||||
|
// 300 AUs, ~2/3 undecodable → well over the 50% threshold and the
|
||||||
|
// 200-AU minimum.
|
||||||
|
for i in 0..300 {
|
||||||
|
if i % 3 == 0 {
|
||||||
|
stream.extend_from_slice(&make_dts_core(512));
|
||||||
|
} else {
|
||||||
|
stream.extend_from_slice(&make_bad_dts_core(512));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A trailing burst of GOOD cores that must be dropped once poisoned.
|
||||||
|
for _ in 0..20 {
|
||||||
|
stream.extend_from_slice(&make_dts_core(512));
|
||||||
|
}
|
||||||
|
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
assert!(
|
||||||
|
parser.tally.is_poisoned(),
|
||||||
|
"track poisoned by >50% drop rate"
|
||||||
|
);
|
||||||
|
// Once poisoned, later good cores are dropped too, so the kept count
|
||||||
|
// (kept = emitted survivors) is far below the ~120 good cores present.
|
||||||
|
let kept = frames.len() as u64;
|
||||||
|
assert!(
|
||||||
|
kept < 120,
|
||||||
|
"post-poison good frames also dropped (kept={kept})"
|
||||||
|
);
|
||||||
|
assert!(parser.dropped_frames() > 150, "majority dropped");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sr_validity_table_marks_reserved_codes() {
|
||||||
|
// The core-header sample-rate validity table must have ZERO (reject) at
|
||||||
|
// exactly the reserved SFREQ codes {0,4,5,9,10} and a real rate
|
||||||
|
// elsewhere — this is what drives the invalid-sample-rate rejection.
|
||||||
|
for code in 0..16usize {
|
||||||
|
let reserved = matches!(code, 0 | 4 | 5 | 9 | 10);
|
||||||
|
assert_eq!(
|
||||||
|
DTS_CORE_SR_VALID[code] == 0,
|
||||||
|
reserved,
|
||||||
|
"SFREQ code {code} reserved={reserved}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_core_header_error_class_is_detected() {
|
||||||
|
// Exercise each header-validity rejection so the gate stays faithful to
|
||||||
|
// the spec. Start from a decodable core and corrupt one field at a time.
|
||||||
|
let good = make_dts_core(512);
|
||||||
|
assert_eq!(core_header_drop_reason(&good), None);
|
||||||
|
|
||||||
|
// deficit_samples != 32: clear SHORT (byte4 bits6-2) → deficit = 1.
|
||||||
|
let mut d = good.clone();
|
||||||
|
d[4] &= !0x7C;
|
||||||
|
assert_eq!(
|
||||||
|
core_header_drop_reason(&d),
|
||||||
|
Some(DropReason::DeficitSamples)
|
||||||
|
);
|
||||||
|
|
||||||
|
// npcmblocks not a multiple of 8: NBLKS low bits (byte5 bits7-2) → 14
|
||||||
|
// (npcmblocks=15, 15 & 7 = 7 ≠ 0).
|
||||||
|
let mut d = good.clone();
|
||||||
|
d[5] = (d[5] & 0x03) | (14u8 << 2);
|
||||||
|
assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmBlocks));
|
||||||
|
|
||||||
|
// audio_mode reserved (>= 16): AMODE = byte7 bits3-0 (high 4) + byte8
|
||||||
|
// bits7-6. Set AMODE high nibble to 0xF → audio_mode = 60, a genuinely
|
||||||
|
// RESERVED code (16-63) a decoder rejects. (Codes 10-15 are LEGAL
|
||||||
|
// multichannel layouts and must NOT be dropped — see
|
||||||
|
// legal_multichannel_amode_is_not_dropped.)
|
||||||
|
let mut d = good.clone();
|
||||||
|
d[7] |= 0x0F;
|
||||||
|
assert_eq!(core_header_drop_reason(&d), Some(DropReason::Amode));
|
||||||
|
|
||||||
|
// sample_rate reserved: SFREQ (byte8 bits5-2) = 0.
|
||||||
|
let mut d = good.clone();
|
||||||
|
d[8] &= !0x3C;
|
||||||
|
assert_eq!(core_header_drop_reason(&d), Some(DropReason::SampleRate));
|
||||||
|
|
||||||
|
// lfe_present == 3: LFE is byte10 bits2-1.
|
||||||
|
let mut d = good.clone();
|
||||||
|
d[10] |= 0x06;
|
||||||
|
assert_eq!(core_header_drop_reason(&d), Some(DropReason::LfeFlag));
|
||||||
|
|
||||||
|
// pcmr_code reserved (7): pcmr is byte11 bit0 + byte12 bits7-6 → set all
|
||||||
|
// three to 1 (code 7 → DTS_CORE_PCMR_BITS[7] = 0, a reserved PCMR code).
|
||||||
|
let mut d = good.clone();
|
||||||
|
d[11] |= 0x01;
|
||||||
|
d[12] |= 0xC0;
|
||||||
|
assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmRes));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legal_multichannel_amode_is_not_dropped() {
|
||||||
|
// ETSI TS 102 114 §5.3.1: AMODE is a 6-bit field with 16 LEGAL
|
||||||
|
// channel-arrangement codes (0-15); only 16-63 are reserved. ffmpeg's
|
||||||
|
// ff_dca_channels[16] = {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8} confirms codes
|
||||||
|
// 10-15 are decodable 6/7/8-channel layouts. The decodability gate must
|
||||||
|
// KEEP them — dropping a spec-legal multichannel core silences audio the
|
||||||
|
// recover-100% goal must preserve.
|
||||||
|
fn set_amode(core: &mut [u8], amode: u32) {
|
||||||
|
// audio_mode = (byte7 & 0x0F) << 2 | (byte8 >> 6).
|
||||||
|
core[7] = (core[7] & 0xF0) | ((amode >> 2) & 0x0F) as u8;
|
||||||
|
core[8] = (core[8] & 0x3F) | (((amode & 0x03) << 6) as u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every legal code 0-15 is kept — the range is a literal (NOT
|
||||||
|
// DTS_AMODE_COUNT) so reverting the bound to 10 makes 10-15 fail here.
|
||||||
|
for amode in 0u32..16 {
|
||||||
|
let mut core = make_dts_core(512);
|
||||||
|
set_amode(&mut core, amode);
|
||||||
|
assert_eq!(
|
||||||
|
core_header_drop_reason(&core),
|
||||||
|
None,
|
||||||
|
"legal AMODE {amode} must not be dropped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The first reserved code (16) and above are still rejected.
|
||||||
|
for amode in [16u32, 40, 63] {
|
||||||
|
let mut core = make_dts_core(512);
|
||||||
|
set_amode(&mut core, amode);
|
||||||
|
assert_eq!(
|
||||||
|
core_header_drop_reason(&core),
|
||||||
|
Some(DropReason::Amode),
|
||||||
|
"reserved AMODE {amode} must be dropped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set FTYPE (byte4 bit7: 1 = normal, 0 = termination) and the 5-bit SHORT
|
||||||
|
/// field (byte4 bits6-2), leaving CPF and the NBLKS high bit (bits1-0) intact.
|
||||||
|
/// `deficit_samples = short_field + 1`.
|
||||||
|
fn set_ftype_short(core: &mut [u8], normal: bool, short_field: u8) {
|
||||||
|
core[4] = (core[4] & 0x03) | ((normal as u8) << 7) | ((short_field & 0x1F) << 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn termination_frame_with_small_deficit_is_kept() {
|
||||||
|
// ETSI TS 102 114 / ffmpeg (`normal_frame && deficit != 32`) / dcadec:
|
||||||
|
// a TERMINATION frame (FTYPE=0) may legally carry fewer than 32 deficit
|
||||||
|
// samples and is fully decodable. It must NOT be dropped — dropping the
|
||||||
|
// last frame of a stream silences real audio (recover-100% violation).
|
||||||
|
let mut core = make_dts_core(512);
|
||||||
|
set_ftype_short(&mut core, false, 10); // termination, deficit = 11 (< 32)
|
||||||
|
assert_eq!(
|
||||||
|
core_header_drop_reason(&core),
|
||||||
|
None,
|
||||||
|
"a termination frame with a small deficit is decodable and must be kept"
|
||||||
|
);
|
||||||
|
// End-to-end: a termination frame closed by a following core survives.
|
||||||
|
let mut term = make_dts_core(512);
|
||||||
|
set_ftype_short(&mut term, false, 5); // deficit = 6
|
||||||
|
let mut stream = term;
|
||||||
|
stream.extend_from_slice(&make_dts_core(640));
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
assert_eq!(frames.len(), 2, "termination frame is emitted, not dropped");
|
||||||
|
assert_eq!(frames[0].data.len(), 512);
|
||||||
|
assert_eq!(parser.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normal_frame_with_wrong_deficit_is_dropped() {
|
||||||
|
// The other side of the FTYPE gate: a NORMAL frame (FTYPE=1) whose
|
||||||
|
// deficit-sample field is not 32 is genuinely undecodable and must be
|
||||||
|
// dropped. Guards against the fix over-relaxing into "never check deficit".
|
||||||
|
let mut core = make_dts_core(512);
|
||||||
|
set_ftype_short(&mut core, true, 10); // normal, deficit = 11 (!= 32)
|
||||||
|
assert_eq!(
|
||||||
|
core_header_drop_reason(&core),
|
||||||
|
Some(DropReason::DeficitSamples),
|
||||||
|
"a normal frame with deficit != 32 is undecodable and must be dropped"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_bit_set_is_not_dropped() {
|
||||||
|
// The bit after RATE is a RESERVED field that both reference decoders
|
||||||
|
// SKIP (ffmpeg `skip_bits1`, dcadec `bits_skip1` "Reserved field") — they
|
||||||
|
// never reject a frame that sets it. Rejecting was a false-drop that
|
||||||
|
// silenced any real stream whose encoder set the bit. Setting it (byte9
|
||||||
|
// bit4) on an otherwise-valid core must leave it KEPT.
|
||||||
|
let mut core = make_dts_core(512);
|
||||||
|
assert_eq!(core_header_drop_reason(&core), None, "baseline decodable");
|
||||||
|
core[9] |= 0x10; // set the reserved bit
|
||||||
|
assert_eq!(
|
||||||
|
core_header_drop_reason(&core),
|
||||||
|
None,
|
||||||
|
"a set reserved bit must NOT drop a decodable frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real-data fixture (ignored). Re-parses a raw `.dts` elementary stream
|
||||||
|
/// through `DtsParser` and writes the emitted access units back out, so the
|
||||||
|
/// garbage-extension → core-only drop can be validated against an actual
|
||||||
|
/// damaged stream (e.g. the extracted Bourne DTS-HD MA track) end-to-end
|
||||||
|
/// with an external DTS decoder. Env: `DTS_IN` (input), `DTS_OUT` (output).
|
||||||
|
/// cargo test --lib dts::tests::reparse_real_dts_file -- --ignored --nocapture
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn reparse_real_dts_file() {
|
||||||
|
use std::io::Write;
|
||||||
|
let inp = std::env::var("DTS_IN").expect("DTS_IN");
|
||||||
|
let outp = std::env::var("DTS_OUT").expect("DTS_OUT");
|
||||||
|
let bytes = std::fs::read(&inp).expect("read DTS_IN");
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let mut out =
|
||||||
|
std::io::BufWriter::new(std::fs::File::create(&outp).expect("create DTS_OUT"));
|
||||||
|
let mut au_count = 0usize;
|
||||||
|
let mut out_bytes = 0usize;
|
||||||
|
// 90 kHz PTS advancing per chunk; arbitrary chunking is faithful because
|
||||||
|
// the framer resyncs on core sync and buffers across PES boundaries.
|
||||||
|
let mut pts: i64 = 90_000;
|
||||||
|
const CHUNK: usize = 64 * 1024;
|
||||||
|
for chunk in bytes.chunks(CHUNK) {
|
||||||
|
let pes = PesPacket {
|
||||||
|
source: None,
|
||||||
|
pid: 0x1100,
|
||||||
|
pts: Some(pts),
|
||||||
|
dts: None,
|
||||||
|
data: chunk.to_vec(),
|
||||||
|
discontinuity: false,
|
||||||
|
};
|
||||||
|
pts += 2_100; // ~one AU worth; value irrelevant to AU framing/bytes
|
||||||
|
for f in parser.parse(&pes) {
|
||||||
|
au_count += 1;
|
||||||
|
out_bytes += f.data.len();
|
||||||
|
out.write_all(&f.data).expect("write AU");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for f in parser.flush() {
|
||||||
|
au_count += 1;
|
||||||
|
out_bytes += f.data.len();
|
||||||
|
out.write_all(&f.data).expect("write AU");
|
||||||
|
}
|
||||||
|
out.flush().expect("flush");
|
||||||
|
eprintln!(
|
||||||
|
"REPARSE in={} bytes -> out={} bytes across {} AUs ({} bytes dropped)",
|
||||||
|
bytes.len(),
|
||||||
|
out_bytes,
|
||||||
|
au_count,
|
||||||
|
bytes.len().saturating_sub(out_bytes)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! FLAC elementary-stream decodability gate.
|
||||||
|
//!
|
||||||
|
//! FLAC frames carry no length field, so a raw stream is delimited only by
|
||||||
|
//! sync-scanning + CRC validation. In freemkv, though, FLAC never arrives raw:
|
||||||
|
//! it comes from mp4/mkv, where each packet is exactly one container-delimited
|
||||||
|
//! FLAC frame (a complete, pre-delimited frame per packet). So this parser
|
||||||
|
//! is a per-packet gate, not a framer: every FLAC frame ends with a 16-bit CRC
|
||||||
|
//! (poly 0x8005, init 0, non-reflected) computed so the residue over the whole
|
||||||
|
//! frame — footer CRC included — is zero (per the FLAC format specification,
|
||||||
|
//! RFC 9639, frame footer). A
|
||||||
|
//! nonzero residue is definitive corruption → drop the frame (a silence gap,
|
||||||
|
//! never a shift — each packet keeps its own PTS), logged via the shared tally.
|
||||||
|
//!
|
||||||
|
//! A packet that does not begin with the FLAC frame sync is not a delimited
|
||||||
|
//! frame we can validate, so it is passed through unchanged (never false-dropped).
|
||||||
|
|
||||||
|
use super::crc::crc16_ansi;
|
||||||
|
use super::dropgate::DropTally;
|
||||||
|
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||||
|
|
||||||
|
/// FLAC frame sync: 14-bit code `0x3FFE` + a mandatory-0 reserved bit; the next
|
||||||
|
/// bit (blocking strategy) is masked off. Test the top 15 bits of the first two
|
||||||
|
/// bytes: `(be16 & 0xFFFE) == 0xFFF8` (per RFC 9639, frame header).
|
||||||
|
fn has_flac_sync(data: &[u8]) -> bool {
|
||||||
|
data.len() >= 2 && ((u16::from(data[0]) << 8 | u16::from(data[1])) & 0xFFFE) == 0xFFF8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block-size code → samples (RFC 9639 block-size table; 0 = reserved/explicit).
|
||||||
|
const FLAC_BLOCKSIZE_TABLE: [u32; 16] = [
|
||||||
|
0, 192, 576, 1152, 2304, 4608, 0, 0, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768,
|
||||||
|
];
|
||||||
|
/// Sample-rate code → Hz (RFC 9639 sample-rate table; 0 = STREAMINFO/explicit).
|
||||||
|
const FLAC_SAMPLE_RATE_TABLE: [u32; 16] = [
|
||||||
|
0, 88_200, 176_400, 192_000, 8_000, 16_000, 22_050, 24_000, 32_000, 44_100, 48_000, 96_000, 0,
|
||||||
|
0, 0, 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Best-effort duration (ns) of a FLAC frame from its header block-size and
|
||||||
|
/// sample-rate codes (byte 2). Only the table-coded cases are resolved; the
|
||||||
|
/// explicit-in-trailing-bytes codes (block 6/7, rate 12/13/14) and
|
||||||
|
/// STREAMINFO-derived (code 0) return `None`. Used only for the dropped-audio
|
||||||
|
/// accounting, so a `None` (→ 0) is harmless.
|
||||||
|
fn flac_frame_duration_ns(frame: &[u8]) -> Option<i64> {
|
||||||
|
if frame.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let bs_code = (frame[2] >> 4) & 0x0F;
|
||||||
|
let sr_code = frame[2] & 0x0F;
|
||||||
|
let blocksize = FLAC_BLOCKSIZE_TABLE[bs_code as usize];
|
||||||
|
let rate = FLAC_SAMPLE_RATE_TABLE[sr_code as usize];
|
||||||
|
if blocksize == 0 || rate == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((blocksize as i64 * 1_000_000_000 + rate as i64 / 2) / rate as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FlacParser {
|
||||||
|
tally: DropTally,
|
||||||
|
/// Last emitted PTS (ns), carried forward across a PES with no PTS rather than
|
||||||
|
/// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync.
|
||||||
|
last_pts_ns: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FlacParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FlacParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tally: DropTally::new("flac"),
|
||||||
|
last_pts_ns: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access units dropped as undecodable so far.
|
||||||
|
pub fn dropped_frames(&self) -> u64 {
|
||||||
|
self.tally.dropped_frames()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total decoded duration (ns) of dropped access units.
|
||||||
|
pub fn dropped_duration_ns(&self) -> u64 {
|
||||||
|
self.tally.dropped_duration_ns()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodecParser for FlacParser {
|
||||||
|
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||||
|
if pes.data.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let pts_ns = pes
|
||||||
|
.pts
|
||||||
|
.or(pes.dts)
|
||||||
|
.map(pts_to_ns)
|
||||||
|
.unwrap_or(self.last_pts_ns);
|
||||||
|
self.last_pts_ns = pts_ns;
|
||||||
|
|
||||||
|
// Gate: a packet that begins with a FLAC frame sync but whose whole-frame
|
||||||
|
// CRC-16 residue is nonzero is corrupt → drop. Anything else passes
|
||||||
|
// through (a non-sync packet is not a frame we can validate; a poisoned
|
||||||
|
// track drops everything).
|
||||||
|
let corrupt = has_flac_sync(&pes.data) && crc16_ansi(&pes.data) != 0;
|
||||||
|
if self.tally.is_poisoned() || corrupt {
|
||||||
|
let reason = if self.tally.is_poisoned() {
|
||||||
|
"track-poisoned"
|
||||||
|
} else {
|
||||||
|
"crc"
|
||||||
|
};
|
||||||
|
let dur = flac_frame_duration_ns(&pes.data).unwrap_or(0);
|
||||||
|
self.tally.record_drop(pts_ns, dur, pes.data.len(), reason);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tally.record_kept();
|
||||||
|
vec![Frame {
|
||||||
|
discontinuity: pes.discontinuity,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: pes.data.clone(),
|
||||||
|
duration_ns: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
|
self.tally.log_summary();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||||
|
PesPacket {
|
||||||
|
source: None,
|
||||||
|
pid: 0x1100,
|
||||||
|
pts,
|
||||||
|
dts: None,
|
||||||
|
data,
|
||||||
|
discontinuity: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal FLAC-frame-shaped buffer: sync `0xFFF8`, a plausible header
|
||||||
|
/// (block code 1 = 192 samples, rate code 9 = 44.1 kHz), some payload, and a
|
||||||
|
/// trailing CRC-16 so the whole-frame residue is zero (a valid frame).
|
||||||
|
fn make_flac_frame(payload_len: usize) -> Vec<u8> {
|
||||||
|
let mut f = vec![0u8; 6 + payload_len + 2];
|
||||||
|
f[0] = 0xFF;
|
||||||
|
f[1] = 0xF8; // sync + fixed blocksize
|
||||||
|
f[2] = (1 << 4) | 9; // bs_code=1 (192), sr_code=9 (44100)
|
||||||
|
// bytes 3..end-2 arbitrary; last two bytes carry the CRC-16.
|
||||||
|
let n = f.len();
|
||||||
|
let c = crc16_ansi(&f[..n - 2]);
|
||||||
|
f[n - 2] = (c >> 8) as u8;
|
||||||
|
f[n - 1] = (c & 0xFF) as u8;
|
||||||
|
assert_eq!(crc16_ansi(&f), 0, "finalized frame has zero residue");
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_frame_is_kept() {
|
||||||
|
let mut p = FlacParser::new();
|
||||||
|
let f = p.parse(&make_pes(make_flac_frame(100), Some(90000)));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
|
||||||
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pes_without_pts_carries_last_timestamp_not_zero() {
|
||||||
|
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
|
||||||
|
// carry the last known timestamp forward — resetting to 0 would corrupt
|
||||||
|
// A/V sync. Mirrors the adts.rs guard test.
|
||||||
|
let mut p = FlacParser::new();
|
||||||
|
p.parse(&make_pes(make_flac_frame(100), Some(90000)));
|
||||||
|
let f = p.parse(&make_pes(make_flac_frame(100), None));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
f[0].pts_ns,
|
||||||
|
pts_to_ns(90000),
|
||||||
|
"carried forward, not reset to 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_frame_is_dropped() {
|
||||||
|
let mut p = FlacParser::new();
|
||||||
|
let mut frame = make_flac_frame(100);
|
||||||
|
frame[20] ^= 0xFF; // corrupt a payload byte → CRC residue nonzero
|
||||||
|
assert!(crc16_ansi(&frame) != 0);
|
||||||
|
let f = p.parse(&make_pes(frame, Some(90000)));
|
||||||
|
assert!(f.is_empty(), "corrupt FLAC frame dropped");
|
||||||
|
assert_eq!(p.dropped_frames(), 1);
|
||||||
|
// 192 samples @ 44.1 kHz ≈ 4.354 ms of silence accounted.
|
||||||
|
assert_eq!(
|
||||||
|
p.dropped_duration_ns(),
|
||||||
|
(192u64 * 1_000_000_000 + 44_100 / 2) / 44_100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_drop_preserves_sync_via_own_pts() {
|
||||||
|
// Each packet carries its own PTS, so dropping one leaves the next frame
|
||||||
|
// on its true timeline — a gap, not a shift.
|
||||||
|
let mut p = FlacParser::new();
|
||||||
|
let mut bad = make_flac_frame(100);
|
||||||
|
bad[20] ^= 0xFF;
|
||||||
|
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
|
||||||
|
let f = p.parse(&make_pes(make_flac_frame(100), Some(96000)));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
f[0].pts_ns,
|
||||||
|
pts_to_ns(96000),
|
||||||
|
"surviving frame keeps its own container PTS — the drop is a gap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_flac_packet_passes_through() {
|
||||||
|
// A packet without the FLAC sync isn't a frame we can validate — never
|
||||||
|
// false-drop it.
|
||||||
|
let mut p = FlacParser::new();
|
||||||
|
let f = p.parse(&make_pes(vec![0x00, 0x01, 0x02, 0x03], Some(0)));
|
||||||
|
assert_eq!(f.len(), 1, "unrecognized packet passed through");
|
||||||
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_pes_emits_nothing() {
|
||||||
|
let mut p = FlacParser::new();
|
||||||
|
assert!(p.parse(&make_pes(Vec::new(), Some(0))).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-7
@@ -99,7 +99,8 @@ fn hevc_first_slice_coding_type(nal: &[u8], nal_type: u8, num_extra: u32) -> Opt
|
|||||||
pub struct HevcParser {
|
pub struct HevcParser {
|
||||||
// First-seen parameter set of each type → seeds the MKV codecPrivate (hvcC).
|
// First-seen parameter set of each type → seeds the MKV codecPrivate (hvcC).
|
||||||
// This is the ONLY copy the player gets out-of-band, and a player re-applies
|
// This is the ONLY copy the player gets out-of-band, and a player re-applies
|
||||||
// it at every keyframe (ffmpeg's hvcC→Annex-B insertion). A stream may
|
// it at every keyframe (the hvcC→Annex-B parameter-set insertion a decoder
|
||||||
|
// performs). A stream may
|
||||||
// redefine a parameter set mid-title under the SAME id with a different body
|
// redefine a parameter set mid-title under the SAME id with a different body
|
||||||
// (some discs redefine PPS id 0 partway through). Any occurrence whose body
|
// (some discs redefine PPS id 0 partway through). Any occurrence whose body
|
||||||
// DIFFERS from this codecPrivate copy must therefore be emitted IN-BAND at
|
// DIFFERS from this codecPrivate copy must therefore be emitted IN-BAND at
|
||||||
@@ -116,8 +117,9 @@ pub struct HevcParser {
|
|||||||
// set mid-title (e.g. PPS id 0 body changes partway through, then the
|
// set mid-title (e.g. PPS id 0 body changes partway through, then the
|
||||||
// source STOPS repeating it at later IRAPs and relies on the decoder
|
// source STOPS repeating it at later IRAPs and relies on the decoder
|
||||||
// retaining it), a raw decode is fine — but an hvcC/MKV decode is NOT: a
|
// retaining it), a raw decode is fine — but an hvcC/MKV decode is NOT: a
|
||||||
// player re-applies the codecPrivate set at EVERY keyframe (ffmpeg's
|
// player re-applies the codecPrivate set at EVERY keyframe (the
|
||||||
// hvcC→Annex-B insertion), reverting id 0 to the stale FIRST body. We must
|
// hvcC→Annex-B parameter-set insertion), reverting id 0 to the stale FIRST
|
||||||
|
// body. We must
|
||||||
// therefore re-emit the active set IN-BAND at every keyframe whenever it
|
// therefore re-emit the active set IN-BAND at every keyframe whenever it
|
||||||
// differs from the codecPrivate copy and the access unit didn't already
|
// differs from the codecPrivate copy and the access unit didn't already
|
||||||
// carry it. See `parse`.
|
// carry it. See `parse`.
|
||||||
@@ -364,9 +366,10 @@ impl HevcParser {
|
|||||||
/// codecPrivate copy (`first`). The two player behaviours for hvcC-in-MKV
|
/// codecPrivate copy (`first`). The two player behaviours for hvcC-in-MKV
|
||||||
/// diverge exactly here:
|
/// diverge exactly here:
|
||||||
///
|
///
|
||||||
/// - A *seek-capable / Annex-B* player (e.g. ffmpeg's `hevc_mp4toannexb`)
|
/// - A *seek-capable / Annex-B* player (one that converts hvcC to Annex-B by
|
||||||
/// re-applies the hvcC sets at every keyframe. `reassert_active` handles it.
|
/// inserting the parameter sets) re-applies the hvcC sets at every keyframe.
|
||||||
/// - A *streaming* decode (ffmpeg decoding the MKV directly — what most
|
/// `reassert_active` handles it.
|
||||||
|
/// - A *streaming* decode (a decoder consuming the MKV directly — what most
|
||||||
/// integrity checkers do) applies hvcC ONCE at init and thereafter updates a
|
/// integrity checkers do) applies hvcC ONCE at init and thereafter updates a
|
||||||
/// parameter set ONLY from an in-band NAL.
|
/// parameter set ONLY from an in-band NAL.
|
||||||
///
|
///
|
||||||
@@ -427,7 +430,7 @@ fn handle_param_set(
|
|||||||
/// or SPS event), nothing re-sends it and every subsequent slice fails with
|
/// or SPS event), nothing re-sends it and every subsequent slice fails with
|
||||||
/// "PPS id out of range" until the next genuine change (observed as a ~24 min
|
/// "PPS id out of range" until the next genuine change (observed as a ~24 min
|
||||||
/// corrupt band on one dual-layer UHD title). Re-asserting the active set at
|
/// corrupt band on one dual-layer UHD title). Re-asserting the active set at
|
||||||
/// EVERY keyframe — what compliant muxers (mkvmerge) do at every IRAP — makes
|
/// EVERY keyframe — what compliant Matroska muxers do at every IRAP — makes
|
||||||
/// streaming decode self-healing. Re-sending an identical param set is benign
|
/// streaming decode self-healing. Re-sending an identical param set is benign
|
||||||
/// (decoders expect it at IRAPs); cost is a few hundred bytes per keyframe.
|
/// (decoders expect it at IRAPs); cost is a few hundred bytes per keyframe.
|
||||||
/// This strictly supersets the earlier change-only re-assert, so the
|
/// This strictly supersets the earlier change-only re-assert, so the
|
||||||
|
|||||||
+44
-14
@@ -9,12 +9,19 @@
|
|||||||
|
|
||||||
/// AC-3 / E-AC-3 (Dolby Digital / Digital Plus) elementary-stream parser.
|
/// AC-3 / E-AC-3 (Dolby Digital / Digital Plus) elementary-stream parser.
|
||||||
pub mod ac3;
|
pub mod ac3;
|
||||||
|
|
||||||
|
pub mod adts;
|
||||||
/// Codec-agnostic per-picture coding carrier (`PictureInfo` + accessors).
|
/// Codec-agnostic per-picture coding carrier (`PictureInfo` + accessors).
|
||||||
pub mod coding;
|
pub mod coding;
|
||||||
/// DTS / DTS-HD elementary-stream parser.
|
/// DTS / DTS-HD elementary-stream parser.
|
||||||
|
pub(crate) mod crc;
|
||||||
|
pub(crate) mod dropgate;
|
||||||
|
|
||||||
pub mod dts;
|
pub mod dts;
|
||||||
/// DVD bitmap subtitle (VobSub) parser.
|
/// DVD bitmap subtitle (VobSub) parser.
|
||||||
pub mod dvdsub;
|
pub mod dvdsub;
|
||||||
|
|
||||||
|
pub mod flac;
|
||||||
/// H.264 (AVC) Annex-B elementary-stream parser.
|
/// H.264 (AVC) Annex-B elementary-stream parser.
|
||||||
pub mod h264;
|
pub mod h264;
|
||||||
/// HEVC (H.265) Annex-B elementary-stream parser.
|
/// HEVC (H.265) Annex-B elementary-stream parser.
|
||||||
@@ -23,6 +30,8 @@ pub mod hevc;
|
|||||||
pub mod lpcm;
|
pub mod lpcm;
|
||||||
/// MPEG-2 Video elementary-stream parser.
|
/// MPEG-2 Video elementary-stream parser.
|
||||||
pub mod mpeg2;
|
pub mod mpeg2;
|
||||||
|
|
||||||
|
pub mod mpegaudio;
|
||||||
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
/// HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||||
pub mod pgs;
|
pub mod pgs;
|
||||||
/// Display-order PTS reconstruction for sparse-PTS program-stream video.
|
/// Display-order PTS reconstruction for sparse-PTS program-stream video.
|
||||||
@@ -112,11 +121,11 @@ pub trait CodecParser: Send {
|
|||||||
|
|
||||||
/// Passthrough parser — treats each PES as one frame, no parsing.
|
/// Passthrough parser — treats each PES as one frame, no parsing.
|
||||||
///
|
///
|
||||||
/// Used for the audio codecs that have no dedicated parser and whose PES
|
/// Used for Opus (and any audio codec with no dedicated parser) whose PES
|
||||||
/// boundaries already line up with frame boundaries (Aac, Mp2, Mp3, Flac,
|
/// boundaries already line up with frame boundaries. AC3/E-AC3, DTS, TrueHD,
|
||||||
/// Opus). AC3/DTS/TrueHD have their own parsers; PGS/DvdSub have their own
|
/// AAC(ADTS), MP2/MP3 and FLAC now have their own gating parsers; PGS/DvdSub
|
||||||
/// subtitle parsers. Video codecs must NOT use the all-keyframe form of this
|
/// have their own subtitle parsers. Video codecs must NOT use the all-keyframe
|
||||||
/// parser — see `parser_for_codec`.
|
/// form of this parser — see `parser_for_codec`.
|
||||||
pub struct PassthroughParser {
|
pub struct PassthroughParser {
|
||||||
keyframe: bool,
|
keyframe: bool,
|
||||||
}
|
}
|
||||||
@@ -154,6 +163,25 @@ impl CodecParser for PassthroughParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop-on-undecodable policy across codecs ("clean muxes always"):
|
||||||
|
///
|
||||||
|
/// - **Audio with independent access units** (DTS, AC-3/E-AC-3, …) gates each AU
|
||||||
|
/// through a per-codec corruption check and drops the ones that fail, keeping
|
||||||
|
/// A/V sync (a drop is a silence gap, never a shift) and logging every drop
|
||||||
|
/// via the shared [`dropgate::DropTally`]. DTS validates via its core-frame
|
||||||
|
/// header (ETSI TS 102 114); AC-3 uses its native frame CRC.
|
||||||
|
/// - **LPCM is excluded on purpose**: raw PCM carries no framing or integrity
|
||||||
|
/// data, so a corrupt sample is indistinguishable from a quiet one — there is
|
||||||
|
/// nothing to detect, so nothing can be honestly dropped.
|
||||||
|
/// - **Video is excluded on purpose**: H.264/HEVC/MPEG-2/VC-1 are inter-frame
|
||||||
|
/// predicted, so dropping one frame corrupts every frame that references it
|
||||||
|
/// until the next keyframe. Video instead resyncs at GOP/IDR boundaries (the
|
||||||
|
/// ResyncGate) and lets the decoder conceal — a fundamentally different model
|
||||||
|
/// than per-frame audio dropping.
|
||||||
|
/// - TrueHD/MLP, FLAC, MP2/MP3 and AAC-ADTS also gate undecodable frames via a
|
||||||
|
/// `DropTally` (poison/drop-forward for MLP's inter-AU restart state on a
|
||||||
|
/// major-sync boundary; CRC/sync-verdict drops for the passthrough codecs).
|
||||||
|
///
|
||||||
/// Create the appropriate parser for a codec, with optional codec private data.
|
/// Create the appropriate parser for a codec, with optional codec private data.
|
||||||
///
|
///
|
||||||
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
|
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
|
||||||
@@ -177,6 +205,9 @@ pub fn parser_for_codec(
|
|||||||
Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()),
|
Codec::Mpeg2 => Box::new(mpeg2::Mpeg2Parser::new()),
|
||||||
Codec::Vc1 => Box::new(vc1::Vc1Parser::new().with_ps_reorder(is_dvd_ps)),
|
Codec::Vc1 => Box::new(vc1::Vc1Parser::new().with_ps_reorder(is_dvd_ps)),
|
||||||
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
|
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
|
||||||
|
Codec::Flac => Box::new(flac::FlacParser::new()),
|
||||||
|
Codec::Mp2 | Codec::Mp3 => Box::new(mpegaudio::MpegAudioParser::new()),
|
||||||
|
Codec::Aac => Box::new(adts::AdtsParser::new()),
|
||||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||||
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
||||||
@@ -197,12 +228,10 @@ pub fn parser_for_codec(
|
|||||||
);
|
);
|
||||||
Box::new(PassthroughParser::new(false))
|
Box::new(PassthroughParser::new(false))
|
||||||
}
|
}
|
||||||
// Remaining audio-only codecs (Aac, Mp2, Mp3, Flac, Opus) where PES =
|
// Opus (PES = frame): all-keyframe passthrough is correct. Subtitle/Unknown
|
||||||
// frame: all-keyframe passthrough is correct. Subtitle/Unknown also land
|
// also land here; the keyframe flag is irrelevant for them. (Aac/Mp2/Mp3/Flac
|
||||||
// here; keyframe flag is irrelevant for them.
|
// have dedicated parsers dispatched earlier in the match.)
|
||||||
Codec::Aac | Codec::Mp2 | Codec::Mp3 | Codec::Flac | Codec::Opus => {
|
Codec::Opus => Box::new(PassthroughParser::new(true)),
|
||||||
Box::new(PassthroughParser::new(true))
|
|
||||||
}
|
|
||||||
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)),
|
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => Box::new(PassthroughParser::new(true)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,9 +285,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unhandled_audio_codecs_use_keyframe_passthrough() {
|
fn audio_codecs_emit_keyframe_frames() {
|
||||||
// PES = frame audio codecs: every frame is independently decodable, so
|
// PES = frame audio: every frame is independently decodable → keyframe.
|
||||||
// all-keyframe passthrough is correct.
|
// Aac/Mp2/Mp3/Flac go through their dedicated gating parsers (which pass a
|
||||||
|
// non-sync/too-short payload straight through); Opus uses PassthroughParser.
|
||||||
for codec in [Codec::Aac, Codec::Mp2, Codec::Mp3, Codec::Flac, Codec::Opus] {
|
for codec in [Codec::Aac, Codec::Mp2, Codec::Mp3, Codec::Flac, Codec::Opus] {
|
||||||
let mut parser = parser_for_codec(codec, None, false);
|
let mut parser = parser_for_codec(codec, None, false);
|
||||||
let frames = parser.parse(&pes(Some(0), vec![0x01, 0x02]));
|
let frames = parser.parse(&pes(Some(0), vec![0x01, 0x02]));
|
||||||
|
|||||||
@@ -483,7 +483,8 @@ fn coding_type_from_raw(raw: u8) -> CodingType {
|
|||||||
|
|
||||||
/// Number of field-display periods a coded picture occupies, from its picture
|
/// Number of field-display periods a coded picture occupies, from its picture
|
||||||
/// coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 §6.3.10
|
/// coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 §6.3.10
|
||||||
/// and ffmpeg `mpeg_field_start` (`nb_fields = repeat_pict + 2`). This is what
|
/// (`nb_fields = repeat_pict + 2`, the field count the spec's repeat rules
|
||||||
|
/// yield). This is what
|
||||||
/// times soft-telecined (2:3 pulldown) DVD video correctly: a
|
/// times soft-telecined (2:3 pulldown) DVD video correctly: a
|
||||||
/// `repeat_first_field` frame occupies 3 fields, a normal frame 2, so honoring
|
/// `repeat_first_field` frame occupies 3 fields, a normal frame 2, so honoring
|
||||||
/// it spreads the ~23.976 coded frames across the 29.97 display span with no
|
/// it spreads the ~23.976 coded frames across the 29.97 display span with no
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! MPEG-1/2/2.5 audio (MP1/MP2/MP3) decodability gate.
|
||||||
|
//!
|
||||||
|
//! Per ISO/IEC 11172-3 / ISO/IEC 13818-3, an MPEG-audio frame is validated by
|
||||||
|
//! header sanity + framing resync, not a payload CRC (the optional 16-bit CRC in
|
||||||
|
//! the header protects only the side-information and is absent unless the
|
||||||
|
//! protection bit says otherwise). The gate mirrors that header-only check and
|
||||||
|
//! ACCEPTS free-format (`bitrate_index == 0`) as a legal decodable mode — it
|
||||||
|
//! deliberately does NOT apply the stricter free-format reject that a full
|
||||||
|
//! decoder would (see the note at the `bitrate_index` check). So the gate rejects
|
||||||
|
//! only the truly invalid headers: a packet that begins with the 11-bit
|
||||||
|
//! MPEG-audio sync but whose version / layer / sample-rate fields (or the
|
||||||
|
//! reserved bitrate index 15) are reserved/invalid is undecodable → drop it (a
|
||||||
|
//! silence gap; each packet keeps its own PTS). A packet with no leading sync is
|
||||||
|
//! not a frame we can validate (raw payload / continuation), so it passes through
|
||||||
|
//! unchanged — never false-dropped.
|
||||||
|
|
||||||
|
use super::dropgate::DropTally;
|
||||||
|
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||||
|
|
||||||
|
/// Decoded validity of a candidate MPEG-audio header.
|
||||||
|
enum MpaVerdict {
|
||||||
|
/// No 11-bit sync at the packet head — not a frame we can validate.
|
||||||
|
NoSync,
|
||||||
|
/// Sync present and every field is legal — decodable.
|
||||||
|
Valid,
|
||||||
|
/// Sync present but a field is reserved/invalid — a conformant header parser
|
||||||
|
/// rejects this exactly.
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Header-only validity check per ISO/IEC 11172-3 / ISO/IEC 13818-3 (which
|
||||||
|
/// ACCEPTS free-format, `bitrate_index == 0`) — deliberately NOT the stricter
|
||||||
|
/// free-format reject a full decoder applies. A dropped MPEG-audio frame has a
|
||||||
|
/// corrupt header, so no duration is computed (the fields it would come from are
|
||||||
|
/// the invalid ones).
|
||||||
|
fn mpa_verdict(data: &[u8]) -> MpaVerdict {
|
||||||
|
if data.len() < 4 {
|
||||||
|
return MpaVerdict::NoSync;
|
||||||
|
}
|
||||||
|
let h = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
|
||||||
|
// 11-bit sync (0x7FF at the top).
|
||||||
|
if (h & 0xffe0_0000) != 0xffe0_0000 {
|
||||||
|
return MpaVerdict::NoSync;
|
||||||
|
}
|
||||||
|
// Reject per spec: version field 01, layer field 00, bitrate_index 15,
|
||||||
|
// sample-rate field 3.
|
||||||
|
if (h & (3 << 19)) == (1 << 19)
|
||||||
|
|| (h & (3 << 17)) == 0
|
||||||
|
|| (h & (0xf << 12)) == (0xf << 12)
|
||||||
|
|| (h & (3 << 10)) == (3 << 10)
|
||||||
|
{
|
||||||
|
return MpaVerdict::Invalid;
|
||||||
|
}
|
||||||
|
// NOTE: bitrate_index == 0 (free format) is NOT rejected. It is a legal,
|
||||||
|
// decodable MPEG-audio mode (the spec permits it and a decoder derives the
|
||||||
|
// frame size from the sync spacing). Dropping it would be a false positive on
|
||||||
|
// a clean stream, so it passes the gate.
|
||||||
|
MpaVerdict::Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MpegAudioParser {
|
||||||
|
tally: DropTally,
|
||||||
|
/// Last emitted PTS (ns), carried forward across a PES with no PTS rather than
|
||||||
|
/// resetting the timeline to 0 (see the AC-3/DTS parsers) — preserves A/V sync.
|
||||||
|
last_pts_ns: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MpegAudioParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MpegAudioParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tally: DropTally::new("mpegaudio"),
|
||||||
|
last_pts_ns: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dropped_frames(&self) -> u64 {
|
||||||
|
self.tally.dropped_frames()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dropped_duration_ns(&self) -> u64 {
|
||||||
|
self.tally.dropped_duration_ns()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodecParser for MpegAudioParser {
|
||||||
|
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||||
|
if pes.data.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let pts_ns = pes
|
||||||
|
.pts
|
||||||
|
.or(pes.dts)
|
||||||
|
.map(pts_to_ns)
|
||||||
|
.unwrap_or(self.last_pts_ns);
|
||||||
|
self.last_pts_ns = pts_ns;
|
||||||
|
|
||||||
|
let drop =
|
||||||
|
self.tally.is_poisoned() || matches!(mpa_verdict(&pes.data), MpaVerdict::Invalid);
|
||||||
|
if drop {
|
||||||
|
let reason = if self.tally.is_poisoned() {
|
||||||
|
"track-poisoned"
|
||||||
|
} else {
|
||||||
|
"header"
|
||||||
|
};
|
||||||
|
self.tally.record_drop(pts_ns, 0, pes.data.len(), reason);
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tally.record_kept();
|
||||||
|
vec![Frame {
|
||||||
|
discontinuity: pes.discontinuity,
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: pes.data.clone(),
|
||||||
|
duration_ns: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
|
self.tally.log_summary();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||||
|
PesPacket {
|
||||||
|
source: None,
|
||||||
|
pid: 0x1100,
|
||||||
|
pts,
|
||||||
|
dts: None,
|
||||||
|
data,
|
||||||
|
discontinuity: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid MPEG-1 Layer III header: sync 0xFFF, version MPEG-1 (11), layer
|
||||||
|
/// III (01), bitrate_index 9, sample-rate 0 (44.1 kHz), no CRC. Bytes:
|
||||||
|
/// 0xFF 0xFB 0x90 0x00 — the canonical MP3 frame header.
|
||||||
|
fn mp3_frame(payload: usize) -> Vec<u8> {
|
||||||
|
let mut f = vec![0xFF, 0xFB, 0x90, 0x00];
|
||||||
|
f.extend(std::iter::repeat(0xAA).take(payload));
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_header_is_kept() {
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let f = p.parse(&make_pes(mp3_frame(400), Some(90000)));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(f[0].pts_ns, pts_to_ns(90000));
|
||||||
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pes_without_pts_carries_last_timestamp_not_zero() {
|
||||||
|
// A PES with no PTS (legal for audio, e.g. after a discontinuity) must
|
||||||
|
// carry the last known timestamp forward — resetting to 0 would corrupt
|
||||||
|
// A/V sync. Mirrors the adts.rs guard test.
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
p.parse(&make_pes(mp3_frame(400), Some(90000)));
|
||||||
|
let f = p.parse(&make_pes(mp3_frame(400), None));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
f[0].pts_ns,
|
||||||
|
pts_to_ns(90000),
|
||||||
|
"carried forward, not reset to 0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_version_field_is_dropped() {
|
||||||
|
// version field = 01 (reserved) → rejected. byte1 = 111_01_01_1 = 0xEB
|
||||||
|
// keeps the 11-bit sync (0xFF + top 3 bits 111) but sets version bits to 01.
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let mut frame = mp3_frame(400);
|
||||||
|
frame[1] = 0xEB;
|
||||||
|
let f = p.parse(&make_pes(frame, Some(90000)));
|
||||||
|
assert!(f.is_empty(), "reserved version dropped");
|
||||||
|
assert_eq!(p.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_sample_rate_is_dropped() {
|
||||||
|
// Sync present but sample-rate field = 3 (reserved) → rejected per spec.
|
||||||
|
// 0xFF 0xFB then byte2 with bits 11..10 = 11: 0x9C.
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let mut frame = mp3_frame(400);
|
||||||
|
frame[2] = 0x9C; // freq field = 3
|
||||||
|
let f = p.parse(&make_pes(frame, Some(90000)));
|
||||||
|
assert!(f.is_empty(), "reserved sample rate dropped");
|
||||||
|
assert_eq!(p.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserved_layer_is_dropped() {
|
||||||
|
// Layer field 00 (reserved). byte1 bits 2..1 = 00 → 0xF9 keeps sync
|
||||||
|
// (0xFFF needs byte1 top 3 bits set) and sets layer=00.
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let mut frame = mp3_frame(400);
|
||||||
|
frame[1] = 0xF9; // 1111_1001: sync ok (top 3 =111), version 11, layer 00
|
||||||
|
let f = p.parse(&make_pes(frame, Some(0)));
|
||||||
|
assert!(f.is_empty(), "reserved layer dropped");
|
||||||
|
assert_eq!(p.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_bitrate_index_15_is_dropped() {
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let mut frame = mp3_frame(400);
|
||||||
|
frame[2] = 0xF0; // bitrate_index = 1111
|
||||||
|
assert!(p.parse(&make_pes(frame, Some(0))).is_empty());
|
||||||
|
assert_eq!(p.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn free_format_bitrate_zero_is_kept() {
|
||||||
|
// Free format (bitrate_index == 0) is legal and decodable — it must NOT
|
||||||
|
// be dropped (that would be a false positive on a clean stream).
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let mut frame = mp3_frame(400);
|
||||||
|
frame[2] = 0x00; // bitrate_index = 0000 (free format); sync/layer/rate ok
|
||||||
|
let f = p.parse(&make_pes(frame, Some(0)));
|
||||||
|
assert_eq!(f.len(), 1, "free-format frame kept");
|
||||||
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_sync_packet_passes_through() {
|
||||||
|
// No 11-bit sync → not a validatable frame → keep (conservative).
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let f = p.parse(&make_pes(vec![0x00, 0x11, 0x22, 0x33, 0x44], Some(0)));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(p.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_preserves_sync_via_own_pts() {
|
||||||
|
let mut p = MpegAudioParser::new();
|
||||||
|
let mut bad = mp3_frame(400);
|
||||||
|
bad[2] = 0x9C; // reserved sample rate
|
||||||
|
assert!(p.parse(&make_pes(bad, Some(90000))).is_empty());
|
||||||
|
let f = p.parse(&make_pes(mp3_frame(400), Some(96000)));
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
f[0].pts_ns,
|
||||||
|
pts_to_ns(96000),
|
||||||
|
"next frame keeps its own PTS"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,90 @@ const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
|
|||||||
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
|
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
|
||||||
// palette_id_ref) = 13.
|
// palette_id_ref) = 13.
|
||||||
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
|
const PCS_NUM_OBJECTS_OFFSET: usize = 13;
|
||||||
|
// Offset of the first composition_object's flags byte within a PCS PES payload:
|
||||||
|
// PCS header(13) + number_of_composition_objects(1) + object_id_ref(2) +
|
||||||
|
// window_id_ref(1) = 17. `forced_on_flag` is bit 0x40 of that byte (HDMV PCS).
|
||||||
|
const PCS_FIRST_OBJECT_FLAGS_OFFSET: usize = 17;
|
||||||
|
const PCS_FORCED_ON_FLAG: u8 = 0x40;
|
||||||
|
|
||||||
|
/// Whether an emitted PGS display-set frame is a FORCED subtitle — the
|
||||||
|
/// `forced_on_flag` (0x40) on its first composition object. The frame data an
|
||||||
|
/// emitted PGS block carries begins with the display PCS (segment type 0x16), so
|
||||||
|
/// the flag is read directly from it. Returns `None` when the block is not a
|
||||||
|
/// display PCS with a composition object (nothing to classify — a clear PCS, a
|
||||||
|
/// non-PCS segment, or a truncated header).
|
||||||
|
///
|
||||||
|
/// The mux uses this to detect a *forced-narrative track* (every displayed
|
||||||
|
/// subtitle forced) without relying on the disc's vendor label metadata, so
|
||||||
|
/// forced subs are flagged `FlagForced` even on discs that carry no such blob.
|
||||||
|
pub fn display_set_is_forced(frame_data: &[u8]) -> Option<bool> {
|
||||||
|
if frame_data.first() != Some(&SEGMENT_PCS) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if *frame_data.get(PCS_NUM_OBJECTS_OFFSET)? == 0 {
|
||||||
|
return None; // clear PCS — no composition to classify
|
||||||
|
}
|
||||||
|
let flags = *frame_data.get(PCS_FIRST_OBJECT_FLAGS_OFFSET)?;
|
||||||
|
Some(flags & PCS_FORCED_ON_FLAG != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulates the "is this PGS subtitle track a forced-narrative track?" verdict
|
||||||
|
/// from its display sets. A track is forced iff it displayed at least one subtitle
|
||||||
|
/// and EVERY display set carried the forced_on_flag — a dedicated forced track,
|
||||||
|
/// as opposed to a full track that merely has occasional forced signs.
|
||||||
|
///
|
||||||
|
/// This is the SINGLE classification used by both the MKV muxer (accumulating a
|
||||||
|
/// track's frames during a rip) and the `info`-time forced probe (feeding the
|
||||||
|
/// demuxed display sets), so both reach the identical verdict.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ForcedTracker {
|
||||||
|
has_display: bool,
|
||||||
|
all_forced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ForcedTracker {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
has_display: false,
|
||||||
|
all_forced: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForcedTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one emitted PGS block into the verdict. Non-display blocks (clear
|
||||||
|
/// PCS, other segments) are ignored.
|
||||||
|
pub fn observe(&mut self, frame_data: &[u8]) {
|
||||||
|
if let Some(forced) = display_set_is_forced(frame_data) {
|
||||||
|
self.has_display = true;
|
||||||
|
self.all_forced &= forced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the track has already shown a NON-forced subtitle — i.e. its
|
||||||
|
/// verdict is settled at "not forced" and further observation can be skipped
|
||||||
|
/// (the early-exit the probe uses to avoid reading the whole clip).
|
||||||
|
pub fn settled_not_forced(&self) -> bool {
|
||||||
|
self.has_display && !self.all_forced
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether ANY display set was observed. When false the track's forced state
|
||||||
|
/// is unknown (no PGS content seen — e.g. an undecrypted/unread stream), so a
|
||||||
|
/// probe should leave any existing (vendor-derived) flag untouched rather
|
||||||
|
/// than assert "not forced".
|
||||||
|
pub fn observed(&self) -> bool {
|
||||||
|
self.has_display
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final verdict: forced iff it displayed subtitles and every one was forced.
|
||||||
|
pub fn is_forced(&self) -> bool {
|
||||||
|
self.has_display && self.all_forced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stateful parser that collapses PGS display/clear PCS pairs into
|
/// Stateful parser that collapses PGS display/clear PCS pairs into
|
||||||
/// duration-bearing Matroska frames. Implements [`CodecParser`].
|
/// duration-bearing Matroska frames. Implements [`CodecParser`].
|
||||||
@@ -222,6 +306,41 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::mux::ts::PesPacket;
|
use crate::mux::ts::PesPacket;
|
||||||
|
|
||||||
|
/// A PCS display-set block with one composition object; `forced` sets the
|
||||||
|
/// forced_on_flag (0x40) in its flags byte at offset 17.
|
||||||
|
fn pcs_display(forced: bool) -> Vec<u8> {
|
||||||
|
let mut d = vec![0u8; 18];
|
||||||
|
d[0] = SEGMENT_PCS;
|
||||||
|
d[PCS_NUM_OBJECTS_OFFSET] = 1;
|
||||||
|
d[PCS_FIRST_OBJECT_FLAGS_OFFSET] = if forced { PCS_FORCED_ON_FLAG } else { 0 };
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_set_forced_flag_detection() {
|
||||||
|
assert_eq!(display_set_is_forced(&pcs_display(true)), Some(true));
|
||||||
|
assert_eq!(display_set_is_forced(&pcs_display(false)), Some(false));
|
||||||
|
// Other flag bits set but not forced_on_flag → still not forced.
|
||||||
|
let mut cropped = pcs_display(false);
|
||||||
|
cropped[PCS_FIRST_OBJECT_FLAGS_OFFSET] = 0x80; // object_cropped_flag only
|
||||||
|
assert_eq!(display_set_is_forced(&cropped), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_set_forced_none_for_non_display() {
|
||||||
|
// Clear PCS (0 objects) → None.
|
||||||
|
let mut clear = pcs_display(false);
|
||||||
|
clear[PCS_NUM_OBJECTS_OFFSET] = 0;
|
||||||
|
assert_eq!(display_set_is_forced(&clear), None);
|
||||||
|
// Non-PCS segment → None.
|
||||||
|
let mut ods = pcs_display(true);
|
||||||
|
ods[0] = 0x15; // ODS
|
||||||
|
assert_eq!(display_set_is_forced(&ods), None);
|
||||||
|
// Truncated (no flags byte) → None, no panic.
|
||||||
|
assert_eq!(display_set_is_forced(&pcs_display(true)[..15]), None);
|
||||||
|
assert_eq!(display_set_is_forced(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||||
PesPacket {
|
PesPacket {
|
||||||
source: None,
|
source: None,
|
||||||
|
|||||||
+655
-16
@@ -12,6 +12,8 @@
|
|||||||
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
|
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
|
||||||
//! We skip AC-3 frames and only emit TrueHD access units.
|
//! We skip AC-3 frames and only emit TrueHD access units.
|
||||||
|
|
||||||
|
use super::crc::crc16_mlp;
|
||||||
|
use super::dropgate::DropTally;
|
||||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||||
use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS;
|
use crate::mux::timeline::DISCONTINUITY_BACKSTEP_NS;
|
||||||
|
|
||||||
@@ -43,6 +45,18 @@ pub struct TrueHdParser {
|
|||||||
/// yet seen (head of stream) — preserving byte-identical timing for the
|
/// yet seen (head of stream) — preserving byte-identical timing for the
|
||||||
/// common 48 kHz case.
|
/// common 48 kHz case.
|
||||||
au_duration_ns: i64,
|
au_duration_ns: i64,
|
||||||
|
/// Keep/drop bookkeeping for the decodability gate.
|
||||||
|
tally: DropTally,
|
||||||
|
/// `num_substreams` from the most recent major sync — needed to size the
|
||||||
|
/// substream directory for the per-AU parity check. `None` until the first
|
||||||
|
/// major sync is seen (before which no AU can be parity-checked).
|
||||||
|
num_substreams: Option<u8>,
|
||||||
|
/// True while dropping forward to the next clean resync point. MLP/TrueHD
|
||||||
|
/// carries filter/predictor + restart state ACROSS access units, so a corrupt
|
||||||
|
/// AU cannot be excised in place — it poisons decoding until the next major
|
||||||
|
/// sync re-initialises state. On corruption we set this and drop every AU
|
||||||
|
/// until a major sync whose header CRC validates, which we then emit.
|
||||||
|
resync_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TrueHdParser {
|
impl Default for TrueHdParser {
|
||||||
@@ -57,6 +71,82 @@ impl TrueHdParser {
|
|||||||
buf: Vec::with_capacity(32768),
|
buf: Vec::with_capacity(32768),
|
||||||
next_pts_ns: 0,
|
next_pts_ns: 0,
|
||||||
au_duration_ns: AU_DURATION_NS,
|
au_duration_ns: AU_DURATION_NS,
|
||||||
|
tally: DropTally::new("truehd"),
|
||||||
|
num_substreams: None,
|
||||||
|
resync_pending: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access units dropped as undecodable so far.
|
||||||
|
pub fn dropped_frames(&self) -> u64 {
|
||||||
|
self.tally.dropped_frames()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total decoded duration (ns) of dropped access units.
|
||||||
|
pub fn dropped_duration_ns(&self) -> u64 {
|
||||||
|
self.tally.dropped_duration_ns()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether an access unit is corrupt, updating `num_substreams` from a
|
||||||
|
/// valid major sync. Per the MLP/TrueHD access-unit decode rules: a major sync
|
||||||
|
/// with a bad header CRC, or any AU whose header parity fails, is undecodable.
|
||||||
|
/// Returns `false` (not corrupt) when the AU is too short to judge or no
|
||||||
|
/// major sync has established `num_substreams` yet — we never drop what we
|
||||||
|
/// cannot verify. Verified against real TrueHD streams (3600/3600 AUs).
|
||||||
|
fn au_check(&mut self, au: &[u8], is_major_sync: bool) -> AuCheck {
|
||||||
|
let mut header_size = 4;
|
||||||
|
let mut format_info = None;
|
||||||
|
if is_major_sync {
|
||||||
|
let ms = &au[4..];
|
||||||
|
let Some(mshdr) = mlp_major_sync_header_size(ms) else {
|
||||||
|
// A major sync too short to hold its header can't be CRC-validated
|
||||||
|
// — NOT a safe resync/re-init point. Treat as unverifiable, not a
|
||||||
|
// clean major sync.
|
||||||
|
return AuCheck::Unverifiable;
|
||||||
|
};
|
||||||
|
if !mlp_major_sync_crc_ok(ms, mshdr) {
|
||||||
|
// A failing major-sync checksum is only a TRUSTWORTHY corruption
|
||||||
|
// signal once we already hold a validated baseline (num_substreams
|
||||||
|
// captured from a PRIOR checksum-clean major sync). Before that — at
|
||||||
|
// stream head, when no major sync has validated yet — a checksum
|
||||||
|
// mismatch is far more likely a limitation of our own major-sync
|
||||||
|
// header-size parse than real corruption. Arming the drop-forward
|
||||||
|
// there is catastrophic: every following AU is collateral-dropped
|
||||||
|
// waiting for a "validated" major sync that (if our parse is the
|
||||||
|
// problem) NEVER comes, so the WHOLE TrueHD track is silently dropped
|
||||||
|
// — the AUs are physically emitted late (empty until the mux end),
|
||||||
|
// de-interleaving the track and sending decoders into an unbounded
|
||||||
|
// memory spiral. So until we have a baseline to protect, treat a
|
||||||
|
// checksum-failed major sync as UNVERIFIABLE (keep it) rather than
|
||||||
|
// Corrupt. Once a clean major sync HAS established the baseline, a
|
||||||
|
// later failure is a real re-sync trigger and still drops forward.
|
||||||
|
if self.num_substreams.is_some() {
|
||||||
|
return AuCheck::Corrupt; // real corruption vs a proven baseline
|
||||||
|
}
|
||||||
|
return AuCheck::Unverifiable; // no baseline yet — keep, don't nuke the track
|
||||||
|
}
|
||||||
|
self.num_substreams = mlp_num_substreams(ms);
|
||||||
|
header_size += mshdr;
|
||||||
|
// The rate nibble is only trustworthy once the major sync's CRC has
|
||||||
|
// validated (above), so capture format_info here and refine the PTS
|
||||||
|
// cadence from it ONLY on this validated path.
|
||||||
|
if au.len() >= 12 {
|
||||||
|
format_info = Some(u32::from_be_bytes([au[8], au[9], au[10], au[11]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(nss) = self.num_substreams else {
|
||||||
|
return AuCheck::Unverifiable; // no major sync seen yet — can't check parity
|
||||||
|
};
|
||||||
|
let Some(shs) = mlp_substr_header_size(au, header_size, nss) else {
|
||||||
|
return AuCheck::Unverifiable; // directory runs off the AU — can't judge
|
||||||
|
};
|
||||||
|
if !mlp_parity_ok(au, header_size, shs) {
|
||||||
|
return AuCheck::Corrupt;
|
||||||
|
}
|
||||||
|
if is_major_sync {
|
||||||
|
AuCheck::ValidMajorSync { format_info }
|
||||||
|
} else {
|
||||||
|
AuCheck::Ok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +204,23 @@ fn ac3_boundary_corroborated(buf: &[u8], frame_bytes: usize) -> bool {
|
|||||||
next_words != 0 && next_words * 2 <= 32768
|
next_words != 0 && next_words * 2 <= 32768
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodability verdict for one TrueHD/MLP access unit.
|
||||||
|
enum AuCheck {
|
||||||
|
/// Verified undecodable: a major-sync header whose CRC failed, or any AU
|
||||||
|
/// whose substream-directory parity failed. Feeds the poison verdict.
|
||||||
|
Corrupt,
|
||||||
|
/// A CRC-validated major sync — a safe re-init / resync point. `format_info`
|
||||||
|
/// (AU bytes 8..12, present when the AU is long enough) is trustworthy here,
|
||||||
|
/// so the caller refines the PTS cadence ONLY from this validated path.
|
||||||
|
ValidMajorSync { format_info: Option<u32> },
|
||||||
|
/// A valid (parity-OK) non-major-sync access unit.
|
||||||
|
Ok,
|
||||||
|
/// Cannot be judged — a major sync too short to hold/CRC its header, or a
|
||||||
|
/// stream head before any major sync established `num_substreams`. Never
|
||||||
|
/// dropped on its own, and never treated as a clean resync point.
|
||||||
|
Unverifiable,
|
||||||
|
}
|
||||||
|
|
||||||
/// Outcome of sizing the AC-3 frame at the TrueHD buffer head.
|
/// Outcome of sizing the AC-3 frame at the TrueHD buffer head.
|
||||||
enum Ac3Size {
|
enum Ac3Size {
|
||||||
/// fscod/frmsizecod don't map to a real frame size — resync, don't wait.
|
/// fscod/frmsizecod don't map to a real frame size — resync, don't wait.
|
||||||
@@ -124,6 +231,92 @@ enum Ac3Size {
|
|||||||
Frame(usize),
|
Frame(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- MLP/TrueHD access-unit integrity (per the MLP/TrueHD bitstream spec) ---
|
||||||
|
|
||||||
|
/// Major-sync header size in bytes: base 28, plus `2 + extensions*2` when the
|
||||||
|
/// extension flag (major-sync byte 25, bit 0) is set (`extensions` = byte 26
|
||||||
|
/// high nibble). `ms` is the major-sync header, i.e. AU bytes `[4..]`. `None`
|
||||||
|
/// when the AU is too short to contain the full header.
|
||||||
|
fn mlp_major_sync_header_size(ms: &[u8]) -> Option<usize> {
|
||||||
|
if ms.len() < 28 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut size = 28;
|
||||||
|
if ms[25] & 1 != 0 {
|
||||||
|
size += 2 + ((ms[26] >> 4) as usize) * 2;
|
||||||
|
}
|
||||||
|
if ms.len() < size {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the MLP/TrueHD major-sync header checksum (a CRC-16 with polynomial
|
||||||
|
/// 0x002D). The stored trailer is the last 2 header bytes; because
|
||||||
|
/// MLP's checksum is byte-reversed relative to a standard CRC, a standard CRC of
|
||||||
|
/// the header body XOR the little-endian word before the trailer must equal the
|
||||||
|
/// trailer read big-endian.
|
||||||
|
fn mlp_major_sync_crc_ok(ms: &[u8], mshdr: usize) -> bool {
|
||||||
|
if mshdr < 4 || ms.len() < mshdr {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The MLP major-sync checksum, `checksum16(buf, buf_size)`, is defined as
|
||||||
|
// crc16_2D(buf, buf_size - 2) ^ read_le16(buf + buf_size - 2)
|
||||||
|
// evaluated with `buf_size = mshdr - 2` and its result compared to
|
||||||
|
// `read_le16(buf + mshdr - 2)` — i.e. the 16-bit CRC (poly 0x2D, MSB-first)
|
||||||
|
// over `ms[..mshdr-4]`, XORed with the LITTLE-ENDIAN word just before the
|
||||||
|
// trailer, must equal the LITTLE-ENDIAN trailer word. `crc16_mlp` uses that
|
||||||
|
// same poly-0x2D MSB-first table but yields its two bytes in the OPPOSITE
|
||||||
|
// order to a standard little-endian CRC readout, so swap them back to match.
|
||||||
|
// (The previous code mixed endianness —
|
||||||
|
// little-endian XOR word but big-endian compare — so the checksum could never
|
||||||
|
// validate any real extended major sync, silently dropping the whole track;
|
||||||
|
// cross-verified byte-exact against real 7.1/Atmos and 5.1 discs.)
|
||||||
|
let checksum = crc16_mlp(&ms[..mshdr - 4]).swap_bytes()
|
||||||
|
^ u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
|
||||||
|
checksum == u16::from_le_bytes([ms[mshdr - 2], ms[mshdr - 1]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `num_substreams` from a major-sync header: it sits at bit 128 (byte 16, top
|
||||||
|
/// nibble) for both MLP (0xbb) and TrueHD (0xba) — the fields before it total
|
||||||
|
/// the same 128 bits in either layout.
|
||||||
|
fn mlp_num_substreams(ms: &[u8]) -> Option<u8> {
|
||||||
|
ms.get(16).map(|&b| b >> 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Size in bytes of the substream directory that follows the AU header: each of
|
||||||
|
/// the `num_substreams` entries is 2 bytes, plus 2 more when its extraword flag
|
||||||
|
/// (entry's top bit) is set. `None` if the directory runs past the AU.
|
||||||
|
fn mlp_substr_header_size(au: &[u8], header_size: usize, num_substreams: u8) -> Option<usize> {
|
||||||
|
let mut off = header_size;
|
||||||
|
let mut shs = 0;
|
||||||
|
for _ in 0..num_substreams {
|
||||||
|
if off + 2 > au.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let extraword = au[off] & 0x80 != 0;
|
||||||
|
shs += 2;
|
||||||
|
off += 2;
|
||||||
|
if extraword {
|
||||||
|
shs += 2;
|
||||||
|
off += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(shs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MLP/TrueHD AU-header parity check: the XOR of the 4-byte AU header with the
|
||||||
|
/// substream directory, folded, must have its two nibbles XOR to 0xF.
|
||||||
|
fn mlp_parity_ok(au: &[u8], header_size: usize, substr_header_size: usize) -> bool {
|
||||||
|
let end = header_size + substr_header_size;
|
||||||
|
if end > au.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let xor_fold = |d: &[u8]| d.iter().fold(0u8, |a, &b| a ^ b);
|
||||||
|
let p = xor_fold(&au[0..4]) ^ xor_fold(&au[header_size..end]);
|
||||||
|
((p >> 4) ^ p) & 0xF == 0xF
|
||||||
|
}
|
||||||
|
|
||||||
impl CodecParser for TrueHdParser {
|
impl CodecParser for TrueHdParser {
|
||||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||||
// B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED.
|
// B1: a concealed/lost gap means the buffered TrueHD AU is TRUNCATED.
|
||||||
@@ -256,26 +449,93 @@ impl CodecParser for TrueHdParser {
|
|||||||
& 0xFFFF_FFFE)
|
& 0xFFFF_FFFE)
|
||||||
== 0xF872_6FBA;
|
== 0xF872_6FBA;
|
||||||
|
|
||||||
// On a major sync the 32-bit `format_info` word (immediately after
|
// Decodability gate. MLP/TrueHD decode state persists across access
|
||||||
// the 4-byte sync, i.e. AU bytes 8..12) carries the rate nibble.
|
// units, so a corrupt AU is dropped FORWARD to the next VALIDATED
|
||||||
// Refine the per-AU PTS increment to the actual rate family. The
|
// major sync (the clean re-init point) rather than excised in place.
|
||||||
// 48 kHz family resolves to the unchanged 833_333 default, so the
|
// The PTS clock advances across every dropped AU so a drop is a
|
||||||
// common case stays byte-identical; only the 44.1 kHz family shifts.
|
// silence gap, never a shift.
|
||||||
if is_major_sync && unit_bytes >= 12 {
|
let au = self.buf[..unit_bytes].to_vec();
|
||||||
let format_info =
|
let pts = self.next_pts_ns;
|
||||||
u32::from_be_bytes([self.buf[8], self.buf[9], self.buf[10], self.buf[11]]);
|
let mut emit_keyframe: Option<bool> = None; // Some(is_keyframe) => emit
|
||||||
self.au_duration_ns = truehd_au_duration_ns(format_info);
|
let mut drop_reason: Option<(&'static str, bool)> = None; // (reason, verified)
|
||||||
|
|
||||||
|
if self.tally.is_poisoned() {
|
||||||
|
// Whole track already judged dead — collateral drop (does not
|
||||||
|
// re-feed the poison verdict).
|
||||||
|
drop_reason = Some(("track-poisoned", false));
|
||||||
|
} else {
|
||||||
|
match self.au_check(&au, is_major_sync) {
|
||||||
|
AuCheck::ValidMajorSync { format_info } => {
|
||||||
|
// The rate nibble is trustworthy only now that the major
|
||||||
|
// sync's CRC has validated. Refine the per-AU PTS
|
||||||
|
// increment (48 kHz family stays the 833_333 default).
|
||||||
|
if let Some(fi) = format_info {
|
||||||
|
self.au_duration_ns = truehd_au_duration_ns(fi);
|
||||||
|
}
|
||||||
|
// A validated major sync is the ONLY clean resync point.
|
||||||
|
self.resync_pending = false;
|
||||||
|
emit_keyframe = Some(true);
|
||||||
|
}
|
||||||
|
AuCheck::Corrupt => {
|
||||||
|
if self.resync_pending {
|
||||||
|
// Part of the current drop-forward run — collateral.
|
||||||
|
drop_reason = Some(("resync", false));
|
||||||
|
} else {
|
||||||
|
// The trigger: one verified corruption that starts the
|
||||||
|
// drop-forward. Only this counts toward poison.
|
||||||
|
let r = if is_major_sync {
|
||||||
|
"major-sync-crc"
|
||||||
|
} else {
|
||||||
|
"parity"
|
||||||
|
};
|
||||||
|
drop_reason = Some((r, true));
|
||||||
|
self.resync_pending = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuCheck::Ok => {
|
||||||
|
if self.resync_pending {
|
||||||
|
// Decode state is invalid until the next validated
|
||||||
|
// major sync, so even a parity-OK AU is undecodable
|
||||||
|
// here — collateral drop.
|
||||||
|
drop_reason = Some(("resync", false));
|
||||||
|
} else {
|
||||||
|
emit_keyframe = Some(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuCheck::Unverifiable => {
|
||||||
|
if self.resync_pending {
|
||||||
|
// Not a validated major sync — do NOT clear the resync
|
||||||
|
// on it; keep dropping forward.
|
||||||
|
drop_reason = Some(("resync", false));
|
||||||
|
} else {
|
||||||
|
// Head of stream / too-short AU: keep (never drop what
|
||||||
|
// we cannot verify).
|
||||||
|
emit_keyframe = Some(is_major_sync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(keyframe) = emit_keyframe {
|
||||||
|
self.tally.record_kept();
|
||||||
frames.push(Frame {
|
frames.push(Frame {
|
||||||
discontinuity: false,
|
discontinuity: false,
|
||||||
coding: None,
|
coding: None,
|
||||||
source: None,
|
source: None,
|
||||||
pts_ns: self.next_pts_ns,
|
pts_ns: pts,
|
||||||
keyframe: is_major_sync,
|
keyframe,
|
||||||
data: self.buf[..unit_bytes].to_vec(),
|
data: au,
|
||||||
duration_ns: None,
|
duration_ns: None,
|
||||||
});
|
});
|
||||||
|
} else if let Some((reason, verified)) = drop_reason {
|
||||||
|
if verified {
|
||||||
|
self.tally
|
||||||
|
.record_drop(pts, self.au_duration_ns, au.len(), reason);
|
||||||
|
} else {
|
||||||
|
self.tally
|
||||||
|
.record_collateral_drop(pts, self.au_duration_ns, au.len(), reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.buf.drain(..unit_bytes);
|
self.buf.drain(..unit_bytes);
|
||||||
self.next_pts_ns += self.au_duration_ns;
|
self.next_pts_ns += self.au_duration_ns;
|
||||||
}
|
}
|
||||||
@@ -289,13 +549,18 @@ impl CodecParser for TrueHdParser {
|
|||||||
frames
|
frames
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> Vec<Frame> {
|
||||||
|
self.tally.log_summary();
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation
|
/// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation
|
||||||
/// channel-assignment masks (per the MLP spec / FFmpeg `thd_channels`). Some
|
/// channel-assignment masks (per the MLP/TrueHD bitstream spec). Some
|
||||||
/// bits denote a stereo pair (2), others a single channel (1).
|
/// bits denote a stereo pair (2), others a single channel (1).
|
||||||
const THD_8CH: [u8; 13] = [2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1];
|
const THD_8CH: [u8; 13] = [2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1];
|
||||||
const THD_6CH: [u8; 5] = [2, 1, 1, 2, 1];
|
const THD_6CH: [u8; 5] = [2, 1, 1, 2, 1];
|
||||||
@@ -451,6 +716,372 @@ mod tests {
|
|||||||
data
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turn a synthetic major-sync AU (sync bytes already set at offset 4, any
|
||||||
|
/// `format_info` set) into one that passes the decodability gate: 1 substream,
|
||||||
|
/// a clean substream directory, a valid major-sync CRC-16, and a valid header
|
||||||
|
/// parity nibble. Mirrors what a real encoder writes (verified against real
|
||||||
|
/// TrueHD streams). The AU must be ≥ 36 bytes (4 AU header + 28 major-sync
|
||||||
|
/// header + 2 directory + slack), which every `make_truehd_unit(≥200)` is.
|
||||||
|
fn finalize_major_sync(au: &mut [u8]) {
|
||||||
|
const MSHDR: usize = 28; // no extension (byte 25 clear)
|
||||||
|
// num_substreams = 1 → major-sync byte 16 (AU[20]) top nibble.
|
||||||
|
au[20] = (au[20] & 0x0F) | 0x10;
|
||||||
|
// Substream directory entry at AU[4+MSHDR] = AU[32]: extraword flag clear.
|
||||||
|
au[32] &= 0x7F;
|
||||||
|
// Major-sync checksum, built EXACTLY as `mlp_major_sync_crc_ok` verifies it
|
||||||
|
// (the MLP checksum16): swap_bytes(crc16_mlp(body)) ^ LE word before
|
||||||
|
// the trailer, stored little-endian in the trailer.
|
||||||
|
let body_end = 4 + MSHDR - 4; // AU[4..28]
|
||||||
|
let crc = super::crc16_mlp(&au[4..body_end]).swap_bytes()
|
||||||
|
^ u16::from_le_bytes([au[body_end], au[body_end + 1]]);
|
||||||
|
au[4 + MSHDR - 2] = (crc & 0xFF) as u8;
|
||||||
|
au[4 + MSHDR - 1] = (crc >> 8) as u8;
|
||||||
|
// Parity: choose the AU check nibble (AU[0] high bits) so the header +
|
||||||
|
// directory fold to 0xF. The length low nibble (AU[0] low bits) is kept.
|
||||||
|
let hi = au[0] & 0x0F;
|
||||||
|
let p0 = (hi ^ au[1] ^ au[2] ^ au[3]) ^ (au[32] ^ au[33]);
|
||||||
|
let c = ((p0 >> 4) ^ (p0 & 0x0F) ^ 0x0F) & 0x0F;
|
||||||
|
au[0] = (c << 4) | hi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give a synthetic NON-major-sync AU a valid header parity nibble (1
|
||||||
|
/// substream, directory at AU[4..6]), so it passes the gate once a preceding
|
||||||
|
/// major sync has established `num_substreams`.
|
||||||
|
fn finalize_normal_parity(au: &mut [u8]) {
|
||||||
|
au[4] &= 0x7F; // no extraword
|
||||||
|
let hi = au[0] & 0x0F;
|
||||||
|
let p0 = (hi ^ au[1] ^ au[2] ^ au[3]) ^ (au[4] ^ au[5]);
|
||||||
|
let c = ((p0 >> 4) ^ (p0 & 0x0F) ^ 0x0F) & 0x0F;
|
||||||
|
au[0] = (c << 4) | hi;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_major_sync() -> Vec<u8> {
|
||||||
|
let mut u = make_truehd_unit(200);
|
||||||
|
u[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
||||||
|
finalize_major_sync(&mut u);
|
||||||
|
u
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_normal_au() -> Vec<u8> {
|
||||||
|
let mut u = make_truehd_unit(200);
|
||||||
|
finalize_normal_parity(&mut u);
|
||||||
|
u
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_major_sync_drops_forward_to_next_valid() {
|
||||||
|
// MLP state carries across AUs, so a corrupt AU is dropped FORWARD to the
|
||||||
|
// next valid major sync (the clean re-init point). Sequence: valid MS,
|
||||||
|
// corrupt MS (bad CRC), a normal AU, then a valid MS. Only the two valid
|
||||||
|
// major syncs survive; the corrupt MS and the intervening normal AU are
|
||||||
|
// dropped (the latter because decode state is poisoned until re-init).
|
||||||
|
let mut parser = TrueHdParser::new();
|
||||||
|
let ms1 = valid_major_sync();
|
||||||
|
let mut ms_bad = valid_major_sync();
|
||||||
|
ms_bad[10] ^= 0xFF; // corrupt a CRC-covered header byte
|
||||||
|
let normal = valid_normal_au(); // clean parity, but arrives mid-resync
|
||||||
|
let ms2 = valid_major_sync();
|
||||||
|
|
||||||
|
let mut data = ms1.clone();
|
||||||
|
data.extend_from_slice(&ms_bad);
|
||||||
|
data.extend_from_slice(&normal);
|
||||||
|
data.extend_from_slice(&ms2);
|
||||||
|
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 2, "only the two valid major syncs survive");
|
||||||
|
assert!(frames[0].keyframe && frames[1].keyframe);
|
||||||
|
assert_eq!(
|
||||||
|
parser.dropped_frames(),
|
||||||
|
2,
|
||||||
|
"corrupt MS + poisoned normal AU"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc_failed_head_major_sync_is_kept_not_track_killed() {
|
||||||
|
// REGRESSION (every TrueHD title muxed after the checksum gate landed): a
|
||||||
|
// real-world major sync whose checksum our own header-size parse can't
|
||||||
|
// validate must NOT, at stream head — before ANY major sync has validated —
|
||||||
|
// arm the drop-forward. Doing so latched `resync_pending` on the very first
|
||||||
|
// AU and collateral-dropped EVERY following AU forever (no "validated" major
|
||||||
|
// sync ever came), silently dropping the entire TrueHD track: its AUs were
|
||||||
|
// emitted only at mux end, de-interleaving the track and spiralling decoders
|
||||||
|
// into unbounded memory ("decoder ran out of memory"). With no baseline to
|
||||||
|
// protect, a checksum-failed major sync is KEPT and the audio flows.
|
||||||
|
let mut parser = TrueHdParser::new();
|
||||||
|
let mut ms_bad = valid_major_sync();
|
||||||
|
ms_bad[10] ^= 0xFF; // break a checksum-covered header byte → checksum fails
|
||||||
|
let mut data = ms_bad;
|
||||||
|
for _ in 0..6 {
|
||||||
|
data.extend_from_slice(&valid_normal_au());
|
||||||
|
}
|
||||||
|
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
assert_eq!(
|
||||||
|
frames.len(),
|
||||||
|
7,
|
||||||
|
"no baseline yet: the CRC-failed head major sync + all following AUs are \
|
||||||
|
kept, not dropped (got {})",
|
||||||
|
frames.len()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parser.dropped_frames(),
|
||||||
|
0,
|
||||||
|
"nothing dropped without a validated baseline to protect"
|
||||||
|
);
|
||||||
|
// And the invariant still holds ONCE a baseline exists: after a genuinely
|
||||||
|
// valid major sync, a later corrupt one IS dropped (see
|
||||||
|
// `corrupt_major_sync_drops_forward_to_next_valid`).
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Independent bitwise CRC-16 (poly 0x002D, init 0, MSB-first) — a SEPARATE
|
||||||
|
/// oracle from `crc16_mlp`, so a fixture built with it is not tautological
|
||||||
|
/// with the validator under test. Anchored to the catalogue check value
|
||||||
|
/// (0x4FF7 for "123456789") so the oracle itself is proven correct without
|
||||||
|
/// reference to the code under test.
|
||||||
|
fn ref_crc16_2d(data: &[u8]) -> u16 {
|
||||||
|
let mut crc: u16 = 0;
|
||||||
|
for &b in data {
|
||||||
|
crc ^= (b as u16) << 8;
|
||||||
|
for _ in 0..8 {
|
||||||
|
crc = if crc & 0x8000 != 0 {
|
||||||
|
(crc << 1) ^ 0x002D
|
||||||
|
} else {
|
||||||
|
crc << 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extended_major_sync_crc_validates_and_rejects() {
|
||||||
|
// COVERAGE GAP (the endianness bug that once "silently dropped the whole
|
||||||
|
// track" on real 7.1/Atmos): the EXTENDED major-sync header path
|
||||||
|
// (ms[25]&1 set, mshdr = 28 + 2 + 2*n) had ZERO test coverage — every
|
||||||
|
// other fixture builds only the basic 28-byte header. Build an extended
|
||||||
|
// header whose trailer is an INDEPENDENTLY-computed oracle (ref_crc16_2d,
|
||||||
|
// NOT crc16_mlp) stored LITTLE-ENDIAN, and assert the validator accepts
|
||||||
|
// it, rejects a body corruption, and rejects the same trailer stored
|
||||||
|
// big-endian (which is exactly the endianness-mix regression).
|
||||||
|
assert_eq!(
|
||||||
|
ref_crc16_2d(b"123456789"),
|
||||||
|
0x4FF7,
|
||||||
|
"oracle anchored to catalogue"
|
||||||
|
);
|
||||||
|
|
||||||
|
// n = 3 extension words → mshdr = 28 + 2 + 2*3 = 36.
|
||||||
|
let n = 3usize;
|
||||||
|
let mshdr = 28 + 2 + 2 * n;
|
||||||
|
assert_eq!(mshdr, 36);
|
||||||
|
let mut ms = vec![0u8; 40]; // slack past the 36-byte header
|
||||||
|
// Non-trivial, varied body so the CRC is a meaningful function of it.
|
||||||
|
for (i, b) in ms.iter_mut().enumerate().take(mshdr - 4) {
|
||||||
|
*b = (0x37u8).wrapping_add((i as u8).wrapping_mul(0x53));
|
||||||
|
}
|
||||||
|
ms[25] |= 1; // extension flag → selects the extended header size
|
||||||
|
ms[26] = (ms[26] & 0x0F) | ((n as u8) << 4); // extension word count in high nibble
|
||||||
|
|
||||||
|
// The 2-byte "penultimate" word (between the CRC-covered body and the
|
||||||
|
// trailer). Chosen non-zero and non-palindromic so the LE/BE distinction
|
||||||
|
// is observable.
|
||||||
|
ms[mshdr - 4] = 0x12;
|
||||||
|
ms[mshdr - 3] = 0x34;
|
||||||
|
|
||||||
|
// Oracle: checksum16 = crc16_2D(body).swap_bytes() ^ le16(penultimate),
|
||||||
|
// computed with the INDEPENDENT ref CRC, then stored LITTLE-ENDIAN.
|
||||||
|
let le_word = u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
|
||||||
|
let trailer = ref_crc16_2d(&ms[..mshdr - 4]).swap_bytes() ^ le_word;
|
||||||
|
ms[mshdr - 2] = (trailer & 0xFF) as u8;
|
||||||
|
ms[mshdr - 1] = (trailer >> 8) as u8;
|
||||||
|
assert_ne!(
|
||||||
|
ms[mshdr - 2],
|
||||||
|
ms[mshdr - 1],
|
||||||
|
"trailer bytes must differ so the LE/BE swap below is a real distinction"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The extended header size is computed from ms[25]/ms[26].
|
||||||
|
assert_eq!(
|
||||||
|
mlp_major_sync_header_size(&ms),
|
||||||
|
Some(mshdr),
|
||||||
|
"extended header size = 28 + 2 + 2*n"
|
||||||
|
);
|
||||||
|
// The validator accepts the independently-built extended major sync.
|
||||||
|
assert!(
|
||||||
|
mlp_major_sync_crc_ok(&ms, mshdr),
|
||||||
|
"valid extended major-sync checksum must validate"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A single corrupted body byte must be rejected.
|
||||||
|
let mut corrupt = ms.clone();
|
||||||
|
corrupt[10] ^= 0xFF;
|
||||||
|
assert!(
|
||||||
|
!mlp_major_sync_crc_ok(&corrupt, mshdr),
|
||||||
|
"a corrupted extended major sync must be rejected"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The endianness regression: the SAME checksum stored big-endian must be
|
||||||
|
// rejected. A validator that reads the trailer big-endian (the shipped
|
||||||
|
// bug) would instead accept this and reject the correct LE form above.
|
||||||
|
let mut swapped = ms.clone();
|
||||||
|
swapped.swap(mshdr - 2, mshdr - 1);
|
||||||
|
assert!(
|
||||||
|
!mlp_major_sync_crc_ok(&swapped, mshdr),
|
||||||
|
"a big-endian-stored trailer must be rejected (little-endian is load-bearing)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parity_failure_is_dropped() {
|
||||||
|
// A normal AU whose header parity is broken (after a major sync sets
|
||||||
|
// num_substreams) is undecodable → dropped.
|
||||||
|
let mut parser = TrueHdParser::new();
|
||||||
|
let ms1 = valid_major_sync();
|
||||||
|
let mut bad = valid_normal_au();
|
||||||
|
// A single-nibble flip: MLP's nibble-fold parity is blind
|
||||||
|
// to a full-byte flip, which changes both nibbles equally and cancels.
|
||||||
|
bad[2] ^= 0x01;
|
||||||
|
let ms2 = valid_major_sync();
|
||||||
|
let mut data = ms1;
|
||||||
|
data.extend_from_slice(&bad);
|
||||||
|
data.extend_from_slice(&ms2);
|
||||||
|
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
assert_eq!(frames.len(), 2, "the parity-broken AU is dropped");
|
||||||
|
assert_eq!(parser.dropped_frames(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drop_forward_preserves_av_sync_no_shift() {
|
||||||
|
// THE INVARIANT: the resumed major sync keeps the exact PTS it would have
|
||||||
|
// had with no drop — base + 3 AU durations (MS1, corrupt-MS, normal, MS2)
|
||||||
|
// — so the drop is a silence gap, never a shift.
|
||||||
|
let mut parser = TrueHdParser::new();
|
||||||
|
let ms1 = valid_major_sync();
|
||||||
|
let mut ms_bad = valid_major_sync();
|
||||||
|
ms_bad[10] ^= 0xFF;
|
||||||
|
let normal = valid_normal_au();
|
||||||
|
let ms2 = valid_major_sync();
|
||||||
|
let mut data = ms1;
|
||||||
|
data.extend_from_slice(&ms_bad);
|
||||||
|
data.extend_from_slice(&normal);
|
||||||
|
data.extend_from_slice(&ms2);
|
||||||
|
let mut frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
frames.extend(parser.flush());
|
||||||
|
assert_eq!(frames.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
frames[1].pts_ns - frames[0].pts_ns,
|
||||||
|
3 * AU_DURATION_NS,
|
||||||
|
"resumed major sync keeps its true timeline (gap, not shift)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transient_corruptions_do_not_poison_whole_track() {
|
||||||
|
// Regression (audit HIGH): TrueHD drop-forward must NOT amplify a couple
|
||||||
|
// of transient errors into a false whole-track poison. Two corruptions,
|
||||||
|
// each forcing a long collateral resync run past 200 total AUs, must
|
||||||
|
// leave the track un-poisoned and keep the good audio that follows.
|
||||||
|
let mut parser = TrueHdParser::new();
|
||||||
|
let mut data = valid_major_sync();
|
||||||
|
// Corruption #1 then a long run of normal AUs (all collateral-dropped
|
||||||
|
// while resyncing — no major sync to re-init on).
|
||||||
|
let mut bad1 = valid_normal_au();
|
||||||
|
bad1[2] ^= 0x01; // single-nibble parity break
|
||||||
|
data.extend_from_slice(&bad1);
|
||||||
|
for _ in 0..210 {
|
||||||
|
data.extend_from_slice(&valid_normal_au());
|
||||||
|
}
|
||||||
|
// A valid major sync resumes; the good AUs after it MUST be kept.
|
||||||
|
data.extend_from_slice(&valid_major_sync());
|
||||||
|
for _ in 0..5 {
|
||||||
|
data.extend_from_slice(&valid_normal_au());
|
||||||
|
}
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
assert!(
|
||||||
|
!parser.tally.is_poisoned(),
|
||||||
|
"two transient errors must not poison the track"
|
||||||
|
);
|
||||||
|
// MS1 + resumed MS2 + the 5 good AUs after it survive.
|
||||||
|
assert_eq!(
|
||||||
|
frames.len(),
|
||||||
|
7,
|
||||||
|
"post-resync good audio is kept, not poisoned away"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parser.dropped_frames() > 200,
|
||||||
|
"the resync run was still counted for reporting"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_major_sync_rate_nibble_does_not_shift_pts() {
|
||||||
|
// Regression (audit MED): a corrupt major sync whose rate nibble decodes
|
||||||
|
// to the 44.1 kHz family must NOT refine au_duration_ns — the rate is only
|
||||||
|
// trustworthy after the CRC validates. Otherwise the resumed 48 kHz audio
|
||||||
|
// is shifted (not gapped).
|
||||||
|
let mut parser = TrueHdParser::new();
|
||||||
|
let ms1 = valid_major_sync(); // 48 kHz
|
||||||
|
let mut ms_bad = valid_major_sync();
|
||||||
|
// Set the rate nibble (top nibble of format_info = au[8]) to 0x8 (44.1k).
|
||||||
|
// au[8] is CRC-covered, so this also breaks the major-sync CRC → corrupt.
|
||||||
|
ms_bad[8] = (ms_bad[8] & 0x0F) | 0x80;
|
||||||
|
let ms2 = valid_major_sync(); // 48 kHz
|
||||||
|
let mut data = ms1;
|
||||||
|
data.extend_from_slice(&ms_bad);
|
||||||
|
data.extend_from_slice(&ms2);
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
assert_eq!(frames.len(), 2, "corrupt MS dropped; MS1 and MS2 survive");
|
||||||
|
assert_eq!(
|
||||||
|
frames[1].pts_ns - frames[0].pts_ns,
|
||||||
|
2 * AU_DURATION_NS,
|
||||||
|
"resumed audio keeps the 48 kHz cadence — the corrupt MS's 44.1k rate was ignored"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn too_short_major_sync_does_not_clear_resync() {
|
||||||
|
// Regression (audit LOW): while resyncing, a major sync too short to hold
|
||||||
|
// (and CRC-validate) its header must NOT be treated as a clean resync
|
||||||
|
// point — the runt is dropped and only a real validated major sync resumes.
|
||||||
|
let mut parser = TrueHdParser::new();
|
||||||
|
let ms1 = valid_major_sync();
|
||||||
|
let mut bad = valid_normal_au();
|
||||||
|
bad[2] ^= 0x01; // parity break → triggers resync
|
||||||
|
// An 8-byte "major sync": length=4 words, sync at bytes 4..8, too short
|
||||||
|
// to hold the 28-byte major-sync header.
|
||||||
|
let runt = vec![0x00, 0x04, 0x00, 0x00, 0xF8, 0x72, 0x6F, 0xBA];
|
||||||
|
let ms2 = valid_major_sync();
|
||||||
|
let mut data = ms1;
|
||||||
|
data.extend_from_slice(&bad);
|
||||||
|
data.extend_from_slice(&runt);
|
||||||
|
data.extend_from_slice(&ms2);
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
assert_eq!(frames.len(), 2, "the runt major sync did not resume decode");
|
||||||
|
for f in &frames {
|
||||||
|
assert_eq!(
|
||||||
|
f.data.len(),
|
||||||
|
200,
|
||||||
|
"only the real 200-byte major syncs survive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_truehd_stream_drops_nothing() {
|
||||||
|
// A run of valid AUs passes untouched — zero false positives (the CRC and
|
||||||
|
// parity are verified against real TrueHD output).
|
||||||
|
let mut parser = TrueHdParser::new();
|
||||||
|
let mut data = valid_major_sync();
|
||||||
|
for _ in 0..5 {
|
||||||
|
data.extend_from_slice(&valid_normal_au());
|
||||||
|
}
|
||||||
|
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
|
assert_eq!(frames.len(), 6);
|
||||||
|
assert_eq!(parser.dropped_frames(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
fn make_ac3_frame() -> Vec<u8> {
|
fn make_ac3_frame() -> Vec<u8> {
|
||||||
// Minimal AC-3 frame: sync 0x0B77, fscod=0 (48kHz), frmsizecod=0 (64 words = 128 bytes)
|
// Minimal AC-3 frame: sync 0x0B77, fscod=0 (48kHz), frmsizecod=0 (64 words = 128 bytes)
|
||||||
let mut data = vec![0u8; 128];
|
let mut data = vec![0u8; 128];
|
||||||
@@ -763,7 +1394,7 @@ mod tests {
|
|||||||
assert_eq!(truehd_channels_from_stream(&data), Some(8));
|
assert_eq!(truehd_channels_from_stream(&data), Some(8));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- truehd_channels: per-bit mask channel counts (MLP / FFmpeg table) ---
|
// --- truehd_channels: per-bit mask channel counts (MLP channel table) ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn truehd_channels_8ch_single_bit_counts() {
|
fn truehd_channels_8ch_single_bit_counts() {
|
||||||
@@ -878,6 +1509,7 @@ mod tests {
|
|||||||
let mut parser = TrueHdParser::new();
|
let mut parser = TrueHdParser::new();
|
||||||
let mut unit = make_truehd_unit(200);
|
let mut unit = make_truehd_unit(200);
|
||||||
unit[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
unit[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
||||||
|
finalize_major_sync(&mut unit);
|
||||||
let f = parser.parse(&make_pes(unit, Some(90000)));
|
let f = parser.parse(&make_pes(unit, Some(90000)));
|
||||||
assert_eq!(f.len(), 1);
|
assert_eq!(f.len(), 1);
|
||||||
assert!(f[0].keyframe, "major-sync AU must be flagged keyframe");
|
assert!(f[0].keyframe, "major-sync AU must be flagged keyframe");
|
||||||
@@ -899,6 +1531,7 @@ mod tests {
|
|||||||
let mut parser = TrueHdParser::new();
|
let mut parser = TrueHdParser::new();
|
||||||
let mut unit = make_truehd_unit(200);
|
let mut unit = make_truehd_unit(200);
|
||||||
unit[4..8].copy_from_slice(&0xF872_6FBBu32.to_be_bytes());
|
unit[4..8].copy_from_slice(&0xF872_6FBBu32.to_be_bytes());
|
||||||
|
finalize_major_sync(&mut unit);
|
||||||
let f = parser.parse(&make_pes(unit, Some(90000)));
|
let f = parser.parse(&make_pes(unit, Some(90000)));
|
||||||
assert_eq!(f.len(), 1);
|
assert_eq!(f.len(), 1);
|
||||||
assert!(f[0].keyframe, "major-sync variant 0xFB also a keyframe");
|
assert!(f[0].keyframe, "major-sync variant 0xFB also a keyframe");
|
||||||
@@ -1101,8 +1734,11 @@ mod tests {
|
|||||||
let mut a1 = make_truehd_unit(200);
|
let mut a1 = make_truehd_unit(200);
|
||||||
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); // major sync
|
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); // major sync
|
||||||
a1[8..12].copy_from_slice(&format_info_with(0x8).to_be_bytes()); // 44.1 k
|
a1[8..12].copy_from_slice(&format_info_with(0x8).to_be_bytes()); // 44.1 k
|
||||||
|
finalize_major_sync(&mut a1);
|
||||||
|
let mut a2 = make_truehd_unit(200);
|
||||||
|
finalize_normal_parity(&mut a2);
|
||||||
let mut data = a1;
|
let mut data = a1;
|
||||||
data.extend_from_slice(&make_truehd_unit(200));
|
data.extend_from_slice(&a2);
|
||||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
assert_eq!(frames.len(), 2);
|
assert_eq!(frames.len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1120,8 +1756,11 @@ mod tests {
|
|||||||
let mut a1 = make_truehd_unit(200);
|
let mut a1 = make_truehd_unit(200);
|
||||||
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
a1[4..8].copy_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
||||||
a1[8..12].copy_from_slice(&format_info_with(0x0).to_be_bytes()); // 48 k
|
a1[8..12].copy_from_slice(&format_info_with(0x0).to_be_bytes()); // 48 k
|
||||||
|
finalize_major_sync(&mut a1);
|
||||||
|
let mut a2 = make_truehd_unit(200);
|
||||||
|
finalize_normal_parity(&mut a2);
|
||||||
let mut data = a1;
|
let mut data = a1;
|
||||||
data.extend_from_slice(&make_truehd_unit(200));
|
data.extend_from_slice(&a2);
|
||||||
let frames = parser.parse(&make_pes(data, Some(90000)));
|
let frames = parser.parse(&make_pes(data, Some(90000)));
|
||||||
assert_eq!(frames.len(), 2);
|
assert_eq!(frames.len(), 2);
|
||||||
assert_eq!(frames[1].pts_ns - frames[0].pts_ns, 833_333);
|
assert_eq!(frames[1].pts_ns - frames[0].pts_ns, 833_333);
|
||||||
|
|||||||
+104
-22
@@ -31,6 +31,10 @@ use std::io::{self, BufWriter, Write};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// Filename-naming strategy for the per-track files.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum Naming {
|
pub enum Naming {
|
||||||
/// `<base> <track> <lang> <codec> [DELAY <n>ms].<ext>` — human-readable.
|
/// `<base> <track> <lang> <codec> [DELAY <n>ms].<ext>` — human-readable.
|
||||||
@@ -45,7 +49,8 @@ pub enum Naming {
|
|||||||
/// How (and whether) to record audio sync delay.
|
/// How (and whether) to record audio sync delay.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum DelayMode {
|
pub enum DelayMode {
|
||||||
/// Embed `DELAY <n>ms` in each audio filename (mkvmerge-readable).
|
/// Embed `DELAY <n>ms` in each audio filename (the filename-delay
|
||||||
|
/// convention downstream muxers parse).
|
||||||
#[default]
|
#[default]
|
||||||
Filename,
|
Filename,
|
||||||
/// Write a `<base> delays.txt` sidecar instead.
|
/// Write a `<base> delays.txt` sidecar instead.
|
||||||
@@ -55,9 +60,12 @@ pub enum DelayMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Chapter export format.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
pub enum ChaptersFmt {
|
pub enum ChaptersFmt {
|
||||||
/// mkvmerge chapter XML.
|
/// Matroska chapter XML.
|
||||||
#[default]
|
#[default]
|
||||||
Xml,
|
Xml,
|
||||||
/// OGM/simple `CHAPTERnn=`/`CHAPTERnnNAME=` text.
|
/// OGM/simple `CHAPTERnn=`/`CHAPTERnnNAME=` text.
|
||||||
@@ -81,6 +89,10 @@ pub struct DemuxOptions {
|
|||||||
pub export_chapters: bool,
|
pub export_chapters: bool,
|
||||||
/// Selected track indices. `None` = all tracks.
|
/// Selected track indices. `None` = all tracks.
|
||||||
pub selection: Option<Vec<usize>>,
|
pub selection: Option<Vec<usize>>,
|
||||||
|
/// Restrict output to one track class. `None` = every class (plain
|
||||||
|
/// `demux://`). `Some(Audio)` is the `audio://` sink; `Some(Subtitle)` is
|
||||||
|
/// `sub://`. Filtered tracks are skipped entirely (no file written).
|
||||||
|
pub kind_filter: Option<TrackKind>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DemuxOptions {
|
impl Default for DemuxOptions {
|
||||||
@@ -92,13 +104,16 @@ impl Default for DemuxOptions {
|
|||||||
chapters_fmt: ChaptersFmt::default(),
|
chapters_fmt: ChaptersFmt::default(),
|
||||||
export_chapters: true,
|
export_chapters: true,
|
||||||
selection: None,
|
selection: None,
|
||||||
|
kind_filter: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track class, used for delay attribution and naming.
|
/// Track class, used for delay attribution and naming — and, via
|
||||||
|
/// [`DemuxOptions::kind_filter`], to restrict a demux to one class (the
|
||||||
|
/// `audio://` / `sub://` sinks are a `demux://` filtered to Audio / Subtitle).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum TrackKind {
|
pub enum TrackKind {
|
||||||
Video,
|
Video,
|
||||||
Audio,
|
Audio,
|
||||||
Subtitle,
|
Subtitle,
|
||||||
@@ -107,7 +122,8 @@ enum TrackKind {
|
|||||||
// ── Codec → on-disk extension ────────────────────────────────────────────────
|
// ── Codec → on-disk extension ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// File extension (without the dot) for a codec's standalone elementary stream.
|
/// File extension (without the dot) for a codec's standalone elementary stream.
|
||||||
/// Chosen to match what mkvmerge / x265 / ffmpeg / BDSup2Sub expect.
|
/// Chosen to match the conventional elementary-stream extensions downstream
|
||||||
|
/// muxers and codec tools expect.
|
||||||
fn extension_for(codec: Codec) -> &'static str {
|
fn extension_for(codec: Codec) -> &'static str {
|
||||||
match codec {
|
match codec {
|
||||||
Codec::Hevc => "hevc",
|
Codec::Hevc => "hevc",
|
||||||
@@ -136,7 +152,7 @@ fn extension_for(codec: Codec) -> &'static str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Short codec label for friendly filenames.
|
/// Short codec label for friendly filenames.
|
||||||
fn codec_label(codec: Codec) -> &'static str {
|
pub(crate) fn codec_label(codec: Codec) -> &'static str {
|
||||||
match codec {
|
match codec {
|
||||||
Codec::Hevc => "HEVC",
|
Codec::Hevc => "HEVC",
|
||||||
Codec::H264 => "AVC",
|
Codec::H264 => "AVC",
|
||||||
@@ -241,11 +257,23 @@ impl EsWriter for AnnexBWriter {
|
|||||||
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
|
||||||
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
|
||||||
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
|
||||||
match codec {
|
let converted = match codec {
|
||||||
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
|
Codec::Hevc => hvcc_to_annex_b(record),
|
||||||
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
|
Codec::H264 => avcc_to_annex_b(record),
|
||||||
_ => Vec::new(),
|
_ => return Vec::new(),
|
||||||
}
|
};
|
||||||
|
converted.unwrap_or_else(|| {
|
||||||
|
// A malformed hvcC/avcC record yields no parameter sets. Returning empty
|
||||||
|
// means keyframes ship WITHOUT in-band SPS/PPS — playable from the first
|
||||||
|
// keyframe but broken for seek-to-arbitrary-point and hardware decoders.
|
||||||
|
// Surface it rather than silently degrading the output.
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
?codec,
|
||||||
|
"codec-private (hvcC/avcC) parse failed; keyframes will lack in-band SPS/PPS"
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
|
||||||
@@ -414,7 +442,7 @@ impl VobSubWriter {
|
|||||||
.map(|s| s.trim_end().to_string());
|
.map(|s| s.trim_end().to_string());
|
||||||
// VobSub `id:` lines use a 2-letter code; stream languages are ISO
|
// VobSub `id:` lines use a 2-letter code; stream languages are ISO
|
||||||
// 639-2 (3-letter). Take the leading two chars — the convention
|
// 639-2 (3-letter). Take the leading two chars — the convention
|
||||||
// mkvmerge reads to assign a track language.
|
// downstream muxers read to assign a track language.
|
||||||
let lang2: String = lang.chars().take(2).collect();
|
let lang2: String = lang.chars().take(2).collect();
|
||||||
Self {
|
Self {
|
||||||
idx_path,
|
idx_path,
|
||||||
@@ -442,8 +470,8 @@ impl EsWriter for VobSubWriter {
|
|||||||
idx.push('\n');
|
idx.push('\n');
|
||||||
}
|
}
|
||||||
idx.push_str("langidx: 0\n\n");
|
idx.push_str("langidx: 0\n\n");
|
||||||
// The conventional `id: <lang2>, index: 0` line mkvmerge reads to
|
// The conventional `id: <lang2>, index: 0` line downstream muxers read
|
||||||
// assign the subtitle track's language. Omit the language token when
|
// to assign the subtitle track's language. Omit the language token when
|
||||||
// unknown but still emit the index so the entry list is well-formed.
|
// unknown but still emit the index so the entry list is well-formed.
|
||||||
if self.lang2.is_empty() {
|
if self.lang2.is_empty() {
|
||||||
idx.push_str("id: , index: 0\n");
|
idx.push_str("id: , index: 0\n");
|
||||||
@@ -504,8 +532,8 @@ fn delay_ms(audio_first_pts_ns: i64, ref_video_first_pts_ns: i64) -> i64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `DELAY <signed-int>ms` — matches mkvmerge's case-insensitive
|
/// `DELAY <signed-int>ms` — matches the conventional case-insensitive
|
||||||
/// `delay\s+(-?\d+)` filename-delay parser.
|
/// `delay\s+(-?\d+)` filename-delay convention downstream muxers parse.
|
||||||
fn delay_token(ms: i64) -> String {
|
fn delay_token(ms: i64) -> String {
|
||||||
format!("DELAY {ms}ms")
|
format!("DELAY {ms}ms")
|
||||||
}
|
}
|
||||||
@@ -521,8 +549,8 @@ fn fmt_chapter_time_ns(time_secs: f64) -> String {
|
|||||||
format!("{h:02}:{m:02}:{s:02}.{ns:09}")
|
format!("{h:02}:{m:02}:{s:02}.{ns:09}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize chapters as mkvmerge chapter XML.
|
/// Serialize chapters as Matroska chapter XML.
|
||||||
fn chapters_xml(chapters: &[Chapter]) -> String {
|
pub(crate) fn chapters_xml(chapters: &[Chapter]) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
s.push_str("<!DOCTYPE Chapters SYSTEM \"matroskachapters.dtd\">\n");
|
s.push_str("<!DOCTYPE Chapters SYSTEM \"matroskachapters.dtd\">\n");
|
||||||
@@ -552,7 +580,7 @@ fn chapters_xml(chapters: &[Chapter]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize chapters as OGM/simple chapter text.
|
/// Serialize chapters as OGM/simple chapter text.
|
||||||
fn chapters_ogm(chapters: &[Chapter]) -> String {
|
pub(crate) fn chapters_ogm(chapters: &[Chapter]) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
for (i, c) in chapters.iter().enumerate() {
|
for (i, c) in chapters.iter().enumerate() {
|
||||||
let n = i + 1;
|
let n = i + 1;
|
||||||
@@ -639,9 +667,18 @@ impl DemuxSink {
|
|||||||
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone())
|
(TrackKind::Subtitle, s.codec, s.pid, s.language.clone())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Record the primary-video reference BEFORE the kind filter: the video
|
||||||
|
// track drives multi-clip PTS-continuity rebasing and the audio DELAY
|
||||||
|
// tag even for `audio://` / `sub://` outputs, where its frames flow
|
||||||
|
// through write() but are not persisted to disk.
|
||||||
if kind == TrackKind::Video && ref_video_track.is_none() {
|
if kind == TrackKind::Video && ref_video_track.is_none() {
|
||||||
ref_video_track = Some(idx);
|
ref_video_track = Some(idx);
|
||||||
}
|
}
|
||||||
|
// Kind filter: `audio://` / `sub://` keep only their class.
|
||||||
|
if opts.kind_filter.is_some_and(|k| k != kind) {
|
||||||
|
tracks.push(None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let ext = extension_for(codec);
|
let ext = extension_for(codec);
|
||||||
let stem = Self::stem_for(opts, idx, pid, &lang, codec);
|
let stem = Self::stem_for(opts, idx, pid, &lang, codec);
|
||||||
@@ -855,6 +892,17 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn subtitle_stream(codec: Codec, lang: &str) -> DiscStream {
|
||||||
|
DiscStream::Subtitle(crate::disc::SubtitleStream {
|
||||||
|
pid: 0x1200,
|
||||||
|
codec,
|
||||||
|
language: lang.to_string(),
|
||||||
|
forced: false,
|
||||||
|
qualifier: crate::disc::LabelQualifier::None,
|
||||||
|
codec_data: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn title_with(streams: Vec<DiscStream>, privates: Vec<Option<Vec<u8>>>) -> DiscTitle {
|
fn title_with(streams: Vec<DiscStream>, privates: Vec<Option<Vec<u8>>>) -> DiscTitle {
|
||||||
let mut t = DiscTitle::empty();
|
let mut t = DiscTitle::empty();
|
||||||
t.streams = streams;
|
t.streams = streams;
|
||||||
@@ -863,6 +911,40 @@ mod tests {
|
|||||||
t
|
t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `audio://` and `sub://` are `demux://` with a kind filter: only tracks of
|
||||||
|
/// the selected class get a file; every other track is skipped entirely.
|
||||||
|
#[test]
|
||||||
|
fn kind_filter_keeps_only_the_selected_class() {
|
||||||
|
let title = title_with(
|
||||||
|
vec![
|
||||||
|
video_stream(Codec::H264),
|
||||||
|
audio_stream(Codec::Ac3, "eng"),
|
||||||
|
subtitle_stream(Codec::Pgs, "eng"),
|
||||||
|
],
|
||||||
|
vec![None, None, None],
|
||||||
|
);
|
||||||
|
let sub_opts = DemuxOptions {
|
||||||
|
kind_filter: Some(TrackKind::Subtitle),
|
||||||
|
export_chapters: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let sub = DemuxSink::create(&tempdir(), &title, &sub_opts).unwrap();
|
||||||
|
assert!(
|
||||||
|
sub.tracks[0].is_none() && sub.tracks[1].is_none() && sub.tracks[2].is_some(),
|
||||||
|
"sub:// keeps only the subtitle track"
|
||||||
|
);
|
||||||
|
let audio_opts = DemuxOptions {
|
||||||
|
kind_filter: Some(TrackKind::Audio),
|
||||||
|
export_chapters: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let audio = DemuxSink::create(&tempdir(), &title, &audio_opts).unwrap();
|
||||||
|
assert!(
|
||||||
|
audio.tracks[0].is_none() && audio.tracks[1].is_some() && audio.tracks[2].is_none(),
|
||||||
|
"audio:// keeps only the audio track"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Annex-B reframing ────────────────────────────────────────────────────
|
// ── Annex-B reframing ────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
|
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
|
||||||
@@ -957,7 +1039,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delay_token_matches_mkvmerge_regex() {
|
fn delay_token_matches_mkvmerge_regex() {
|
||||||
// mkvmerge: case-insensitive /delay\s+(-?\d+)/.
|
// Convention: case-insensitive /delay\s+(-?\d+)/.
|
||||||
let re = regex_lite_delay;
|
let re = regex_lite_delay;
|
||||||
assert_eq!(re("Movie eng AC3 DELAY -248ms.ac3"), Some(-248));
|
assert_eq!(re("Movie eng AC3 DELAY -248ms.ac3"), Some(-248));
|
||||||
assert_eq!(re(&format!("x {}.dts", delay_token(1000))), Some(1000));
|
assert_eq!(re(&format!("x {}.dts", delay_token(1000))), Some(1000));
|
||||||
@@ -965,7 +1047,7 @@ mod tests {
|
|||||||
assert_eq!(re(&format!("x {}.eac3", delay_token(-5))), Some(-5));
|
assert_eq!(re(&format!("x {}.eac3", delay_token(-5))), Some(-5));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal stand-in for mkvmerge's `delay\s+(-?\d+)` (case-insensitive).
|
/// Minimal stand-in for the `delay\s+(-?\d+)` convention (case-insensitive).
|
||||||
fn regex_lite_delay(name: &str) -> Option<i64> {
|
fn regex_lite_delay(name: &str) -> Option<i64> {
|
||||||
let lower = name.to_lowercase();
|
let lower = name.to_lowercase();
|
||||||
let idx = lower.find("delay")?;
|
let idx = lower.find("delay")?;
|
||||||
@@ -1117,7 +1199,7 @@ mod tests {
|
|||||||
w.finish(&mut sub).unwrap();
|
w.finish(&mut sub).unwrap();
|
||||||
let idx_text = std::fs::read_to_string(&idx).unwrap();
|
let idx_text = std::fs::read_to_string(&idx).unwrap();
|
||||||
assert!(idx_text.contains("palette: 000000, ffffff"));
|
assert!(idx_text.contains("palette: 000000, ffffff"));
|
||||||
// The conventional `id:` line mkvmerge reads to assign the language.
|
// The conventional `id:` line downstream muxers read to assign the language.
|
||||||
assert!(
|
assert!(
|
||||||
idx_text.contains("id: en, index: 0"),
|
idx_text.contains("id: en, index: 0"),
|
||||||
"missing id: line, got:\n{idx_text}"
|
"missing id: line, got:\n{idx_text}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
|
||||||
//! already runs on a producer thread; the *consumer* (main) thread
|
//! already runs on a producer thread; the *consumer* (main) thread
|
||||||
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
|
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
|
||||||
//! parsers. Profiling on the rip1 testbed showed feed at ~37 % and
|
//! parsers. Profiling showed feed at ~37 % and
|
||||||
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
|
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
|
||||||
//! enough that pipelining it with parse pays for itself.
|
//! enough that pipelining it with parse pays for itself.
|
||||||
//!
|
//!
|
||||||
|
|||||||
+213
-38
@@ -108,11 +108,6 @@ pub struct DiscStream {
|
|||||||
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
||||||
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
||||||
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
||||||
/// Shared decrypt-loss counter, cloned once at construction from
|
|
||||||
/// `reader.decrypt_loss()`. `lost_bytes()` loads it directly so the
|
|
||||||
/// per-frame hot path performs no per-call `Arc::clone` (matching the
|
|
||||||
/// `PipelinedPesStream` pattern).
|
|
||||||
decrypt_loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
/// Mirror of the keys handed in at construction. The decorator
|
/// Mirror of the keys handed in at construction. The decorator
|
||||||
/// owns the cryptographic state; this field is kept for
|
/// owns the cryptographic state; this field is kept for
|
||||||
@@ -144,14 +139,14 @@ pub struct DiscStream {
|
|||||||
// Adaptive batch sizer — preferred comes from the caller
|
// Adaptive batch sizer — preferred comes from the caller
|
||||||
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
|
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
|
||||||
adaptive: AdaptiveBatch,
|
adaptive: AdaptiveBatch,
|
||||||
pub errors: u64,
|
errors: u64,
|
||||||
/// Cumulative bytes actually skipped (zero-filled) on read error.
|
/// Cumulative bytes actually skipped (zero-filled) on read error.
|
||||||
/// Distinct from `errors`, which counts skip *events*: one event can
|
/// Distinct from `errors`, which counts skip *events*: one event can
|
||||||
/// cover a whole AACS unit (`unit_align` sectors = 6144 bytes), so
|
/// cover a whole AACS unit (`unit_align` sectors = 6144 bytes), so
|
||||||
/// `errors * 2048` understates real loss by the alignment factor.
|
/// `errors * 2048` understates real loss by the alignment factor.
|
||||||
/// Consumers estimating lost video time must scale by this, not by
|
/// Consumers estimating lost video time must scale by this, not by
|
||||||
/// the event count.
|
/// the event count.
|
||||||
pub lost_bytes: u64,
|
lost_bytes: u64,
|
||||||
pub skip_errors: bool,
|
pub skip_errors: bool,
|
||||||
/// When set and the token is cancelled, fill_extents returns Err(Halted)
|
/// When set and the token is cancelled, fill_extents returns Err(Halted)
|
||||||
/// at the next retry boundary. Unlike skip_errors, this propagates the
|
/// at the next retry boundary. Unlike skip_errors, this propagates the
|
||||||
@@ -214,14 +209,34 @@ impl DiscStream {
|
|||||||
/// The caller opens the source, scans for titles/keys, and passes them in.
|
/// The caller opens the source, scans for titles/keys, and passes them in.
|
||||||
/// The stream handles demuxing, decryption, and codec parsing internally.
|
/// The stream handles demuxing, decryption, and codec parsing internally.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
reader: Box<dyn SectorSource>,
|
mut reader: Box<dyn SectorSource>,
|
||||||
title: DiscTitle,
|
title: DiscTitle,
|
||||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
mut decrypt_keys: crate::decrypt::DecryptKeys,
|
||||||
batch_sectors: u16,
|
batch_sectors: u16,
|
||||||
content_format: crate::disc::ContentFormat,
|
content_format: crate::disc::ContentFormat,
|
||||||
) -> Self {
|
raw: bool,
|
||||||
|
halt: Option<Halt>,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
let mut title = title;
|
let mut title = title;
|
||||||
let extents = title.extents.clone();
|
let extents = title.extents.clone();
|
||||||
|
|
||||||
|
// Resolve this title's CSS key from the reader if the caller supplied
|
||||||
|
// none — the SAME shared step the file-backed mux highway
|
||||||
|
// (`build_iso_pipeline`) uses, so single-pass and multi-pass descramble a
|
||||||
|
// DVD identically. No-op for AACS / already-keyed / genuinely-clear input
|
||||||
|
// or `raw`; a scrambled-but-uncrackable DVD is a hard `CssKeyMissing`.
|
||||||
|
// `halt` is passed here (not deferred to `with_halt`) so a Stop during the
|
||||||
|
// crack scan is honored — the scan runs at construction, before the caller
|
||||||
|
// can attach a token.
|
||||||
|
crate::css::resolve_dvd_title_key(
|
||||||
|
&mut *reader,
|
||||||
|
&extents,
|
||||||
|
&mut decrypt_keys,
|
||||||
|
batch_sectors,
|
||||||
|
content_format,
|
||||||
|
raw,
|
||||||
|
halt.as_ref(),
|
||||||
|
)?;
|
||||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||||
|
|
||||||
// Debug log reader type at construction — critical for diagnosing mux
|
// Debug log reader type at construction — critical for diagnosing mux
|
||||||
@@ -237,12 +252,12 @@ impl DiscStream {
|
|||||||
|
|
||||||
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
|
||||||
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
|
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
|
||||||
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
|
// MUX path (read > decrypt > mux): decrypt every unit in place and pass the
|
||||||
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
|
// bytes to the muxer; a unit that decrypts to broken TS is the muxer's
|
||||||
// decode/mux stream (live-drive single-pass / direct), never the
|
// concern, never conceal / re-fetch / count as loss (fail loud only on a
|
||||||
// ciphertext-preserving sweep, so concealment is always correct here.
|
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
|
||||||
let mut reader =
|
// single-pass / direct), never the ciphertext-preserving sweep.
|
||||||
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
|
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
|
||||||
|
|
||||||
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
|
||||||
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
|
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
|
||||||
@@ -293,9 +308,6 @@ impl DiscStream {
|
|||||||
// the decorator is a pass-through). Reset the unit base the probe read
|
// the decorator is a pass-through). Reset the unit base the probe read
|
||||||
// advanced so the first fill_extents read starts cleanly.
|
// advanced so the first fill_extents read starts cleanly.
|
||||||
reader.set_unit_base(0);
|
reader.set_unit_base(0);
|
||||||
// Clone the shared loss counter once here so `lost_bytes()` never
|
|
||||||
// clones an Arc per frame on the mux hot path.
|
|
||||||
let decrypt_loss = reader.decrypt_loss();
|
|
||||||
|
|
||||||
// B1 resync gates: one per stream, video flagged so the gate only
|
// B1 resync gates: one per stream, video flagged so the gate only
|
||||||
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
// drop-to-keyframes video (audio/subtitle always admit). Computed before
|
||||||
@@ -309,9 +321,8 @@ impl DiscStream {
|
|||||||
.map(|_| super::resync::ResyncGate::new())
|
.map(|_| super::resync::ResyncGate::new())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self {
|
Ok(Self {
|
||||||
reader,
|
reader,
|
||||||
decrypt_loss,
|
|
||||||
title,
|
title,
|
||||||
decrypt_keys,
|
decrypt_keys,
|
||||||
unit_align,
|
unit_align,
|
||||||
@@ -324,7 +335,7 @@ impl DiscStream {
|
|||||||
errors: 0,
|
errors: 0,
|
||||||
lost_bytes: 0,
|
lost_bytes: 0,
|
||||||
skip_errors: false,
|
skip_errors: false,
|
||||||
halt: None,
|
halt,
|
||||||
event_fn: None,
|
event_fn: None,
|
||||||
eof: false,
|
eof: false,
|
||||||
dropped_nav_packets: 0,
|
dropped_nav_packets: 0,
|
||||||
@@ -339,7 +350,7 @@ impl DiscStream {
|
|||||||
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
||||||
resync,
|
resync,
|
||||||
is_video,
|
is_video,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set event handler for sector-level events (binary search, skip, recover).
|
/// Set event handler for sector-level events (binary search, skip, recover).
|
||||||
@@ -360,6 +371,27 @@ impl DiscStream {
|
|||||||
self
|
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 {
|
fn is_halted(&self) -> bool {
|
||||||
self.halt
|
self.halt
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1003,14 +1035,12 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn lost_bytes(&self) -> u64 {
|
fn lost_bytes(&self) -> u64 {
|
||||||
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
|
// Read-error zero-fill loss (counted in fill_extents) — real missing
|
||||||
// loss — bytes of scrambled AACS units the decorator could not decrypt
|
// content the abort gate must see. There is no decrypt-loss term: the
|
||||||
// and passed through still encrypted (the TS assembler silently drops
|
// decrypt path passes bad-encoded/undecryptable units through (a broken-TS
|
||||||
// them). Both are real missing content the abort gate must see; without
|
// unit is the muxer's concern, and a missing key is indistinguishable from
|
||||||
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
|
// bad authoring here), so only physical read loss is reported.
|
||||||
// missing segments passes even under abort_on_lost_secs=0.
|
|
||||||
self.lost_bytes
|
self.lost_bytes
|
||||||
.saturating_add(self.decrypt_loss.load(std::sync::atomic::Ordering::Relaxed))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,7 +1114,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let mut src: Box<dyn Stream> = Box::new(stream);
|
let mut src: Box<dyn Stream> = Box::new(stream);
|
||||||
|
|
||||||
@@ -1119,7 +1152,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
crate::disc::ContentFormat::BdTs,
|
crate::disc::ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
|
.unwrap()
|
||||||
.with_halt(halt.clone());
|
.with_halt(halt.clone());
|
||||||
assert!(!stream.is_halted());
|
assert!(!stream.is_halted());
|
||||||
halt.cancel();
|
halt.cancel();
|
||||||
@@ -1129,10 +1165,51 @@ 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)]);
|
||||||
|
let stream = DiscStream::new(
|
||||||
|
Box::new(ZeroReader { capacity: 300 }),
|
||||||
|
synthetic_title(300),
|
||||||
|
aacs,
|
||||||
|
8,
|
||||||
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.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
|
/// Recording `SectorSource`: logs every `(lba, count)` request and
|
||||||
/// returns `Err` whenever the requested range covers `bad_sector`.
|
/// returns `Err` whenever the requested range covers `bad_sector`.
|
||||||
/// Successful reads return zeroed sectors (which are NOT
|
/// Successful reads return zeroed sectors (which the content-clarity check
|
||||||
/// `ts_sync_destroyed`, so `DecryptingSectorSource` passes them through
|
/// does not flag as scrambled, so `DecryptingSectorSource` passes them through
|
||||||
/// even with synthetic AACS keys — no real decrypt is attempted).
|
/// even with synthetic AACS keys — no real decrypt is attempted).
|
||||||
struct RecordingReader {
|
struct RecordingReader {
|
||||||
capacity: u32,
|
capacity: u32,
|
||||||
@@ -1272,7 +1349,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
|
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
|
||||||
// would return Err — so reaching EOF cleanly proves recovery worked.
|
// would return Err — so reaching EOF cleanly proves recovery worked.
|
||||||
stream.skip_errors = false;
|
stream.skip_errors = false;
|
||||||
@@ -1385,7 +1465,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
|
|
||||||
// Drive fill_extents across batches: the good leading sectors mux fine,
|
// Drive fill_extents across batches: the good leading sectors mux fine,
|
||||||
@@ -1445,7 +1528,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
|
|
||||||
let res = stream.fill_extents();
|
let res = stream.fill_extents();
|
||||||
@@ -1489,8 +1575,18 @@ mod tests {
|
|||||||
let keys = crate::decrypt::DecryptKeys::Aacs {
|
let keys = crate::decrypt::DecryptKeys::Aacs {
|
||||||
unit_keys: vec![(0, [0u8; 16])],
|
unit_keys: vec![(0, [0u8; 16])],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: crate::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
|
let mut stream = DiscStream::new(
|
||||||
|
Box::new(reader),
|
||||||
|
title,
|
||||||
|
keys,
|
||||||
|
8,
|
||||||
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stream.unit_align, ALIGN as u16,
|
stream.unit_align, ALIGN as u16,
|
||||||
@@ -1586,7 +1682,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
);
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
stream.skip_errors = true;
|
stream.skip_errors = true;
|
||||||
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
|
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
|
||||||
|
|
||||||
@@ -1627,7 +1726,10 @@ mod tests {
|
|||||||
crate::decrypt::DecryptKeys::None,
|
crate::decrypt::DecryptKeys::None,
|
||||||
8,
|
8,
|
||||||
crate::disc::ContentFormat::BdTs,
|
crate::disc::ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
|
.unwrap()
|
||||||
.with_halt(Halt::from_arc(arc.clone()));
|
.with_halt(Halt::from_arc(arc.clone()));
|
||||||
assert!(!stream.is_halted());
|
assert!(!stream.is_halted());
|
||||||
arc.store(true, std::sync::atomic::Ordering::Relaxed);
|
arc.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
@@ -1636,4 +1738,77 @@ mod tests {
|
|||||||
"with_halt(Halt::from_arc) must observe Arc-side flips"
|
"with_halt(Halt::from_arc) must observe Arc-side flips"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every read fails CSS-locked (`05/6F/03`) — a scrambled DVD whose title key
|
||||||
|
/// can't be cracked. Drives `resolve_dvd_title_key` to `ScrambledUncracked`.
|
||||||
|
struct LockedReader;
|
||||||
|
impl crate::sector::SectorSource for LockedReader {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
lba: u32,
|
||||||
|
_count: u16,
|
||||||
|
_buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> crate::error::Result<usize> {
|
||||||
|
Err(crate::error::Error::DiscRead {
|
||||||
|
sector: lba as u64,
|
||||||
|
status: Some(2),
|
||||||
|
sense: Some(crate::scsi::ScsiSense {
|
||||||
|
sense_key: 0x05,
|
||||||
|
asc: 0x6F,
|
||||||
|
ascq: 0x03,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mpegps_title(sector_count: u32) -> DiscTitle {
|
||||||
|
let mut t = synthetic_title(sector_count);
|
||||||
|
t.content_format = ContentFormat::MpegPs;
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PARITY with `build_iso_pipeline_dvd_none_keys_scrambled_hard_fails`: the
|
||||||
|
/// live-drive single-pass constructor must ALSO hard-fail (not build a
|
||||||
|
/// scrambled-passthrough stream) for a `None`-keyed scrambled MPEG-PS DVD —
|
||||||
|
/// the exact 328k-decode-error corruption path, on the single-pass side.
|
||||||
|
#[test]
|
||||||
|
fn disc_stream_new_dvd_none_scrambled_hard_fails() {
|
||||||
|
let res = DiscStream::new(
|
||||||
|
Box::new(LockedReader),
|
||||||
|
mpegps_title(8),
|
||||||
|
crate::decrypt::DecryptKeys::None,
|
||||||
|
8,
|
||||||
|
ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
res.is_err(),
|
||||||
|
"single-pass DiscStream must hard-fail on a scrambled, keyless CSS DVD"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `raw` must bypass the CSS crack at the DiscStream boundary too: the same
|
||||||
|
/// scrambled-uncrackable input that hard-fails above must CONSTRUCT in raw
|
||||||
|
/// mode (ciphertext passthrough), never hard-fail.
|
||||||
|
#[test]
|
||||||
|
fn disc_stream_new_raw_bypasses_css_crack() {
|
||||||
|
let res = DiscStream::new(
|
||||||
|
Box::new(LockedReader),
|
||||||
|
mpegps_title(8),
|
||||||
|
crate::decrypt::DecryptKeys::None,
|
||||||
|
8,
|
||||||
|
ContentFormat::MpegPs,
|
||||||
|
true, // raw
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
res.is_ok(),
|
||||||
|
"raw single-pass must construct without cracking, even on scrambled-uncrackable input"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2128
File diff suppressed because it is too large
Load Diff
+4
-16
@@ -157,31 +157,19 @@ impl FviSink {
|
|||||||
///
|
///
|
||||||
/// `source_path` / `source_title` record where the index was built from
|
/// `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
|
/// (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
|
/// header's `source` object. The remaining provenance (medium, playlist,
|
||||||
/// known medium / playlist / volume use [`FviSink::create_with_source`].
|
/// volume) takes its `SourceInfo` defaults — no caller needs to override them.
|
||||||
pub fn create(
|
pub fn create(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
title: &DiscTitle,
|
title: &DiscTitle,
|
||||||
source_path: String,
|
source_path: String,
|
||||||
source_title: usize,
|
source_title: usize,
|
||||||
) -> io::Result<Self> {
|
) -> io::Result<Self> {
|
||||||
Self::create_with_source(
|
let source = SourceInfo {
|
||||||
path,
|
|
||||||
title,
|
|
||||||
SourceInfo {
|
|
||||||
path: source_path,
|
path: source_path,
|
||||||
title: source_title,
|
title: source_title,
|
||||||
..SourceInfo::default()
|
..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 file = File::create(path)?;
|
let file = File::create(path)?;
|
||||||
|
|
||||||
let video_track = title
|
let video_track = title
|
||||||
|
|||||||
+2
-2
@@ -29,8 +29,8 @@ const HEVC_NAL_TYPE_MASK: u8 = 0x3F;
|
|||||||
///
|
///
|
||||||
/// One instance per output stream. Tracks whether parameter sets have
|
/// One instance per output stream. Tracks whether parameter sets have
|
||||||
/// already been emitted so they're written exactly once at the head of
|
/// already been emitted so they're written exactly once at the head of
|
||||||
/// the stream, mirroring the convention used by `ffmpeg -c:v copy -f
|
/// the stream, per the Annex B convention of ITU-T H.265 / ISO/IEC
|
||||||
/// hevc`.
|
/// 23008-2 (parameter sets precede the coded slices they govern).
|
||||||
pub struct HevcMux<W: Write> {
|
pub struct HevcMux<W: Write> {
|
||||||
writer: W,
|
writer: W,
|
||||||
/// `HEVCDecoderConfigurationRecord` payload (hvcC). Parsed lazily
|
/// `HEVCDecoderConfigurationRecord` payload (hvcC). Parsed lazily
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
//! Standard MPEG-TS (188-byte packets) muxer — sequential-only.
|
//! Standard MPEG-TS (188-byte packets) muxer — sequential-only.
|
||||||
//!
|
//!
|
||||||
//! Distinct from `super::tsmux::TsMuxer` (BD-TS with 192-byte packets
|
//! Distinct from `super::tsmux::TsMuxer` (BD-TS with 192-byte packets
|
||||||
//! and the 4-byte TP_extra_header). This muxer emits the IETF / ISO/IEC
|
//! and the 4-byte TP_extra_header). This muxer emits the ITU-T H.222.0 /
|
||||||
//! 13818-1 wire format that ffmpeg, VLC, and `m2tsindex` consume
|
//! ISO/IEC 13818-1 wire format that any conformant transport-stream
|
||||||
//! out of the box. Use it for plain `.ts` / `.m2ts` files over a
|
//! demuxer or player consumes out of the box. Use it for plain
|
||||||
|
//! `.ts` / `.m2ts` files over a
|
||||||
//! [`SequentialSink`](crate::io::sink::SequentialSink), and for
|
//! [`SequentialSink`](crate::io::sink::SequentialSink), and for
|
||||||
//! MPEG-TS-over-UDP via [`UdpSocketSink`](crate::io::sink::UdpSocketSink).
|
//! MPEG-TS-over-UDP via [`UdpSocketSink`](crate::io::sink::UdpSocketSink).
|
||||||
//!
|
//!
|
||||||
@@ -44,8 +45,8 @@
|
|||||||
//! attached to the video PID's adaptation field every
|
//! attached to the video PID's adaptation field every
|
||||||
//! `PCR_INTERVAL_PACKETS` packets.
|
//! `PCR_INTERVAL_PACKETS` packets.
|
||||||
//! - No language / descriptor tags, no SCTE-35 markers, no per-PID
|
//! - No language / descriptor tags, no SCTE-35 markers, no per-PID
|
||||||
//! PMT version bumps, no SDT/EIT. Sufficient for "ffmpeg can play
|
//! PMT version bumps, no SDT/EIT. Sufficient for a conformant
|
||||||
//! this back", not for full broadcast deployment.
|
//! demuxer to play this back, not for full broadcast deployment.
|
||||||
|
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//! `chapters://` and `json://` metadata sinks.
|
||||||
|
//!
|
||||||
|
//! Both ignore the PES stream entirely: everything they emit is already known
|
||||||
|
//! from the [`DiscTitle`] at construction, so each writes its whole file at
|
||||||
|
//! `create()` and treats every `write()` frame as a no-op. They are wired
|
||||||
|
//! through [`super::resolve::output`] like the other write-only sinks; the
|
||||||
|
//! ISO/disc scan that builds the title is all they need.
|
||||||
|
|
||||||
|
use crate::disc::{Chapter, DiscTitle, Stream as DiscStream};
|
||||||
|
use crate::pes::{PesFrame, Stream};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
// ── chapters:// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// `HH:MM:SS.mmm` for a WebVTT cue timestamp.
|
||||||
|
fn vtt_time(secs: f64) -> String {
|
||||||
|
let total_ms = (secs.max(0.0) * 1000.0).round() as u64;
|
||||||
|
let ms = total_ms % 1000;
|
||||||
|
let total_s = total_ms / 1000;
|
||||||
|
format!(
|
||||||
|
"{:02}:{:02}:{:02}.{:03}",
|
||||||
|
total_s / 3600,
|
||||||
|
(total_s / 60) % 60,
|
||||||
|
total_s % 60,
|
||||||
|
ms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WebVTT chapter cues (`.vtt`). Each chapter spans until the next one starts
|
||||||
|
/// (the last runs to its own start — length is unknown without the title tail).
|
||||||
|
fn chapters_vtt(chapters: &[Chapter]) -> String {
|
||||||
|
let mut s = String::from("WEBVTT\n\n");
|
||||||
|
for (i, c) in chapters.iter().enumerate() {
|
||||||
|
let start = c.time_secs.max(0.0);
|
||||||
|
// Each cue runs until the next chapter. WebVTT drops a cue whose end is not
|
||||||
|
// strictly after its start, so the last chapter (and any degenerate
|
||||||
|
// equal-timestamp pair) gets a 1 s minimum duration rather than being lost.
|
||||||
|
let end = chapters
|
||||||
|
.get(i + 1)
|
||||||
|
.map(|n| n.time_secs.max(0.0))
|
||||||
|
.filter(|&e| e > start)
|
||||||
|
.unwrap_or(start + 1.0);
|
||||||
|
// No localized prose in the library (see Chapter::name): emit the bare
|
||||||
|
// name, or a plain ordinal when unnamed — the app prepends any "Chapter "
|
||||||
|
// prefix in the user's language. Matches chapters_xml / chapters_ogm.
|
||||||
|
let name = if c.name.is_empty() {
|
||||||
|
(i + 1).to_string()
|
||||||
|
} else {
|
||||||
|
c.name.clone()
|
||||||
|
};
|
||||||
|
s.push_str(&format!(
|
||||||
|
"{}\n{} --> {}\n{}\n\n",
|
||||||
|
i + 1,
|
||||||
|
vtt_time(start),
|
||||||
|
vtt_time(end),
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chapter content in the format the output extension selects: `.txt`/`.ogm`
|
||||||
|
/// (OGM simple), `.vtt` (WebVTT), else Matroska XML (`.xml` / default).
|
||||||
|
pub(crate) fn chapters_content(chapters: &[Chapter], ext: Option<&str>) -> String {
|
||||||
|
match ext.map(|e| e.to_ascii_lowercase()).as_deref() {
|
||||||
|
Some("txt") | Some("ogm") => super::demux_sink::chapters_ogm(chapters),
|
||||||
|
Some("vtt") => chapters_vtt(chapters),
|
||||||
|
_ => super::demux_sink::chapters_xml(chapters),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `chapters://` sink: writes the title's chapter markers at construction; the
|
||||||
|
/// PES stream is ignored.
|
||||||
|
pub struct ChaptersSink {
|
||||||
|
title: DiscTitle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChaptersSink {
|
||||||
|
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
|
||||||
|
let ext = path.extension().and_then(|e| e.to_str());
|
||||||
|
let content = chapters_content(&title.chapters, ext);
|
||||||
|
File::create(path)?.write_all(content.as_bytes())?;
|
||||||
|
Ok(Self {
|
||||||
|
title: title.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream for ChaptersSink {
|
||||||
|
fn read(&mut self) -> io::Result<Option<PesFrame>> {
|
||||||
|
Err(crate::error::Error::StreamWriteOnly.into())
|
||||||
|
}
|
||||||
|
fn write(&mut self, _frame: &PesFrame) -> io::Result<()> {
|
||||||
|
Ok(()) // whole file written at create()
|
||||||
|
}
|
||||||
|
fn finish(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn info(&self) -> &DiscTitle {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── json:// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Serialization id for an audio stream's editorial purpose.
|
||||||
|
fn purpose_id(p: crate::labels::LabelPurpose) -> &'static str {
|
||||||
|
use crate::labels::LabelPurpose::*;
|
||||||
|
match p {
|
||||||
|
Normal => "normal",
|
||||||
|
Commentary => "commentary",
|
||||||
|
Descriptive => "descriptive",
|
||||||
|
Score => "score",
|
||||||
|
Ime => "ime",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialization id for a subtitle stream's qualifier.
|
||||||
|
fn qualifier_id(q: crate::labels::LabelQualifier) -> &'static str {
|
||||||
|
use crate::labels::LabelQualifier::*;
|
||||||
|
match q {
|
||||||
|
None => "none",
|
||||||
|
Sdh => "sdh",
|
||||||
|
DescriptiveService => "descriptive_service",
|
||||||
|
Forced => "forced",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One stream as JSON — every field the scan resolved, nothing dropped. This is
|
||||||
|
/// the complete per-stream model (`disc::Stream`), not a summary: consumers get
|
||||||
|
/// resolution/HDR/aspect for video, channels/sample-rate/purpose for audio, and
|
||||||
|
/// the qualifier for subtitles, all in machine-readable form.
|
||||||
|
fn stream_json(s: &DiscStream) -> serde_json::Value {
|
||||||
|
use super::demux_sink::codec_label;
|
||||||
|
use serde_json::json;
|
||||||
|
match s {
|
||||||
|
DiscStream::Video(v) => {
|
||||||
|
let (w, h) = v.resolution.pixels();
|
||||||
|
let (fps_num, fps_den) = v.frame_rate.as_fraction();
|
||||||
|
let mut o = json!({
|
||||||
|
"kind": "video",
|
||||||
|
"codec": codec_label(v.codec),
|
||||||
|
"pid": v.pid,
|
||||||
|
"resolution": v.resolution.to_string(),
|
||||||
|
"width": w,
|
||||||
|
"height": h,
|
||||||
|
"interlaced": v.resolution.is_interlaced(),
|
||||||
|
"frame_rate": v.frame_rate.to_string(),
|
||||||
|
"frame_rate_num": fps_num,
|
||||||
|
"frame_rate_den": fps_den,
|
||||||
|
"hdr": v.hdr.id(),
|
||||||
|
"color_space": v.color_space.id(),
|
||||||
|
"secondary": v.secondary,
|
||||||
|
"mvc_dependent": v.is_mvc_dependent(),
|
||||||
|
});
|
||||||
|
if let Some((num, den)) = v.display_aspect {
|
||||||
|
o["display_aspect"] = json!(format!("{num}:{den}"));
|
||||||
|
}
|
||||||
|
if let Some(c) = v.measured_cicp {
|
||||||
|
o["measured_cicp"] = json!({
|
||||||
|
"matrix": c.matrix,
|
||||||
|
"transfer": c.transfer,
|
||||||
|
"primaries": c.primaries,
|
||||||
|
"range": c.range,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !v.label.is_empty() {
|
||||||
|
o["label"] = json!(v.label);
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
DiscStream::Audio(a) => {
|
||||||
|
let mut o = json!({
|
||||||
|
"kind": "audio",
|
||||||
|
"codec": codec_label(a.codec),
|
||||||
|
"pid": a.pid,
|
||||||
|
"language": a.language,
|
||||||
|
"channels": a.channels.to_string(),
|
||||||
|
"channel_count": a.channels.count(),
|
||||||
|
"sample_rate": a.sample_rate.to_string(),
|
||||||
|
"sample_rate_hz": a.sample_rate.hz(),
|
||||||
|
"secondary": a.secondary,
|
||||||
|
"purpose": purpose_id(a.purpose),
|
||||||
|
});
|
||||||
|
if !a.label.is_empty() {
|
||||||
|
o["label"] = json!(a.label);
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
DiscStream::Subtitle(t) => json!({
|
||||||
|
"kind": "subtitle",
|
||||||
|
"codec": codec_label(t.codec),
|
||||||
|
"pid": t.pid,
|
||||||
|
"language": t.language,
|
||||||
|
"forced": t.forced,
|
||||||
|
"qualifier": qualifier_id(t.qualifier),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `json://` document for one title: identity, duration/size, its clips,
|
||||||
|
/// its complete stream models, and its chapter points. A stable, machine-
|
||||||
|
/// readable view of one title — the same information the scan resolved, no loss.
|
||||||
|
pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value {
|
||||||
|
use serde_json::json;
|
||||||
|
let streams: Vec<_> = title.streams.iter().map(stream_json).collect();
|
||||||
|
let clips: Vec<_> = title
|
||||||
|
.clips
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
json!({
|
||||||
|
"clip_id": c.clip_id,
|
||||||
|
"duration_secs": c.duration_secs,
|
||||||
|
"source_packets": c.source_packets,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let chapters: Vec<_> = title
|
||||||
|
.chapters
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| json!({ "n": i + 1, "start_secs": c.time_secs, "name": c.name }))
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"playlist": title.playlist,
|
||||||
|
"playlist_id": title.playlist_id,
|
||||||
|
"duration_secs": title.duration_secs,
|
||||||
|
"size_bytes": title.size_bytes,
|
||||||
|
"format": format!("{:?}", title.content_format),
|
||||||
|
"clips": clips,
|
||||||
|
"streams": streams,
|
||||||
|
"chapters": chapters,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `json://` sink: writes the title's structured metadata at construction; the
|
||||||
|
/// PES stream is ignored.
|
||||||
|
pub struct JsonSink {
|
||||||
|
title: DiscTitle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonSink {
|
||||||
|
pub fn create(path: &Path, title: &DiscTitle) -> io::Result<Self> {
|
||||||
|
// Serializing our own `Value` is infallible in practice (serde_json maps
|
||||||
|
// any non-finite float to `null` at Value construction, so `title_json`
|
||||||
|
// never holds an unencodable value); still, propagate rather than silently
|
||||||
|
// writing "{}" if that ever changes — an empty metadata file must not
|
||||||
|
// masquerade as a successful json:// export.
|
||||||
|
let doc = serde_json::to_string_pretty(&title_json(title))
|
||||||
|
.map_err(|_| crate::error::Error::MkvInvalid)?;
|
||||||
|
let mut f = File::create(path)?;
|
||||||
|
f.write_all(doc.as_bytes())?;
|
||||||
|
f.write_all(b"\n")?;
|
||||||
|
Ok(Self {
|
||||||
|
title: title.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream for JsonSink {
|
||||||
|
fn read(&mut self) -> io::Result<Option<PesFrame>> {
|
||||||
|
Err(crate::error::Error::StreamWriteOnly.into())
|
||||||
|
}
|
||||||
|
fn write(&mut self, _frame: &PesFrame) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn finish(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn info(&self) -> &DiscTitle {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::disc::Chapter;
|
||||||
|
|
||||||
|
fn chaps() -> Vec<Chapter> {
|
||||||
|
vec![
|
||||||
|
Chapter {
|
||||||
|
time_secs: 0.0,
|
||||||
|
name: "1".into(),
|
||||||
|
},
|
||||||
|
Chapter {
|
||||||
|
time_secs: 62.5,
|
||||||
|
name: "2".into(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chapters_format_selected_by_extension() {
|
||||||
|
let xml = chapters_content(&chaps(), Some("xml"));
|
||||||
|
assert!(xml.contains("<Chapters>"), "xml chosen for .xml");
|
||||||
|
let ogm = chapters_content(&chaps(), Some("txt"));
|
||||||
|
assert!(ogm.contains("CHAPTER01="), "ogm chosen for .txt");
|
||||||
|
let vtt = chapters_content(&chaps(), Some("vtt"));
|
||||||
|
assert!(
|
||||||
|
vtt.starts_with("WEBVTT") && vtt.contains("00:01:02.500"),
|
||||||
|
"vtt chosen for .vtt, with cue timing"
|
||||||
|
);
|
||||||
|
// Unknown / missing extension defaults to XML.
|
||||||
|
assert!(chapters_content(&chaps(), None).contains("<Chapters>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn title_json_carries_streams_and_chapters() {
|
||||||
|
use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle};
|
||||||
|
use crate::disc::{LabelPurpose, SampleRate, Stream as DiscStream};
|
||||||
|
let mut t = DiscTitle::empty();
|
||||||
|
t.playlist = "MAIN".into();
|
||||||
|
t.chapters = chaps();
|
||||||
|
t.streams = vec![DiscStream::Audio(AudioStream {
|
||||||
|
pid: 0x1100,
|
||||||
|
codec: Codec::TrueHd,
|
||||||
|
channels: AudioChannels::Stereo,
|
||||||
|
language: "eng".into(),
|
||||||
|
sample_rate: SampleRate::S48,
|
||||||
|
secondary: false,
|
||||||
|
purpose: LabelPurpose::Normal,
|
||||||
|
label: String::new(),
|
||||||
|
})];
|
||||||
|
let v = title_json(&t);
|
||||||
|
assert_eq!(v["playlist"], "MAIN");
|
||||||
|
let a = &v["streams"][0];
|
||||||
|
assert_eq!(a["kind"], "audio");
|
||||||
|
assert_eq!(a["codec"], "TrueHD");
|
||||||
|
assert_eq!(a["language"], "eng");
|
||||||
|
// Completeness: audio carries channels + sample rate + purpose, not just codec.
|
||||||
|
assert_eq!(a["channels"], "stereo");
|
||||||
|
assert_eq!(a["channel_count"], 2);
|
||||||
|
assert_eq!(a["sample_rate"], "48kHz");
|
||||||
|
assert_eq!(a["sample_rate_hz"], 48000.0);
|
||||||
|
assert_eq!(a["purpose"], "normal");
|
||||||
|
assert_eq!(v["chapters"][1]["n"], 2);
|
||||||
|
assert_eq!(v["chapters"][1]["start_secs"], 62.5);
|
||||||
|
assert_eq!(v["chapters"][1]["name"], "2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_json_carries_resolution_and_hdr() {
|
||||||
|
use crate::disc::Codec;
|
||||||
|
use crate::disc::{
|
||||||
|
ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream as DiscStream,
|
||||||
|
VideoStream,
|
||||||
|
};
|
||||||
|
let mut t = DiscTitle::empty();
|
||||||
|
t.streams = vec![DiscStream::Video(VideoStream {
|
||||||
|
pid: 0x1011,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R2160p,
|
||||||
|
frame_rate: FrameRate::F23_976,
|
||||||
|
hdr: HdrFormat::Hdr10,
|
||||||
|
color_space: ColorSpace::Bt2020,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
})];
|
||||||
|
let vid = &title_json(&t)["streams"][0];
|
||||||
|
assert_eq!(vid["kind"], "video");
|
||||||
|
assert_eq!(vid["resolution"], "2160p");
|
||||||
|
assert_eq!(vid["width"], 3840);
|
||||||
|
assert_eq!(vid["height"], 2160);
|
||||||
|
assert_eq!(vid["frame_rate"], "23.976");
|
||||||
|
assert_eq!(vid["frame_rate_num"], 24000);
|
||||||
|
assert_eq!(vid["hdr"], "hdr10");
|
||||||
|
assert_eq!(vid["color_space"], "bt2020");
|
||||||
|
}
|
||||||
|
}
|
||||||
+354
-48
@@ -121,6 +121,29 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
|
|||||||
Some(record)
|
Some(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the `CodecPrivate` for an MVC (Blu-ray 3D) base track: the base view's
|
||||||
|
/// `AVCDecoderConfigurationRecord` (`avcc`) followed by an `mvcC` extension
|
||||||
|
/// block, per the Matroska Codec Specifications §4.3.9:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// avcC ‖ u32be(extension_block_size − 4) ‖ "mvcC" ‖ MVCDecoderConfigurationRecord
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The size field is the extension block length **excluding the 4-byte size
|
||||||
|
/// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level
|
||||||
|
/// MVC signal that decoders and media analyzers read (the per-frame `BlockAdditional`
|
||||||
|
/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A
|
||||||
|
/// plain (2D) track never calls this — it writes its `avcc` verbatim.
|
||||||
|
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
|
||||||
|
let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size − 4
|
||||||
|
let mut out = Vec::with_capacity(avcc.len() + 8 + record.len());
|
||||||
|
out.extend_from_slice(avcc);
|
||||||
|
out.extend_from_slice(&ext_size.to_be_bytes());
|
||||||
|
out.extend_from_slice(b"mvcC");
|
||||||
|
out.extend_from_slice(record);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
|
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
|
||||||
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
|
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
|
||||||
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
|
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
|
||||||
@@ -409,22 +432,23 @@ impl MkvTrack {
|
|||||||
// to fix the Windows-fps report, on the theory that Windows derives
|
// to fix the Windows-fps report, on the theory that Windows derives
|
||||||
// fps from it. The captured SOTL evidence proves the opposite: with
|
// fps from it. The captured SOTL evidence proves the opposite: with
|
||||||
// FlagInterlaced=1 + DefaultDuration=40 ms + DefaultDecodedFieldDuration=20 ms,
|
// FlagInterlaced=1 + DefaultDuration=40 ms + DefaultDecodedFieldDuration=20 ms,
|
||||||
// Windows Explorer reports 12.5 fps (half), and MediaInfo flips the
|
// Windows Explorer reports 12.5 fps (half), and a media analyzer
|
||||||
// track to "Frame rate mode: Variable" with no clean rate. MakeMKV's
|
// flips the track to "Frame rate mode: Variable" with no clean rate. A
|
||||||
// correct rip of the same disc OMITS DefaultDecodedFieldDuration,
|
// known-correct rip of the same disc OMITS DefaultDecodedFieldDuration,
|
||||||
// keeps FlagInterlaced=1 + FieldOrder=TFF + DefaultDuration=40 ms, and
|
// keeps FlagInterlaced=1 + FieldOrder=TFF + DefaultDuration=40 ms, and
|
||||||
// Explorer reports the full 25 fps with MediaInfo "Constant". ffmpeg's
|
// Explorer reports the full 25 fps with the analyzer showing "Constant".
|
||||||
// matroskaenc.c does the same (full-frame DefaultDuration, no field
|
// A conformant Matroska muxer does the same (full-frame DefaultDuration,
|
||||||
// duration). The lone frame-rate signal every tool actually trusts is
|
// no field duration). The lone frame-rate signal every tool actually
|
||||||
|
// trusts is
|
||||||
// `1 / DefaultDuration`; that full-frame value (40 ms → 25 fps) is kept
|
// `1 / DefaultDuration`; that full-frame value (40 ms → 25 fps) is kept
|
||||||
// below. Dropping the field-duration element removes the per-field
|
// below. Dropping the field-duration element removes the per-field
|
||||||
// signal that made Explorer halve the rate.
|
// signal that made Explorer halve the rate.
|
||||||
//
|
//
|
||||||
// Trade-off: the container no longer carries an explicit per-field
|
// Trade-off: the container no longer carries an explicit per-field
|
||||||
// decoded duration. Nothing is lost in practice — the interlace
|
// decoded duration. Nothing is lost in practice — the interlace
|
||||||
// signaling that deinterlacers and MediaInfo rely on lives in the
|
// signaling that deinterlacers and media analyzers rely on lives in the
|
||||||
// MPEG-2 elementary stream's picture_coding_extension (picture_structure /
|
// MPEG-2 elementary stream's picture_coding_extension (picture_structure /
|
||||||
// top_field_first), which MediaInfo reads directly (so it still reports
|
// top_field_first), which analyzers read directly (so they still report
|
||||||
// "Interlaced / Top Field First"), and the container still flags
|
// "Interlaced / Top Field First"), and the container still flags
|
||||||
// FlagInterlaced=1 + FieldOrder=TFF so players keep deinterlacing.
|
// FlagInterlaced=1 + FieldOrder=TFF so players keep deinterlacing.
|
||||||
field_duration_ns: 0,
|
field_duration_ns: 0,
|
||||||
@@ -457,8 +481,8 @@ impl MkvTrack {
|
|||||||
// and DTS-HD Master Audio." Players distinguish core vs HD-HRA vs
|
// and DTS-HD Master Audio." Players distinguish core vs HD-HRA vs
|
||||||
// HD-MA by parsing the DTS bitstream extension substreams, not by
|
// HD-MA by parsing the DTS bitstream extension substreams, not by
|
||||||
// the container codec ID. The previously-emitted `A_DTS/MA` and
|
// the container codec ID. The previously-emitted `A_DTS/MA` and
|
||||||
// `A_DTS/HR` suffixes are NOT registered codec IDs; strict parsers
|
// `A_DTS/HR` suffixes are NOT registered codec IDs; strict Matroska
|
||||||
// (libmatroska) and some hardware renderers fail to recognise the
|
// parsers and some hardware renderers fail to recognise the
|
||||||
// track at all. Emit plain `A_DTS` for every DTS variant — the
|
// track at all. Emit plain `A_DTS` for every DTS variant — the
|
||||||
// lossless MA / HRA payload bytes are unchanged, only the
|
// lossless MA / HRA payload bytes are unchanged, only the
|
||||||
// container codec-ID string differs.
|
// container codec-ID string differs.
|
||||||
@@ -579,7 +603,7 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
base_pts_ticks: Option<i64>,
|
base_pts_ticks: Option<i64>,
|
||||||
/// Last block timecode (TimestampScale ticks, relative to base_pts) written
|
/// Last block timecode (TimestampScale ticks, relative to base_pts) written
|
||||||
/// PER TRACK, to enforce strictly-monotonic per-track timestamps —
|
/// PER TRACK, to enforce strictly-monotonic per-track timestamps —
|
||||||
/// players/ffmpeg reject non-monotonic DTS, and some audio PES PTS land on
|
/// players and decoders reject non-monotonic DTS, and some audio PES PTS land on
|
||||||
/// the same tick (or tick back one from rounding).
|
/// the same tick (or tick back one from rounding).
|
||||||
last_pts_ticks: std::collections::HashMap<usize, i64>,
|
last_pts_ticks: std::collections::HashMap<usize, i64>,
|
||||||
/// Per-track-index flag: true if the track is video. The strictly-monotonic
|
/// Per-track-index flag: true if the track is video. The strictly-monotonic
|
||||||
@@ -649,6 +673,14 @@ pub struct MkvMuxer<W: Write + Seek> {
|
|||||||
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
|
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
|
||||||
/// `corrected` flips once patched so we only act on the first frame.
|
/// `corrected` flips once patched so we only act on the first frame.
|
||||||
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
|
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
|
||||||
|
/// Deferred PGS forced-subtitle detection. A PGS subtitle track reserves a
|
||||||
|
/// 1-byte `FlagForced` value up-front; as its display sets are written, the
|
||||||
|
/// track is judged forced iff it displayed at least one subtitle and EVERY
|
||||||
|
/// display set carried the HDMV `forced_on_flag` (a dedicated forced/narrative
|
||||||
|
/// track). At `finish()` the reserved byte is promoted to 1 for such tracks —
|
||||||
|
/// so forced subs are flagged even on discs without vendor label metadata.
|
||||||
|
/// Only ever promotes (0→1); a scan/vendor forced flag is never demoted.
|
||||||
|
pgs_forced_fixups: std::collections::HashMap<usize, PgsForcedFixup>,
|
||||||
/// `--log-level 3` opening-frame capture: the first ~100 coded frames per
|
/// `--log-level 3` opening-frame capture: the first ~100 coded frames per
|
||||||
/// track are written (raw) to a `<output>.opening.bin` side file with a
|
/// track are written (raw) to a `<output>.opening.bin` side file with a
|
||||||
/// per-frame summary logged, so an opening-GOP / menu / mid-GOP-open issue is
|
/// per-frame summary logged, so an opening-GOP / menu / mid-GOP-open issue is
|
||||||
@@ -669,6 +701,15 @@ struct Ac3ChannelFixup {
|
|||||||
corrected: bool,
|
corrected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deferred PGS forced-subtitle detection state for one PGS subtitle track.
|
||||||
|
struct PgsForcedFixup {
|
||||||
|
/// Absolute file offset of the 1-byte `FlagForced` value in the Tracks element.
|
||||||
|
value_offset: u64,
|
||||||
|
/// Shared forced-narrative classifier fed the track's display sets. The same
|
||||||
|
/// type drives the `info`-time forced probe, so both classify identically.
|
||||||
|
tracker: super::codec::pgs::ForcedTracker,
|
||||||
|
}
|
||||||
|
|
||||||
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
|
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
|
||||||
///
|
///
|
||||||
/// The classic 1 ms scale truncates two distinct cadences onto the same tick:
|
/// The classic 1 ms scale truncates two distinct cadences onto the same tick:
|
||||||
@@ -722,7 +763,7 @@ const MIN_BLOCK_REL: i64 = i16::MIN as i64;
|
|||||||
/// later than the previous one written for that track. `prev` is the last
|
/// later than the previous one written for that track. `prev` is the last
|
||||||
/// timestamp for the track (`None` for the first frame). Fixes non-monotonic
|
/// timestamp for the track (`None` for the first frame). Fixes non-monotonic
|
||||||
/// DTS: some audio PES PTS truncate to the same tick as the prior frame (or tick
|
/// DTS: some audio PES PTS truncate to the same tick as the prior frame (or tick
|
||||||
/// back one from rounding), which ffmpeg/strict players reject. At the 0.1 ms
|
/// back one from rounding), which strict players/decoders reject. At the 0.1 ms
|
||||||
/// scale a TrueHD AU (0.833 ms = ~8 ticks) no longer collides with its
|
/// scale a TrueHD AU (0.833 ms = ~8 ticks) no longer collides with its
|
||||||
/// neighbour, so this rarely fires for lossless audio — but a +1-tick nudge
|
/// neighbour, so this rarely fires for lossless audio — but a +1-tick nudge
|
||||||
/// (0.1 ms, sub-AU and inaudible) still guards genuine same-tick collisions on
|
/// (0.1 ms, sub-AU and inaudible) still guards genuine same-tick collisions on
|
||||||
@@ -762,23 +803,35 @@ fn block_ts(is_video: bool, prev: Option<i64>, pts_ticks: i64) -> i64 {
|
|||||||
|
|
||||||
/// Encode a Matroska track number as an EBML VINT into a stack buffer,
|
/// Encode a Matroska track number as an EBML VINT into a stack buffer,
|
||||||
/// returning the buffer and the used length. Track numbers are small (1-based,
|
/// returning the buffer and the used length. Track numbers are small (1-based,
|
||||||
/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest;
|
/// a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers `< 0x4000`,
|
||||||
/// no heap allocation, called once per block on the mux hot path.
|
/// and 3 bytes covers `< 0x20_0000`; no heap allocation, called once per block
|
||||||
|
/// on the mux hot path.
|
||||||
///
|
///
|
||||||
/// The 2-byte form holds 14 payload bits (max 0x3FFF). The `debug_assert`
|
/// Each width uses a marker bit that must NOT collide with the payload's top
|
||||||
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and
|
/// byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40
|
||||||
/// OR-ing the 0x40 length marker would clobber it, corrupting the track
|
/// (14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all
|
||||||
/// number. Not reachable today (track numbers are `i+1` over a few streams),
|
/// three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range
|
||||||
/// so this documents the bound rather than handling 3-byte VINTs.
|
/// track number can never silently clobber the marker bit and corrupt the
|
||||||
fn track_vint(track_num: usize) -> ([u8; 2], usize) {
|
/// block. Real discs never approach even the 2-byte range; the 21-bit ceiling
|
||||||
|
/// is an absurd upper bound kept as a `debug_assert`.
|
||||||
|
fn track_vint(track_num: usize) -> ([u8; 3], usize) {
|
||||||
if track_num < 0x80 {
|
if track_num < 0x80 {
|
||||||
([(track_num as u8) | 0x80, 0], 1)
|
([(track_num as u8) | 0x80, 0, 0], 1)
|
||||||
|
} else if track_num < 0x4000 {
|
||||||
|
([0x40 | ((track_num >> 8) as u8), track_num as u8, 0], 2)
|
||||||
} else {
|
} else {
|
||||||
debug_assert!(
|
debug_assert!(
|
||||||
track_num < 0x4000,
|
track_num < 0x20_0000,
|
||||||
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range"
|
"track number {track_num} exceeds the 21-bit 3-byte EBML VINT range"
|
||||||
);
|
);
|
||||||
([0x40 | ((track_num >> 8) as u8), track_num as u8], 2)
|
(
|
||||||
|
[
|
||||||
|
0x20 | ((track_num >> 16) as u8),
|
||||||
|
(track_num >> 8) as u8,
|
||||||
|
track_num as u8,
|
||||||
|
],
|
||||||
|
3,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -867,7 +920,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
Some(pos + 3)
|
Some(pos + 3)
|
||||||
};
|
};
|
||||||
// Stamp the freemkv version so any muxed file is traceable to the build
|
// Stamp the freemkv version so any muxed file is traceable to the build
|
||||||
// that produced it (MediaInfo "Writing application"/"library").
|
// that produced it (surfaced as a media analyzer's "Writing
|
||||||
|
// application"/"library" field).
|
||||||
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
|
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
|
||||||
ebml::write_string(&mut writer, ebml::WRITING_APP, crate::MUX_APP)?;
|
ebml::write_string(&mut writer, ebml::WRITING_APP, crate::MUX_APP)?;
|
||||||
if let Some(t) = title {
|
if let Some(t) = title {
|
||||||
@@ -882,9 +936,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
||||||
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
|
let mut pgs_forced_fixups: std::collections::HashMap<usize, PgsForcedFixup> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
|
||||||
|
// Filled below from the SAME built record that drives the CodecPrivate
|
||||||
|
// mvcC extension, so the three MVC signals never diverge.
|
||||||
|
let mut track_has_mvc_mapping: Vec<bool> = Vec::with_capacity(tracks.len());
|
||||||
for (i, track) in tracks.iter().enumerate() {
|
for (i, track) in tracks.iter().enumerate() {
|
||||||
let track_uid = (i + 1) as u64 | 0x100_0000;
|
let track_uid = (i + 1) as u64 | 0x100_0000;
|
||||||
track_uids.push(track_uid);
|
track_uids.push(track_uid);
|
||||||
|
// Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per
|
||||||
|
// track from the dependent view's subset-SPS/PPS. `None` for every
|
||||||
|
// non-3D track (and if the params are malformed) — the single source
|
||||||
|
// of truth for the CodecPrivate mvcC extension, the
|
||||||
|
// BlockAdditionMapping, and whether BlockAdditionals are conforming.
|
||||||
|
let mvc_record = track
|
||||||
|
.mvc_params
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|(sps, pps)| mvc_decoder_config_record(sps, pps));
|
||||||
|
track_has_mvc_mapping.push(mvc_record.is_some());
|
||||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
||||||
@@ -899,12 +969,42 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
if !track.is_default {
|
if !track.is_default {
|
||||||
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
|
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
|
||||||
}
|
}
|
||||||
if track.is_forced {
|
if track.track_type == ebml::TRACK_TYPE_SUBTITLE && track.codec_id == ebml::CODEC_PGS {
|
||||||
|
// Reserve a 1-byte FlagForced (initial value = the scan/vendor
|
||||||
|
// flag) and record its offset, so PGS content can promote it to 1
|
||||||
|
// at finish() if the track proves to be forced narrative. Written
|
||||||
|
// explicitly (ID + size + value) so the value is a single,
|
||||||
|
// in-place-patchable byte — same idiom as the Channels fixup.
|
||||||
|
ebml::write_id(&mut writer, ebml::FLAG_FORCED)?;
|
||||||
|
ebml::write_size(&mut writer, 1)?;
|
||||||
|
let value_offset = writer.stream_position()?;
|
||||||
|
writer.write_all(&[track.is_forced as u8])?;
|
||||||
|
pgs_forced_fixups.insert(
|
||||||
|
i,
|
||||||
|
PgsForcedFixup {
|
||||||
|
value_offset,
|
||||||
|
tracker: super::codec::pgs::ForcedTracker::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if track.is_forced {
|
||||||
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
|
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref cp) = track.codec_private {
|
if let Some(ref cp) = track.codec_private {
|
||||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
match mvc_record.as_ref() {
|
||||||
|
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC
|
||||||
|
// followed by the `mvcC` extension block. This is the
|
||||||
|
// track-level signal decoders/analyzers read to recognise the
|
||||||
|
// stereoscopic MVC track (the per-frame dependent view rides
|
||||||
|
// in BlockAdditional under the mapping below).
|
||||||
|
Some(record) => {
|
||||||
|
let cp_mvc = mvc_codec_private(cp, record);
|
||||||
|
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?;
|
||||||
|
}
|
||||||
|
// Non-MVC (2D/UHD/audio/…): write the codec_private verbatim —
|
||||||
|
// the unchanged path, byte-identical to a 2D mux.
|
||||||
|
None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
||||||
// (placeholder reserve + later seek-back fill via
|
// (placeholder reserve + later seek-back fill via
|
||||||
@@ -925,9 +1025,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// child of TrackEntry. The production video path now ALWAYS passes
|
// child of TrackEntry. The production video path now ALWAYS passes
|
||||||
// `field_duration_ns == 0` (see `MkvTrack::video`) so this element is
|
// `field_duration_ns == 0` (see `MkvTrack::video`) so this element is
|
||||||
// NOT written: emitting it (20 ms for 576i25) is exactly what made
|
// NOT written: emitting it (20 ms for 576i25) is exactly what made
|
||||||
// Windows Explorer report 12.5 fps and MediaInfo flip to VFR on the
|
// Windows Explorer report 12.5 fps and a media analyzer flip to VFR on
|
||||||
// captured SOTL rip, while MakeMKV — which omits it — shows the full
|
// the captured SOTL rip, while a known-correct rip — which omits it —
|
||||||
// 25 fps. The guard below is retained so a non-zero value still emits
|
// shows the full 25 fps. The guard below is retained so a non-zero
|
||||||
|
// value still emits
|
||||||
// a well-formed element for any future caller / round-trip test, but
|
// a well-formed element for any future caller / round-trip test, but
|
||||||
// the muxer's own callers no longer trigger it.
|
// the muxer's own callers no longer trigger it.
|
||||||
if track.track_type == ebml::TRACK_TYPE_VIDEO
|
if track.track_type == ebml::TRACK_TYPE_VIDEO
|
||||||
@@ -1002,10 +1103,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
|
|
||||||
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
|
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
|
||||||
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
||||||
// mediainfo recognise the dependent (right-eye) view that rides as a
|
// analyzers recognise the dependent (right-eye) view that rides as a
|
||||||
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
||||||
if let Some((subset_sps, pps)) = track.mvc_params.as_ref() {
|
match mvc_record.as_ref() {
|
||||||
if let Some(record) = mvc_decoder_config_record(subset_sps, pps) {
|
Some(record) => {
|
||||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||||
ebml::write_uint(
|
ebml::write_uint(
|
||||||
&mut writer,
|
&mut writer,
|
||||||
@@ -1013,23 +1114,30 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
BLOCK_ADD_ID_VALUE_MVC,
|
BLOCK_ADD_ID_VALUE_MVC,
|
||||||
)?;
|
)?;
|
||||||
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
|
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
|
||||||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, &record)?;
|
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, record)?;
|
||||||
ebml::end_master(&mut writer, map_pos)?;
|
ebml::end_master(&mut writer, map_pos)?;
|
||||||
} else {
|
}
|
||||||
|
// `mvc_params` present but the record failed to build (malformed
|
||||||
|
// parameter sets): no mapping, and `track_has_mvc_mapping` above
|
||||||
|
// is already `false`, so BlockAdditionals are dropped — the file
|
||||||
|
// stays conforming rather than carrying an orphaned BlockAddID.
|
||||||
|
None if track.mvc_params.is_some() => {
|
||||||
|
let (s, p) = track.mvc_params.as_ref().unwrap();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
"MVC track: could not build MVCDecoderConfigurationRecord from the \
|
"MVC track: could not build MVCDecoderConfigurationRecord from the \
|
||||||
dependent view's parameter sets (subset_sps={} B, pps={} B); \
|
dependent view's parameter sets (subset_sps={} B, pps={} B); \
|
||||||
emitting no mvcC mapping — the 3D pairing will not be signalled.",
|
emitting no mvcC mapping — the 3D pairing will not be signalled.",
|
||||||
subset_sps.len(),
|
s.len(),
|
||||||
pps.len(),
|
p.len(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||||
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
||||||
// mediainfo recognise the track as Dolby Vision.
|
// analyzers recognise the track as Dolby Vision.
|
||||||
if let Some(ref dvcc) = track.dv_config {
|
if let Some(ref dvcc) = track.dv_config {
|
||||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||||
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
|
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
|
||||||
@@ -1117,7 +1225,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
primary_video_track: tracks
|
primary_video_track: tracks
|
||||||
.iter()
|
.iter()
|
||||||
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
||||||
track_has_mvc_mapping: tracks.iter().map(|t| t.mvc_params.is_some()).collect(),
|
track_has_mvc_mapping,
|
||||||
continuity: TimelineContinuity::new(),
|
continuity: TimelineContinuity::new(),
|
||||||
cues: Vec::new(),
|
cues: Vec::new(),
|
||||||
frame_count: 0,
|
frame_count: 0,
|
||||||
@@ -1134,6 +1242,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
max_block_ticks: 0,
|
max_block_ticks: 0,
|
||||||
last_video_keyframe_ticks: None,
|
last_video_keyframe_ticks: None,
|
||||||
ac3_channel_fixups,
|
ac3_channel_fixups,
|
||||||
|
pgs_forced_fixups,
|
||||||
opening_capture: None,
|
opening_capture: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1445,6 +1554,14 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate PGS forced-subtitle state: a display set marks the track as
|
||||||
|
// having shown a subtitle, and clears `all_forced` the moment a
|
||||||
|
// non-forced set appears. `finish()` promotes FlagForced only for a track
|
||||||
|
// that displayed subtitles and had every one forced.
|
||||||
|
if let Some(fixup) = self.pgs_forced_fixups.get_mut(&track_idx) {
|
||||||
|
fixup.tracker.observe(data);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1477,6 +1594,26 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
|||||||
// Close final cluster
|
// Close final cluster
|
||||||
self.end_cluster()?;
|
self.end_cluster()?;
|
||||||
|
|
||||||
|
// Promote FlagForced for PGS subtitle tracks that proved to be forced
|
||||||
|
// narrative (displayed subtitles, every one forced). In-place single-byte
|
||||||
|
// rewrite of the reserved value, then restore the append position for the
|
||||||
|
// Cues that follow. Only promotes (0→1); a track already forced from the
|
||||||
|
// scan/vendor flag stays forced.
|
||||||
|
let forced_offsets: Vec<u64> = self
|
||||||
|
.pgs_forced_fixups
|
||||||
|
.values()
|
||||||
|
.filter(|f| f.tracker.is_forced())
|
||||||
|
.map(|f| f.value_offset)
|
||||||
|
.collect();
|
||||||
|
if !forced_offsets.is_empty() {
|
||||||
|
let here = self.writer.stream_position()?;
|
||||||
|
for off in forced_offsets {
|
||||||
|
self.writer.seek(std::io::SeekFrom::Start(off))?;
|
||||||
|
self.writer.write_all(&[1u8])?;
|
||||||
|
}
|
||||||
|
self.writer.seek(std::io::SeekFrom::Start(here))?;
|
||||||
|
}
|
||||||
|
|
||||||
// Write Cues
|
// Write Cues
|
||||||
let cues_start = self.writer.stream_position()?;
|
let cues_start = self.writer.stream_position()?;
|
||||||
let cues_offset = cues_start - self.segment_start;
|
let cues_offset = cues_start - self.segment_start;
|
||||||
@@ -2531,13 +2668,122 @@ mod tests {
|
|||||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||||
let data = muxer.writer.into_inner();
|
let data = muxer.writer.into_inner();
|
||||||
|
|
||||||
// FlagForced should NOT be written for non-forced tracks
|
// A PGS subtitle track now RESERVES a FlagForced element (so PGS content
|
||||||
assert!(
|
// can promote it later), but for a non-forced track its value is 0. The
|
||||||
find_id(&data, ebml::FLAG_FORCED).is_none(),
|
// value byte sits after the 2-byte ID (0x55AA) + 1-byte size.
|
||||||
"FlagForced element should not be present for non-forced subtitle"
|
let pos =
|
||||||
|
find_id(&data, ebml::FLAG_FORCED).expect("PGS subtitle reserves a FlagForced element");
|
||||||
|
assert_eq!(
|
||||||
|
data[pos + 3],
|
||||||
|
0,
|
||||||
|
"reserved FlagForced value must be 0 for a non-forced subtitle"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A PGS display-set PES block (PCS) with one composition object, whose
|
||||||
|
/// forced_on_flag is set per `forced`.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn pgs_display_set(forced: bool) -> Vec<u8> {
|
||||||
|
let mut pcs = vec![0u8; 18];
|
||||||
|
pcs[0] = 0x16; // segment type PCS
|
||||||
|
pcs[13] = 1; // number_of_composition_objects
|
||||||
|
pcs[17] = if forced { 0x40 } else { 0x00 }; // first object flags
|
||||||
|
pcs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mkv_pgs_forced_promoted_when_all_display_sets_forced() {
|
||||||
|
// End-to-end: a PGS subtitle track whose every display set is forced is
|
||||||
|
// promoted to FlagForced=1 at finish(), even though the scan flag was
|
||||||
|
// false (no vendor metadata).
|
||||||
|
use crate::disc::SubtitleStream;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||||
|
let sub = MkvTrack::subtitle(&SubtitleStream {
|
||||||
|
pid: 0x1200,
|
||||||
|
codec: Codec::Pgs,
|
||||||
|
language: "eng".into(),
|
||||||
|
forced: false,
|
||||||
|
qualifier: crate::disc::LabelQualifier::None,
|
||||||
|
codec_data: None,
|
||||||
|
});
|
||||||
|
let tracks = [make_video_track(), sub];
|
||||||
|
let mut muxer =
|
||||||
|
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
|
||||||
|
.unwrap(); // video keyframe opens a cluster
|
||||||
|
muxer
|
||||||
|
.write_frame(
|
||||||
|
1,
|
||||||
|
1_000_000,
|
||||||
|
true,
|
||||||
|
&pgs_display_set(true),
|
||||||
|
Some(2_000_000),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
muxer.finish().unwrap();
|
||||||
|
|
||||||
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
|
let pos = find_id(&data, ebml::FLAG_FORCED).expect("reserved FlagForced");
|
||||||
|
assert_eq!(
|
||||||
|
data[pos + 3],
|
||||||
|
1,
|
||||||
|
"all-forced PGS track promoted to FlagForced=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mkv_pgs_not_promoted_when_a_display_set_is_not_forced() {
|
||||||
|
// A track with ANY non-forced display set is a full track, not forced
|
||||||
|
// narrative — FlagForced stays 0.
|
||||||
|
use crate::disc::SubtitleStream;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||||
|
let sub = MkvTrack::subtitle(&SubtitleStream {
|
||||||
|
pid: 0x1200,
|
||||||
|
codec: Codec::Pgs,
|
||||||
|
language: "eng".into(),
|
||||||
|
forced: false,
|
||||||
|
qualifier: crate::disc::LabelQualifier::None,
|
||||||
|
codec_data: None,
|
||||||
|
});
|
||||||
|
let tracks = [make_video_track(), sub];
|
||||||
|
let mut muxer =
|
||||||
|
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
|
||||||
|
.unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(
|
||||||
|
1,
|
||||||
|
1_000_000,
|
||||||
|
true,
|
||||||
|
&pgs_display_set(true),
|
||||||
|
Some(2_000_000),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
muxer
|
||||||
|
.write_frame(
|
||||||
|
1,
|
||||||
|
3_000_000,
|
||||||
|
true,
|
||||||
|
&pgs_display_set(false),
|
||||||
|
Some(4_000_000),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap(); // a normal (non-forced) subtitle → not a forced track
|
||||||
|
muxer.finish().unwrap();
|
||||||
|
|
||||||
|
let data = shared.lock().unwrap().clone().into_inner();
|
||||||
|
let pos = find_id(&data, ebml::FLAG_FORCED).expect("reserved FlagForced");
|
||||||
|
assert_eq!(data[pos + 3], 0, "mixed PGS track stays FlagForced=0");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Seekability tests: SeekHead, keyframe-aligned clusters, Cues
|
// Seekability tests: SeekHead, keyframe-aligned clusters, Cues
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -3450,7 +3696,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn track_vint_encodes_one_and_two_byte_forms() {
|
fn track_vint_encodes_one_two_and_three_byte_forms() {
|
||||||
// 1-byte form for track numbers < 0x80, high bit set.
|
// 1-byte form for track numbers < 0x80, high bit set.
|
||||||
let (b, n) = track_vint(1);
|
let (b, n) = track_vint(1);
|
||||||
assert_eq!(&b[..n], &[0x81]);
|
assert_eq!(&b[..n], &[0x81]);
|
||||||
@@ -3461,6 +3707,12 @@ mod tests {
|
|||||||
assert_eq!(&b[..n], &[0x40, 0x80]);
|
assert_eq!(&b[..n], &[0x40, 0x80]);
|
||||||
let (b, n) = track_vint(0x3FFF);
|
let (b, n) = track_vint(0x3FFF);
|
||||||
assert_eq!(&b[..n], &[0x7F, 0xFF]);
|
assert_eq!(&b[..n], &[0x7F, 0xFF]);
|
||||||
|
// 3-byte form at/above 0x4000, 0x20 length marker in the top byte —
|
||||||
|
// handled in RELEASE (no silent marker-bit clobber), not just debug.
|
||||||
|
let (b, n) = track_vint(0x4000);
|
||||||
|
assert_eq!(&b[..n], &[0x20, 0x40, 0x00]);
|
||||||
|
let (b, n) = track_vint(0x1F_FFFF);
|
||||||
|
assert_eq!(&b[..n], &[0x3F, 0xFF, 0xFF]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -4030,8 +4282,8 @@ mod tests {
|
|||||||
// fps, the only rate every tool trusts) and must NOT emit
|
// fps, the only rate every tool trusts) and must NOT emit
|
||||||
// DefaultDecodedFieldDuration. rc.5.1 emitted the 20 ms field duration to
|
// DefaultDecodedFieldDuration. rc.5.1 emitted the 20 ms field duration to
|
||||||
// try to fix Windows; the captured SOTL evidence proved it did the
|
// try to fix Windows; the captured SOTL evidence proved it did the
|
||||||
// opposite (Explorer 12.5 fps, MediaInfo VFR). MakeMKV's correct rip omits
|
// opposite (Explorer 12.5 fps, analyzer VFR). A known-correct rip omits
|
||||||
// it (Explorer 25 fps, MediaInfo CFR). So: frame duration present = 40 ms,
|
// it (Explorer 25 fps, analyzer CFR). So: frame duration present = 40 ms,
|
||||||
// field duration ABSENT, interlace signalling (FlagInterlaced/FieldOrder)
|
// field duration ABSENT, interlace signalling (FlagInterlaced/FieldOrder)
|
||||||
// retained.
|
// retained.
|
||||||
let v = VideoStream {
|
let v = VideoStream {
|
||||||
@@ -4064,7 +4316,7 @@ mod tests {
|
|||||||
"DefaultDecodedFieldDuration must NOT be written (Windows halves the rate when it is)"
|
"DefaultDecodedFieldDuration must NOT be written (Windows halves the rate when it is)"
|
||||||
);
|
);
|
||||||
// Interlace signalling is RETAINED so deinterlacers still engage and
|
// Interlace signalling is RETAINED so deinterlacers still engage and
|
||||||
// MediaInfo (which also reads scan type from the MPEG-2 ES) agrees.
|
// a media analyzer (which also reads scan type from the MPEG-2 ES) agrees.
|
||||||
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
|
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
data[fi + 2],
|
data[fi + 2],
|
||||||
@@ -4567,7 +4819,7 @@ mod tests {
|
|||||||
fn mvc_track_emits_mvcc_block_addition_mapping() {
|
fn mvc_track_emits_mvcc_block_addition_mapping() {
|
||||||
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
|
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
|
||||||
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
|
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
|
||||||
// players / mediainfo recognise the Blu-ray 3D dependent view.
|
// players / analyzers recognise the Blu-ray 3D dependent view.
|
||||||
let mut v = make_video_track();
|
let mut v = make_video_track();
|
||||||
v.mvc_params = Some((
|
v.mvc_params = Some((
|
||||||
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
||||||
@@ -4600,6 +4852,60 @@ mod tests {
|
|||||||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mvc_codec_private_appends_mvcc_extension_block() {
|
||||||
|
// avcC ‖ u32be(4 + record.len()) ‖ "mvcC" ‖ record (Matroska Codec Spec §4.3.9).
|
||||||
|
let avcc = vec![0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0xAA];
|
||||||
|
let record = vec![0x01, 0x80, 0x00, 0x33, 0xBF, 0x01, 0xCC]; // 7 bytes
|
||||||
|
let out = mvc_codec_private(&avcc, &record);
|
||||||
|
assert_eq!(&out[..avcc.len()], &avcc[..], "avcC preserved verbatim");
|
||||||
|
// size field = 4 ("mvcC") + 7 (record) = 11 = extension block size minus 4.
|
||||||
|
assert_eq!(&out[avcc.len()..avcc.len() + 4], &11u32.to_be_bytes());
|
||||||
|
assert_eq!(&out[avcc.len() + 4..avcc.len() + 8], b"mvcC");
|
||||||
|
assert_eq!(
|
||||||
|
&out[avcc.len() + 8..],
|
||||||
|
&record[..],
|
||||||
|
"record after the mvcC fourcc"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
|
||||||
|
// An MVC base track's CodecPrivate must be the base avcC followed by the
|
||||||
|
// mvcC extension — the track-level signal analyzers/decoders read.
|
||||||
|
let avcc = vec![
|
||||||
|
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
|
||||||
|
];
|
||||||
|
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
|
||||||
|
let pps = vec![0x68, 0xEE, 0x3C];
|
||||||
|
let mut v = make_video_track();
|
||||||
|
v.codec_private = Some(avcc.clone());
|
||||||
|
v.mvc_params = Some((subset_sps.clone(), pps.clone()));
|
||||||
|
let data = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[])
|
||||||
|
.unwrap()
|
||||||
|
.writer
|
||||||
|
.into_inner();
|
||||||
|
let record = mvc_decoder_config_record(&subset_sps, &pps).unwrap();
|
||||||
|
let expected = mvc_codec_private(&avcc, &record);
|
||||||
|
assert!(
|
||||||
|
data.windows(expected.len())
|
||||||
|
.any(|w| w == expected.as_slice()),
|
||||||
|
"emitted CodecPrivate must be avcC + mvcC extension"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A non-MVC (2D) track writes its avcC VERBATIM — no mvcC appended.
|
||||||
|
let mut v2 = make_video_track();
|
||||||
|
v2.codec_private = Some(avcc.clone());
|
||||||
|
let d2 = MkvMuxer::new(Cursor::new(Vec::new()), &[v2], None, 0.0, &[])
|
||||||
|
.unwrap()
|
||||||
|
.writer
|
||||||
|
.into_inner();
|
||||||
|
assert!(
|
||||||
|
!d2.windows(4).any(|w| w == b"mvcC"),
|
||||||
|
"2D track CodecPrivate must not carry an mvcC extension"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
||||||
//
|
//
|
||||||
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
||||||
|
|||||||
+14
-13
@@ -323,17 +323,14 @@ fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MkvStream {
|
impl MkvStream {
|
||||||
/// Create for writing PES frames → MKV container.
|
/// Create for writing PES frames → MKV container. Codec privates come from
|
||||||
/// Codec privates come from title.codec_privates (populated by input stream).
|
/// `title.codec_privates` (populated by the input stream).
|
||||||
pub fn create(writer: Box<dyn WriteSeek + Send>, title: &DiscTitle) -> io::Result<Self> {
|
///
|
||||||
Self::create_at(writer, title, None)
|
/// `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
|
||||||
/// As [`create`](Self::create), but `output_path` (when known) enables the
|
/// fires either way.
|
||||||
/// `--log-level 3` opening-frame capture to `<output>.opening.bin`. A `None`
|
pub fn create(
|
||||||
/// 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(
|
|
||||||
writer: Box<dyn WriteSeek + Send>,
|
writer: Box<dyn WriteSeek + Send>,
|
||||||
title: &DiscTitle,
|
title: &DiscTitle,
|
||||||
output_path: Option<&std::path::Path>,
|
output_path: Option<&std::path::Path>,
|
||||||
@@ -1016,7 +1013,11 @@ fn parse_track(
|
|||||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||||
match aid {
|
match aid {
|
||||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
||||||
ebml::CHANNELS => ch = read_uint_bounded(r, as_)? as u8,
|
// Clamp instead of `as u8`: a foreign/corrupt MKV with a
|
||||||
|
// CHANNELS value that is a multiple of 256 would truncate to
|
||||||
|
// 0 (an invalid channel count) on a bare cast. Saturate to
|
||||||
|
// u8::MAX so an absurd count degrades to "many", never to 0.
|
||||||
|
ebml::CHANNELS => ch = read_uint_bounded(r, as_)?.min(u8::MAX as u64) as u8,
|
||||||
_ => {
|
_ => {
|
||||||
skip_bytes(r, as_)?;
|
skip_bytes(r, as_)?;
|
||||||
}
|
}
|
||||||
@@ -1405,7 +1406,7 @@ mod tests {
|
|||||||
streams: vec![Stream::Video(dep)],
|
streams: vec![Stream::Video(dep)],
|
||||||
..DiscTitle::empty()
|
..DiscTitle::empty()
|
||||||
};
|
};
|
||||||
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title)
|
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title, None)
|
||||||
.expect("create must succeed, not panic");
|
.expect("create must succeed, not panic");
|
||||||
assert!(
|
assert!(
|
||||||
s.mvc.is_none(),
|
s.mvc.is_none(),
|
||||||
|
|||||||
+16
-2
@@ -25,8 +25,10 @@
|
|||||||
|
|
||||||
// Public modules — types here are intentionally part of the consumable API.
|
// Public modules — types here are intentionally part of the consumable API.
|
||||||
pub mod disc;
|
pub mod disc;
|
||||||
|
pub mod driver;
|
||||||
pub mod pipelined_stream;
|
pub mod pipelined_stream;
|
||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
|
pub mod select;
|
||||||
|
|
||||||
// Internal-only modules. Every reference is via `crate::mux::…` /
|
// Internal-only modules. Every reference is via `crate::mux::…` /
|
||||||
// `super::…` from inside the crate; nothing in the downstream crates or
|
// `super::…` from inside the crate; nothing in the downstream crates or
|
||||||
@@ -62,6 +64,7 @@ pub(crate) mod m2ts;
|
|||||||
/// to round-trip codec_privates that don't fit inside the underlying format).
|
/// to round-trip codec_privates that don't fit inside the underlying format).
|
||||||
/// Exposed for integration tests that exercise the wire format directly.
|
/// Exposed for integration tests that exercise the wire format directly.
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
|
pub(crate) mod meta_sink;
|
||||||
|
|
||||||
// ── Sequential-sink muxers ──────────────────────────────────────────────────
|
// ── Sequential-sink muxers ──────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -89,6 +92,7 @@ pub(crate) mod hevc;
|
|||||||
pub(crate) mod m2ts_mux;
|
pub(crate) mod m2ts_mux;
|
||||||
pub(crate) mod mkv;
|
pub(crate) mod mkv;
|
||||||
pub(crate) mod mkvstream;
|
pub(crate) mod mkvstream;
|
||||||
|
pub(crate) mod mp4;
|
||||||
pub(crate) mod network;
|
pub(crate) mod network;
|
||||||
pub(crate) mod null;
|
pub(crate) mod null;
|
||||||
pub(crate) mod ps;
|
pub(crate) mod ps;
|
||||||
@@ -108,15 +112,19 @@ pub(crate) mod tsmux;
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) mod videomap;
|
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 disc::DiscStream;
|
||||||
pub use fvi_sink::FviSink;
|
pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream};
|
||||||
pub use m2ts::M2tsStream;
|
pub use m2ts::M2tsStream;
|
||||||
pub use mkvstream::MkvStream;
|
pub use mkvstream::MkvStream;
|
||||||
|
pub use mp4::{Mp4FitReport, Mp4SkipReason, fit_report as mp4_fit_report};
|
||||||
pub use network::NetworkStream;
|
pub use network::NetworkStream;
|
||||||
pub use null::NullStream;
|
pub use null::NullStream;
|
||||||
pub use pipelined_stream::PipelinedPesStream;
|
pub use pipelined_stream::PipelinedPesStream;
|
||||||
pub use resolve::build_iso_pipeline;
|
pub use resolve::build_iso_pipeline;
|
||||||
|
pub use resolve::resolve_mux_key_map;
|
||||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||||
pub use stdio::StdioStream;
|
pub use stdio::StdioStream;
|
||||||
|
|
||||||
@@ -151,11 +159,17 @@ mod tests {
|
|||||||
assert_eq!(parse_url("disc://").scheme(), "disc");
|
assert_eq!(parse_url("disc://").scheme(), "disc");
|
||||||
assert_eq!(parse_url("m2ts://f").scheme(), "m2ts");
|
assert_eq!(parse_url("m2ts://f").scheme(), "m2ts");
|
||||||
assert_eq!(parse_url("mkv://f").scheme(), "mkv");
|
assert_eq!(parse_url("mkv://f").scheme(), "mkv");
|
||||||
|
assert_eq!(parse_url("mp4://f").scheme(), "mp4");
|
||||||
assert_eq!(parse_url("network://h:1").scheme(), "network");
|
assert_eq!(parse_url("network://h:1").scheme(), "network");
|
||||||
assert_eq!(parse_url("stdio://").scheme(), "stdio");
|
assert_eq!(parse_url("stdio://").scheme(), "stdio");
|
||||||
assert_eq!(parse_url("iso://f").scheme(), "iso");
|
assert_eq!(parse_url("iso://f").scheme(), "iso");
|
||||||
assert_eq!(parse_url("null://").scheme(), "null");
|
assert_eq!(parse_url("null://").scheme(), "null");
|
||||||
assert_eq!(parse_url("demux://out/").scheme(), "demux");
|
assert_eq!(parse_url("demux://out/").scheme(), "demux");
|
||||||
|
assert_eq!(parse_url("video://out/").scheme(), "video");
|
||||||
|
assert_eq!(parse_url("audio://out/").scheme(), "audio");
|
||||||
|
assert_eq!(parse_url("sub://out/").scheme(), "sub");
|
||||||
|
assert_eq!(parse_url("chapters://c.xml").scheme(), "chapters");
|
||||||
|
assert_eq!(parse_url("json://t.json").scheme(), "json");
|
||||||
assert_eq!(parse_url("bogus://x").scheme(), "unknown");
|
assert_eq!(parse_url("bogus://x").scheme(), "unknown");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,603 @@
|
|||||||
|
//! MP4 audio sample entries and codec-config boxes for the `mp4://` muxer.
|
||||||
|
//!
|
||||||
|
//! Covers the codecs that map cleanly into MP4 and play widely: **AC-3**
|
||||||
|
//! (`ac-3` + `dac3`), **E-AC-3 / Dolby Digital Plus** (`ec-3` + `dec3`, incl.
|
||||||
|
//! Atmos-in-DD+ JOC), and **DTS / DTS-HD** (`dtsc`/`dtsh` + `ddts`, describing
|
||||||
|
//! the core with whole access units passed through so an HD decoder finds the
|
||||||
|
//! extension). Config boxes are derived from the first audio frame's bitstream
|
||||||
|
//! (ISO/IEC 14496-12 amendments; ETSI TS 102 366 / 102 114). Codecs with no
|
||||||
|
//! clean MP4 mapping (TrueHD, LPCM, bitmap subtitles) are excluded by the fit
|
||||||
|
//! oracle in the sink.
|
||||||
|
|
||||||
|
use super::boxes::bx;
|
||||||
|
use crate::disc::Codec;
|
||||||
|
|
||||||
|
/// AC-3 / E-AC-3 sample rates indexed by `fscod` (byte-4 bits 7-6).
|
||||||
|
const FSCOD_RATES: [u32; 3] = [48_000, 44_100, 32_000];
|
||||||
|
/// E-AC-3 reduced rates indexed by `fscod2` (byte-4 bits 5-4) when `fscod == 3`.
|
||||||
|
const EAC3_REDUCED_RATES: [u32; 4] = [24_000, 22_050, 16_000, 48_000];
|
||||||
|
/// Base channel count per `acmod` (A/52 Table 5.8), before the LFE.
|
||||||
|
const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5];
|
||||||
|
|
||||||
|
/// A big-endian MSB-first bit reader over a byte slice.
|
||||||
|
struct BitReader<'a> {
|
||||||
|
data: &'a [u8],
|
||||||
|
bit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> BitReader<'a> {
|
||||||
|
fn new(data: &'a [u8]) -> Self {
|
||||||
|
Self { data, bit: 0 }
|
||||||
|
}
|
||||||
|
fn skip(&mut self, n: usize) {
|
||||||
|
self.bit += n;
|
||||||
|
}
|
||||||
|
/// Read `n` bits (n ≤ 32). Returns 0 past end of data (callers pre-check len).
|
||||||
|
fn read(&mut self, n: usize) -> u32 {
|
||||||
|
let mut v = 0u32;
|
||||||
|
for _ in 0..n {
|
||||||
|
let byte = self.data.get(self.bit / 8).copied().unwrap_or(0);
|
||||||
|
let shift = 7 - (self.bit % 8);
|
||||||
|
v = (v << 1) | ((byte >> shift) & 1) as u32;
|
||||||
|
self.bit += 1;
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decoded (E-)AC-3 stream parameters needed for the `dac3`/`dec3` config box
|
||||||
|
/// and the audio sample entry.
|
||||||
|
pub(super) struct DolbyConfig {
|
||||||
|
pub fscod: u8,
|
||||||
|
pub bsid: u8,
|
||||||
|
pub bsmod: u8,
|
||||||
|
pub acmod: u8,
|
||||||
|
pub lfeon: bool,
|
||||||
|
/// AC-3 only: `bit_rate_code` (= `frmsizecod >> 1`). Unused for E-AC-3.
|
||||||
|
pub bit_rate_code: u8,
|
||||||
|
/// E-AC-3 only: nominal data rate in kbps (for `dec3`). 0 for AC-3.
|
||||||
|
pub data_rate_kbps: u16,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub channels: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DolbyConfig {
|
||||||
|
fn channel_count(acmod: u8, lfeon: bool) -> u16 {
|
||||||
|
ACMOD_CHANNELS[acmod as usize] as u16 + lfeon as u16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the first (E-)AC-3 frame starting at the 0x0B77 syncword. Returns
|
||||||
|
/// `None` if the frame is too short or the syncword is absent.
|
||||||
|
pub(super) fn parse_dolby(frame: &[u8]) -> Option<DolbyConfig> {
|
||||||
|
let start =
|
||||||
|
(0..frame.len().saturating_sub(1)).find(|&i| frame[i] == 0x0B && frame[i + 1] == 0x77)?;
|
||||||
|
let f = &frame[start..];
|
||||||
|
if f.len() < 6 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// bsid lives in byte 5 bits 7-3 for both AC-3 and E-AC-3.
|
||||||
|
let bsid = (f[5] >> 3) & 0x1F;
|
||||||
|
if bsid >= 11 {
|
||||||
|
parse_eac3(f)
|
||||||
|
} else {
|
||||||
|
parse_ac3(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy AC-3 (A/52 §5.3.2): syncword | crc(16) | fscod(2) frmsizecod(6) |
|
||||||
|
/// bsid(5) bsmod(3) | acmod(3) …optional… lfeon.
|
||||||
|
fn parse_ac3(f: &[u8]) -> Option<DolbyConfig> {
|
||||||
|
if f.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let fscod = (f[4] >> 6) & 0x03;
|
||||||
|
let frmsizecod = f[4] & 0x3F;
|
||||||
|
let bsid = (f[5] >> 3) & 0x1F;
|
||||||
|
let bsmod = f[5] & 0x07;
|
||||||
|
|
||||||
|
// acmod + trailing optional 2-bit fields, then lfeon (byte 6 onward).
|
||||||
|
let mut r = BitReader::new(f);
|
||||||
|
r.bit = 6 * 8;
|
||||||
|
let acmod = r.read(3) as u8;
|
||||||
|
if (acmod & 0x1) != 0 && acmod != 0x1 {
|
||||||
|
r.skip(2); // cmixlev
|
||||||
|
}
|
||||||
|
if (acmod & 0x4) != 0 {
|
||||||
|
r.skip(2); // surmixlev
|
||||||
|
}
|
||||||
|
if acmod == 0x2 {
|
||||||
|
r.skip(2); // dsurmod
|
||||||
|
}
|
||||||
|
let lfeon = r.read(1) == 1;
|
||||||
|
|
||||||
|
Some(DolbyConfig {
|
||||||
|
fscod,
|
||||||
|
bsid,
|
||||||
|
bsmod,
|
||||||
|
acmod,
|
||||||
|
lfeon,
|
||||||
|
bit_rate_code: frmsizecod >> 1,
|
||||||
|
data_rate_kbps: 0,
|
||||||
|
sample_rate: FSCOD_RATES.get(fscod as usize).copied().unwrap_or(48_000),
|
||||||
|
channels: DolbyConfig::channel_count(acmod, lfeon),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// E-AC-3 (A/52 Annex E BSI): syncword | strmtyp(2) substreamid(3) frmsiz(11) |
|
||||||
|
/// fscod(2) numblkscod(2) acmod(3) lfeon(1) | bsid(5) …
|
||||||
|
fn parse_eac3(f: &[u8]) -> Option<DolbyConfig> {
|
||||||
|
if f.len() < 6 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let frmsiz = (((f[2] & 0x07) as u32) << 8) | f[3] as u32; // words minus one
|
||||||
|
let fscod = (f[4] >> 6) & 0x03;
|
||||||
|
let numblkscod = (f[4] >> 4) & 0x03;
|
||||||
|
let acmod = (f[4] >> 1) & 0x07;
|
||||||
|
let lfeon = (f[4] & 0x01) == 1;
|
||||||
|
let bsid = (f[5] >> 3) & 0x1F;
|
||||||
|
|
||||||
|
let (sample_rate, blocks) = if fscod == 0x03 {
|
||||||
|
let fscod2 = (f[4] >> 4) & 0x03; // shares bits with numblkscod when fscod==3
|
||||||
|
(EAC3_REDUCED_RATES[fscod2 as usize], 6u32)
|
||||||
|
} else {
|
||||||
|
let blocks = [1u32, 2, 3, 6][numblkscod as usize];
|
||||||
|
(FSCOD_RATES[fscod as usize], blocks)
|
||||||
|
};
|
||||||
|
// Nominal data rate (kbps): frame is (frmsiz+1) 16-bit words per (blocks·256)
|
||||||
|
// samples at sample_rate. rate = bytes·8·sr / samples / 1000.
|
||||||
|
let frame_bytes = (frmsiz as u64 + 1) * 2;
|
||||||
|
let samples = blocks as u64 * 256;
|
||||||
|
let data_rate_kbps = if samples > 0 {
|
||||||
|
((frame_bytes * 8 * sample_rate as u64) / samples / 1000) as u16
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(DolbyConfig {
|
||||||
|
fscod,
|
||||||
|
bsid,
|
||||||
|
bsmod: 0, // not in the E-AC-3 main header; dec3 default
|
||||||
|
acmod,
|
||||||
|
lfeon,
|
||||||
|
bit_rate_code: 0,
|
||||||
|
data_rate_kbps,
|
||||||
|
sample_rate,
|
||||||
|
channels: DolbyConfig::channel_count(acmod, lfeon),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `dac3` config box (ETSI TS 102 366 Annex F.4): 24 bits —
|
||||||
|
/// fscod(2) bsid(5) bsmod(3) acmod(3) lfeon(1) bit_rate_code(5) reserved(5).
|
||||||
|
pub(super) fn dac3_box(c: &DolbyConfig) -> Vec<u8> {
|
||||||
|
let mut v: u32 = 0;
|
||||||
|
let mut push = |val: u32, bits: u32| v = (v << bits) | (val & ((1 << bits) - 1));
|
||||||
|
push(c.fscod as u32, 2);
|
||||||
|
push(c.bsid as u32, 5);
|
||||||
|
push(c.bsmod as u32, 3);
|
||||||
|
push(c.acmod as u32, 3);
|
||||||
|
push(c.lfeon as u32, 1);
|
||||||
|
push(c.bit_rate_code as u32, 5);
|
||||||
|
push(0, 5); // reserved
|
||||||
|
// 24 bits → the top 3 bytes of the big-endian u32.
|
||||||
|
let b = v.to_be_bytes();
|
||||||
|
bx(b"dac3", &[b[1], b[2], b[3]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `dec3` config box (ETSI TS 102 366 Annex G.3) for a single independent
|
||||||
|
/// substream, no dependent substreams: data_rate(13) num_ind_sub(3) then
|
||||||
|
/// fscod(2) bsid(5) reserved(1) asvc(1) bsmod(3) acmod(3) lfeon(1) reserved(3)
|
||||||
|
/// num_dep_sub(4) reserved(1).
|
||||||
|
pub(super) fn dec3_box(c: &DolbyConfig) -> Vec<u8> {
|
||||||
|
let mut v: u64 = 0;
|
||||||
|
let mut push = |val: u64, bits: u32| v = (v << bits) | (val & ((1u64 << bits) - 1));
|
||||||
|
push(c.data_rate_kbps as u64, 13);
|
||||||
|
push(0, 3); // num_ind_sub - 1 = 0 (one substream)
|
||||||
|
push(c.fscod as u64, 2);
|
||||||
|
push(c.bsid as u64, 5);
|
||||||
|
push(0, 1); // reserved
|
||||||
|
push(0, 1); // asvc
|
||||||
|
push(c.bsmod as u64, 3);
|
||||||
|
push(c.acmod as u64, 3);
|
||||||
|
push(c.lfeon as u64, 1);
|
||||||
|
push(0, 3); // reserved
|
||||||
|
push(0, 4); // num_dep_sub = 0
|
||||||
|
push(0, 1); // reserved (chan_loc absent when num_dep_sub == 0)
|
||||||
|
// 40 bits → the low 5 bytes of the big-endian u64.
|
||||||
|
let b = v.to_be_bytes();
|
||||||
|
bx(b"dec3", &[b[3], b[4], b[5], b[6], b[7]])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an audio sample entry (`ac-3` / `ec-3`) with the given config box.
|
||||||
|
/// `AudioSampleEntry` per ISO/IEC 14496-12 §12.2.3.
|
||||||
|
pub(super) fn audio_sample_entry(
|
||||||
|
fourcc: &[u8; 4],
|
||||||
|
channels: u16,
|
||||||
|
sample_rate: u32,
|
||||||
|
config: &[u8],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut e = Vec::new();
|
||||||
|
e.extend_from_slice(&[0u8; 6]); // reserved
|
||||||
|
e.extend_from_slice(&1u16.to_be_bytes()); // data_reference_index
|
||||||
|
e.extend_from_slice(&[0u8; 8]); // reserved (version 0)
|
||||||
|
e.extend_from_slice(&channels.to_be_bytes());
|
||||||
|
e.extend_from_slice(&16u16.to_be_bytes()); // samplesize
|
||||||
|
e.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
|
||||||
|
e.extend_from_slice(&0u16.to_be_bytes()); // reserved
|
||||||
|
// samplerate is 16.16 fixed point; the integer rate in the high 16 bits. The
|
||||||
|
// integer part is only 16 bits, so cap at 65535 — 96/192 kHz (DTS-HD) would
|
||||||
|
// otherwise overflow u32 and write a garbage rate (the true rate is in ddts).
|
||||||
|
e.extend_from_slice(&(sample_rate.min(0xFFFF) << 16).to_be_bytes());
|
||||||
|
e.extend_from_slice(config);
|
||||||
|
bx(fourcc, &e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DTS (dtsc/dtsh + ddts) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// DTS core `SFREQ` (4-bit) → sample rate (Hz). Reserved indices → 48 kHz.
|
||||||
|
const DTS_SFREQ: [u32; 16] = [
|
||||||
|
48_000, 8_000, 16_000, 32_000, 48_000, 48_000, 11_025, 22_050, 44_100, 48_000, 48_000, 12_000,
|
||||||
|
24_000, 48_000, 96_000, 192_000,
|
||||||
|
];
|
||||||
|
/// DTS core base channel count per `AMODE` (all 16 defined values). Matches the
|
||||||
|
/// reference `ff_dca_channels[16]` table (ETSI TS 102 114) that the decodability
|
||||||
|
/// gate in `dts.rs` (`DTS_AMODE_COUNT`) also uses, so a spec-legal DTS-ES / 6.1 /
|
||||||
|
/// 7.1 core (AMODE 13→7, 14/15→8) is DECLARED with its true channel count in the
|
||||||
|
/// mp4 AudioSampleEntry / `ddts` box rather than a truncated 6.
|
||||||
|
const DTS_AMODE_CH: [u8; 16] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5, 6, 6, 6, 7, 8, 8];
|
||||||
|
|
||||||
|
/// Decoded DTS core parameters needed for the `ddts` box.
|
||||||
|
struct DtsConfig {
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u16,
|
||||||
|
amode: u8,
|
||||||
|
lfe: bool,
|
||||||
|
core_size: u32,
|
||||||
|
/// Samples per frame ((NBLKS+1)·32).
|
||||||
|
frame_samples: u32,
|
||||||
|
/// Whether a DTS-HD extension substream follows the core.
|
||||||
|
has_extension: bool,
|
||||||
|
channel_layout: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the DTS core header (ETSI TS 102 114 §5.3.1), starting at the
|
||||||
|
/// 0x7FFE8001 big-endian core sync. Returns `None` if too short / no sync.
|
||||||
|
fn parse_dts(frame: &[u8]) -> Option<DtsConfig> {
|
||||||
|
let start = (0..frame.len().saturating_sub(3)).find(|&i| {
|
||||||
|
frame[i] == 0x7F && frame[i + 1] == 0xFE && frame[i + 2] == 0x80 && frame[i + 3] == 0x01
|
||||||
|
})?;
|
||||||
|
let f = &frame[start..];
|
||||||
|
if f.len() < 11 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Bit fields after the 32-bit sync (MSB-first):
|
||||||
|
// FTYPE1 SHORT5 CPF1 NBLKS7 FSIZE14 AMODE6 SFREQ4 RATE5 ...
|
||||||
|
let nblks = (((f[4] & 0x01) as u32) << 6) | ((f[5] >> 2) as u32 & 0x3F);
|
||||||
|
let fsize = (((f[5] & 0x03) as u32) << 12) | ((f[6] as u32) << 4) | ((f[7] >> 4) as u32 & 0x0F);
|
||||||
|
let amode = (((f[7] & 0x0F) << 2) | ((f[8] >> 6) & 0x03)) as usize;
|
||||||
|
let sfreq = ((f[8] >> 2) & 0x0F) as usize;
|
||||||
|
// LFF is 2 bits at bit offset 85 → byte10 bits 2-1.
|
||||||
|
let lff = (f[10] >> 1) & 0x03;
|
||||||
|
let lfe = lff == 1 || lff == 2;
|
||||||
|
|
||||||
|
let sample_rate = DTS_SFREQ[sfreq];
|
||||||
|
let base_ch = DTS_AMODE_CH.get(amode).copied().unwrap_or(6);
|
||||||
|
let channels = base_ch as u16 + lfe as u16;
|
||||||
|
let channel_layout = dts_channel_layout(amode, lfe);
|
||||||
|
// DTS-HD extension substream sync (0x64582025) after the core frame. Search
|
||||||
|
// ONLY the region at/after the core end (core_size = fsize+1): scanning the
|
||||||
|
// whole frame would false-positive on the same 4 bytes occurring inside the
|
||||||
|
// compressed core payload, mislabeling a plain DTS core as DTS-HD (dtsh).
|
||||||
|
let ext_sync = [0x64, 0x58, 0x20, 0x25];
|
||||||
|
// The EXSS begins at byte core_size (= fsize + 1); start the window search
|
||||||
|
// exactly there so no 4-byte window inside the compressed core is ever tested.
|
||||||
|
let ext_sync_start = (fsize as usize + 1).min(f.len());
|
||||||
|
let has_extension = f.windows(4).skip(ext_sync_start).any(|w| w == ext_sync);
|
||||||
|
|
||||||
|
Some(DtsConfig {
|
||||||
|
sample_rate,
|
||||||
|
channels,
|
||||||
|
amode: amode as u8,
|
||||||
|
lfe,
|
||||||
|
core_size: fsize + 1,
|
||||||
|
frame_samples: (nblks + 1) * 32,
|
||||||
|
has_extension,
|
||||||
|
channel_layout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ddts` ChannelLayout (16-bit speaker mask) for the common core layouts.
|
||||||
|
/// bit0=C, bit1=L/R, bit2=Ls/Rs, bit3=LFE.
|
||||||
|
fn dts_channel_layout(amode: usize, lfe: bool) -> u16 {
|
||||||
|
let mut m = match amode {
|
||||||
|
0 => 0x0001, // C (mono)
|
||||||
|
1..=4 => 0x0002, // L/R
|
||||||
|
5 => 0x0003, // C + L/R
|
||||||
|
6 | 8 => 0x0006, // L/R + Ls/Rs (no centre)
|
||||||
|
_ => 0x0007, // C + L/R + surround (amode 7, 9, …)
|
||||||
|
};
|
||||||
|
if lfe {
|
||||||
|
m |= 0x0008;
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `ddts` config box (ETSI TS 102 114 Annex; DTS-in-ISO registration).
|
||||||
|
/// Describes the DTS core; whole access units (core + any extension) are passed
|
||||||
|
/// through as samples, so a DTS-HD-aware decoder still finds the extension.
|
||||||
|
fn ddts_box(c: &DtsConfig) -> Vec<u8> {
|
||||||
|
// avg/max bitrate: computed from the core frame size × frame rate (the core
|
||||||
|
// RATE field reads "open/variable" for lossless, so it's not usable directly).
|
||||||
|
let frames_per_sec = if c.frame_samples > 0 {
|
||||||
|
c.sample_rate as u64 / c.frame_samples as u64
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let bitrate = (c.core_size as u64 * 8 * frames_per_sec) as u32;
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(&c.sample_rate.to_be_bytes()); // DTSSamplingFrequency
|
||||||
|
out.extend_from_slice(&bitrate.to_be_bytes()); // maxBitrate
|
||||||
|
out.extend_from_slice(&bitrate.to_be_bytes()); // avgBitrate
|
||||||
|
out.push(if c.has_extension { 24 } else { 16 }); // pcmSampleDepth
|
||||||
|
// Bit-packed tail (56 bits):
|
||||||
|
// FrameDuration2 StreamConstruction5 CoreLFEPresent1 CoreLayout6 CoreSize14
|
||||||
|
// StereoDownmix1 RepresentationType3 ChannelLayout16 MultiAssetFlag1
|
||||||
|
// LBRDurationMod1 ReservedBoxPresent1 Reserved5
|
||||||
|
let frame_duration = match c.frame_samples {
|
||||||
|
0..=512 => 0,
|
||||||
|
513..=1024 => 1,
|
||||||
|
1025..=2048 => 2,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
// StreamConstruction: 1 = DTS core present. Whole-AU passthrough means an
|
||||||
|
// HD decoder still parses the extension substreams from the stream itself.
|
||||||
|
let stream_construction = 1u128;
|
||||||
|
let mut v: u128 = 0;
|
||||||
|
let mut push = |val: u128, bits: u32| v = (v << bits) | (val & ((1u128 << bits) - 1));
|
||||||
|
push(frame_duration as u128, 2);
|
||||||
|
push(stream_construction, 5);
|
||||||
|
push(c.lfe as u128, 1);
|
||||||
|
push(c.amode as u128, 6);
|
||||||
|
push(c.core_size as u128, 14);
|
||||||
|
push(0, 1); // StereoDownmix
|
||||||
|
push(0, 3); // RepresentationType
|
||||||
|
push(c.channel_layout as u128, 16);
|
||||||
|
push(c.has_extension as u128, 1); // MultiAssetFlag
|
||||||
|
push(0, 1); // LBRDurationMod
|
||||||
|
push(0, 1); // ReservedBoxPresent
|
||||||
|
push(0, 5); // Reserved
|
||||||
|
// 56 bits → the low 7 bytes of the big-endian u128.
|
||||||
|
let b = v.to_be_bytes();
|
||||||
|
out.extend_from_slice(&b[9..16]);
|
||||||
|
bx(b"ddts", &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MP4 fourcc + config box for an audio frame, or `None` if the codec has no
|
||||||
|
/// MP4 mapping here. Together with [`audio_fits`] this is the fit oracle for
|
||||||
|
/// audio: only what returns `Some` is muxable.
|
||||||
|
pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
match codec {
|
||||||
|
Codec::Ac3 => {
|
||||||
|
let c = parse_dolby(first_frame)?;
|
||||||
|
Some(audio_sample_entry(
|
||||||
|
b"ac-3",
|
||||||
|
c.channels,
|
||||||
|
c.sample_rate,
|
||||||
|
&dac3_box(&c),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Codec::Ac3Plus => {
|
||||||
|
let c = parse_dolby(first_frame)?;
|
||||||
|
Some(audio_sample_entry(
|
||||||
|
b"ec-3",
|
||||||
|
c.channels,
|
||||||
|
c.sample_rate,
|
||||||
|
&dec3_box(&c),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => {
|
||||||
|
let c = parse_dts(first_frame)?;
|
||||||
|
// `dtsc` = DTS core; `dtsh` = DTS-HD (core + extension substreams).
|
||||||
|
let fourcc: &[u8; 4] = if c.has_extension { b"dtsh" } else { b"dtsc" };
|
||||||
|
Some(audio_sample_entry(
|
||||||
|
fourcc,
|
||||||
|
c.channels,
|
||||||
|
c.sample_rate,
|
||||||
|
&ddts_box(&c),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fit oracle for an audio codec: does `mp4://` currently carry it? Covers the
|
||||||
|
/// Dolby family (AC-3 / E-AC-3) and DTS (core / DTS-HD HRA / DTS-HD MA — the core
|
||||||
|
/// is described, whole access units pass through). TrueHD, LPCM, AAC are not yet
|
||||||
|
/// mapped and are skipped with a loud report (never silently dropped).
|
||||||
|
pub(super) fn audio_fits(codec: Codec) -> bool {
|
||||||
|
matches!(
|
||||||
|
codec,
|
||||||
|
Codec::Ac3 | Codec::Ac3Plus | Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A synthetic legacy AC-3 header: syncword, crc, fscod=0 (48k),
|
||||||
|
/// frmsizecod, bsid=8, bsmod=0, acmod=7 (3/2), lfeon=1 → 5.1.
|
||||||
|
fn ac3_frame_5_1() -> Vec<u8> {
|
||||||
|
let mut f = vec![0x0B, 0x77, 0x00, 0x00];
|
||||||
|
// byte4: fscod(2)=0 | frmsizecod(6)=0b010110 (22)
|
||||||
|
f.push(0b00_010110);
|
||||||
|
// byte5: bsid(5)=8 (0b01000) | bsmod(3)=0
|
||||||
|
f.push(0b01000_000);
|
||||||
|
// byte6: acmod(3)=7 (0b111) | cmixlev(2) | surmixlev(2) | lfeon(1)...
|
||||||
|
// acmod=7 has centre (needs cmixlev) and surround (needs surmixlev):
|
||||||
|
// 111 | 00 | 00 | 1(lfeon) = 0b111_00_00_1
|
||||||
|
f.push(0b111_00_00_1);
|
||||||
|
f.push(0x00);
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ac3_bsi_and_dac3() {
|
||||||
|
let c = parse_dolby(&ac3_frame_5_1()).expect("parsed");
|
||||||
|
assert!(c.bsid < 11, "legacy AC-3");
|
||||||
|
assert_eq!(c.fscod, 0);
|
||||||
|
assert_eq!(c.sample_rate, 48_000);
|
||||||
|
assert_eq!(c.bsid, 8);
|
||||||
|
assert_eq!(c.acmod, 7);
|
||||||
|
assert!(c.lfeon);
|
||||||
|
assert_eq!(c.channels, 6, "3/2 + LFE = 5.1");
|
||||||
|
assert_eq!(c.bit_rate_code, 22 >> 1);
|
||||||
|
|
||||||
|
let dac3 = dac3_box(&c);
|
||||||
|
// [size:4]["dac3"][3-byte payload] = 11 bytes.
|
||||||
|
assert_eq!(dac3.len(), 11);
|
||||||
|
assert_eq!(&dac3[4..8], b"dac3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eac3_bsi_and_dec3() {
|
||||||
|
// E-AC-3: syncword | strmtyp/substreamid/frmsiz | fscod/numblks/acmod/lfeon | bsid
|
||||||
|
let mut f = vec![0x0B, 0x77];
|
||||||
|
f.push(0x00); // strmtyp=0, substreamid=0, frmsiz high=0
|
||||||
|
f.push(0x3F); // frmsiz low = 63 → frame 128 bytes
|
||||||
|
// byte4: fscod(2)=0 | numblkscod(2)=3 (6 blocks) | acmod(3)=7 | lfeon(1)=1
|
||||||
|
f.push(0b00_11_111_1);
|
||||||
|
// byte5: bsid(5)=16 (E-AC-3) | dialnorm high(3)
|
||||||
|
f.push(0b10000_000);
|
||||||
|
f.push(0x00);
|
||||||
|
let c = parse_dolby(&f).expect("parsed");
|
||||||
|
assert!(c.bsid >= 11, "E-AC-3");
|
||||||
|
assert_eq!(c.bsid, 16);
|
||||||
|
assert_eq!(c.fscod, 0);
|
||||||
|
assert_eq!(c.sample_rate, 48_000);
|
||||||
|
assert_eq!(c.acmod, 7);
|
||||||
|
assert!(c.lfeon);
|
||||||
|
assert_eq!(c.channels, 6);
|
||||||
|
let dec3 = dec3_box(&c);
|
||||||
|
// [size:4]["dec3"][5-byte payload] = 13 bytes.
|
||||||
|
assert_eq!(dec3.len(), 13);
|
||||||
|
assert_eq!(&dec3[4..8], b"dec3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_entry_shape() {
|
||||||
|
let c = parse_dolby(&ac3_frame_5_1()).unwrap();
|
||||||
|
let e = audio_sample_entry(b"ac-3", c.channels, c.sample_rate, &dac3_box(&c));
|
||||||
|
assert_eq!(&e[4..8], b"ac-3");
|
||||||
|
// channelcount at entry-body offset 16 (after 6 reserved + 2 dri + 8 reserved).
|
||||||
|
let ch = u16::from_be_bytes([e[8 + 16], e[8 + 17]]);
|
||||||
|
assert_eq!(ch, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fit_oracle_covers_dolby_and_dts() {
|
||||||
|
assert!(audio_fits(Codec::Ac3));
|
||||||
|
assert!(audio_fits(Codec::Ac3Plus));
|
||||||
|
assert!(audio_fits(Codec::Dts));
|
||||||
|
assert!(audio_fits(Codec::DtsHdMa));
|
||||||
|
assert!(!audio_fits(Codec::TrueHd));
|
||||||
|
assert!(!audio_fits(Codec::Lpcm));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dts_core_5_1_and_ddts() {
|
||||||
|
// Synthetic DTS core: SFREQ=13 (48k), AMODE=9 (5ch), LFF=1 (LFE) → 5.1.
|
||||||
|
let f = vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x3C, 0x05, 0xF2, 0x77, 0x00, 0x02, 0x00,
|
||||||
|
];
|
||||||
|
let c = parse_dts(&f).expect("dts core parsed");
|
||||||
|
assert_eq!(c.sample_rate, 48_000);
|
||||||
|
assert_eq!(c.amode, 9);
|
||||||
|
assert!(c.lfe);
|
||||||
|
assert_eq!(c.channels, 6, "5 core + LFE = 5.1");
|
||||||
|
assert_eq!(c.channel_layout, 0x000F, "C + L/R + Ls/Rs + LFE");
|
||||||
|
assert_eq!(c.core_size, 96);
|
||||||
|
assert_eq!(c.frame_samples, 512);
|
||||||
|
|
||||||
|
let ddts = ddts_box(&c);
|
||||||
|
assert_eq!(&ddts[4..8], b"ddts");
|
||||||
|
// DTSSamplingFrequency (first field) = 48000.
|
||||||
|
assert_eq!(
|
||||||
|
u32::from_be_bytes([ddts[8], ddts[9], ddts[10], ddts[11]]),
|
||||||
|
48_000
|
||||||
|
);
|
||||||
|
// Sample entry uses dtsc (no extension in this synthetic frame).
|
||||||
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||||
|
assert_eq!(&e[4..8], b"dtsc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dts_ext_sync_inside_core_is_not_a_false_positive() {
|
||||||
|
// The 4-byte ext-sync pattern occurring INSIDE the compressed core payload
|
||||||
|
// (before core_size) must NOT be read as a DTS-HD extension → stays dtsc.
|
||||||
|
// f[4..8] = ext_sync makes core_size huge (>> frame len), so the search
|
||||||
|
// region is only after the core (skipped past this frame) → no extension.
|
||||||
|
let f = vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x02, 0x00,
|
||||||
|
];
|
||||||
|
let c = parse_dts(&f).expect("parses");
|
||||||
|
assert!(!c.has_extension, "ext-sync inside core is not an extension");
|
||||||
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||||
|
assert_eq!(&e[4..8], b"dtsc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dts_ext_sync_at_core_end_is_detected() {
|
||||||
|
// fsize=8 → core_size=9; the EXSS sync sits exactly at byte 9 (right after
|
||||||
|
// the core) and MUST be detected → dtsh. Guards the off-by-4 boundary.
|
||||||
|
let f = vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00, 0x00, 0x80, 0x00, 0x64, 0x58, 0x20, 0x25,
|
||||||
|
];
|
||||||
|
let c = parse_dts(&f).expect("parses");
|
||||||
|
assert_eq!(c.core_size, 9);
|
||||||
|
assert!(c.has_extension, "EXSS sync at core end is a real extension");
|
||||||
|
let e = dolby_sample_entry(Codec::DtsHdMa, &f).unwrap();
|
||||||
|
assert_eq!(&e[4..8], b"dtsh");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dts_high_amode_channel_counts_are_declared() {
|
||||||
|
// The 16-entry DTS_AMODE_CH must declare the true core channel count for the
|
||||||
|
// spec-legal high AMODEs that now pass the decodability gate: AMODE 13→7,
|
||||||
|
// 14→8, 15→8 (ETSI TS 102 114 / ff_dca_channels). The old 10-entry table
|
||||||
|
// fell through `unwrap_or(6)` → every one of these was declared as 6.
|
||||||
|
//
|
||||||
|
// Frame layout (mirrors dts_core_5_1_and_ddts): SFREQ=13 (48k), LFF=0 (no
|
||||||
|
// LFE) so `channels` is the bare base count. AMODE is split across
|
||||||
|
// f[7] low nibble (amode>>2) and f[8] top 2 bits (amode&3).
|
||||||
|
// f[8] = (amode&3)<<6 | 13<<2 = ... (keeps SFREQ=13)
|
||||||
|
let frame = |f7: u8, f8: u8| {
|
||||||
|
vec![
|
||||||
|
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x05, 0xF2, f7, f8, 0x00, 0x00, 0x00,
|
||||||
|
]
|
||||||
|
};
|
||||||
|
// AMODE 13 → base 7 channels.
|
||||||
|
let c = parse_dts(&frame(0xF3, 0x74)).expect("amode 13 parses");
|
||||||
|
assert_eq!(c.amode, 13);
|
||||||
|
assert!(!c.lfe);
|
||||||
|
assert_eq!(c.channels, 7, "AMODE 13 core is 7 channels, not 6");
|
||||||
|
// AMODE 14 → base 8 channels.
|
||||||
|
let c = parse_dts(&frame(0xF3, 0xB4)).expect("amode 14 parses");
|
||||||
|
assert_eq!(c.amode, 14);
|
||||||
|
assert_eq!(c.channels, 8, "AMODE 14 core is 8 channels, not 6");
|
||||||
|
// AMODE 15 → base 8 channels.
|
||||||
|
let c = parse_dts(&frame(0xF3, 0xF4)).expect("amode 15 parses");
|
||||||
|
assert_eq!(c.amode, 15);
|
||||||
|
assert_eq!(c.channels, 8, "AMODE 15 core is 8 channels, not 6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_entry_samplerate_does_not_overflow_at_96k() {
|
||||||
|
// 96 kHz > 65535: the 16.16 integer part must saturate, not wrap to garbage.
|
||||||
|
let e = audio_sample_entry(b"ac-3", 6, 96_000, &[]);
|
||||||
|
// 8-byte box header + body offset 24 (6+2+8+2+2+2+2) → samplerate at 32;
|
||||||
|
// high 16 bits = the integer rate.
|
||||||
|
assert_eq!(&e[32..34], &[0xFF, 0xFF], "capped to 65535, not wrapped");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//! ISO-BMFF box primitives: `[size:u32-BE][type:4][body]` (ISO/IEC 14496-12
|
||||||
|
//! §4.2), and the FullBox variant that prefixes a 1-byte version + 3-byte flags.
|
||||||
|
|
||||||
|
/// Wrap a body in a plain box `[size][type][body]`. `size` counts the 8-byte
|
||||||
|
/// header. All `moov`-tree boxes are small (the large `mdat` is written directly
|
||||||
|
/// with a 64-bit size, not through here), so a `u32` size never overflows.
|
||||||
|
pub(super) fn bx(box_type: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||||
|
let total = body.len() + 8;
|
||||||
|
debug_assert!(
|
||||||
|
total <= u32::MAX as usize,
|
||||||
|
"mp4 box {box_type:?} exceeds u32"
|
||||||
|
);
|
||||||
|
let mut out = Vec::with_capacity(total);
|
||||||
|
out.extend_from_slice(&(total as u32).to_be_bytes());
|
||||||
|
out.extend_from_slice(box_type);
|
||||||
|
out.extend_from_slice(body);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap a body in a FullBox: `[size][type][version:1][flags:3][body]`.
|
||||||
|
pub(super) fn fullbox(box_type: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Vec<u8> {
|
||||||
|
let mut full = Vec::with_capacity(body.len() + 4);
|
||||||
|
full.push(version);
|
||||||
|
full.extend_from_slice(&flags.to_be_bytes()[1..]); // low 3 bytes
|
||||||
|
full.extend_from_slice(body);
|
||||||
|
bx(box_type, &full)
|
||||||
|
}
|
||||||
+1134
File diff suppressed because it is too large
Load Diff
+1532
File diff suppressed because it is too large
Load Diff
@@ -56,14 +56,6 @@ pub struct PipelinedPesStream {
|
|||||||
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
|
||||||
/// per-poll reads it replaces were needless hot-path overhead.
|
/// per-poll reads it replaces were needless hot-path overhead.
|
||||||
skip_parse: bool,
|
skip_parse: bool,
|
||||||
/// Cumulative bytes of scrambled AACS units the producer's decrypt step
|
|
||||||
/// could not decrypt — silent decrypt loss the demux drops without a sync.
|
|
||||||
/// Shared with the producer thread's [`DecryptingSectorSource`]
|
|
||||||
/// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through
|
|
||||||
/// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial
|
|
||||||
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
|
|
||||||
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
|
|
||||||
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
|
|
||||||
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
|
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
|
||||||
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
||||||
/// and summarised once at EOF.
|
/// and summarised once at EOF.
|
||||||
@@ -134,7 +126,6 @@ impl PipelinedPesStream {
|
|||||||
pending_frames: std::collections::VecDeque::new(),
|
pending_frames: std::collections::VecDeque::new(),
|
||||||
eof: false,
|
eof: false,
|
||||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||||
decrypt_loss: None,
|
|
||||||
dropped_nav_packets: 0,
|
dropped_nav_packets: 0,
|
||||||
resync,
|
resync,
|
||||||
is_video,
|
is_video,
|
||||||
@@ -142,20 +133,6 @@ impl PipelinedPesStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`]
|
|
||||||
/// reports bytes of scrambled AACS units that could not be decrypted (and
|
|
||||||
/// were therefore silently dropped downstream). Obtained from the
|
|
||||||
/// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved
|
|
||||||
/// into the prefetch thread. The M2TS / no-decrypt pipelines leave this
|
|
||||||
/// unset.
|
|
||||||
pub(crate) fn with_decrypt_loss(
|
|
||||||
mut self,
|
|
||||||
loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
|
|
||||||
) -> Self {
|
|
||||||
self.decrypt_loss = Some(loss);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pull one batch of `PesPacket`s from the demux thread, run
|
/// Pull one batch of `PesPacket`s from the demux thread, run
|
||||||
/// codec parse on each, enqueue resulting `PesFrame`s on
|
/// codec parse on each, enqueue resulting `PesFrame`s on
|
||||||
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
|
||||||
@@ -464,17 +441,9 @@ impl Stream for PipelinedPesStream {
|
|||||||
.and_then(|(_, parser)| parser.codec_private())
|
.and_then(|(_, parser)| parser.codec_private())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lost_bytes(&self) -> u64 {
|
// `lost_bytes` uses the trait default (0): the file-backed highway has no
|
||||||
// The file-backed highway has no read-error zero-fill term (resolve
|
// read-error zero-fill term (resolve/mapfile tracks physical read loss
|
||||||
// tracks read loss separately), but the producer's decrypt step can
|
// separately) and the decrypt path no longer reports a decrypt-loss term.
|
||||||
// pass scrambled units through undecrypted — silent loss the demux
|
|
||||||
// drops. Surface that so the mux abort gate sees a partial AACS/CSS
|
|
||||||
// decrypt failure rather than reporting a perfect rip.
|
|
||||||
self.decrypt_loss
|
|
||||||
.as_ref()
|
|
||||||
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -690,8 +659,8 @@ mod tests {
|
|||||||
|
|
||||||
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
|
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
|
||||||
/// must DROP every inter-coded frame until the next keyframe, so no frame
|
/// must DROP every inter-coded frame until the next keyframe, so no frame
|
||||||
/// with a dangling reference reaches the muxer (an ffmpeg deep-scan would
|
/// with a dangling reference reaches the muxer (a strict decode-order
|
||||||
/// otherwise report a missing reference). The frame carrying the
|
/// deep-scan would otherwise report a missing reference). The frame carrying the
|
||||||
/// discontinuity and the inter frames behind it are dropped; the stream
|
/// discontinuity and the inter frames behind it are dropped; the stream
|
||||||
/// resumes cleanly at the next keyframe.
|
/// resumes cleanly at the next keyframe.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1827
-69
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -5,9 +5,9 @@
|
|||||||
//! When packets are lost, the affected access unit is already dropped at the TS
|
//! When packets are lost, the affected access unit is already dropped at the TS
|
||||||
//! layer (the assembler drops the partial PES on the continuity gap). But for
|
//! layer (the assembler drops the partial PES on the continuity gap). But for
|
||||||
//! INTER-CODED video the frames that follow reference the lost frame (and each
|
//! INTER-CODED video the frames that follow reference the lost frame (and each
|
||||||
//! other) until the next IRAP/IDR keyframe — emitting them yields an ffmpeg
|
//! other) until the next IRAP/IDR keyframe — emitting them makes any decoder
|
||||||
//! "missing reference / non-existing PPS" deep-scan error and visibly broken
|
//! fault on the "missing reference / non-existing PPS" condition and visibly
|
||||||
//! decode. So after a gap on a video track we DROP FORWARD to the next keyframe
|
//! break decode. So after a gap on a video track we DROP FORWARD to the keyframe
|
||||||
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
|
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
|
||||||
//! of never emitting a dangling reference; it is logged.
|
//! of never emitting a dangling reference; it is logged.
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
//! Per-title audio/subtitle stream selection — the pure primitive.
|
||||||
|
//!
|
||||||
|
//! The demux pipeline is **declaration-driven**: every demux table
|
||||||
|
//! (`build_demux_state` in `mux/resolve.rs`, `DiscStream::new` in `mux/disc.rs`)
|
||||||
|
//! is built from the input [`DiscTitle`]'s `streams` list, and the MKV writer
|
||||||
|
//! builds its track headers + `codec_privates` from that same list. A PID not
|
||||||
|
//! declared there is never tracked, extracted, or written. So "which streams to
|
||||||
|
//! keep" is already a capability of the pipeline — it just has no public knob.
|
||||||
|
//!
|
||||||
|
//! [`StreamSelection::apply`] is that knob: prune the `DiscTitle.streams` list
|
||||||
|
//! (video always kept) BEFORE the mux path finalizes the title, and everything
|
||||||
|
//! downstream — track headers, `codec_privates`, PID routing, frame emission —
|
||||||
|
//! follows from the pruned list by construction, with zero scattered PID
|
||||||
|
//! checks. This is language-agnostic: PIDs, not languages (the language→PID
|
||||||
|
//! mapping is the caller's/engine's policy).
|
||||||
|
|
||||||
|
use crate::disc::{DiscTitle, Stream};
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// Which PIDs to keep for one stream class (audio or subtitle). Video is always
|
||||||
|
/// kept, so it has no filter.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum PidFilter {
|
||||||
|
/// Keep every stream of this class. The default; [`StreamSelection::apply`]
|
||||||
|
/// is a no-op for an All/All selection, so the no-selection path is
|
||||||
|
/// byte-identical to no selection at all.
|
||||||
|
#[default]
|
||||||
|
All,
|
||||||
|
/// Keep only the streams whose PID is listed. `Only(vec![])` is legal and
|
||||||
|
/// means keep none (a video-only output when both classes are `Only([])`).
|
||||||
|
Only(Vec<u16>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A per-title stream selection: which audio and which subtitle PIDs to keep.
|
||||||
|
/// Video is always retained (it is implicit and never pruned).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct StreamSelection {
|
||||||
|
pub audio: PidFilter,
|
||||||
|
pub subtitle: PidFilter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamSelection {
|
||||||
|
/// True for the All/All default. Apply sites gate on `!is_all()` so the
|
||||||
|
/// no-selection path never even clones the title.
|
||||||
|
pub fn is_all(&self) -> bool {
|
||||||
|
matches!(self.audio, PidFilter::All) && matches!(self.subtitle, PidFilter::All)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prune `title.streams` in place: keep every [`Stream::Video`]
|
||||||
|
/// unconditionally; keep an [`Stream::Audio`]/[`Stream::Subtitle`] iff its
|
||||||
|
/// PID passes the corresponding [`PidFilter`]; drop the rest. Declared order
|
||||||
|
/// is preserved. The parallel `codec_privates` vec is pruned in lockstep
|
||||||
|
/// when it is populated (it is empty on a freshly-scanned title, non-empty
|
||||||
|
/// only if a caller pre-filled it).
|
||||||
|
///
|
||||||
|
/// Errors [`Error::SelectionPidUnknown`] if a filter lists a PID that does
|
||||||
|
/// not exist in `title.streams` — a caller bug (e.g. a stale scan). Fail
|
||||||
|
/// loud rather than silently emit an MKV missing a requested track. On
|
||||||
|
/// error the title is left unmodified.
|
||||||
|
pub fn apply(&self, title: &mut DiscTitle) -> Result<()> {
|
||||||
|
if self.is_all() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate every listed PID exists in the title before mutating, so an
|
||||||
|
// unknown PID leaves the title untouched (no partial prune).
|
||||||
|
for pid in self.listed_pids() {
|
||||||
|
let present = title.streams.iter().any(|s| stream_pid(s) == Some(pid));
|
||||||
|
if !present {
|
||||||
|
return Err(Error::SelectionPidUnknown { pid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let codec_privates_aligned = title.codec_privates.len() == title.streams.len();
|
||||||
|
|
||||||
|
// Retain by index so we can prune the parallel codec_privates in lockstep.
|
||||||
|
let keep: Vec<bool> = title
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.map(|s| self.keeps(s))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
title.streams.retain(|_| {
|
||||||
|
let k = keep[i];
|
||||||
|
i += 1;
|
||||||
|
k
|
||||||
|
});
|
||||||
|
if codec_privates_aligned {
|
||||||
|
let mut j = 0;
|
||||||
|
title.codec_privates.retain(|_| {
|
||||||
|
let k = keep[j];
|
||||||
|
j += 1;
|
||||||
|
k
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this selection keeps `stream`.
|
||||||
|
fn keeps(&self, stream: &Stream) -> bool {
|
||||||
|
match stream {
|
||||||
|
Stream::Video(_) => true,
|
||||||
|
Stream::Audio(a) => filter_keeps(&self.audio, a.pid),
|
||||||
|
Stream::Subtitle(s) => filter_keeps(&self.subtitle, s.pid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every PID explicitly listed across both filters (for existence checking).
|
||||||
|
fn listed_pids(&self) -> Vec<u16> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
if let PidFilter::Only(pids) = &self.audio {
|
||||||
|
v.extend_from_slice(pids);
|
||||||
|
}
|
||||||
|
if let PidFilter::Only(pids) = &self.subtitle {
|
||||||
|
v.extend_from_slice(pids);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_keeps(filter: &PidFilter, pid: u16) -> bool {
|
||||||
|
match filter {
|
||||||
|
PidFilter::All => true,
|
||||||
|
PidFilter::Only(pids) => pids.contains(&pid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PID of an audio/subtitle stream; `None` for video (which is never
|
||||||
|
/// filtered, so its PID is irrelevant to selection).
|
||||||
|
fn stream_pid(stream: &Stream) -> Option<u16> {
|
||||||
|
match stream {
|
||||||
|
Stream::Audio(a) => Some(a.pid),
|
||||||
|
Stream::Subtitle(s) => Some(s.pid),
|
||||||
|
Stream::Video(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::disc::{
|
||||||
|
AudioChannels, AudioStream, Codec, ColorSpace, FrameRate, HdrFormat, LabelPurpose,
|
||||||
|
LabelQualifier, Resolution, SampleRate, SubtitleStream, VideoStream,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn video(pid: u16) -> Stream {
|
||||||
|
Stream::Video(VideoStream {
|
||||||
|
pid,
|
||||||
|
codec: Codec::Hevc,
|
||||||
|
resolution: Resolution::R2160p,
|
||||||
|
frame_rate: FrameRate::F23_976,
|
||||||
|
hdr: HdrFormat::Hdr10,
|
||||||
|
color_space: ColorSpace::Bt2020,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: String::new(),
|
||||||
|
measured_cicp: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn audio(pid: u16, lang: &str) -> Stream {
|
||||||
|
Stream::Audio(AudioStream {
|
||||||
|
pid,
|
||||||
|
codec: Codec::TrueHd,
|
||||||
|
channels: AudioChannels::Stereo,
|
||||||
|
language: lang.into(),
|
||||||
|
sample_rate: SampleRate::S48,
|
||||||
|
secondary: false,
|
||||||
|
purpose: LabelPurpose::Normal,
|
||||||
|
label: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn subtitle(pid: u16, lang: &str) -> Stream {
|
||||||
|
Stream::Subtitle(SubtitleStream {
|
||||||
|
pid,
|
||||||
|
codec: Codec::Pgs,
|
||||||
|
language: lang.into(),
|
||||||
|
forced: false,
|
||||||
|
qualifier: LabelQualifier::None,
|
||||||
|
codec_data: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// video + 3 audio (eng/spa/fra) + 2 subs (eng/spa).
|
||||||
|
fn title() -> DiscTitle {
|
||||||
|
let mut t = DiscTitle::empty();
|
||||||
|
t.streams = vec![
|
||||||
|
video(0x1011),
|
||||||
|
audio(0x1100, "eng"),
|
||||||
|
audio(0x1101, "spa"),
|
||||||
|
audio(0x1102, "fra"),
|
||||||
|
subtitle(0x1200, "eng"),
|
||||||
|
subtitle(0x1201, "spa"),
|
||||||
|
];
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pids(t: &DiscTitle) -> Vec<u16> {
|
||||||
|
t.streams
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s {
|
||||||
|
Stream::Video(v) => Some(v.pid),
|
||||||
|
Stream::Audio(a) => Some(a.pid),
|
||||||
|
Stream::Subtitle(s) => Some(s.pid),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_all_is_identity_and_untouched() {
|
||||||
|
let sel = StreamSelection::default();
|
||||||
|
assert!(sel.is_all());
|
||||||
|
let mut t = title();
|
||||||
|
let before = pids(&t);
|
||||||
|
sel.apply(&mut t).unwrap();
|
||||||
|
assert_eq!(pids(&t), before, "All/All must not change the stream list");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_only_retains_listed_audio_pids_in_declared_order() {
|
||||||
|
// Keep eng+fra audio (skip spa); leave subtitles alone.
|
||||||
|
let sel = StreamSelection {
|
||||||
|
audio: PidFilter::Only(vec![0x1100, 0x1102]),
|
||||||
|
subtitle: PidFilter::All,
|
||||||
|
};
|
||||||
|
let mut t = title();
|
||||||
|
sel.apply(&mut t).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pids(&t),
|
||||||
|
vec![0x1011, 0x1100, 0x1102, 0x1200, 0x1201],
|
||||||
|
"video + eng/fra audio (order preserved) + both subs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_only_empty_yields_video_only() {
|
||||||
|
let sel = StreamSelection {
|
||||||
|
audio: PidFilter::Only(vec![]),
|
||||||
|
subtitle: PidFilter::Only(vec![]),
|
||||||
|
};
|
||||||
|
let mut t = title();
|
||||||
|
sel.apply(&mut t).unwrap();
|
||||||
|
assert_eq!(pids(&t), vec![0x1011], "only the video stream survives");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_subtitle_filter_does_not_touch_audio() {
|
||||||
|
let sel = StreamSelection {
|
||||||
|
audio: PidFilter::All,
|
||||||
|
subtitle: PidFilter::Only(vec![0x1200]),
|
||||||
|
};
|
||||||
|
let mut t = title();
|
||||||
|
sel.apply(&mut t).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pids(&t),
|
||||||
|
vec![0x1011, 0x1100, 0x1101, 0x1102, 0x1200],
|
||||||
|
"all audio kept, only eng subtitle kept"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_unknown_pid_errors_and_leaves_title_untouched() {
|
||||||
|
let sel = StreamSelection {
|
||||||
|
audio: PidFilter::Only(vec![0x9999]),
|
||||||
|
subtitle: PidFilter::All,
|
||||||
|
};
|
||||||
|
let mut t = title();
|
||||||
|
let before = pids(&t);
|
||||||
|
let err = sel.apply(&mut t).unwrap_err();
|
||||||
|
assert!(matches!(err, Error::SelectionPidUnknown { pid: 0x9999 }));
|
||||||
|
assert_eq!(pids(&t), before, "title unmodified on error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_prunes_codec_privates_in_lockstep_when_populated() {
|
||||||
|
// A caller that pre-filled codec_privates parallel to streams: pruning
|
||||||
|
// must keep the two vecs aligned.
|
||||||
|
let mut t = title();
|
||||||
|
t.codec_privates = vec![
|
||||||
|
Some(vec![0xAA]), // video 0x1011
|
||||||
|
Some(vec![0x11]), // audio 0x1100 eng
|
||||||
|
Some(vec![0x22]), // audio 0x1101 spa
|
||||||
|
Some(vec![0x33]), // audio 0x1102 fra
|
||||||
|
None, // sub 0x1200
|
||||||
|
None, // sub 0x1201
|
||||||
|
];
|
||||||
|
let sel = StreamSelection {
|
||||||
|
audio: PidFilter::Only(vec![0x1100]),
|
||||||
|
subtitle: PidFilter::Only(vec![]),
|
||||||
|
};
|
||||||
|
sel.apply(&mut t).unwrap();
|
||||||
|
assert_eq!(pids(&t), vec![0x1011, 0x1100]);
|
||||||
|
assert_eq!(
|
||||||
|
t.codec_privates,
|
||||||
|
vec![Some(vec![0xAA]), Some(vec![0x11])],
|
||||||
|
"codec_privates pruned to match the retained streams, in order"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -23,7 +23,8 @@ pub(crate) const DISCONTINUITY_GAP_NS: i64 = 1_000_000;
|
|||||||
/// one concatenated sector stream (clip boundaries / mpls connection_condition
|
/// one concatenated sector stream (clip boundaries / mpls connection_condition
|
||||||
/// are not plumbed to the mux), so at a non-seamless boundary the source PES
|
/// are not plumbed to the mux), so at a non-seamless boundary the source PES
|
||||||
/// PTS jumps backward. Left uncorrected, that produces a sustained band of
|
/// PTS jumps backward. Left uncorrected, that produces a sustained band of
|
||||||
/// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS).
|
/// non-monotonic block timestamps (a downstream muxer then derives
|
||||||
|
/// non-monotonic DTS from them).
|
||||||
///
|
///
|
||||||
/// A single running `offset_ns` is applied to EVERY track, so the concatenated
|
/// A single running `offset_ns` is applied to EVERY track, so the concatenated
|
||||||
/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a
|
/// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a
|
||||||
|
|||||||
+25
-21
@@ -12,9 +12,11 @@ use crate::consts::TS_PACKET_BYTES;
|
|||||||
|
|
||||||
/// TS sync byte.
|
/// TS sync byte.
|
||||||
const SYNC_BYTE: u8 = 0x47;
|
const SYNC_BYTE: u8 = 0x47;
|
||||||
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
|
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream. The demuxer
|
||||||
/// concealment fill emits null packets on this PID, tagged with an
|
/// still recognises a `0x1FFF` packet with an adaptation-field
|
||||||
/// adaptation-field discontinuity_indicator to signal a concealed gap.
|
/// discontinuity_indicator as a concealed-gap loss signal, but the in-tree WRITER
|
||||||
|
/// that emitted these (the removed NULL-TS concealment fill) is gone — the mux no
|
||||||
|
/// longer conceals; only externally-authored markers reach this path now.
|
||||||
const NULL_PID: u16 = 0x1FFF;
|
const NULL_PID: u16 = 0x1FFF;
|
||||||
|
|
||||||
/// A reassembled PES packet with timestamp info.
|
/// A reassembled PES packet with timestamp info.
|
||||||
@@ -34,8 +36,10 @@ pub struct PesPacket {
|
|||||||
pub source: Option<crate::pes::SourcePos>,
|
pub source: Option<crate::pes::SourcePos>,
|
||||||
/// True when one or more packets for this stream were lost before this PES —
|
/// True when one or more packets for this stream were lost before this PES —
|
||||||
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
|
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
|
||||||
/// a tracked PID, or the CC-independent concealment marker the mux emits when
|
/// a tracked PID, or a CC-independent NULL-TS concealment marker (P3/B1). NOTE:
|
||||||
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
|
/// the mux no longer emits such markers (the concealment writer was removed);
|
||||||
|
/// this now flags only real discontinuities and externally-authored markers.
|
||||||
|
/// This PES is
|
||||||
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
|
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
|
||||||
/// truncated partial and flags the next complete PES; a loss landing on a PES
|
/// truncated partial and flags the next complete PES; a loss landing on a PES
|
||||||
/// boundary flags the PES STARTING after it (never the one just flushed). So
|
/// boundary flags the PES STARTING after it (never the one just flushed). So
|
||||||
@@ -200,7 +204,7 @@ impl PesAssembler {
|
|||||||
/// BD Transport Stream demuxer.
|
/// BD Transport Stream demuxer.
|
||||||
pub struct TsDemuxer {
|
pub struct TsDemuxer {
|
||||||
assemblers: Vec<PesAssembler>,
|
assemblers: Vec<PesAssembler>,
|
||||||
pid_index: Vec<i16>, // PID → index into assemblers, -1 = not tracked
|
pid_index: Vec<i32>, // PID → index into assemblers, -1 = not tracked
|
||||||
remainder: Vec<u8>, // leftover bytes from previous feed() call
|
remainder: Vec<u8>, // leftover bytes from previous feed() call
|
||||||
/// Absolute source byte offset of the NEXT byte to be fed — the running
|
/// Absolute source byte offset of the NEXT byte to be fed — the running
|
||||||
/// base that turns an in-buffer packet offset into a source position.
|
/// base that turns an in-buffer packet offset into a source position.
|
||||||
@@ -224,20 +228,17 @@ impl TsDemuxer {
|
|||||||
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
||||||
/// valid (wholly-unused) table.
|
/// valid (wholly-unused) table.
|
||||||
pub fn new(pids: &[u16]) -> Self {
|
pub fn new(pids: &[u16]) -> Self {
|
||||||
// The PID→assembler index is stored as i16 (-1 = untracked), so a
|
// The PID→assembler index is stored as i32 (-1 = untracked). PIDs are
|
||||||
// 32768th+ tracked PID would truncate to a negative value and be
|
// u16 (≤ 65535) and the assembler index `i` is bounded by the number of
|
||||||
// silently treated as untracked. Callers pass a handful of PIDs
|
// distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can
|
||||||
// (BD-TS has at most ~8192), so this is a programmer-error guard.
|
// never truncate to a negative value and be mis-read as untracked —
|
||||||
debug_assert!(
|
// unlike an i16 table, this is safe in RELEASE, not just under debug.
|
||||||
pids.len() <= i16::MAX as usize,
|
|
||||||
"TsDemuxer: too many PIDs for an i16 index table"
|
|
||||||
);
|
|
||||||
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
||||||
let table_size = (max_pid + 1).max(8192);
|
let table_size = (max_pid + 1).max(8192);
|
||||||
let mut pid_index = vec![-1i16; table_size];
|
let mut pid_index = vec![-1i32; table_size];
|
||||||
let mut assemblers = Vec::with_capacity(pids.len());
|
let mut assemblers = Vec::with_capacity(pids.len());
|
||||||
for (i, &pid) in pids.iter().enumerate() {
|
for (i, &pid) in pids.iter().enumerate() {
|
||||||
pid_index[pid as usize] = i as i16;
|
pid_index[pid as usize] = i as i32;
|
||||||
assemblers.push(PesAssembler::new(pid));
|
assemblers.push(PesAssembler::new(pid));
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
@@ -368,10 +369,13 @@ impl TsDemuxer {
|
|||||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||||
let adaptation = (ts[3] >> 4) & 0x03;
|
let adaptation = (ts[3] >> 4) & 0x03;
|
||||||
|
|
||||||
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
|
// P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an
|
||||||
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
|
// adaptation-field discontinuity_indicator. NOTE: the in-tree writer that
|
||||||
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
|
// laid these down on an undecryptable unit was removed with the pure-decrypt
|
||||||
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
|
// passthrough change (the mux no longer conceals), so this recognition now
|
||||||
|
// only fires on externally-authored markers — a candidate for removal with
|
||||||
|
// the rest of the retired concealment path.
|
||||||
|
// As a loss signal it is CC-INDEPENDENT — unlike a tracked PID's 4-bit
|
||||||
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
|
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
|
||||||
// an exact multiple of 16 packets and a loss at the very start of a PID
|
// an exact multiple of 16 packets and a loss at the very start of a PID
|
||||||
// (no prior CC to diff against). The decrypt layer cannot know which
|
// (no prior CC to diff against). The decrypt layer cannot know which
|
||||||
@@ -1057,7 +1061,7 @@ mod tests {
|
|||||||
|
|
||||||
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
|
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
|
||||||
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
|
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
|
||||||
/// shape `fill_null_ts_unit` writes for every packet of a concealed unit).
|
/// shape of a concealed-unit packet).
|
||||||
fn null_marker_packet() -> Vec<u8> {
|
fn null_marker_packet() -> Vec<u8> {
|
||||||
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
|
||||||
pkt[4] = SYNC_BYTE; // 0x47
|
pkt[4] = SYNC_BYTE; // 0x47
|
||||||
|
|||||||
+3
-2
@@ -8,8 +8,9 @@
|
|||||||
//! when `hdr.timeout` expires, and by the time the ioctl returns the
|
//! when `hdr.timeout` expires, and by the time the ioctl returns the
|
||||||
//! kernel has already done what it can.
|
//! kernel has already done what it can.
|
||||||
//!
|
//!
|
||||||
//! This matches what every reference project does: MakeMKV (8 s sync
|
//! This matches established practice for optical/SCSI I/O: a single
|
||||||
//! ioctl), sg_dd (60 s sync ioctl), the kernel default for SCSI block
|
//! synchronous ioctl with a bounded per-command timeout (commonly in the
|
||||||
|
//! 8–60 s range), consistent with the Linux kernel default for SCSI block
|
||||||
//! devices (30 s `/sys/.../timeout`).
|
//! devices (30 s `/sys/.../timeout`).
|
||||||
//!
|
//!
|
||||||
//! Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout +
|
//! Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout +
|
||||||
|
|||||||
@@ -119,6 +119,76 @@ pub const SENSE_KEY_DATA_PROTECT: u8 = 0x07;
|
|||||||
pub const SENSE_KEY_BLANK_CHECK: u8 = 0x08;
|
pub const SENSE_KEY_BLANK_CHECK: u8 = 0x08;
|
||||||
pub const SENSE_KEY_ABORTED_COMMAND: u8 = 0x0B;
|
pub const SENSE_KEY_ABORTED_COMMAND: u8 = 0x0B;
|
||||||
|
|
||||||
|
/// Coarse classification of a SCSI sense key, for callers that need to
|
||||||
|
/// branch on "what kind of failure was this" without a `match` over every
|
||||||
|
/// `SENSE_KEY_*` constant. Pure hardware-fact translation — no retry policy
|
||||||
|
/// here; see the recovery/engine layer for what to DO about a given family.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SenseFamily {
|
||||||
|
NotReady,
|
||||||
|
Medium,
|
||||||
|
Hardware,
|
||||||
|
IllegalRequest,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SenseFamily {
|
||||||
|
pub fn from_sense_key(sense_key: u8) -> Self {
|
||||||
|
match sense_key {
|
||||||
|
SENSE_KEY_NOT_READY => SenseFamily::NotReady,
|
||||||
|
SENSE_KEY_MEDIUM_ERROR => SenseFamily::Medium,
|
||||||
|
SENSE_KEY_HARDWARE_ERROR => SenseFamily::Hardware,
|
||||||
|
SENSE_KEY_ILLEGAL_REQUEST => SenseFamily::IllegalRequest,
|
||||||
|
_ => SenseFamily::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for the "wedge family" — some drives (e.g. the BU40N over a
|
||||||
|
/// USB-SATA bridge) return Hardware or IllegalRequest sense once their
|
||||||
|
/// firmware enters a fast-fail state after sustained bad-media reads.
|
||||||
|
pub fn is_wedge_family(self) -> bool {
|
||||||
|
matches!(self, SenseFamily::Hardware | SenseFamily::IllegalRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod sense_family_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_each_named_sense_key() {
|
||||||
|
assert_eq!(
|
||||||
|
SenseFamily::from_sense_key(SENSE_KEY_NOT_READY),
|
||||||
|
SenseFamily::NotReady
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SenseFamily::from_sense_key(SENSE_KEY_MEDIUM_ERROR),
|
||||||
|
SenseFamily::Medium
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SenseFamily::from_sense_key(SENSE_KEY_HARDWARE_ERROR),
|
||||||
|
SenseFamily::Hardware
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SenseFamily::from_sense_key(SENSE_KEY_ILLEGAL_REQUEST),
|
||||||
|
SenseFamily::IllegalRequest
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SenseFamily::from_sense_key(SENSE_KEY_ABORTED_COMMAND),
|
||||||
|
SenseFamily::Other
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wedge_family_is_hardware_and_illegal_request_only() {
|
||||||
|
assert!(SenseFamily::Hardware.is_wedge_family());
|
||||||
|
assert!(SenseFamily::IllegalRequest.is_wedge_family());
|
||||||
|
assert!(!SenseFamily::Medium.is_wedge_family());
|
||||||
|
assert!(!SenseFamily::NotReady.is_wedge_family());
|
||||||
|
assert!(!SenseFamily::Other.is_wedge_family());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sense parsing ───────────────────────────────────────────────────────────
|
// ── Sense parsing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Decoded SPC-4 sense triple — the precise reason a SCSI command failed.
|
/// Decoded SPC-4 sense triple — the precise reason a SCSI command failed.
|
||||||
|
|||||||
+173
-1440
File diff suppressed because it is too large
Load Diff
@@ -1,180 +0,0 @@
|
|||||||
//! File-backed sector sink — write 2048-byte sectors to an ISO image
|
|
||||||
//! on disk.
|
|
||||||
//!
|
|
||||||
//! The read-side counterpart ([`crate::io::file_sector_source::FileSectorSource`])
|
|
||||||
//! lives under `io/` because its internals (read-ahead buffer, per-OS
|
|
||||||
//! `fadvise`/`F_RDADVISE` hints) are I/O infrastructure rather than
|
|
||||||
//! sector-trait business logic. Both types remain re-exported at
|
|
||||||
//! [`crate::sector`] for ergonomic imports.
|
|
||||||
|
|
||||||
use std::fs::OpenOptions;
|
|
||||||
use std::io::{Seek, SeekFrom, Write};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
|
|
||||||
use super::SectorSink;
|
|
||||||
|
|
||||||
/// SectorSink backed by a file (ISO image).
|
|
||||||
///
|
|
||||||
/// Writes go through [`crate::io::WritebackFile`], which on Linux drives
|
|
||||||
/// continuous `sync_file_range` + `posix_fadvise(DONTNEED)` to keep
|
|
||||||
/// the kernel dirty page cache bounded during multi-GB sequential
|
|
||||||
/// writes. macOS / Windows fall through to a no-op pipeline.
|
|
||||||
///
|
|
||||||
/// `finish` runs `sync_all` before dropping the underlying file.
|
|
||||||
pub struct FileSectorSink {
|
|
||||||
inner: crate::io::WritebackFile,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileSectorSink {
|
|
||||||
/// Create a new ISO file at `path`, truncating any existing
|
|
||||||
/// file. The file is opened read-write so the same handle can
|
|
||||||
/// later be reused for verification reads if needed (sweep
|
|
||||||
/// doesn't, but it costs nothing here).
|
|
||||||
pub fn create(path: &Path) -> std::io::Result<Self> {
|
|
||||||
let file = OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.open(path)?;
|
|
||||||
Ok(Self {
|
|
||||||
inner: crate::io::WritebackFile::new(file)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open an existing ISO file for in-place updates (e.g. patch
|
|
||||||
/// pass writing recovered sectors over zero-filled holes).
|
|
||||||
/// Does not truncate.
|
|
||||||
pub fn open(path: &Path) -> std::io::Result<Self> {
|
|
||||||
let file = OpenOptions::new().read(true).write(true).open(path)?;
|
|
||||||
Ok(Self {
|
|
||||||
inner: crate::io::WritebackFile::new(file)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SectorSink for FileSectorSink {
|
|
||||||
fn write_sectors(&mut self, lba: u32, buf: &[u8]) -> Result<()> {
|
|
||||||
// SectorSink's contract requires a 2048-multiple buffer. Enforce
|
|
||||||
// it in all build modes (a `debug_assert!` is a no-op in release):
|
|
||||||
// a misaligned buffer would `write_all` partial bytes at
|
|
||||||
// lba*2048 and silently corrupt the ISO. Current in-tree callers
|
|
||||||
// always pass aligned buffers; this guards the public trait
|
|
||||||
// contract against any (including future external) caller.
|
|
||||||
if buf.len() % 2048 != 0 {
|
|
||||||
return Err(Error::IoError {
|
|
||||||
source: std::io::Error::from(std::io::ErrorKind::InvalidInput),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let offset = lba as u64 * 2048;
|
|
||||||
self.inner
|
|
||||||
.seek(SeekFrom::Start(offset))
|
|
||||||
.map_err(|e| Error::IoError { source: e })?;
|
|
||||||
self.inner
|
|
||||||
.write_all(buf)
|
|
||||||
.map_err(|e| Error::IoError { source: e })?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(mut self: Box<Self>) -> Result<()> {
|
|
||||||
self.inner
|
|
||||||
.sync_all()
|
|
||||||
.map_err(|e| Error::IoError { source: e })?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::FileSectorSink;
|
|
||||||
use crate::io::file_sector_source::FileSectorSource;
|
|
||||||
use crate::sector::{SectorSink, SectorSource};
|
|
||||||
use tempfile::tempdir;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_single_sector() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("rt.iso");
|
|
||||||
|
|
||||||
let mut sink = FileSectorSink::create(&path).unwrap();
|
|
||||||
// Pre-extend the file to 4 sectors of zeros so we can write
|
|
||||||
// sector 2 in place. Easiest way: write zeros first.
|
|
||||||
let zeros = [0u8; 4 * 2048];
|
|
||||||
sink.write_sectors(0, &zeros).unwrap();
|
|
||||||
|
|
||||||
let mut payload = [0u8; 2048];
|
|
||||||
for (i, b) in payload.iter_mut().enumerate() {
|
|
||||||
*b = (i as u8).wrapping_mul(17);
|
|
||||||
}
|
|
||||||
sink.write_sectors(2, &payload).unwrap();
|
|
||||||
Box::new(sink).finish().unwrap();
|
|
||||||
|
|
||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
|
||||||
assert_eq!(src.capacity_sectors(), 4);
|
|
||||||
|
|
||||||
let mut got = [0u8; 2048];
|
|
||||||
let n = src.read_sectors(2, 1, &mut got, false).unwrap();
|
|
||||||
assert_eq!(n, 2048);
|
|
||||||
assert_eq!(got, payload);
|
|
||||||
|
|
||||||
// Sectors 0,1,3 still zero.
|
|
||||||
let mut z = [0xffu8; 2048];
|
|
||||||
src.read_sectors(0, 1, &mut z, false).unwrap();
|
|
||||||
assert!(z.iter().all(|b| *b == 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_multi_sector() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("multi.iso");
|
|
||||||
|
|
||||||
let mut sink = FileSectorSink::create(&path).unwrap();
|
|
||||||
let mut payload = vec![0u8; 8 * 2048];
|
|
||||||
for (i, b) in payload.iter_mut().enumerate() {
|
|
||||||
*b = ((i * 31) ^ (i >> 7)) as u8;
|
|
||||||
}
|
|
||||||
sink.write_sectors(0, &payload).unwrap();
|
|
||||||
Box::new(sink).finish().unwrap();
|
|
||||||
|
|
||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
|
||||||
assert_eq!(src.capacity_sectors(), 8);
|
|
||||||
|
|
||||||
let mut got = vec![0u8; 8 * 2048];
|
|
||||||
let n = src.read_sectors(0, 8, &mut got, false).unwrap();
|
|
||||||
assert_eq!(n, 8 * 2048);
|
|
||||||
assert_eq!(got, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn open_existing_does_not_truncate() {
|
|
||||||
let dir = tempdir().unwrap();
|
|
||||||
let path = dir.path().join("open.iso");
|
|
||||||
|
|
||||||
// Create with 4 sectors of pattern A.
|
|
||||||
let mut sink = FileSectorSink::create(&path).unwrap();
|
|
||||||
let pat_a = [0xaau8; 4 * 2048];
|
|
||||||
sink.write_sectors(0, &pat_a).unwrap();
|
|
||||||
Box::new(sink).finish().unwrap();
|
|
||||||
|
|
||||||
// Reopen and overwrite sector 1 only.
|
|
||||||
let mut sink = FileSectorSink::open(&path).unwrap();
|
|
||||||
let pat_b = [0xbbu8; 2048];
|
|
||||||
sink.write_sectors(1, &pat_b).unwrap();
|
|
||||||
Box::new(sink).finish().unwrap();
|
|
||||||
|
|
||||||
let mut src = FileSectorSource::open(&path).unwrap();
|
|
||||||
assert_eq!(src.capacity_sectors(), 4);
|
|
||||||
let mut got = [0u8; 2048];
|
|
||||||
|
|
||||||
src.read_sectors(0, 1, &mut got, false).unwrap();
|
|
||||||
assert_eq!(got, [0xaau8; 2048]);
|
|
||||||
|
|
||||||
src.read_sectors(1, 1, &mut got, false).unwrap();
|
|
||||||
assert_eq!(got, [0xbbu8; 2048]);
|
|
||||||
|
|
||||||
src.read_sectors(2, 1, &mut got, false).unwrap();
|
|
||||||
assert_eq!(got, [0xaau8; 2048]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+59
-34
@@ -1,22 +1,15 @@
|
|||||||
//! Sector-level I/O traits.
|
//! Sector-level read I/O traits.
|
||||||
//!
|
//!
|
||||||
//! The sector layer is direction-typed: [`SectorSource`] reads
|
//! [`SectorSource`] reads 2048-byte sectors from a disc.
|
||||||
//! 2048-byte sectors, [`SectorSink`] writes them. Concrete impls
|
|
||||||
//! never do both — physical drives are read-only, file-backed
|
|
||||||
//! ISO images are opened for read OR write at construction time.
|
|
||||||
//!
|
//!
|
||||||
//! - [`SectorSource`] is implemented by `Drive` (hardware) and
|
//! - [`SectorSource`] is implemented by `Drive` (hardware) and
|
||||||
//! [`FileSectorSource`] (file-backed).
|
//! [`FileSectorSource`] (file-backed).
|
||||||
//! - [`SectorSink`] is implemented by [`FileSectorSink`]
|
|
||||||
//! (ISO-backed).
|
|
||||||
//! - [`DecryptingSectorSource`] is a decorator that wraps any
|
//! - [`DecryptingSectorSource`] is a decorator that wraps any
|
||||||
//! `SectorSource` and applies AACS / CSS in-place decrypt to
|
//! `SectorSource` and applies AACS / CSS in-place decrypt to
|
||||||
//! yield plaintext sectors.
|
//! yield plaintext sectors.
|
||||||
|
|
||||||
pub mod decrypting;
|
pub mod decrypting;
|
||||||
pub mod file;
|
|
||||||
pub mod prefetched;
|
pub mod prefetched;
|
||||||
pub mod recovery;
|
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
|
||||||
@@ -129,6 +122,10 @@ impl SectorSource for Box<dyn SectorSource> {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
(**self).set_speed(kbs)
|
(**self).set_speed(kbs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_unit_base(&mut self, lba: u32) {
|
||||||
|
(**self).set_unit_base(lba)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SectorSource for &mut (dyn SectorSource + '_) {
|
impl SectorSource for &mut (dyn SectorSource + '_) {
|
||||||
@@ -160,29 +157,14 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
(**self).set_speed(kbs)
|
(**self).set_speed(kbs)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Write 2048-byte sectors to a disc image or composed sink.
|
fn set_unit_base(&mut self, lba: u32) {
|
||||||
///
|
(**self).set_unit_base(lba)
|
||||||
/// The terminal [`finish`] takes `Box<Self>` so it can run on `dyn
|
}
|
||||||
/// SectorSink` and consume the sink (`fsync` + close).
|
|
||||||
///
|
|
||||||
/// [`finish`]: SectorSink::finish
|
|
||||||
pub trait SectorSink: Send {
|
|
||||||
/// Write the sectors in `buf` starting at `lba`. `buf.len()`
|
|
||||||
/// must be a multiple of 2048; the implementation seeks to
|
|
||||||
/// `lba as u64 * 2048` before writing (the `u64` cast is required —
|
|
||||||
/// a bare `u32` `lba * 2048` wraps past ~4 GB on UHD-scale images).
|
|
||||||
fn write_sectors(&mut self, lba: u32, buf: &[u8]) -> Result<()>;
|
|
||||||
|
|
||||||
/// Flush, fsync, and close. Consumes the sink. Always called
|
|
||||||
/// last; subsequent operations are not defined.
|
|
||||||
fn finish(self: Box<Self>) -> Result<()>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use crate::io::file_sector_source::FileSectorSource;
|
pub use crate::io::file_sector_source::FileSectorSource;
|
||||||
pub use decrypting::{DECRYPT_VERIFY_READ, DecryptingSectorSource, KeyFetch};
|
pub use decrypting::{DecryptingSectorSource, KeyFetch, KeyFetchFn};
|
||||||
pub use file::FileSectorSink;
|
|
||||||
pub use prefetched::PrefetchedSectorSource;
|
pub use prefetched::PrefetchedSectorSource;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -198,23 +180,33 @@ mod tests {
|
|||||||
capacity: u32,
|
capacity: u32,
|
||||||
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||||
speeds: Arc<Mutex<Vec<u16>>>,
|
speeds: Arc<Mutex<Vec<u16>>>,
|
||||||
|
unit_bases: Arc<Mutex<Vec<u32>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `Spy` under test plus the handles recording its reads and speed sets.
|
/// A `Spy` under test plus the handles recording its reads, speed sets,
|
||||||
type SpyHarness = (Spy, Arc<Mutex<Vec<(u32, u16, bool)>>>, Arc<Mutex<Vec<u16>>>);
|
/// and unit-base sets.
|
||||||
|
type SpyHarness = (
|
||||||
|
Spy,
|
||||||
|
Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
||||||
|
Arc<Mutex<Vec<u16>>>,
|
||||||
|
Arc<Mutex<Vec<u32>>>,
|
||||||
|
);
|
||||||
|
|
||||||
impl Spy {
|
impl Spy {
|
||||||
fn new(capacity: u32) -> SpyHarness {
|
fn new(capacity: u32) -> SpyHarness {
|
||||||
let reads = Arc::new(Mutex::new(Vec::new()));
|
let reads = Arc::new(Mutex::new(Vec::new()));
|
||||||
let speeds = Arc::new(Mutex::new(Vec::new()));
|
let speeds = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let unit_bases = Arc::new(Mutex::new(Vec::new()));
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
capacity,
|
capacity,
|
||||||
reads: reads.clone(),
|
reads: reads.clone(),
|
||||||
speeds: speeds.clone(),
|
speeds: speeds.clone(),
|
||||||
|
unit_bases: unit_bases.clone(),
|
||||||
},
|
},
|
||||||
reads,
|
reads,
|
||||||
speeds,
|
speeds,
|
||||||
|
unit_bases,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +230,17 @@ mod tests {
|
|||||||
fn set_speed(&mut self, kbs: u16) {
|
fn set_speed(&mut self, kbs: u16) {
|
||||||
self.speeds.lock().unwrap().push(kbs);
|
self.speeds.lock().unwrap().push(kbs);
|
||||||
}
|
}
|
||||||
|
fn set_unit_base(&mut self, lba: u32) {
|
||||||
|
self.unit_bases.lock().unwrap().push(lba);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call `set_unit_base` through a generic `S: SectorSource` bound so the
|
||||||
|
/// `Box<dyn SectorSource>` / `&mut dyn SectorSource` FORWARDING impls are the
|
||||||
|
/// ones invoked (the generic monomorphizes to each forwarding body — the same
|
||||||
|
/// body a direct call on those receiver types also resolves to).
|
||||||
|
fn set_unit_base_generic<S: SectorSource>(mut s: S, base: u32) {
|
||||||
|
s.set_unit_base(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default `capacity_sectors` is 0 (unknown). Grounding: trait
|
/// The default `capacity_sectors` is 0 (unknown). Grounding: trait
|
||||||
@@ -286,7 +289,7 @@ mod tests {
|
|||||||
/// Box<dyn SectorSource>` forwarding bodies.
|
/// Box<dyn SectorSource>` forwarding bodies.
|
||||||
#[test]
|
#[test]
|
||||||
fn boxed_dyn_forwards_all_methods() {
|
fn boxed_dyn_forwards_all_methods() {
|
||||||
let (spy, reads, speeds) = Spy::new(777);
|
let (spy, reads, speeds, unit_bases) = Spy::new(777);
|
||||||
let mut boxed: Box<dyn SectorSource> = Box::new(spy);
|
let mut boxed: Box<dyn SectorSource> = Box::new(spy);
|
||||||
|
|
||||||
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
|
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
|
||||||
@@ -308,15 +311,26 @@ mod tests {
|
|||||||
vec![5400],
|
vec![5400],
|
||||||
"set_speed must forward"
|
"set_speed must forward"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// set_unit_base through the generic bound exercises the forwarding impl
|
||||||
|
// (a direct `boxed.set_unit_base()` would vtable-dispatch instead). A
|
||||||
|
// missing forwarding body would silently no-op and record nothing.
|
||||||
|
set_unit_base_generic(boxed, 64);
|
||||||
|
assert_eq!(
|
||||||
|
*unit_bases.lock().unwrap(),
|
||||||
|
vec![64],
|
||||||
|
"set_unit_base must forward through Box<dyn>"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `&mut dyn SectorSource` must likewise forward all three methods.
|
/// `&mut dyn SectorSource` must likewise forward every method.
|
||||||
/// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`.
|
/// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`.
|
||||||
#[test]
|
#[test]
|
||||||
fn mut_ref_dyn_forwards_all_methods() {
|
fn mut_ref_dyn_forwards_all_methods() {
|
||||||
let (mut spy, reads, speeds) = Spy::new(123);
|
let (mut spy, reads, speeds, unit_bases) = Spy::new(123);
|
||||||
let r: &mut dyn SectorSource = &mut spy;
|
|
||||||
|
|
||||||
|
{
|
||||||
|
let r: &mut dyn SectorSource = &mut spy;
|
||||||
assert_eq!(r.capacity_sectors(), 123);
|
assert_eq!(r.capacity_sectors(), 123);
|
||||||
|
|
||||||
let mut buf = vec![0u8; 2 * 2048];
|
let mut buf = vec![0u8; 2 * 2048];
|
||||||
@@ -324,8 +338,19 @@ mod tests {
|
|||||||
assert_eq!(n, 2 * 2048);
|
assert_eq!(n, 2 * 2048);
|
||||||
|
|
||||||
r.set_speed(8800);
|
r.set_speed(8800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass `&mut dyn` as a generic S so the forwarding impl's set_unit_base
|
||||||
|
// is the one under test, not the vtable path.
|
||||||
|
let r2: &mut dyn SectorSource = &mut spy;
|
||||||
|
set_unit_base_generic(r2, 128);
|
||||||
|
|
||||||
assert_eq!(*reads.lock().unwrap(), vec![(7, 2, false)]);
|
assert_eq!(*reads.lock().unwrap(), vec![(7, 2, false)]);
|
||||||
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
|
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
|
||||||
|
assert_eq!(
|
||||||
|
*unit_bases.lock().unwrap(),
|
||||||
|
vec![128],
|
||||||
|
"set_unit_base must forward through &mut dyn"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,11 +252,14 @@ impl PrefetchedSectorSource {
|
|||||||
};
|
};
|
||||||
if bytes <= buf.capacity() {
|
if bytes <= buf.capacity() {
|
||||||
// Re-expose `bytes` without zero-filling pages that
|
// Re-expose `bytes` without zero-filling pages that
|
||||||
// `read_sectors` is about to overwrite. The enclosing
|
// `read_sectors` is about to overwrite. Sound because the
|
||||||
// capacity guard makes the `set_len` provably sound even
|
// enclosing `bytes <= capacity` guard bounds the length,
|
||||||
// if a recycled buffer ever comes back smaller than the
|
// and every byte below `capacity` is physically
|
||||||
// `vec![0u8; batch_bytes]` it was born with.
|
// initialised: buffers are born `vec![0u8; batch_bytes]`
|
||||||
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity");
|
// and only ever grown via `resize(_, 0)`, so a recycled
|
||||||
|
// buffer that came back shorter (consumer `truncate`)
|
||||||
|
// still has initialised backing storage under `set_len`,
|
||||||
|
// which `read_sectors` then overwrites before any read.
|
||||||
unsafe { buf.set_len(bytes) };
|
unsafe { buf.set_len(bytes) };
|
||||||
} else {
|
} else {
|
||||||
buf.resize(bytes, 0);
|
buf.resize(bytes, 0);
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
//! The recovery seam: what a read does when a content unit will not decrypt.
|
|
||||||
//!
|
|
||||||
//! Per-format miss policy does NOT belong in the generic decrypt decorator
|
|
||||||
//! (L2). The input stream (L3, e.g. [`crate::mux::disc::DiscStream`]) knows what
|
|
||||||
//! it is reading and installs a [`Recover`] at construction; the decorator
|
|
||||||
//! executes it at the one seam and honours the returned outcome. This keeps
|
|
||||||
//! "a DVD re-cracks, a BD/UHD fetches a fresh key" out of the decryptor, where
|
|
||||||
//! it would otherwise smear across `if`-branches.
|
|
||||||
//!
|
|
||||||
//! The recovery type ([`Recover`]) names **no encryption scheme**. It is a
|
|
||||||
//! generic `FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome` that
|
|
||||||
//! operates on the generic [`DecryptKeys`] the whole decrypt path already uses,
|
|
||||||
//! so a scheme is never baked into the type — only into the factory that builds
|
|
||||||
//! a recovery:
|
|
||||||
//! * [`none`] — no recovery; a miss is loss (raw sweep / clear).
|
|
||||||
//! * [`key_fetch`] — AACS key-fetch: hand the failing ciphertext to the
|
|
||||||
//! application's key source and add any returned keys to the pool. An AACS
|
|
||||||
//! 2.1 forensic-segment unit that no key opens is just an undecryptable unit
|
|
||||||
//! like any other — a loss is a loss, with no FMTS-specific branch here.
|
|
||||||
//!
|
|
||||||
//! CSS is deliberately NOT on this seam — and the reason is precise: this seam is
|
|
||||||
//! for recovery that needs something `decrypt_sectors` does not have (an EXTERNAL
|
|
||||||
//! key source for AACS, a segment map for FMTS). CSS's title key changes per VOB
|
|
||||||
//! region and is re-cracked constantly, but always FROM THE DATA ITSELF — no
|
|
||||||
//! external input — so CSS SELF-recovers inside `decrypt_sectors` (see
|
|
||||||
//! [`crate::css::descramble_region`]). The generic type here would accept a CSS
|
|
||||||
//! recovery, but CSS has no reason to use it.
|
|
||||||
|
|
||||||
use crate::decrypt::DecryptKeys;
|
|
||||||
use crate::sector::KeyFetch;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// The result of running a recovery on a read's still-scrambled units: how many
|
|
||||||
/// bytes remain loss after recovery ran. A loss is a loss — an undecryptable
|
|
||||||
/// unit is concealed and counted the same whatever the scheme (an AACS 2.1
|
|
||||||
/// forensic-segment unit with no variant key is just another undecryptable
|
|
||||||
/// unit).
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
||||||
pub struct MissOutcome {
|
|
||||||
/// Bytes that remain loss after recovery.
|
|
||||||
pub dropped: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MissOutcome {
|
|
||||||
/// All `n` bytes are loss.
|
|
||||||
fn loss(n: usize) -> Self {
|
|
||||||
Self { dropped: n }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cap on how many times one recovery will call its fetch closure over its
|
|
||||||
/// lifetime — bounds key-server traffic to ~O(distinct CPS units) even if
|
|
||||||
/// scrambled units keep arriving. A disc has only a handful of unit keys.
|
|
||||||
const MAX_FETCH_CALLS: usize = 16;
|
|
||||||
|
|
||||||
/// Cap on how many still-scrambled sample units are handed to the fetch closure
|
|
||||||
/// per call — a few samples suffice for a key service to identify and validate
|
|
||||||
/// the key, and it bounds the request size.
|
|
||||||
const MAX_FETCH_SAMPLES: usize = 8;
|
|
||||||
|
|
||||||
/// Stable per-run fingerprint of a failing unit's ciphertext, for the dedup set.
|
|
||||||
/// `DefaultHasher` is fixed-seed, so equal samples map to equal fingerprints
|
|
||||||
/// within a process — all the dedup needs.
|
|
||||||
fn sample_fp(sample: &[u8]) -> u64 {
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
||||||
sample.hash(&mut h);
|
|
||||||
h.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-decrypt `buf` after the key pool grew, content-gated identically to the
|
|
||||||
/// first read so a non-content unit is never re-attempted. Mirrors the
|
|
||||||
/// decorator's `decrypt_buf` dispatch.
|
|
||||||
fn redecrypt(
|
|
||||||
buf: &mut [u8],
|
|
||||||
keys: &mut DecryptKeys,
|
|
||||||
unit_key_idx: usize,
|
|
||||||
lba: u32,
|
|
||||||
content: Option<&[(u32, u32)]>,
|
|
||||||
prev_dropped: usize,
|
|
||||||
) -> usize {
|
|
||||||
match content {
|
|
||||||
Some(ranges) => {
|
|
||||||
crate::decrypt::decrypt_sectors_in_content(buf, keys, unit_key_idx, lba, ranges)
|
|
||||||
}
|
|
||||||
None => crate::decrypt::decrypt_sectors(buf, keys, unit_key_idx),
|
|
||||||
}
|
|
||||||
.unwrap_or(prev_dropped)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a read hands a recovery on a miss: the disc's decrypt parameters and how
|
|
||||||
/// many bytes the held keys could not decrypt. Scheme-neutral — a recovery reads
|
|
||||||
/// only the generic [`DecryptKeys`] and these fields.
|
|
||||||
pub struct RecoverCtx {
|
|
||||||
/// Which AACS unit-key index the read decrypts with (ignored by non-AACS).
|
|
||||||
pub unit_key_idx: usize,
|
|
||||||
/// Base LBA of the read.
|
|
||||||
pub lba: u32,
|
|
||||||
/// The encrypted-content extent map, when the read is content-gated.
|
|
||||||
pub content: Option<Arc<[(u32, u32)]>>,
|
|
||||||
/// Bytes the held keys could not decrypt before recovery ran.
|
|
||||||
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>;
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
fn aacs_fetch_step(
|
|
||||||
dry: &mut HashSet<u64>,
|
|
||||||
calls: &mut usize,
|
|
||||||
fetch: &KeyFetch,
|
|
||||||
buf: &mut [u8],
|
|
||||||
keys: &mut DecryptKeys,
|
|
||||||
ctx: &RecoverCtx,
|
|
||||||
) -> usize {
|
|
||||||
let prev_dropped = ctx.prev_dropped;
|
|
||||||
if *calls >= MAX_FETCH_CALLS {
|
|
||||||
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
|
|
||||||
// (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());
|
|
||||||
if samples.len() >= MAX_FETCH_SAMPLES {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if samples.is_empty() {
|
|
||||||
return prev_dropped;
|
|
||||||
}
|
|
||||||
// Skip the call when EVERY failing unit here is one a prior fetch already
|
|
||||||
// came back empty for — re-asking identical ciphertext only burns a request.
|
|
||||||
// A unit not asked about yet (e.g. a second CPS unit) still gets its chance.
|
|
||||||
let fps: Vec<u64> = samples.iter().map(|s| sample_fp(s)).collect();
|
|
||||||
if fps.iter().all(|fp| dry.contains(fp)) {
|
|
||||||
return prev_dropped;
|
|
||||||
}
|
|
||||||
*calls += 1;
|
|
||||||
let fresh = (fetch)(&samples);
|
|
||||||
// Add only keys we don't already hold (dedup by value).
|
|
||||||
let mut added = 0usize;
|
|
||||||
if let DecryptKeys::Aacs { unit_keys, .. } = keys {
|
|
||||||
for k in fresh {
|
|
||||||
if !unit_keys.iter().any(|(_, have)| *have == k) {
|
|
||||||
let idx = unit_keys.len() as u32;
|
|
||||||
unit_keys.push((idx, k));
|
|
||||||
added += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if added == 0 {
|
|
||||||
// Nothing new for THESE units — remember them so we don't re-ask the same
|
|
||||||
// ciphertext, but leave the door open for other units.
|
|
||||||
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.
|
|
||||||
redecrypt(
|
|
||||||
buf,
|
|
||||||
keys,
|
|
||||||
ctx.unit_key_idx,
|
|
||||||
ctx.lba,
|
|
||||||
ctx.content.as_deref(),
|
|
||||||
prev_dropped,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
|
|
||||||
/// source for a key that opens the failing ciphertext and add it to the pool.
|
|
||||||
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| {
|
|
||||||
MissOutcome::loss(aacs_fetch_step(
|
|
||||||
&mut dry, &mut calls, &fetch, buf, keys, ctx,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
/// A 6144-byte aligned unit that reads as still-scrambled: CPI bits set on
|
|
||||||
/// byte 0 (so `aacs_unit_encrypted` flags it) and every 192-byte TS-sync
|
|
||||||
/// probe position forced off 0x47. `tag` varies the whole body so distinct
|
|
||||||
/// tags produce distinct fingerprints (mirrors decrypt.rs `scrambled_region`).
|
|
||||||
fn scrambled_unit(tag: u8) -> Vec<u8> {
|
|
||||||
let len = ALIGNED_UNIT_LEN;
|
|
||||||
let mut v: Vec<u8> = (0..len).map(|i| (i as u8).wrapping_mul(31) ^ tag).collect();
|
|
||||||
let mut off = 4;
|
|
||||||
while off < len {
|
|
||||||
v[off] = 0xA5; // never a 0x47 sync
|
|
||||||
off += 192;
|
|
||||||
}
|
|
||||||
v[0] |= 0xC0; // CPI: reads as encrypted content
|
|
||||||
v
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A recovery context reading at clip-relative `lba` with `prev` bytes the
|
|
||||||
/// held keys could not decrypt.
|
|
||||||
fn ctx(lba: u32, prev: usize) -> RecoverCtx {
|
|
||||||
RecoverCtx {
|
|
||||||
unit_key_idx: 0,
|
|
||||||
lba,
|
|
||||||
content: None,
|
|
||||||
prev_dropped: prev,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn none_recovers_nothing() {
|
|
||||||
let mut r = none();
|
|
||||||
let mut buf = scrambled_unit(0x33);
|
|
||||||
let mut keys = DecryptKeys::Aacs {
|
|
||||||
unit_keys: vec![],
|
|
||||||
read_data_key: None,
|
|
||||||
};
|
|
||||||
let out = r(&mut buf, &mut keys, &ctx(0, 6144));
|
|
||||||
assert_eq!(out.dropped, 6144);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn key_fetch_adds_returned_keys_to_the_pool() {
|
|
||||||
// The fetch returns one key; it must be appended to the (empty) pool. We
|
|
||||||
// assert the pool grew (the decrypt itself is exercised end-to-end by the
|
|
||||||
// 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>]| {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
r(&mut buf, &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!()
|
|
||||||
};
|
|
||||||
assert_eq!(unit_keys.len(), 1, "returned key added to the pool");
|
|
||||||
assert_eq!(unit_keys[0].1, [0xAB; 16]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn key_fetch_does_not_re_ask_dry_ciphertext() {
|
|
||||||
// A fetch that returns nothing marks the ciphertext dry; a second miss on
|
|
||||||
// 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>]| {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
let mut buf = scrambled_unit(0x44);
|
|
||||||
r(&mut buf, &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));
|
|
||||||
assert_eq!(
|
|
||||||
calls.load(Ordering::SeqCst),
|
|
||||||
1,
|
|
||||||
"identical dry ciphertext is not re-asked"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
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>]| {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
calls.load(Ordering::SeqCst),
|
|
||||||
MAX_FETCH_CALLS,
|
|
||||||
"fetch is capped at MAX_FETCH_CALLS"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+757
@@ -0,0 +1,757 @@
|
|||||||
|
//! Disc session — one place that opens an optical drive and brings the SCSI
|
||||||
|
//! transport up, so the consumers (CLI, autorip) stop hand-rolling the
|
||||||
|
//! `open → wait_ready → init → probe_disc → identify → scan` preamble.
|
||||||
|
//!
|
||||||
|
//! The session owns the [`Drive`] by value (tray unlock stays guaranteed via
|
||||||
|
//! `Drive::drop`) and, after [`DiscSession::scan`], the resulting [`Disc`].
|
||||||
|
//! Lifecycle is intentionally SPLIT — `open` does transport mechanics only,
|
||||||
|
//! `identify` / `scan` are separate — so a consumer can fetch a poster off a
|
||||||
|
//! fast `identify` and update its UI before committing to a full `scan`.
|
||||||
|
//!
|
||||||
|
//! libfreemkv resolves no keys and reads no keydb: the consumer builds the
|
||||||
|
//! host credentials / key-source layer (from `freemkv_keysources`) and hands
|
||||||
|
//! them in via [`KeySpec`]; the session merely FORWARDS them into
|
||||||
|
//! [`ScanOptions`] at scan time. No cert derivation happens here.
|
||||||
|
|
||||||
|
use crate::aacs::trace::ResolutionTrace;
|
||||||
|
use crate::disc::{Disc, DiscId, DriveCredentials, ScanOptions};
|
||||||
|
use crate::drive::{Drive, find_drive};
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::keysource::{
|
||||||
|
KeySource, MIN_SAMPLE_UNITS, key_fetch, read_encrypted_units, resolve_and_apply_traced,
|
||||||
|
};
|
||||||
|
use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// A consumer-supplied factory for the ordered AACS key-source layer.
|
||||||
|
///
|
||||||
|
/// libfreemkv builds no key sources itself (the `freemkv_keysources` crate that
|
||||||
|
/// implements [`KeySource`] depends on libfreemkv, not the other way round), so
|
||||||
|
/// the consumer hands in a way to (re)build its sources. It is invoked once for
|
||||||
|
/// the up-front resolve and again per on-decrypt-miss fetch (the cold path), so
|
||||||
|
/// it stays `Send + Sync` without requiring `KeySource: Send`. Mirrors the
|
||||||
|
/// `make_sources` argument [`key_fetch`] already takes.
|
||||||
|
pub type KeySourceFactory = Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>;
|
||||||
|
|
||||||
|
/// The outcome of resolving a disc's base AACS unit keys: the structured
|
||||||
|
/// per-source [`ResolutionTrace`] (for the consumer to render) plus the
|
||||||
|
/// read-time [`KeyFetch`] built from the disc's public inputs.
|
||||||
|
///
|
||||||
|
/// `key_fetch` is `None` only for a disc that carries no AACS inputs (an
|
||||||
|
/// unencrypted / CSS / non-AACS disc); it is `Some` whenever the disc is AACS,
|
||||||
|
/// independent of whether a key actually resolved — the on-decrypt-miss fetch is
|
||||||
|
/// wired the same way regardless.
|
||||||
|
pub struct ResolvedKeys {
|
||||||
|
/// Per-source walk of the resolve, for the consumer to render (English-free
|
||||||
|
/// typed enums only; the app layer maps them to text).
|
||||||
|
pub trace: ResolutionTrace,
|
||||||
|
/// The read-time fetch closure, or `None` for a non-AACS disc.
|
||||||
|
pub key_fetch: Option<KeyFetch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve and bank a keyless-scanned disc's BASE AACS unit keys, and build the
|
||||||
|
/// read-time [`KeyFetch`] — the one place the sampling / ordered-apply / banking
|
||||||
|
/// / fetch-construction glue lives, so the CLI and autorip stop hand-rolling it.
|
||||||
|
///
|
||||||
|
/// Steps, identical to what the consumers did inline:
|
||||||
|
/// 1. Take the disc's public AACS inputs ([`Disc::inputs`]); a non-AACS disc has
|
||||||
|
/// none, so this is a no-op returning an empty trace and no fetch.
|
||||||
|
/// 2. Sample up to [`MIN_SAMPLE_UNITS`] encrypted content units from the LARGEST
|
||||||
|
/// title via `reader` ([`read_encrypted_units`]) so a candidate key is
|
||||||
|
/// validated against real ciphertext. Skipped (no wasted read) when the
|
||||||
|
/// factory yields no sources — resolution is then a guaranteed miss anyway.
|
||||||
|
/// 3. Run the ordered sources first-valid-wins ([`resolve_and_apply_traced`]),
|
||||||
|
/// which banks the winning unit keys onto `disc`'s AACS state.
|
||||||
|
/// 4. Build the read-time [`KeyFetch`] from the disc's inputs (its per-call
|
||||||
|
/// samples are swapped in by the closure) using the same source factory.
|
||||||
|
///
|
||||||
|
/// The `reader` is whatever the disc lives behind — a live [`Drive`] or a
|
||||||
|
/// file-backed [`SectorSource`] from [`scan_iso`]; both implement
|
||||||
|
/// [`SectorSource`].
|
||||||
|
pub fn resolve_keys_for(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
disc: &mut Disc,
|
||||||
|
sources: KeySourceFactory,
|
||||||
|
) -> ResolvedKeys {
|
||||||
|
// A disc with no captured AACS inputs is unencrypted / CSS / non-AACS —
|
||||||
|
// nothing to resolve, nothing to fetch.
|
||||||
|
let Some(mut inputs) = disc.inputs() else {
|
||||||
|
return ResolvedKeys {
|
||||||
|
trace: ResolutionTrace::new(),
|
||||||
|
key_fetch: None,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the ordered sources once for the up-front resolve. Sampling reads the
|
||||||
|
// disc, so skip it when there is no source to validate against (a dropped /
|
||||||
|
// SSRF-rejected online-only source) — resolution is a miss regardless and the
|
||||||
|
// read would be pure waste.
|
||||||
|
let src_vec = sources();
|
||||||
|
inputs.samples = if src_vec.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
match disc.titles.iter().max_by_key(|t| t.size_bytes).cloned() {
|
||||||
|
Some(title) => read_encrypted_units(reader, &title, MIN_SAMPLE_UNITS),
|
||||||
|
None => Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ordered, first-valid-wins; banks the winning unit keys onto `disc`.
|
||||||
|
let (_resolved, trace) = resolve_and_apply_traced(&src_vec, &inputs, disc);
|
||||||
|
|
||||||
|
// Build the read-time fetch from the disc's public inputs (fresh, so it
|
||||||
|
// reflects any banked state); its per-fetch `samples` are filled by the
|
||||||
|
// closure. `inputs()` is still `Some` here (the disc is AACS).
|
||||||
|
let fetch_inputs = disc.inputs().unwrap_or(inputs);
|
||||||
|
let fetch = key_fetch(fetch_inputs, sources);
|
||||||
|
ResolvedKeys {
|
||||||
|
trace,
|
||||||
|
key_fetch: Some(fetch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which optical device a [`DiscSession`] should open.
|
||||||
|
pub enum DeviceTarget {
|
||||||
|
/// Open this exact device path (e.g. `/dev/sg0`).
|
||||||
|
Path(PathBuf),
|
||||||
|
/// Enumerate drives and pick one that currently has media
|
||||||
|
/// (see [`find_drive`]).
|
||||||
|
Autodetect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumer-supplied key material for the live-drive AACS handshake.
|
||||||
|
///
|
||||||
|
/// libfreemkv does NOT read `keydb.cfg`, build a `KeydbSource`, or extract host
|
||||||
|
/// certs — that layer lives in the application (`freemkv_keysources`), which
|
||||||
|
/// depends on libfreemkv, not the other way round. The consumer builds the
|
||||||
|
/// credentials / key-source layer and passes them in here; [`DiscSession::scan`]
|
||||||
|
/// forwards them into [`ScanOptions`]. The `keydb_path` / `key_url` / `key_auth`
|
||||||
|
/// fields are carried purely for the CONSUMER's own bookkeeping — the library
|
||||||
|
/// ignores them.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct KeySpec {
|
||||||
|
/// Consumer bookkeeping only — the library does not read it.
|
||||||
|
pub keydb_path: Option<PathBuf>,
|
||||||
|
/// Consumer bookkeeping only — the library does not read it.
|
||||||
|
pub key_url: Option<String>,
|
||||||
|
/// Consumer bookkeeping only — the library does not read it.
|
||||||
|
pub key_auth: Option<String>,
|
||||||
|
/// Host cert(s) for the live-drive handshake, pre-built by the consumer.
|
||||||
|
/// Forwarded to [`ScanOptions::credentials`] at scan time.
|
||||||
|
pub credentials: Option<DriveCredentials>,
|
||||||
|
/// Consumer-built key-source layer; the handshake collects host certs
|
||||||
|
/// across these. Moved into [`ScanOptions::key_sources`] at scan time.
|
||||||
|
pub key_sources: Vec<Box<dyn KeySource>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An opened optical drive plus the disc scanned off it.
|
||||||
|
///
|
||||||
|
/// Owns the [`Drive`] by value. Consumers that still need the raw drive (e.g.
|
||||||
|
/// to sample ciphertext for key validation, or to move it into a
|
||||||
|
/// `DiscStream`) reach it via [`Self::drive_mut`] / [`Self::into_drive`]; the
|
||||||
|
/// scanned [`Disc`] comes out via [`Self::disc`] / [`Self::take_disc`].
|
||||||
|
pub struct DiscSession {
|
||||||
|
/// The opened drive. `Some` from [`Self::open`] until
|
||||||
|
/// [`Self::stage_drive_as_reader`] (live-drive mux) or [`Self::into_drive`]
|
||||||
|
/// moves it out. The cached [`Self::device_path`] survives that move so the
|
||||||
|
/// mux driver can still name the device in an error without the drive.
|
||||||
|
drive: Option<Drive>,
|
||||||
|
/// The drive's device path, cached at [`Self::open`] so it outlives a
|
||||||
|
/// [`Self::stage_drive_as_reader`] that moves the drive into `reader`.
|
||||||
|
device: String,
|
||||||
|
spec: KeySpec,
|
||||||
|
disc: Option<Disc>,
|
||||||
|
/// Sector source for a later file/live mux to `.take()` (steps 3–4). The
|
||||||
|
/// file path stages a `FileSectorSource`; the live-drive path stages the
|
||||||
|
/// drive itself via [`Self::stage_drive_as_reader`].
|
||||||
|
reader: Option<Box<dyn SectorSource>>,
|
||||||
|
/// The read-time AACS fetch closure, built by [`Self::resolve_keys`] and
|
||||||
|
/// retained so a later mux (step 4) can install it into the decrypt
|
||||||
|
/// decorator. `None` until keys are resolved / for a non-AACS disc.
|
||||||
|
key_fetch: Option<KeyFetch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlay the session's consumer-supplied key material onto a caller's
|
||||||
|
/// [`ScanOptions`], without ever clobbering what the caller already set.
|
||||||
|
///
|
||||||
|
/// Pure (no drive I/O) so the KeySpec → ScanOptions derivation is unit-testable
|
||||||
|
/// without hardware. `credentials` is copied (it is `Clone`); `key_sources` is
|
||||||
|
/// MOVED out of the spec (trait objects are not `Clone`), leaving the spec's
|
||||||
|
/// vec empty once consumed.
|
||||||
|
fn forward_key_material(spec: &mut KeySpec, mut opts: ScanOptions) -> ScanOptions {
|
||||||
|
if opts.credentials.is_none() {
|
||||||
|
opts.credentials = spec.credentials.clone();
|
||||||
|
}
|
||||||
|
if opts.key_sources.is_empty() {
|
||||||
|
opts.key_sources = std::mem::take(&mut spec.key_sources);
|
||||||
|
}
|
||||||
|
opts
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscSession {
|
||||||
|
/// Open a drive and bring the SCSI transport up.
|
||||||
|
///
|
||||||
|
/// Resolves the device (`Autodetect` → [`find_drive`]), opens it (FATAL —
|
||||||
|
/// the only hard failure here), then runs `wait_ready` → `init` →
|
||||||
|
/// `probe_disc`. Those three are ADVISORY exactly as every consumer treated
|
||||||
|
/// them: a failure is logged via `tracing` and discarded — the later
|
||||||
|
/// [`Self::scan`] is the authoritative gate. No scan, no identify, no key
|
||||||
|
/// resolution runs here.
|
||||||
|
pub fn open(target: DeviceTarget, spec: KeySpec) -> Result<DiscSession> {
|
||||||
|
let mut drive = match target {
|
||||||
|
DeviceTarget::Path(ref path) => Drive::open(path)?,
|
||||||
|
// Autodetect yields an already-opened drive; a missing drive is a
|
||||||
|
// typed `DeviceNotFound` the application maps to its own message.
|
||||||
|
DeviceTarget::Autodetect => find_drive().ok_or_else(|| Error::DeviceNotFound {
|
||||||
|
path: String::new(),
|
||||||
|
})?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Advisory bring-up — non-fatal in every consumer today. Preserve that:
|
||||||
|
// log and continue, never propagate. (The CLI printed these to stderr /
|
||||||
|
// discarded them; autorip `tracing::warn`'d them. The advisory SEMANTICS
|
||||||
|
// are what matter and are preserved identically; the sink is now here.)
|
||||||
|
if let Err(e) = drive.wait_ready() {
|
||||||
|
tracing::warn!(target: "freemkv::session", error = %e, "wait_ready advisory failed (continuing)");
|
||||||
|
}
|
||||||
|
if let Err(e) = drive.init() {
|
||||||
|
tracing::warn!(target: "freemkv::session", error = %e, "init advisory failed (continuing)");
|
||||||
|
}
|
||||||
|
if let Err(e) = drive.probe_disc() {
|
||||||
|
tracing::warn!(target: "freemkv::session", error = %e, "probe_disc advisory failed (continuing)");
|
||||||
|
}
|
||||||
|
|
||||||
|
let device = drive.device_path().to_string();
|
||||||
|
Ok(DiscSession {
|
||||||
|
drive: Some(drive),
|
||||||
|
device,
|
||||||
|
spec,
|
||||||
|
disc: None,
|
||||||
|
reader: None,
|
||||||
|
key_fetch: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fast disc identification — name/format only, no playlist parse. Wraps
|
||||||
|
/// [`Disc::identify`].
|
||||||
|
pub fn identify(&mut self) -> Result<DiscId> {
|
||||||
|
Disc::identify(self.drive_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full structure scan. Forwards the session's [`KeySpec`] credentials /
|
||||||
|
/// key-sources into `opts` (without clobbering anything the caller already
|
||||||
|
/// set), runs [`Disc::scan`], stores the result, and returns a borrow.
|
||||||
|
pub fn scan(&mut self, opts: ScanOptions) -> Result<&Disc> {
|
||||||
|
let opts = forward_key_material(&mut self.spec, opts);
|
||||||
|
let disc = Disc::scan(self.drive.as_mut().expect("drive present for scan"), &opts)?;
|
||||||
|
self.disc = Some(disc);
|
||||||
|
Ok(self.disc.as_ref().expect("disc just stored"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve and bank the scanned disc's base AACS unit keys from the
|
||||||
|
/// consumer-supplied `sources`, and retain the read-time [`KeyFetch`] on the
|
||||||
|
/// session (see [`Self::key_fetch`]) for a later mux.
|
||||||
|
///
|
||||||
|
/// Samples ciphertext through the session's own reader — the staged file
|
||||||
|
/// reader if one is present, otherwise the live drive — so it works for both
|
||||||
|
/// a live-drive session and a file-backed one. Returns the structured
|
||||||
|
/// [`ResolutionTrace`] for the consumer to render; a non-AACS disc resolves
|
||||||
|
/// to an empty trace with no error. Requires [`Self::scan`] to have run.
|
||||||
|
pub fn resolve_keys(&mut self, sources: KeySourceFactory) -> Result<ResolutionTrace> {
|
||||||
|
// The disc must have been scanned so its AACS inputs are captured.
|
||||||
|
if self.disc.is_none() {
|
||||||
|
return Err(Error::DeviceNotReady {
|
||||||
|
path: self.device.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Sample through the staged reader when present (file-backed), else the
|
||||||
|
// live drive. `self.reader` / `self.disc` / `self.drive` are disjoint
|
||||||
|
// fields, so the borrows below don't conflict.
|
||||||
|
let resolved = if let Some(reader) = self.reader.as_mut() {
|
||||||
|
let disc = self.disc.as_mut().expect("disc present (checked above)");
|
||||||
|
resolve_keys_for(reader.as_mut(), disc, sources)
|
||||||
|
} else {
|
||||||
|
let disc = self.disc.as_mut().expect("disc present (checked above)");
|
||||||
|
resolve_keys_for(
|
||||||
|
self.drive.as_mut().expect("drive present for key sampling"),
|
||||||
|
disc,
|
||||||
|
sources,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.key_fetch = resolved.key_fetch;
|
||||||
|
Ok(resolved.trace)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The read-time AACS fetch closure retained by [`Self::resolve_keys`], for a
|
||||||
|
/// later mux (step 4) to install into the decrypt decorator. `None` before
|
||||||
|
/// keys are resolved, or for a non-AACS disc.
|
||||||
|
pub fn key_fetch(&self) -> Option<&KeyFetch> {
|
||||||
|
self.key_fetch.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scanned disc, if [`Self::scan`] has run.
|
||||||
|
pub fn disc(&self) -> Option<&Disc> {
|
||||||
|
self.disc.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable access to the scanned disc, if [`Self::scan`] has run.
|
||||||
|
pub fn disc_mut(&mut self) -> Option<&mut Disc> {
|
||||||
|
self.disc.as_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take ownership of the scanned disc out of the session, leaving `None`.
|
||||||
|
/// Consumers that need the owned `Disc` alongside a live `&mut Drive`
|
||||||
|
/// (key-resolution, per-title crack) take the disc, then borrow the drive.
|
||||||
|
pub fn take_disc(&mut self) -> Option<Disc> {
|
||||||
|
self.disc.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared access to the opened drive (identity, profile, path). Panics if the
|
||||||
|
/// drive has already been staged into the reader slot
|
||||||
|
/// ([`Self::stage_drive_as_reader`]) or moved out via [`Self::into_drive`] —
|
||||||
|
/// use [`Self::device_path`] for a name that survives those moves.
|
||||||
|
pub fn drive(&self) -> &Drive {
|
||||||
|
self.drive.as_ref().expect("drive present")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opened drive's device path. Cached at [`Self::open`], so it remains
|
||||||
|
/// available after [`Self::stage_drive_as_reader`] moves the drive into the
|
||||||
|
/// reader slot (the mux driver names the device here without the drive).
|
||||||
|
pub fn device_path(&self) -> &str {
|
||||||
|
&self.device
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable access to the opened drive — for ciphertext sampling and other
|
||||||
|
/// direct reads consumers still perform.
|
||||||
|
pub fn drive_mut(&mut self) -> &mut Drive {
|
||||||
|
self.drive.as_mut().expect("drive present")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock the tray so the disc cannot eject mid-rip. Unlock is guaranteed by
|
||||||
|
/// `Drive::drop`. A no-op if the drive is no longer held by the session.
|
||||||
|
pub fn lock_tray(&mut self) {
|
||||||
|
if let Some(drive) = self.drive.as_mut() {
|
||||||
|
drive.lock_tray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the session, returning the owned drive (e.g. to move into a
|
||||||
|
/// `DiscStream` for a live-drive mux).
|
||||||
|
pub fn into_drive(self) -> Drive {
|
||||||
|
self.drive.expect("drive present")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage the owned drive as the session's boxed sector source so a live
|
||||||
|
/// single-pass mux can drive it through
|
||||||
|
/// [`MuxInput::Session`](crate::mux::MuxInput::Session). Moves the `Drive`
|
||||||
|
/// (itself a [`SectorSource`]) into the `reader` slot; the cached
|
||||||
|
/// [`Self::device_path`] keeps the device name available afterward. A no-op
|
||||||
|
/// if the drive was already staged or moved out.
|
||||||
|
pub fn stage_drive_as_reader(&mut self) {
|
||||||
|
if let Some(drive) = self.drive.take() {
|
||||||
|
self.reader = Some(Box::new(drive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume the session, returning the sector source staged for a later mux
|
||||||
|
/// (steps 3–4). `None` until that path populates it.
|
||||||
|
pub fn into_reader(self) -> Option<Box<dyn SectorSource>> {
|
||||||
|
self.reader
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the staged sector source out of the session by mutable borrow,
|
||||||
|
/// leaving `None` behind. Used by [`crate::mux::mux_stream`]'s
|
||||||
|
/// [`MuxInput::Session`](crate::mux::MuxInput::Session) arm, which drives
|
||||||
|
/// the mux from `&mut DiscSession` and so cannot consume the whole session.
|
||||||
|
/// A second call (or a call before the reader is staged) returns `None`, and
|
||||||
|
/// the driver maps that to a clean error rather than a panic (see Q2 of the
|
||||||
|
/// boundary-audit contract).
|
||||||
|
pub fn take_reader(&mut self) -> Option<Box<dyn SectorSource>> {
|
||||||
|
self.reader.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test-only constructor: build a session over an INJECTED reader + already-
|
||||||
|
/// scanned disc WITHOUT opening a live [`Drive`]. `DiscSession::open` needs
|
||||||
|
/// real hardware, so this is the only way to exercise the
|
||||||
|
/// [`MuxInput::Session`](crate::mux::MuxInput::Session) mux arm (take_reader →
|
||||||
|
/// resolve_inline_base_map → DiscStream → with_key_map) and
|
||||||
|
/// [`Self::resolve_keys`]'s title-sampling branch against a synthetic reader.
|
||||||
|
///
|
||||||
|
/// The drive slot stays `None` (a `MuxInput::Session` mux never touches it —
|
||||||
|
/// it reads through the staged `reader`); `device` carries a sentinel path so
|
||||||
|
/// the driver's missing-reader error still has a name.
|
||||||
|
///
|
||||||
|
/// `disc` is an `Option` so a test can construct a session that has NOT been
|
||||||
|
/// scanned (`None`) to exercise the `resolve_keys` "called before scan" guard.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_parts_for_test(
|
||||||
|
disc: Option<Disc>,
|
||||||
|
reader: Option<Box<dyn SectorSource>>,
|
||||||
|
key_fetch: Option<KeyFetch>,
|
||||||
|
) -> DiscSession {
|
||||||
|
DiscSession {
|
||||||
|
drive: None,
|
||||||
|
device: "test://session".to_string(),
|
||||||
|
spec: KeySpec::default(),
|
||||||
|
disc,
|
||||||
|
reader,
|
||||||
|
key_fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan an ISO image's structure from a file path, returning the scanned
|
||||||
|
/// [`Disc`] together with a reusable [`SectorSource`] over the same file.
|
||||||
|
///
|
||||||
|
/// This is the file-backed counterpart to [`DiscSession::scan`]: it is the one
|
||||||
|
/// place that opens a [`FileSectorSource`], reads its capacity, and runs
|
||||||
|
/// [`Disc::scan_image`], so consumers (CLI, autorip) stop hand-rolling that
|
||||||
|
/// triple and stop constructing the low-level reader themselves. No SCSI, no
|
||||||
|
/// handshake, no key resolution — AACS resolution during the scan uses only
|
||||||
|
/// whatever `opts` already carries (mirroring how `Disc::scan_image` forwards
|
||||||
|
/// `ScanOptions`).
|
||||||
|
///
|
||||||
|
/// The returned reader is a fresh handle positioned at the start of the image;
|
||||||
|
/// callers that need to sample ciphertext (key resolution) or feed a mux can
|
||||||
|
/// reuse it directly rather than re-opening the file. `Disc::scan_image` reads
|
||||||
|
/// only through the same reader, and all reads are LBA-addressed, so the
|
||||||
|
/// handle is fully reusable afterward.
|
||||||
|
pub fn scan_iso(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorSource>)> {
|
||||||
|
let mut reader = FileSectorSource::open(path)?;
|
||||||
|
let capacity = reader.capacity_sectors();
|
||||||
|
let disc = Disc::scan_image(&mut reader, capacity, &opts)?;
|
||||||
|
Ok((disc, Box::new(reader)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::aacs::types::{HostCert, UnitKey};
|
||||||
|
use crate::keysource::ResolveCtx;
|
||||||
|
|
||||||
|
fn creds_with(n: usize) -> DriveCredentials {
|
||||||
|
DriveCredentials {
|
||||||
|
host_certs: (0..n)
|
||||||
|
.map(|_| HostCert {
|
||||||
|
private_key: [0u8; 20],
|
||||||
|
certificate: Vec::new(),
|
||||||
|
private_key_v2: None,
|
||||||
|
certificate_v2: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestSource;
|
||||||
|
impl KeySource for TestSource {
|
||||||
|
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
fn label(&self) -> &'static str {
|
||||||
|
"test-source"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forwards_spec_credentials_into_empty_opts() {
|
||||||
|
let mut spec = KeySpec {
|
||||||
|
credentials: Some(creds_with(2)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = forward_key_material(&mut spec, ScanOptions::default());
|
||||||
|
// Kills the "drop the forward" mutant.
|
||||||
|
assert_eq!(opts.credentials.map(|c| c.host_certs.len()), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_clobber_caller_credentials() {
|
||||||
|
let mut spec = KeySpec {
|
||||||
|
credentials: Some(creds_with(2)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = ScanOptions {
|
||||||
|
credentials: Some(creds_with(5)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = forward_key_material(&mut spec, opts);
|
||||||
|
// Kills a mutant that flips `is_none()` → always-overwrite.
|
||||||
|
assert_eq!(opts.credentials.map(|c| c.host_certs.len()), Some(5));
|
||||||
|
// The unused spec creds stay put.
|
||||||
|
assert_eq!(spec.credentials.map(|c| c.host_certs.len()), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moves_spec_key_sources_into_empty_opts() {
|
||||||
|
let mut spec = KeySpec {
|
||||||
|
key_sources: vec![Box::new(TestSource)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = forward_key_material(&mut spec, ScanOptions::default());
|
||||||
|
assert_eq!(opts.key_sources.len(), 1);
|
||||||
|
assert_eq!(opts.key_sources[0].label(), "test-source");
|
||||||
|
// Moved, not cloned — the spec is emptied (kills a copy-instead-of-move
|
||||||
|
// mutant, and confirms the take()).
|
||||||
|
assert!(spec.key_sources.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_clobber_caller_key_sources() {
|
||||||
|
let mut spec = KeySpec {
|
||||||
|
key_sources: vec![Box::new(TestSource)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = ScanOptions {
|
||||||
|
key_sources: vec![Box::new(TestSource), Box::new(TestSource)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let opts = forward_key_material(&mut spec, opts);
|
||||||
|
// Kills a mutant that flips `is_empty()` → always-overwrite.
|
||||||
|
assert_eq!(opts.key_sources.len(), 2);
|
||||||
|
// Caller's non-empty vec means the spec is left untouched.
|
||||||
|
assert_eq!(spec.key_sources.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyspec_default_is_all_empty() {
|
||||||
|
let spec = KeySpec::default();
|
||||||
|
assert!(spec.keydb_path.is_none());
|
||||||
|
assert!(spec.key_url.is_none());
|
||||||
|
assert!(spec.key_auth.is_none());
|
||||||
|
assert!(spec.credentials.is_none());
|
||||||
|
assert!(spec.key_sources.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── resolve_keys_for: sampling → ordered apply → bank → fetch ─────────────
|
||||||
|
|
||||||
|
/// A no-op reader — the resolve tests use discs with no titles, so no
|
||||||
|
/// sampling read fires; this satisfies the `&mut dyn SectorSource` seam.
|
||||||
|
struct NullReader;
|
||||||
|
impl SectorSource for NullReader {
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
fn read_sectors(&mut self, _: u32, _: u16, _: &mut [u8], _: bool) -> Result<usize> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source that hands back one terminal Unit Key.
|
||||||
|
struct HasUnitKey([u8; 16]);
|
||||||
|
impl KeySource for HasUnitKey {
|
||||||
|
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>> {
|
||||||
|
Ok(vec![UnitKey::new(0, self.0)])
|
||||||
|
}
|
||||||
|
fn label(&self) -> &'static str {
|
||||||
|
"has-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source with no key for this disc.
|
||||||
|
struct NoUnitKey;
|
||||||
|
impl KeySource for NoUnitKey {
|
||||||
|
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
fn label(&self) -> &'static str {
|
||||||
|
"empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal keyless AACS `Disc` — `inputs()` returns `Some`, so
|
||||||
|
/// `resolve_keys_for` proceeds to the sources. No titles (no sampling read).
|
||||||
|
fn aacs_disc() -> Disc {
|
||||||
|
Disc {
|
||||||
|
volume_id: "TEST".into(),
|
||||||
|
meta_title: None,
|
||||||
|
format: crate::DiscFormat::Uhd,
|
||||||
|
capacity_sectors: 0,
|
||||||
|
capacity_bytes: 0,
|
||||||
|
layers: 1,
|
||||||
|
titles: Vec::new(),
|
||||||
|
region: crate::disc::DiscRegion::Free,
|
||||||
|
aacs: Some(crate::disc::AacsState {
|
||||||
|
version: crate::aacs::mkb::AACS_MAJOR_UHD,
|
||||||
|
bus_encryption: false,
|
||||||
|
mkb_version: None,
|
||||||
|
disc_hash: "0xabc".into(),
|
||||||
|
key_source: crate::disc::KeyOrigin::KeyDb,
|
||||||
|
vuk: None,
|
||||||
|
unit_keys: Vec::new(),
|
||||||
|
read_data_key: None,
|
||||||
|
volume_id: [0u8; 16],
|
||||||
|
uk_ro: Vec::new(),
|
||||||
|
mkb: Vec::new(),
|
||||||
|
}),
|
||||||
|
css: None,
|
||||||
|
encrypted: true,
|
||||||
|
aacs_error: None,
|
||||||
|
css_error: None,
|
||||||
|
content_format: crate::ContentFormat::BdTs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn factory_of<S: KeySource + 'static>(make: fn() -> S) -> KeySourceFactory {
|
||||||
|
Arc::new(move || vec![Box::new(make()) as Box<dyn KeySource>])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The happy path: a source's Unit Key is BANKED onto the disc's AACS state
|
||||||
|
/// (so `decrypt_keys()` now yields it) and a `KeyFetch` is retained.
|
||||||
|
///
|
||||||
|
/// Mutation guard: if the banking step (`resolve_and_apply_traced`) is
|
||||||
|
/// dropped, `decrypt_keys()` stays `None` and this assertion fails.
|
||||||
|
#[test]
|
||||||
|
fn resolve_keys_for_banks_unit_key_and_builds_fetch() {
|
||||||
|
use crate::decrypt::DecryptKeys;
|
||||||
|
const K: [u8; 16] = [0x5A; 16];
|
||||||
|
let mut disc = aacs_disc();
|
||||||
|
let mut reader = NullReader;
|
||||||
|
|
||||||
|
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey(K)));
|
||||||
|
|
||||||
|
match disc.decrypt_keys() {
|
||||||
|
DecryptKeys::Aacs { unit_keys, .. } => {
|
||||||
|
// CPS-unit number is positional index + 1 (idx 0 → unit 1).
|
||||||
|
assert_eq!(unit_keys, vec![(1u32, K)], "the source's key is banked");
|
||||||
|
}
|
||||||
|
_ => panic!("expected banked AACS keys"),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
resolved.key_fetch.is_some(),
|
||||||
|
"an AACS disc always retains a read-time fetch"
|
||||||
|
);
|
||||||
|
// The trace recorded exactly one source, which resolved.
|
||||||
|
assert_eq!(resolved.trace.keys.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source with no key: nothing is banked (`decrypt_keys()` stays `None`),
|
||||||
|
/// but a `KeyFetch` is STILL built (the on-decrypt-miss path is wired
|
||||||
|
/// regardless of the up-front resolve succeeding).
|
||||||
|
#[test]
|
||||||
|
fn resolve_keys_for_no_key_leaves_disc_unkeyed_but_builds_fetch() {
|
||||||
|
use crate::decrypt::DecryptKeys;
|
||||||
|
let mut disc = aacs_disc();
|
||||||
|
let mut reader = NullReader;
|
||||||
|
|
||||||
|
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| NoUnitKey));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(disc.decrypt_keys(), DecryptKeys::None),
|
||||||
|
"no source key ⇒ disc stays unkeyed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resolved.key_fetch.is_some(),
|
||||||
|
"an AACS disc retains a fetch even when the up-front resolve misses"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A counting reader over zeros — records the highest LBA sampled so the test
|
||||||
|
/// can prove the LARGEST title's extent (not the small one) was read.
|
||||||
|
struct SamplingReader {
|
||||||
|
reads: u32,
|
||||||
|
max_lba: u32,
|
||||||
|
}
|
||||||
|
impl SectorSource for SamplingReader {
|
||||||
|
fn capacity_sectors(&self) -> u32 {
|
||||||
|
100_000
|
||||||
|
}
|
||||||
|
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result<usize> {
|
||||||
|
self.reads += 1;
|
||||||
|
self.max_lba = self.max_lba.max(lba);
|
||||||
|
let want = count as usize * 2048;
|
||||||
|
buf[..want].fill(0);
|
||||||
|
Ok(want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resolve_keys_for` samples the LARGEST title's ciphertext through the
|
||||||
|
/// reader when a source is configured (the `session.rs:90` sampling branch).
|
||||||
|
/// The other tests use a title-less disc (no sampling read), so this branch
|
||||||
|
/// was uncovered. With two titles and a non-empty source, the sampling read
|
||||||
|
/// fires against the LARGER title's extent.
|
||||||
|
#[test]
|
||||||
|
fn resolve_keys_for_samples_largest_title_through_reader() {
|
||||||
|
use crate::disc::{DiscTitle, Extent};
|
||||||
|
let mut disc = aacs_disc();
|
||||||
|
let mut small = DiscTitle::empty();
|
||||||
|
small.size_bytes = 1_000;
|
||||||
|
small.extents = vec![Extent {
|
||||||
|
start_lba: 100,
|
||||||
|
sector_count: 300,
|
||||||
|
}];
|
||||||
|
let mut large = DiscTitle::empty();
|
||||||
|
large.size_bytes = 9_000_000;
|
||||||
|
large.extents = vec![Extent {
|
||||||
|
start_lba: 9_000,
|
||||||
|
sector_count: 300,
|
||||||
|
}];
|
||||||
|
disc.titles = vec![small, large];
|
||||||
|
let mut reader = SamplingReader {
|
||||||
|
reads: 0,
|
||||||
|
max_lba: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-empty source ⇒ the sampling read is NOT skipped.
|
||||||
|
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16])));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reader.reads > 0,
|
||||||
|
"the largest title was sampled via the reader"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
reader.max_lba >= 9_000,
|
||||||
|
"sampling read the LARGER title's extent (lba>=9000), not the small one \
|
||||||
|
(max_lba={})",
|
||||||
|
reader.max_lba
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resolved.key_fetch.is_some(),
|
||||||
|
"an AACS disc still retains a read-time fetch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-AACS disc (CSS / unencrypted — `inputs()` is `None`): resolution is a
|
||||||
|
/// no-op. Empty trace, NO fetch, disc untouched. This is the out-of-the-box
|
||||||
|
/// CSS/None path that must keep working with no keydb.
|
||||||
|
#[test]
|
||||||
|
fn resolve_keys_for_non_aacs_disc_is_a_noop() {
|
||||||
|
use crate::decrypt::DecryptKeys;
|
||||||
|
let mut disc = aacs_disc();
|
||||||
|
disc.aacs = None; // now carries no AACS inputs
|
||||||
|
disc.encrypted = false;
|
||||||
|
let mut reader = NullReader;
|
||||||
|
|
||||||
|
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16])));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
resolved.trace.keys.is_empty() && resolved.trace.unlock.is_empty(),
|
||||||
|
"a non-AACS disc yields an empty trace"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resolved.key_fetch.is_none(),
|
||||||
|
"a non-AACS disc has nothing to fetch"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(disc.decrypt_keys(), DecryptKeys::None),
|
||||||
|
"the disc is left untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `resolve_keys` called before `scan` (disc slot still `None`) must return the
|
||||||
|
/// clean typed `DeviceNotReady` guard, never reach the `.expect("disc present
|
||||||
|
/// (checked above)")` below it and panic.
|
||||||
|
///
|
||||||
|
/// Mutation: change the `if self.disc.is_none()` guard to `.expect()`/panic
|
||||||
|
/// (e.g. drop the early return) → this test panics instead of getting an Err.
|
||||||
|
#[test]
|
||||||
|
fn resolve_keys_before_scan_is_clean_device_not_ready() {
|
||||||
|
let mut session = DiscSession::from_parts_for_test(None, None, None);
|
||||||
|
let err = session
|
||||||
|
.resolve_keys(factory_of(|| HasUnitKey([1; 16])))
|
||||||
|
.expect_err("resolve_keys before scan must error, not panic");
|
||||||
|
assert!(
|
||||||
|
matches!(err, Error::DeviceNotReady { .. }),
|
||||||
|
"expected DeviceNotReady, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
//! Drive speed constants.
|
|
||||||
|
|
||||||
/// Common optical drive speeds with KB/s values for SET_CD_SPEED.
|
|
||||||
///
|
|
||||||
/// Ordering is by [`to_kbps`](Self::to_kbps) throughput, not declaration
|
|
||||||
/// order — `PartialOrd`/`Ord` are implemented manually so e.g.
|
|
||||||
/// `DVD1x < BD1x` (1385 < 4500 KB/s) holds. A naive derive would have
|
|
||||||
/// ordered by variant position, making the slow DVD speeds sort above the
|
|
||||||
/// fast BD speeds. `Max` (0xFFFF) sorts highest, as intended.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DriveSpeed {
|
|
||||||
BD1x,
|
|
||||||
BD2x,
|
|
||||||
BD4x,
|
|
||||||
BD6x,
|
|
||||||
BD8x,
|
|
||||||
BD10x,
|
|
||||||
BD12x,
|
|
||||||
DVD1x,
|
|
||||||
DVD2x,
|
|
||||||
DVD4x,
|
|
||||||
DVD8x,
|
|
||||||
DVD16x,
|
|
||||||
Max,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DriveSpeed {
|
|
||||||
/// Throughput in KB/s for the SET_CD_SPEED CDB. `Max` maps to the
|
|
||||||
/// 0xFFFF sentinel that tells the drive to use its maximum speed.
|
|
||||||
pub fn to_kbps(self) -> u16 {
|
|
||||||
match self {
|
|
||||||
DriveSpeed::BD1x => 4_500,
|
|
||||||
DriveSpeed::BD2x => 9_000,
|
|
||||||
DriveSpeed::BD4x => 18_000,
|
|
||||||
DriveSpeed::BD6x => 27_000,
|
|
||||||
DriveSpeed::BD8x => 36_000,
|
|
||||||
DriveSpeed::BD10x => 45_000,
|
|
||||||
DriveSpeed::BD12x => 54_000,
|
|
||||||
DriveSpeed::DVD1x => 1_385,
|
|
||||||
DriveSpeed::DVD2x => 2_770,
|
|
||||||
DriveSpeed::DVD4x => 5_540,
|
|
||||||
DriveSpeed::DVD8x => 11_080,
|
|
||||||
DriveSpeed::DVD16x => 22_160,
|
|
||||||
DriveSpeed::Max => 0xFFFF,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialOrd for DriveSpeed {
|
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
||||||
Some(self.cmp(other))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ord for DriveSpeed {
|
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
||||||
self.to_kbps().cmp(&other.to_kbps())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for DriveSpeed {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
// `Max` is the "let the drive pick its maximum" sentinel; printing
|
|
||||||
// its 0xFFFF KB/s value would read as a real (absurd) throughput.
|
|
||||||
match self {
|
|
||||||
DriveSpeed::Max => write!(f, "Max"),
|
|
||||||
_ => write!(f, "{:?} ({} KB/s)", self, self.to_kbps()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ordering_is_by_throughput_not_declaration() {
|
|
||||||
assert!(DriveSpeed::DVD1x < DriveSpeed::BD1x);
|
|
||||||
assert!(DriveSpeed::DVD16x < DriveSpeed::BD8x);
|
|
||||||
assert!(DriveSpeed::BD12x < DriveSpeed::Max);
|
|
||||||
let mut v = [DriveSpeed::Max, DriveSpeed::DVD1x, DriveSpeed::BD4x];
|
|
||||||
v.sort();
|
|
||||||
assert_eq!(v, [DriveSpeed::DVD1x, DriveSpeed::BD4x, DriveSpeed::Max]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn max_display_omits_sentinel_value() {
|
|
||||||
assert_eq!(DriveSpeed::Max.to_string(), "Max");
|
|
||||||
assert!(DriveSpeed::BD1x.to_string().contains("4500 KB/s"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+59
-15
@@ -731,11 +731,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
|
|||||||
|
|
||||||
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
||||||
if tag_id != 2 {
|
if tag_id != 2 {
|
||||||
return Err(Error::DiscRead {
|
// Sector 256 read fine but carries no Anchor Volume Descriptor Pointer:
|
||||||
sector: 256,
|
// this is deterministically not a UDF disc, not a transient read fault.
|
||||||
status: None,
|
return Err(Error::UdfNotFilesystem);
|
||||||
sense: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
|
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
|
||||||
@@ -776,11 +774,8 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if partition_start == 0 {
|
if partition_start == 0 {
|
||||||
return Err(Error::DiscRead {
|
// No Partition Descriptor found in the VDS: structurally not a UDF disc.
|
||||||
sector: 0,
|
return Err(Error::UdfNotFilesystem);
|
||||||
status: None,
|
|
||||||
sense: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Parse partition maps from LVD to find metadata partition
|
// Step 3: Parse partition maps from LVD to find metadata partition
|
||||||
@@ -871,11 +866,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
|
|||||||
|
|
||||||
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
|
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
|
||||||
if fsd_tag != 256 {
|
if fsd_tag != 256 {
|
||||||
return Err(Error::DiscRead {
|
// The metadata sector read fine but carries no File Set Descriptor:
|
||||||
sector: metadata_start as u64,
|
// structurally not a UDF disc, not a transient read fault.
|
||||||
status: None,
|
return Err(Error::UdfNotFilesystem);
|
||||||
sense: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Root Directory ICB: long_ad at FSD offset 400
|
// Root Directory ICB: long_ad at FSD offset 400
|
||||||
@@ -1790,6 +1783,57 @@ mod tests {
|
|||||||
assert!(inf[2048..].iter().all(|&b| b == 0xBB));
|
assert!(inf[2048..].iter().all(|&b| b == 0xBB));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_aacs_inputs_falls_through_to_hddvd_any_dir() {
|
||||||
|
// HD DVD keeps its AACS material under /ANY!/ (VTKF000.AACS title-key
|
||||||
|
// file + MKBROM.AACS), NOT /AACS/Unit_Key_RO.inf + /AACS/MKB_RO.inf. The
|
||||||
|
// role-based candidate lists must fall through to the /ANY!/ files with
|
||||||
|
// NO disc-type branch, so the online keyserver POST carries the HD DVD
|
||||||
|
// title-key file (magic "DVD_HD_V_TKF") as inf_b64 + MKBROM as mkb_b64 —
|
||||||
|
// the server then classifies the disc as HD DVD by that magic.
|
||||||
|
let any = DirEntry {
|
||||||
|
name: "ANY!".to_string(),
|
||||||
|
is_dir: true,
|
||||||
|
meta_lba: 0,
|
||||||
|
size: 0,
|
||||||
|
entries: vec![
|
||||||
|
file_entry("VTKF000.AACS", 5, 2048),
|
||||||
|
file_entry("MKBROM.AACS", 7, 2048),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let root = DirEntry {
|
||||||
|
name: String::new(),
|
||||||
|
is_dir: true,
|
||||||
|
meta_lba: 0,
|
||||||
|
size: 0,
|
||||||
|
entries: vec![any], // deliberately NO /AACS/ dir
|
||||||
|
};
|
||||||
|
let mut reader = MapReader::new();
|
||||||
|
// VTKF000.AACS: one extent whose content opens with the HD DVD magic.
|
||||||
|
let mut vtkf = [0u8; 2048];
|
||||||
|
vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF");
|
||||||
|
reader.put(5, build_efe_long(2048, &[(0, 2048, 10)]));
|
||||||
|
reader.put(10, vtkf);
|
||||||
|
// MKBROM.AACS: one extent with a type-0x10 AACS-1.0 (HD DVD) version record.
|
||||||
|
let mut mkb = [0u8; 2048];
|
||||||
|
mkb[..12].copy_from_slice(&[
|
||||||
|
0x10, 0x00, 0x00, 0x0C, 0x00, 0x04, 0x10, 0x03, 0x00, 0x00, 0x00, 0x03,
|
||||||
|
]);
|
||||||
|
reader.put(7, build_efe_long(2048, &[(0, 2048, 50)]));
|
||||||
|
reader.put(50, mkb);
|
||||||
|
|
||||||
|
let fs = fs_with(0, 0, root);
|
||||||
|
let (inf, _mkb, _version) =
|
||||||
|
crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
|
||||||
|
.expect("read_aacs_inputs must source the HD DVD /ANY!/ files");
|
||||||
|
assert_eq!(
|
||||||
|
&inf[..12],
|
||||||
|
b"DVD_HD_V_TKF",
|
||||||
|
"inf must be the HD DVD VTKF (its magic), sourced from /ANY!/ via the \
|
||||||
|
candidate fall-through — not /AACS/Unit_Key_RO.inf"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_ranges_saturates_near_u32_max() {
|
fn merge_ranges_saturates_near_u32_max() {
|
||||||
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.
|
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.
|
||||||
|
|||||||
+67
-61
@@ -131,16 +131,15 @@ fn aacs_decrypt_unit_roundtrip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify it looks encrypted (body TS syncs scrambled)
|
// Verify it looks encrypted (body TS syncs scrambled)
|
||||||
assert!(aacs::content::ts_sync_destroyed(&plain));
|
assert!(!aacs::content::is_clean(
|
||||||
|
&plain,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Now decrypt
|
// Now decrypt
|
||||||
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
|
aacs::content::decrypt_unit(&mut plain, &unit_key);
|
||||||
assert!(
|
assert!(
|
||||||
result,
|
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"decrypt_unit should return true on valid encrypted unit"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!aacs::content::ts_sync_destroyed(&plain),
|
|
||||||
"decrypted unit should read as clear (TS syncs restored)"
|
"decrypted unit should read as clear (TS syncs restored)"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -258,9 +257,9 @@ fn aacs_vuk_derivation_roundtrip() {
|
|||||||
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: aacs_ts_sync_destroyed detects scrambled units via the raw TS syncs.
|
/// Test: `is_clean` distinguishes clean vs scrambled units via the TS proof floor.
|
||||||
#[test]
|
#[test]
|
||||||
fn aacs_ts_sync_destroyed_detection() {
|
fn aacs_is_clean_detection() {
|
||||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||||
// scrambled. (Flag bits play no role.)
|
// scrambled. (Flag bits play no role.)
|
||||||
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
@@ -270,7 +269,7 @@ fn aacs_ts_sync_destroyed_detection() {
|
|||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&clear),
|
aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"clear unit (syncs intact) must not be scrambled"
|
"clear unit (syncs intact) must not be scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -279,32 +278,35 @@ fn aacs_ts_sync_destroyed_detection() {
|
|||||||
flagged[0] = 0xC0; // copy-control bits
|
flagged[0] = 0xC0; // copy-control bits
|
||||||
flagged[7] = 0xC0; // TSC bits
|
flagged[7] = 0xC0; // TSC bits
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&flagged),
|
aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"flag bits must not be read as encryption"
|
"flag bits must not be read as encryption"
|
||||||
);
|
);
|
||||||
|
|
||||||
// A scrambled body (syncs destroyed) → scrambled.
|
// A scrambled body (syncs destroyed) → scrambled.
|
||||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
assert!(
|
assert!(
|
||||||
aacs::content::ts_sync_destroyed(&scrambled),
|
!aacs::content::is_clean(&scrambled, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"unit with no intact TS syncs must read as scrambled"
|
"unit with no intact TS syncs must read as scrambled"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Too short
|
// Too short
|
||||||
let short = vec![0xFFu8; 100];
|
let short = vec![0xFFu8; 100];
|
||||||
assert!(
|
assert!(
|
||||||
!aacs::content::ts_sync_destroyed(&short),
|
aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
"short buffer should not be detected"
|
"short buffer should not be detected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: aacs_decrypt_unit_unencrypted_passthrough
|
/// Test: aacs_clear_unit_reports_not_encrypted
|
||||||
///
|
///
|
||||||
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
|
/// `decrypt_unit` is now PURE (applies the key unconditionally). The "leave a
|
||||||
|
/// clear unit untouched" policy lives at the caller's gate `aacs_unit_encrypted`:
|
||||||
|
/// a CPI-clear unit reports not-encrypted, so the caller never hands it to
|
||||||
|
/// decrypt_unit.
|
||||||
#[test]
|
#[test]
|
||||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
fn aacs_clear_unit_reports_not_encrypted() {
|
||||||
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
|
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
|
// Intact TS syncs every 192 bytes → not scrambled.
|
||||||
let mut off = 4;
|
let mut off = 4;
|
||||||
while off < aacs::content::ALIGNED_UNIT_LEN {
|
while off < aacs::content::ALIGNED_UNIT_LEN {
|
||||||
unit[off] = 0x47;
|
unit[off] = 0x47;
|
||||||
@@ -312,13 +314,15 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
|
|||||||
}
|
}
|
||||||
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
|
||||||
unit[0] &= 0x3F;
|
unit[0] &= 0x3F;
|
||||||
let original = unit.clone();
|
|
||||||
let key = [0xAA; 16];
|
|
||||||
|
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
let result = aacs::content::decrypt_unit(&mut unit, &key);
|
&unit,
|
||||||
assert!(result, "clear unit should return true");
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
));
|
||||||
|
assert!(
|
||||||
|
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
|
||||||
|
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AACS cross-validation with independent AES implementation ──────────────
|
// ── AACS cross-validation with independent AES implementation ──────────────
|
||||||
@@ -413,11 +417,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// -- Decrypt with the library --
|
// -- Decrypt with the library --
|
||||||
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||||
assert!(
|
|
||||||
ok,
|
|
||||||
"decrypt_unit returned false (TS sync verification failed)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -458,44 +458,14 @@ fn aacs_cross_validation_alternate_key() {
|
|||||||
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
|
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(aacs::content::decrypt_unit(&mut plaintext, &unit_key));
|
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
|
||||||
|
|
||||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||||
assert_eq!(&plaintext[..], &expected[..]);
|
assert_eq!(&plaintext[..], &expected[..]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
// (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate
|
||||||
/// per-sector to bytes 16..2048 (bus encryption layer).
|
// in `aacs::content`'s unit tests, not here.)
|
||||||
#[test]
|
|
||||||
fn aacs_bus_decrypt_cross_validation() {
|
|
||||||
let read_data_key: [u8; 16] = [
|
|
||||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
|
||||||
0x00,
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
for i in 0..aacs::content::ALIGNED_UNIT_LEN {
|
|
||||||
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
|
|
||||||
}
|
|
||||||
let expected = plaintext.clone();
|
|
||||||
|
|
||||||
// Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector
|
|
||||||
for sector_start in (0..aacs::content::ALIGNED_UNIT_LEN).step_by(2048) {
|
|
||||||
ref_aes_cbc_encrypt(
|
|
||||||
&read_data_key,
|
|
||||||
&CROSS_AACS_IV,
|
|
||||||
&mut plaintext[sector_start + 16..sector_start + 2048],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_ne!(&plaintext[16..32], &expected[16..32]);
|
|
||||||
|
|
||||||
aacs::content::decrypt_bus(&mut plaintext, &read_data_key);
|
|
||||||
assert_eq!(
|
|
||||||
plaintext, expected,
|
|
||||||
"bus decrypt did not recover original plaintext"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
|
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -663,6 +633,28 @@ fn css_stevenson_attack_validates_cracked_key() {
|
|||||||
This is expected: synthetic sectors lack the TAB1 output encoding \
|
This is expected: synthetic sectors lack the TAB1 output encoding \
|
||||||
present in real CSS-encrypted DVD sectors."
|
present in real CSS-encrypted DVD sectors."
|
||||||
);
|
);
|
||||||
|
// Never let this test pass vacuously: when the attack can't converge on
|
||||||
|
// synthetic data, still assert always-true properties of the CSS keystream
|
||||||
|
// so a real regression is caught on every run — descramble_sector is
|
||||||
|
// DETERMINISTIC (same key/seed/data → same output) and NON-TRIVIAL (it
|
||||||
|
// actually transforms the payload, not a silent no-op).
|
||||||
|
for (key, seed) in candidates {
|
||||||
|
let mut base = vec![0x00u8; 2048];
|
||||||
|
base[0x14] = 0x30;
|
||||||
|
base[0x54..0x59].copy_from_slice(seed);
|
||||||
|
base[0x80..0x8A]
|
||||||
|
.copy_from_slice(&[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21]);
|
||||||
|
let mut a = base.clone();
|
||||||
|
let mut b = base.clone();
|
||||||
|
css::lfsr::descramble_sector(key, &mut a);
|
||||||
|
css::lfsr::descramble_sector(key, &mut b);
|
||||||
|
assert_eq!(a, b, "descramble must be deterministic for key={key:02X?}");
|
||||||
|
assert_ne!(
|
||||||
|
&a[0x80..2048],
|
||||||
|
&base[0x80..2048],
|
||||||
|
"descramble must transform the payload for key={key:02X?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +699,20 @@ fn css_recover_title_key_with_exact_plaintext() {
|
|||||||
The LFSR0 recovery phase may not converge for this combination.",
|
The LFSR0 recovery phase may not converge for this combination.",
|
||||||
title_key, seed
|
title_key, seed
|
||||||
);
|
);
|
||||||
|
// Never pass vacuously: when LFSR0 recovery can't converge on this
|
||||||
|
// synthetic sector, still assert always-true properties of the cipher so
|
||||||
|
// a real regression is caught on every run — descramble_sector is
|
||||||
|
// DETERMINISTIC and NON-TRIVIAL (actually transforms the payload).
|
||||||
|
let mut a = original.clone();
|
||||||
|
let mut b = original.clone();
|
||||||
|
css::lfsr::descramble_sector(&title_key, &mut a);
|
||||||
|
css::lfsr::descramble_sector(&title_key, &mut b);
|
||||||
|
assert_eq!(a, b, "descramble must be deterministic");
|
||||||
|
assert_ne!(
|
||||||
|
&a[0x80..2048],
|
||||||
|
&original[0x80..2048],
|
||||||
|
"descramble must transform the payload"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
|
|||||||
DecryptKeys::None,
|
DecryptKeys::None,
|
||||||
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
|
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
|
||||||
ContentFormat::MpegPs,
|
ContentFormat::MpegPs,
|
||||||
|
false,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -1,826 +0,0 @@
|
|||||||
//! Integration tests for progress reporting, halt behavior, drop safety,
|
|
||||||
//! and the file-backed sector reader round trip.
|
|
||||||
|
|
||||||
use libfreemkv::disc::{CopyOptions, DiscRegion};
|
|
||||||
use libfreemkv::error::Result;
|
|
||||||
use libfreemkv::pes::Stream as PesStream;
|
|
||||||
use libfreemkv::{
|
|
||||||
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorSource,
|
|
||||||
SectorSource,
|
|
||||||
};
|
|
||||||
use std::io::Write;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
const SECTOR_SIZE: usize = 2048;
|
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Returns zeroed sectors. Always succeeds. Counts each call.
|
|
||||||
struct ZeroSectorReader {
|
|
||||||
capacity: u32,
|
|
||||||
calls: Arc<AtomicU64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ZeroSectorReader {
|
|
||||||
fn new(capacity: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
capacity,
|
|
||||||
calls: Arc::new(AtomicU64::new(0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SectorSource for ZeroSectorReader {
|
|
||||||
fn read_sectors(
|
|
||||||
&mut self,
|
|
||||||
_lba: u32,
|
|
||||||
count: u16,
|
|
||||||
buf: &mut [u8],
|
|
||||||
_recovery: bool,
|
|
||||||
) -> Result<usize> {
|
|
||||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let bytes = count as usize * SECTOR_SIZE;
|
|
||||||
buf[..bytes].fill(0);
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capacity_sectors(&self) -> u32 {
|
|
||||||
self.capacity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like ZeroSectorReader but sleeps a configurable duration per call.
|
|
||||||
/// Used by the halt test so the copy takes >1 s.
|
|
||||||
struct SlowZeroSectorReader {
|
|
||||||
capacity: u32,
|
|
||||||
sleep_per_call: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SlowZeroSectorReader {
|
|
||||||
fn new(capacity: u32, sleep_per_call: Duration) -> Self {
|
|
||||||
Self {
|
|
||||||
capacity,
|
|
||||||
sleep_per_call,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SectorSource for SlowZeroSectorReader {
|
|
||||||
fn read_sectors(
|
|
||||||
&mut self,
|
|
||||||
_lba: u32,
|
|
||||||
count: u16,
|
|
||||||
buf: &mut [u8],
|
|
||||||
_recovery: bool,
|
|
||||||
) -> Result<usize> {
|
|
||||||
std::thread::sleep(self.sleep_per_call);
|
|
||||||
let bytes = count as usize * SECTOR_SIZE;
|
|
||||||
buf[..bytes].fill(0);
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capacity_sectors(&self) -> u32 {
|
|
||||||
self.capacity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a Disc instance with a known capacity, no titles, no encryption.
|
|
||||||
/// Sufficient for `Disc::copy` (which only uses capacity_sectors + decrypt keys).
|
|
||||||
fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
|
||||||
Disc {
|
|
||||||
volume_id: String::new(),
|
|
||||||
meta_title: None,
|
|
||||||
format: DiscFormat::BluRay,
|
|
||||||
capacity_sectors,
|
|
||||||
capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64,
|
|
||||||
layers: 1,
|
|
||||||
titles: Vec::new(),
|
|
||||||
region: DiscRegion::Free,
|
|
||||||
aacs: None,
|
|
||||||
css: None,
|
|
||||||
encrypted: false,
|
|
||||||
aacs_error: None,
|
|
||||||
css_error: None,
|
|
||||||
content_format: ContentFormat::BdTs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a DiscTitle with a single extent of `sector_count` sectors and no
|
|
||||||
/// streams (DiscStream still iterates sectors and would emit BytesRead).
|
|
||||||
fn synthetic_title(sector_count: u32) -> DiscTitle {
|
|
||||||
DiscTitle {
|
|
||||||
playlist: String::new(),
|
|
||||||
playlist_id: 0,
|
|
||||||
duration_secs: 0.0,
|
|
||||||
size_bytes: sector_count as u64 * SECTOR_SIZE as u64,
|
|
||||||
clips: Vec::new(),
|
|
||||||
streams: Vec::new(),
|
|
||||||
chapters: Vec::new(),
|
|
||||||
extents: vec![Extent {
|
|
||||||
start_lba: 0,
|
|
||||||
sector_count,
|
|
||||||
}],
|
|
||||||
content_format: ContentFormat::BdTs,
|
|
||||||
codec_privates: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1. BytesRead events emitted during disc copy ──────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_bytes_read_emitted_during_disc_copy() {
|
|
||||||
// Build a tiny synthetic disc and stream it through DiscStream.
|
|
||||||
let reader = ZeroSectorReader::new(64);
|
|
||||||
let title = synthetic_title(64);
|
|
||||||
let keys = libfreemkv::DecryptKeys::None;
|
|
||||||
|
|
||||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
|
||||||
|
|
||||||
let count = Arc::new(AtomicU64::new(0));
|
|
||||||
let count_cb = count.clone();
|
|
||||||
stream.on_event(move |ev| {
|
|
||||||
if let EventKind::BytesRead { .. } = ev.kind {
|
|
||||||
count_cb.fetch_add(1, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drive the stream to EOF. With no streams configured, read() returns
|
|
||||||
// Ok(None) once all extents are exhausted.
|
|
||||||
loop {
|
|
||||||
match stream.read() {
|
|
||||||
Ok(Some(_frame)) => {}
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(e) => panic!("stream read failed: {e:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = count.load(Ordering::Relaxed);
|
|
||||||
assert!(
|
|
||||||
n > 0,
|
|
||||||
"expected at least one BytesRead event during disc copy, got {n}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. Disc::copy on_progress callback fires (regression guard) ───────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_disc_copy_progress_callback_fires() {
|
|
||||||
let disc = synthetic_disc(64);
|
|
||||||
let mut reader = ZeroSectorReader::new(64);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp); // we want the path, not the file handle
|
|
||||||
|
|
||||||
let calls = Arc::new(AtomicU64::new(0));
|
|
||||||
let last_bytes = Arc::new(AtomicU64::new(0));
|
|
||||||
|
|
||||||
struct CountingReporter {
|
|
||||||
calls: Arc<AtomicU64>,
|
|
||||||
last_bytes: Arc<AtomicU64>,
|
|
||||||
}
|
|
||||||
impl libfreemkv::progress::Progress for CountingReporter {
|
|
||||||
fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool {
|
|
||||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
|
||||||
self.last_bytes.store(p.bytes_good_total, Ordering::Relaxed);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let reporter = CountingReporter {
|
|
||||||
calls: calls.clone(),
|
|
||||||
last_bytes: last_bytes.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
progress: Some(&reporter),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = disc.copy(&mut reader, &iso_path, &opts).expect("copy ok");
|
|
||||||
|
|
||||||
// Cleanup any sidecar mapfile + ISO before assertions.
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
||||||
|
|
||||||
assert!(result.complete, "copy should be complete");
|
|
||||||
let n = calls.load(Ordering::Relaxed);
|
|
||||||
let last = last_bytes.load(Ordering::Relaxed);
|
|
||||||
assert!(n > 0, "on_progress should fire at least once, got {n}");
|
|
||||||
assert!(
|
|
||||||
last > 0,
|
|
||||||
"final progress bytes should be non-zero, got {last}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. Halt aborts disc copy promptly ─────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_halt_aborts_disc_copy_promptly() {
|
|
||||||
// 6000 sectors, 60-sector batches → 100 read_sectors() calls.
|
|
||||||
// 10 ms sleep per call → ~1 s total without halt.
|
|
||||||
let capacity_sectors: u32 = 6000;
|
|
||||||
let mut reader = SlowZeroSectorReader::new(capacity_sectors, Duration::from_millis(10));
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let halt = Arc::new(AtomicBool::new(false));
|
|
||||||
let halt_for_thread = halt.clone();
|
|
||||||
let iso_path_for_thread = iso_path.clone();
|
|
||||||
|
|
||||||
let join = std::thread::spawn(move || {
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
halt: Some(halt_for_thread),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let t0 = Instant::now();
|
|
||||||
let res = disc.copy(&mut reader, &iso_path_for_thread, &opts);
|
|
||||||
(res, t0.elapsed())
|
|
||||||
});
|
|
||||||
|
|
||||||
// Let copy run, then halt.
|
|
||||||
std::thread::sleep(Duration::from_millis(200));
|
|
||||||
halt.store(true, Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Bound the join: should exit far before the full 1 s otherwise needed.
|
|
||||||
let started = Instant::now();
|
|
||||||
let mut joined = None;
|
|
||||||
while started.elapsed() < Duration::from_millis(2000) {
|
|
||||||
if join.is_finished() {
|
|
||||||
joined = Some(join.join().expect("thread join"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(20));
|
|
||||||
}
|
|
||||||
let (result, elapsed) = joined.expect("copy thread did not exit within 2s of halt");
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
||||||
|
|
||||||
let copy_result = result.expect("copy returns Ok with halted=true on halt");
|
|
||||||
assert!(
|
|
||||||
copy_result.halted,
|
|
||||||
"copy_result.halted should be true after halt"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!copy_result.complete,
|
|
||||||
"copy_result.complete should be false when halted"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
elapsed < Duration::from_millis(2000),
|
|
||||||
"copy thread exit elapsed {elapsed:?} exceeded 2s"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. DiscStream Drop does not panic or block ────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_drop_impls_do_not_panic_or_block() {
|
|
||||||
let reader = ZeroSectorReader::new(64);
|
|
||||||
let title = synthetic_title(64);
|
|
||||||
let keys = libfreemkv::DecryptKeys::None;
|
|
||||||
let stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
|
||||||
|
|
||||||
// Drop on a worker thread; main thread enforces the timeout.
|
|
||||||
let handle = std::thread::spawn(move || {
|
|
||||||
drop(stream);
|
|
||||||
});
|
|
||||||
|
|
||||||
let started = Instant::now();
|
|
||||||
while started.elapsed() < Duration::from_millis(100) {
|
|
||||||
if handle.is_finished() {
|
|
||||||
handle.join().expect("drop thread join");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(5));
|
|
||||||
}
|
|
||||||
panic!("DiscStream drop did not complete within 100ms");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 5. FileSectorSource round trip ────────────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_file_sector_reader_round_trip() {
|
|
||||||
// Build 8 sectors of pseudo-random bytes (sector-aligned).
|
|
||||||
const N_SECTORS: usize = 8;
|
|
||||||
let mut data = vec![0u8; N_SECTORS * SECTOR_SIZE];
|
|
||||||
for (i, b) in data.iter_mut().enumerate() {
|
|
||||||
// Cheap PRNG: just a multiplicative pattern, deterministic for asserts.
|
|
||||||
*b = ((i as u64).wrapping_mul(2654435761) >> 16) as u8;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
||||||
tmp.write_all(&data).expect("write data");
|
|
||||||
tmp.flush().expect("flush");
|
|
||||||
|
|
||||||
let path = tmp.path().to_path_buf();
|
|
||||||
let mut fsr = FileSectorSource::open(&path).expect("open FileSectorSource");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
fsr.capacity_sectors(),
|
|
||||||
N_SECTORS as u32,
|
|
||||||
"capacity mismatch"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Read each sector individually and compare.
|
|
||||||
let mut buf = vec![0u8; SECTOR_SIZE];
|
|
||||||
for lba in 0..N_SECTORS as u32 {
|
|
||||||
let n = fsr
|
|
||||||
.read_sectors(lba, 1, &mut buf, false)
|
|
||||||
.expect("read_sectors");
|
|
||||||
assert_eq!(n, SECTOR_SIZE);
|
|
||||||
let off = lba as usize * SECTOR_SIZE;
|
|
||||||
assert_eq!(
|
|
||||||
&buf[..],
|
|
||||||
&data[off..off + SECTOR_SIZE],
|
|
||||||
"sector {lba} mismatch"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read all sectors at once and compare.
|
|
||||||
let mut all = vec![0u8; N_SECTORS * SECTOR_SIZE];
|
|
||||||
let n = fsr
|
|
||||||
.read_sectors(0, N_SECTORS as u16, &mut all, false)
|
|
||||||
.expect("read all sectors");
|
|
||||||
assert_eq!(n, N_SECTORS * SECTOR_SIZE);
|
|
||||||
assert_eq!(all, data, "bulk read mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 6. Pass 1 sweeps the entire disc even when every read fails ───────────
|
|
||||||
//
|
|
||||||
// Per RIP_DESIGN.md §2.1 + §3: Disc::copy must reach the end of the disc
|
|
||||||
// regardless of how many reads fail. The only legitimate early exit is the
|
|
||||||
// halt flag. With `skip_on_error` and a reader that returns
|
|
||||||
// Err for every read, Pass 1 must:
|
|
||||||
// - mark every sector NonTrimmed (so Pass 2 can retry them)
|
|
||||||
// - return cleanly (no panic, no hang)
|
|
||||||
// - bytes_good = 0
|
|
||||||
// - bytes_pending = total_bytes (NonTrimmed counts as pending in mapfile
|
|
||||||
// accounting; see disc/mapfile.rs::stats)
|
|
||||||
// - bytes_unreadable = 0 (only Pass 2 marks Unreadable)
|
|
||||||
// - complete = false (work remains for Pass 2)
|
|
||||||
// - halted = false (no user stop)
|
|
||||||
// - ISO file is `total_bytes` size on disk (sparse zeros)
|
|
||||||
|
|
||||||
/// Reader that returns Err for every read. Optionally signals a halt
|
|
||||||
/// flag on the first read so tests can exercise the halt-during-skip-forward
|
|
||||||
/// path deterministically (no wallclock dependency).
|
|
||||||
struct FailingSectorReader {
|
|
||||||
capacity: u32,
|
|
||||||
/// If set, signals halt on the first `read_sectors` call. Cleared after
|
|
||||||
/// the first signal so subsequent reads are plain Err.
|
|
||||||
halt_on_first_read: Option<Arc<AtomicBool>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FailingSectorReader {
|
|
||||||
fn new(capacity: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
capacity,
|
|
||||||
halt_on_first_read: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_halt_on_first_read(capacity: u32, halt: Arc<AtomicBool>) -> Self {
|
|
||||||
Self {
|
|
||||||
capacity,
|
|
||||||
halt_on_first_read: Some(halt),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SectorSource for FailingSectorReader {
|
|
||||||
fn read_sectors(
|
|
||||||
&mut self,
|
|
||||||
_lba: u32,
|
|
||||||
_count: u16,
|
|
||||||
_buf: &mut [u8],
|
|
||||||
_recovery: bool,
|
|
||||||
) -> Result<usize> {
|
|
||||||
if let Some(h) = self.halt_on_first_read.take() {
|
|
||||||
h.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
// Model what a real damaged-disc read returns: CHECK CONDITION +
|
|
||||||
// MEDIUM ERROR (sense_key 3, ASC 0x11 UNRECOVERED READ ERROR,
|
|
||||||
// ASCQ 0x05 L-EC UNCORRECTABLE). Disc::copy's hysteresis must
|
|
||||||
// engage on this — `Error::DiscRead` is libfreemkv's own
|
|
||||||
// post-classification signal, not what a real reader emits.
|
|
||||||
Err(libfreemkv::error::Error::ScsiError {
|
|
||||||
opcode: libfreemkv::scsi::SCSI_READ_10,
|
|
||||||
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
|
|
||||||
sense: Some(libfreemkv::ScsiSense {
|
|
||||||
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x05,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capacity_sectors(&self) -> u32 {
|
|
||||||
self.capacity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_disc_copy_completes_full_disc_with_failing_reader() {
|
|
||||||
// 1024 sectors = 2 MB. Reader fails every read. With skip_on_error +
|
|
||||||
// skip_on_error, Pass 1 must mark every sector NonTrimmed and return
|
|
||||||
// cleanly — no bail, no hang.
|
|
||||||
let capacity_sectors: u32 = 1024;
|
|
||||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
||||||
|
|
||||||
let mut reader = FailingSectorReader::new(capacity_sectors);
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let t0 = Instant::now();
|
|
||||||
let result = disc
|
|
||||||
.copy(&mut reader, &iso_path, &opts)
|
|
||||||
.expect("copy returns Ok");
|
|
||||||
let elapsed = t0.elapsed();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
||||||
|
|
||||||
// Hard bound — Pass 1 must NOT infinite-loop on a fully-failing
|
|
||||||
// reader. The threshold accommodates the 2026-05-10 wedge-
|
|
||||||
// avoidance pause (PASS_1_FAIL_PAUSE_SECS = 5 s on each failed
|
|
||||||
// batch). With batch=32 and 1024 sectors that's up to ~5 batch
|
|
||||||
// failures + a few damage-jump pauses before fast-trigger jumps
|
|
||||||
// us past end-of-disc — well-bounded total, ~20-30 s typical.
|
|
||||||
// The point of this test is "finishes cleanly, not infinitely",
|
|
||||||
// not "completes in milliseconds."
|
|
||||||
assert!(
|
|
||||||
elapsed < Duration::from_secs(60),
|
|
||||||
"Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 60 s (not infinite)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Per RIP_DESIGN.md §2.1: Pass 1 must reach end of disc regardless of
|
|
||||||
// read outcomes.
|
|
||||||
assert_eq!(
|
|
||||||
result.bytes_total, total_bytes,
|
|
||||||
"bytes_total must match disc capacity"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.bytes_good, 0,
|
|
||||||
"no reads succeeded, bytes_good must be 0"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.bytes_unreadable, 0,
|
|
||||||
"Pass 1 does not mark Unreadable; only Pass 2 (Disc::patch) does"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.bytes_pending, total_bytes,
|
|
||||||
"every sector must be NonTrimmed → counted as pending. \
|
|
||||||
Got bytes_pending={} of total {}",
|
|
||||||
result.bytes_pending, total_bytes
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!result.complete,
|
|
||||||
"complete=false because NonTrimmed regions remain (work for Pass 2)"
|
|
||||||
);
|
|
||||||
assert!(!result.halted, "no halt was set; halted must be false");
|
|
||||||
|
|
||||||
// ISO file should be the full disc size on disk (sparse zeros where
|
|
||||||
// reads failed).
|
|
||||||
// Note: tempfile was dropped above; the file may or may not still exist
|
|
||||||
// depending on cleanup ordering. We only assert what we can observe in
|
|
||||||
// the CopyResult.
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 7. Halt during Pass 1 skip-forward path returns promptly (deterministic) ─
|
|
||||||
//
|
|
||||||
// Per RIP_DESIGN.md §3: halt is the only legitimate early exit from Pass 1.
|
|
||||||
// Even when every read is failing (skip-forward path), a halt must be
|
|
||||||
// honored within a small bounded time.
|
|
||||||
//
|
|
||||||
// Deterministic fixture: the reader signals halt on its FIRST read. The
|
|
||||||
// inner copy loop's halt check fires on the next iteration, breaking out
|
|
||||||
// of 'outer. This avoids any wallclock race on fast CI runners (where a
|
|
||||||
// 2 GB synthetic disc can sweep skip-forward in <100 ms).
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_disc_copy_halts_promptly_on_failing_reader() {
|
|
||||||
let capacity_sectors: u32 = 1024 * 1024; // 2 GB synthetic disc
|
|
||||||
|
|
||||||
let halt = Arc::new(AtomicBool::new(false));
|
|
||||||
let mut reader = FailingSectorReader::with_halt_on_first_read(capacity_sectors, halt.clone());
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
|
|
||||||
halt: Some(halt),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let t0 = Instant::now();
|
|
||||||
let result = disc
|
|
||||||
.copy(&mut reader, &iso_path, &opts)
|
|
||||||
.expect("copy returns Ok on halt");
|
|
||||||
let elapsed = t0.elapsed();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
elapsed < Duration::from_secs(2),
|
|
||||||
"halt must return within 2 s; took {elapsed:?}"
|
|
||||||
);
|
|
||||||
assert!(result.halted, "result.halted must be true");
|
|
||||||
assert!(
|
|
||||||
!result.complete,
|
|
||||||
"halted run cannot be complete (bytes_pending > 0 expected)"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.bytes_pending > 0,
|
|
||||||
"halt fired before sweep completed; bytes_pending must be > 0"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 8. Hysteresis recovers data the drive can read individually ──────────
|
|
||||||
//
|
|
||||||
// Pass 1 reads in batch (32 sectors = 1 ECC block). Failed blocks are marked
|
|
||||||
// NonTrimmed for Pass 2 recovery. This test verifies that a reader where every
|
|
||||||
// multi-sector read fails produces all NonTrimmed output with zero bytes_good.
|
|
||||||
|
|
||||||
struct BlockSizeFailingReader {
|
|
||||||
capacity: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SectorSource for BlockSizeFailingReader {
|
|
||||||
fn read_sectors(
|
|
||||||
&mut self,
|
|
||||||
lba: u32,
|
|
||||||
count: u16,
|
|
||||||
buf: &mut [u8],
|
|
||||||
_recovery: bool,
|
|
||||||
) -> Result<usize> {
|
|
||||||
if count == 1 {
|
|
||||||
for chunk in buf.chunks_mut(SECTOR_SIZE) {
|
|
||||||
chunk.fill((lba & 0xff) as u8);
|
|
||||||
}
|
|
||||||
Ok(buf.len())
|
|
||||||
} else {
|
|
||||||
Err(libfreemkv::error::Error::ScsiError {
|
|
||||||
opcode: libfreemkv::scsi::SCSI_READ_10,
|
|
||||||
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
|
|
||||||
sense: Some(libfreemkv::ScsiSense {
|
|
||||||
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capacity_sectors(&self) -> u32 {
|
|
||||||
self.capacity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
||||||
|
|
||||||
let mut reader = BlockSizeFailingReader {
|
|
||||||
capacity: capacity_sectors,
|
|
||||||
};
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = disc
|
|
||||||
.copy(&mut reader, &iso_path, &opts)
|
|
||||||
.expect("copy returns Ok");
|
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
||||||
|
|
||||||
// Pass 1's job is "fast and accurate, get the most data in the
|
|
||||||
// shortest time." It no longer bisects on marginal media — that's
|
|
||||||
// Pass N's purpose-built role. So a BlockSizeFailingReader that
|
|
||||||
// fails on multi-sector reads and succeeds on single-sector
|
|
||||||
// results in: every batch fails → SkipBlock → whole 32-sector
|
|
||||||
// ECC block marked NonTrimmed → Pass N (Disc::patch) revisits and
|
|
||||||
// recovers via single-sector reads with proper recovery semantics.
|
|
||||||
//
|
|
||||||
// Pass 1 alone:
|
|
||||||
assert_eq!(
|
|
||||||
result.bytes_good, 0,
|
|
||||||
"Pass 1 doesn't bisect on marginal media — failed batches become NonTrimmed for Pass N to revisit"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
result.bytes_pending, total_bytes,
|
|
||||||
"every sector is NonTrimmed (pending) after Pass 1, awaiting Pass N"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!result.complete,
|
|
||||||
"complete=false because NonTrimmed regions remain (Pass N's work)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
|
|
||||||
//
|
|
||||||
// 2026-05-11 design call: Pass N never marks bytes as `Unreadable` mid-multipass —
|
|
||||||
// failed reads stay `NonTrimmed` so the next pass can retry them. The orchestrator
|
|
||||||
// (autorip) promotes still-NonTrimmed bytes to Unreadable after the FINAL retry
|
|
||||||
// pass completes. This test was rewritten from its pre-design-call shape (which
|
|
||||||
// asserted Pass 2 produced bytes_unreadable > 0) to verify the new invariant:
|
|
||||||
// pass-level retries keep failed bytes in `bytes_pending` so subsequent passes
|
|
||||||
// get more shots at them.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_pass2_leaves_failed_reads_as_pending_not_unreadable() {
|
|
||||||
let capacity_sectors: u32 = 128;
|
|
||||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
||||||
|
|
||||||
let mut reader = FailingSectorReader::new(capacity_sectors);
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let pass1 = disc.copy(&mut reader, &iso_path, &opts).expect("pass1 ok");
|
|
||||||
|
|
||||||
assert_eq!(pass1.bytes_good, 0, "pass1: no good sectors");
|
|
||||||
assert_eq!(pass1.bytes_unreadable, 0, "pass1: no confirmed unreadable");
|
|
||||||
assert_eq!(
|
|
||||||
pass1.bytes_pending, total_bytes,
|
|
||||||
"pass1: all sectors NonTrimmed"
|
|
||||||
);
|
|
||||||
|
|
||||||
let last_unreadable = Arc::new(AtomicU64::new(0));
|
|
||||||
let last_pending = Arc::new(AtomicU64::new(0));
|
|
||||||
let last_good = Arc::new(AtomicU64::new(0));
|
|
||||||
let last_dur = Arc::new(AtomicU64::new(0));
|
|
||||||
|
|
||||||
struct SnapshotReporter {
|
|
||||||
unreadable: Arc<AtomicU64>,
|
|
||||||
pending: Arc<AtomicU64>,
|
|
||||||
good: Arc<AtomicU64>,
|
|
||||||
dur: Arc<AtomicU64>,
|
|
||||||
}
|
|
||||||
impl libfreemkv::progress::Progress for SnapshotReporter {
|
|
||||||
fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool {
|
|
||||||
self.unreadable
|
|
||||||
.store(p.bytes_unreadable_total, Ordering::Relaxed);
|
|
||||||
self.pending.store(p.bytes_pending_total, Ordering::Relaxed);
|
|
||||||
self.good.store(p.bytes_good_total, Ordering::Relaxed);
|
|
||||||
if let Some(d) = p.disc_duration_secs {
|
|
||||||
self.dur.store((d * 1000.0) as u64, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let reporter = SnapshotReporter {
|
|
||||||
unreadable: last_unreadable.clone(),
|
|
||||||
pending: last_pending.clone(),
|
|
||||||
good: last_good.clone(),
|
|
||||||
dur: last_dur.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let pass2_opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
progress: Some(&reporter),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let pass2 = disc
|
|
||||||
.copy(&mut reader, &iso_path, &pass2_opts)
|
|
||||||
.expect("pass2 ok");
|
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
pass2.bytes_good, 0,
|
|
||||||
"pass2: still no good sectors (reader always fails)"
|
|
||||||
);
|
|
||||||
// 2026-05-11 design: pass-level retries do NOT promote failed bytes
|
|
||||||
// to Unreadable. Failed bytes stay NonTrimmed (pending) so a later
|
|
||||||
// pass can retry. End-of-recovery promotion is an orchestrator
|
|
||||||
// concern (autorip), not the patch loop's.
|
|
||||||
assert_eq!(
|
|
||||||
pass2.bytes_unreadable, 0,
|
|
||||||
"pass2: Disc::patch never marks Unreadable mid-multipass — orchestrator promotes after final pass"
|
|
||||||
);
|
|
||||||
// bytes_pending stays at total_bytes because everything still
|
|
||||||
// failed and nothing got recovered or promoted out of pending.
|
|
||||||
assert_eq!(
|
|
||||||
pass2.bytes_pending, total_bytes,
|
|
||||||
"pass2: failed bytes remain NonTrimmed for the next pass to retry"
|
|
||||||
);
|
|
||||||
|
|
||||||
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
|
|
||||||
let observed_pending = last_pending.load(Ordering::Relaxed);
|
|
||||||
assert_eq!(
|
|
||||||
observed_unreadable, 0,
|
|
||||||
"progress should report zero confirmed-unreadable mid-pass under the new design"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
observed_pending > 0,
|
|
||||||
"progress should report pending bytes as the reader keeps failing"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Video damage time: unreadable / total * duration
|
|
||||||
// With no titles on synthetic disc, disc_duration_secs = None
|
|
||||||
assert_eq!(
|
|
||||||
last_dur.load(Ordering::Relaxed),
|
|
||||||
0,
|
|
||||||
"synthetic disc has no titles, duration should be None/0"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 10. Damage time calculation (unit test) ────────────────────────────────
|
|
||||||
//
|
|
||||||
// Verifies the formula: damage_secs = bytes_unreadable / bytes_total * duration
|
|
||||||
// This mirrors the CLI's print_disc_progress logic.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_damage_time_calculation() {
|
|
||||||
// 78.8 GB disc, 2h45m movie (9900s), 74 KB unreadable
|
|
||||||
let disc_bytes: u64 = 78_800_000_000;
|
|
||||||
let duration_secs: f64 = 9900.0;
|
|
||||||
|
|
||||||
let cases: Vec<(u64, &str)> = vec![
|
|
||||||
(74 * 1024, "~10ms"), // 74 KB → ~9ms, negligible
|
|
||||||
(10 * 1024 * 1024, "~1.3s"), // 10 MB → ~1.3s
|
|
||||||
(100 * 1024 * 1024, "~13s"), // 100 MB → ~13s
|
|
||||||
(1024 * 1024 * 1024, "~134s"), // 1 GB → ~134s
|
|
||||||
];
|
|
||||||
|
|
||||||
for (bad_bytes, label) in cases {
|
|
||||||
let damage_secs = bad_bytes as f64 / disc_bytes as f64 * duration_secs;
|
|
||||||
match label {
|
|
||||||
"~10ms" => assert!(damage_secs < 0.05, "{label}: {damage_secs:.3}s"),
|
|
||||||
"~1.3s" => assert!(
|
|
||||||
(damage_secs - 1.3).abs() < 0.2,
|
|
||||||
"{label}: {damage_secs:.2}s"
|
|
||||||
),
|
|
||||||
"~13s" => assert!(
|
|
||||||
(damage_secs - 13.0).abs() < 1.0,
|
|
||||||
"{label}: {damage_secs:.1}s"
|
|
||||||
),
|
|
||||||
"~134s" => assert!(
|
|
||||||
(damage_secs - 134.0).abs() < 2.0,
|
|
||||||
"{label}: {damage_secs:.0}s"
|
|
||||||
),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0.25s threshold: how many bad bytes = 0.25s of damage?
|
|
||||||
let threshold_bytes = (0.25 / duration_secs * disc_bytes as f64) as u64;
|
|
||||||
assert!(
|
|
||||||
threshold_bytes > 0,
|
|
||||||
"0.25s damage threshold should be > 0 bytes"
|
|
||||||
);
|
|
||||||
// At 9900s / 78.8 GB ≈ 0.25s = ~2 MB
|
|
||||||
let expected_mb = threshold_bytes as f64 / (1024.0 * 1024.0);
|
|
||||||
assert!(
|
|
||||||
(expected_mb - 2.0).abs() < 0.5,
|
|
||||||
"0.25s ≈ {expected_mb:.2} MB (expected ~2 MB)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+13
-42
@@ -6,45 +6,6 @@
|
|||||||
|
|
||||||
use libfreemkv::{aacs, decrypt::DecryptKeys};
|
use libfreemkv::{aacs, decrypt::DecryptKeys};
|
||||||
|
|
||||||
/// Test: decrypt_sectors with AACS keys actually decrypts units.
|
|
||||||
#[test]
|
|
||||||
fn decrypt_sectors_with_aacs_keys_works() {
|
|
||||||
// Build an encrypted aligned unit
|
|
||||||
let mut unit = vec![0xFFu8; aacs::content::ALIGNED_UNIT_LEN];
|
|
||||||
|
|
||||||
// Set encryption flag (bits 6-7 of byte 0)
|
|
||||||
unit[0] |= 0xC0;
|
|
||||||
|
|
||||||
// Fill with recognizable pattern
|
|
||||||
for (i, byte) in unit
|
|
||||||
.iter_mut()
|
|
||||||
.enumerate()
|
|
||||||
.take(aacs::content::ALIGNED_UNIT_LEN)
|
|
||||||
.skip(1)
|
|
||||||
{
|
|
||||||
*byte = ((i * 3 + 7) & 0xFF) as u8;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
// Now we have encrypted data - create DecryptKeys with actual keys
|
|
||||||
let mut keys = DecryptKeys::Aacs {
|
|
||||||
unit_keys: vec![(0u32, unit_key)],
|
|
||||||
read_data_key: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// decrypt_sectors should handle this without error
|
|
||||||
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"decrypt_sectors with AACS keys should not error"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test: decrypt_sectors with DecryptKeys::None is a no-op.
|
/// Test: decrypt_sectors with DecryptKeys::None is a no-op.
|
||||||
#[test]
|
#[test]
|
||||||
fn decrypt_sectors_with_none_keys_is_noop() {
|
fn decrypt_sectors_with_none_keys_is_noop() {
|
||||||
@@ -90,16 +51,25 @@ fn aacs_encryption_flag_detection() {
|
|||||||
off += 192;
|
off += 192;
|
||||||
}
|
}
|
||||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
|
&unit,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Flag bits on a synced unit do not make it look encrypted.
|
// Flag bits on a synced unit do not make it look encrypted.
|
||||||
unit[0] = 0xC0;
|
unit[0] = 0xC0;
|
||||||
unit[7] = 0xC0;
|
unit[7] = 0xC0;
|
||||||
assert!(!aacs::content::ts_sync_destroyed(&unit));
|
assert!(aacs::content::is_clean(
|
||||||
|
&unit,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
|
|
||||||
// Scrambled body (syncs gone) → encrypted.
|
// Scrambled body (syncs gone) → encrypted.
|
||||||
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
|
||||||
assert!(aacs::content::ts_sync_destroyed(&scrambled));
|
assert!(!aacs::content::is_clean(
|
||||||
|
&scrambled,
|
||||||
|
libfreemkv::disc::ContentFormat::BdTs
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||||
@@ -111,6 +81,7 @@ fn decrypt_keys_is_encrypted_variants() {
|
|||||||
let aacs = DecryptKeys::Aacs {
|
let aacs = DecryptKeys::Aacs {
|
||||||
unit_keys: vec![],
|
unit_keys: vec![],
|
||||||
read_data_key: None,
|
read_data_key: None,
|
||||||
|
format: libfreemkv::disc::ContentFormat::BdTs,
|
||||||
};
|
};
|
||||||
assert!(aacs.is_encrypted());
|
assert!(aacs.is_encrypted());
|
||||||
|
|
||||||
|
|||||||
@@ -1,492 +0,0 @@
|
|||||||
//! Pass N (Disc::patch) size-aware-skip targeted tests.
|
|
||||||
//!
|
|
||||||
//! The user's failure mode (2026-05-07): "what if we have a 100 sector zone
|
|
||||||
//! and its really 2 25 sector zones and we keep jumping over the good in
|
|
||||||
//! the middle." Today's pre-fix patch escalates skip-distance based on
|
|
||||||
//! `consecutive_skips_without_recovery` with hardcoded 32 → 4096 sector
|
|
||||||
//! caps. A 100-sector bad range whose actual layout is 25 bad + 50 good +
|
|
||||||
//! 25 bad would have the patch skip 32-4096 sectors after a couple of
|
|
||||||
//! failures, leaping over the entire range AND the good middle.
|
|
||||||
//!
|
|
||||||
//! The fix: cap each skip at `range_remaining/4`. These tests exercise
|
|
||||||
//! that boundary.
|
|
||||||
|
|
||||||
use libfreemkv::disc::CopyOptions;
|
|
||||||
use libfreemkv::disc::DiscRegion;
|
|
||||||
use libfreemkv::disc::PatchOptions;
|
|
||||||
use libfreemkv::disc::mapfile::{Mapfile, SectorStatus};
|
|
||||||
use libfreemkv::error::Result;
|
|
||||||
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorSource};
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
const SECTOR_SIZE: usize = 2048;
|
|
||||||
|
|
||||||
/// Reader where you specify exactly which LBAs return Err. Everything else
|
|
||||||
/// returns Ok with the LBA encoded in each byte for verification.
|
|
||||||
struct PatternedSectorReader {
|
|
||||||
capacity: u32,
|
|
||||||
bad_lbas: HashSet<u32>,
|
|
||||||
/// Trace every read so tests can assert what was actually attempted.
|
|
||||||
trace: Arc<Mutex<Vec<(u32, u16)>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReadTrace = Arc<Mutex<Vec<(u32, u16)>>>;
|
|
||||||
|
|
||||||
impl PatternedSectorReader {
|
|
||||||
fn new(capacity: u32, bad_lbas: HashSet<u32>) -> (Self, ReadTrace) {
|
|
||||||
let trace = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
capacity,
|
|
||||||
bad_lbas,
|
|
||||||
trace: trace.clone(),
|
|
||||||
},
|
|
||||||
trace,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SectorSource for PatternedSectorReader {
|
|
||||||
fn read_sectors(
|
|
||||||
&mut self,
|
|
||||||
lba: u32,
|
|
||||||
count: u16,
|
|
||||||
buf: &mut [u8],
|
|
||||||
_recovery: bool,
|
|
||||||
) -> Result<usize> {
|
|
||||||
self.trace.lock().unwrap().push((lba, count));
|
|
||||||
// Whole-batch fails if ANY sector in the batch is bad. (Models a
|
|
||||||
// real drive: a multi-sector READ aborts on the first ECC failure.)
|
|
||||||
for offset in 0..count as u32 {
|
|
||||||
if self.bad_lbas.contains(&(lba + offset)) {
|
|
||||||
return Err(libfreemkv::error::Error::ScsiError {
|
|
||||||
opcode: libfreemkv::scsi::SCSI_READ_10,
|
|
||||||
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
|
|
||||||
sense: Some(libfreemkv::ScsiSense {
|
|
||||||
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fill each sector with ITS OWN LBA byte, not the starting LBA's
|
|
||||||
// byte. This matches real drive behavior: a multi-sector READ
|
|
||||||
// returns per-sector-correct data. Pre-0.18.13 only single-sector
|
|
||||||
// reads were exercised by patch tests, so the cheaper "fill the
|
|
||||||
// whole batch with one byte" worked; adaptive batching needs the
|
|
||||||
// per-sector pattern to verify correct positioning.
|
|
||||||
for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() {
|
|
||||||
chunk.fill(((lba + i as u32) & 0xff) as u8);
|
|
||||||
}
|
|
||||||
Ok(buf.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capacity_sectors(&self) -> u32 {
|
|
||||||
self.capacity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
|
||||||
Disc {
|
|
||||||
volume_id: String::new(),
|
|
||||||
meta_title: None,
|
|
||||||
format: DiscFormat::BluRay,
|
|
||||||
capacity_sectors,
|
|
||||||
capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64,
|
|
||||||
layers: 1,
|
|
||||||
titles: Vec::new(),
|
|
||||||
region: DiscRegion::Free,
|
|
||||||
aacs: None,
|
|
||||||
css: None,
|
|
||||||
encrypted: false,
|
|
||||||
aacs_error: None,
|
|
||||||
css_error: None,
|
|
||||||
content_format: ContentFormat::BdTs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pre-populate a mapfile with one large NonTrimmed range so patch's work-
|
|
||||||
/// list has something to do. Caller pre-allocates the ISO at `total_bytes`
|
|
||||||
/// so seeks don't fail.
|
|
||||||
fn prep_iso_and_mapfile(
|
|
||||||
iso_path: &std::path::Path,
|
|
||||||
total_bytes: u64,
|
|
||||||
finished_ranges: &[(u64, u64)],
|
|
||||||
nontrimmed_ranges: &[(u64, u64)],
|
|
||||||
) {
|
|
||||||
use std::fs::OpenOptions;
|
|
||||||
use std::io::{Seek, SeekFrom, Write};
|
|
||||||
let mut f = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.write(true)
|
|
||||||
.truncate(true)
|
|
||||||
.open(iso_path)
|
|
||||||
.unwrap();
|
|
||||||
f.set_len(total_bytes).unwrap();
|
|
||||||
f.seek(SeekFrom::Start(0)).unwrap();
|
|
||||||
f.write_all(&[]).unwrap();
|
|
||||||
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(iso_path);
|
|
||||||
let mut mf = Mapfile::create(&map_path, total_bytes, "test").unwrap();
|
|
||||||
for &(pos, size) in finished_ranges {
|
|
||||||
mf.record(pos, size, SectorStatus::Finished).unwrap();
|
|
||||||
}
|
|
||||||
for &(pos, size) in nontrimmed_ranges {
|
|
||||||
mf.record(pos, size, SectorStatus::NonTrimmed).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// THE critical test. A 100-sector "bad" range hides 50 good sectors in
|
|
||||||
/// the middle (LBAs 125-174). Pre-fix patch would skip-escalate at 32+
|
|
||||||
/// sectors and leap over the whole range. Post-fix: skip is capped at
|
|
||||||
/// range_remaining/4 (=25 sectors initially), which forces convergence.
|
|
||||||
#[test]
|
|
||||||
fn patch_recovers_good_middle_of_a_bad_range() {
|
|
||||||
let capacity_sectors: u32 = 1024;
|
|
||||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
||||||
|
|
||||||
// Bad range layout: LBAs 100-124 bad, 125-174 GOOD, 175-199 bad.
|
|
||||||
let mut bad_lbas = HashSet::new();
|
|
||||||
for lba in 100..125 {
|
|
||||||
bad_lbas.insert(lba);
|
|
||||||
}
|
|
||||||
for lba in 175..200 {
|
|
||||||
bad_lbas.insert(lba);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas);
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
// Pre-populate: 0..100 already Finished from an imagined Pass 1,
|
|
||||||
// 100..200 NonTrimmed (the range we want patch to retry),
|
|
||||||
// 200..1024 already Finished.
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(200 * 2048, (capacity_sectors as u64 - 200) * 2048),
|
|
||||||
];
|
|
||||||
let nontrimmed = [(100 * 2048, 100 * 2048)];
|
|
||||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
|
||||||
|
|
||||||
// Run patch.
|
|
||||||
// disc.copy() with multipass=true auto-dispatches to patch when the
|
|
||||||
// mapfile already covers the disc and has retryable ranges.
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let pr = disc
|
|
||||||
.copy(&mut reader, &iso_path, &opts)
|
|
||||||
.expect("copy returns Ok");
|
|
||||||
|
|
||||||
// Re-load mapfile and inspect.
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
|
||||||
let map = Mapfile::load(&map_path).unwrap();
|
|
||||||
|
|
||||||
// The good middle (125..175) MUST end up Finished. If size-aware skip
|
|
||||||
// is not enabled, patch would skip 32+ sectors after a few failures
|
|
||||||
// and leap clean over LBA 125 → middle stays NonTrimmed.
|
|
||||||
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
|
|
||||||
let total_finished_in_middle: u64 = finished_ranges
|
|
||||||
.iter()
|
|
||||||
.map(|&(pos, sz)| {
|
|
||||||
let start = pos.max(125 * 2048);
|
|
||||||
let end = (pos + sz).min(175 * 2048);
|
|
||||||
end.saturating_sub(start)
|
|
||||||
})
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
// Allow 2 sectors (4 KB) of boundary slop — patch's bisection may
|
|
||||||
// not converge exactly on the good/bad boundary in a single pass,
|
|
||||||
// and that's acceptable. The pre-fix behaviour would have left the
|
|
||||||
// entire good middle as NonTrimmed (~0 bytes recovered).
|
|
||||||
let good_middle_bytes: u64 = 50 * 2048;
|
|
||||||
let min_acceptable: u64 = good_middle_bytes - 2 * 2048;
|
|
||||||
|
|
||||||
// Cleanup before assertions
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(&map_path);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
total_finished_in_middle >= min_acceptable,
|
|
||||||
"size-aware skip should have discovered most of the 50 good sectors in the middle. \
|
|
||||||
Recovered {} of {} good middle bytes (min acceptable {}). bytes_good={} bytes_total={}",
|
|
||||||
total_finished_in_middle,
|
|
||||||
good_middle_bytes,
|
|
||||||
min_acceptable,
|
|
||||||
pr.bytes_good,
|
|
||||||
pr.bytes_total,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression: `PatchOptions::block_sectors == Some(0)` must not
|
|
||||||
/// busy-spin. `block_sectors` is a public `Option<u16>` field; a zero
|
|
||||||
/// value would compute a zero-length read every iteration, never
|
|
||||||
/// advance `block_end`, and burn a CPU core until the per-range
|
|
||||||
/// watchdog fired (up to 30 min on a large range). The entry-point
|
|
||||||
/// `.max(1)` clamp turns Some(0) into a single-sector batch so the
|
|
||||||
/// range recovers and the call returns promptly.
|
|
||||||
#[test]
|
|
||||||
fn patch_block_sectors_zero_does_not_busy_spin() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
||||||
|
|
||||||
// Small NonTrimmed range that is entirely readable (no bad LBAs), so
|
|
||||||
// single-sector patch reads recover it immediately. Without the
|
|
||||||
// clamp the loop would never progress regardless of readability.
|
|
||||||
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, HashSet::new());
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(110 * 2048, (capacity_sectors as u64 - 110) * 2048),
|
|
||||||
];
|
|
||||||
let nontrimmed = [(100 * 2048, 10 * 2048)];
|
|
||||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
|
||||||
|
|
||||||
// A halt watchdog bounds the run: the inner loop polls `halt` every
|
|
||||||
// iteration, so even a busy-spin regression breaks out within the
|
|
||||||
// window instead of hanging the test binary. With the clamp the run
|
|
||||||
// finishes long before the watchdog fires; without it the watchdog
|
|
||||||
// trips and the bytes_good assertion below fails loudly.
|
|
||||||
let halt = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let halt_for_watchdog = halt.clone();
|
|
||||||
let watchdog = std::thread::spawn(move || {
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(20));
|
|
||||||
halt_for_watchdog.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
});
|
|
||||||
|
|
||||||
let opts = PatchOptions {
|
|
||||||
decrypt: false,
|
|
||||||
block_sectors: Some(0),
|
|
||||||
full_recovery: false,
|
|
||||||
reverse: false,
|
|
||||||
wedged_threshold: 0,
|
|
||||||
progress: None,
|
|
||||||
halt: Some(halt.clone()),
|
|
||||||
key_fetch: None,
|
|
||||||
fast_capture: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
let outcome = disc.patch(&mut reader, &iso_path, &opts);
|
|
||||||
// Stop the watchdog regardless of outcome.
|
|
||||||
halt.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
let _ = watchdog.join();
|
|
||||||
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(&map_path);
|
|
||||||
|
|
||||||
let outcome = outcome.expect("patch returns Ok");
|
|
||||||
assert!(
|
|
||||||
!outcome.halted,
|
|
||||||
"patch with block_sectors=Some(0) must complete on its own \
|
|
||||||
(clamped to a 1-sector batch), not be cut off by the watchdog"
|
|
||||||
);
|
|
||||||
let bytes_good = outcome.bytes_good;
|
|
||||||
// The 10-sector NonTrimmed range was fully readable; clamped to a
|
|
||||||
// 1-sector batch it must recover. Initial good = 100 + (256-110) =
|
|
||||||
// 246 sectors; after patch the 10-sector range is also Finished.
|
|
||||||
let initial_good_sectors: u64 = 100 + (capacity_sectors as u64 - 110);
|
|
||||||
assert!(
|
|
||||||
bytes_good >= (initial_good_sectors + 10) * 2048,
|
|
||||||
"block_sectors=Some(0) clamped to 1 should recover the readable range; \
|
|
||||||
bytes_good={bytes_good}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A second test: a bad range that's actually 4 small bad sub-zones
|
|
||||||
/// separated by good sectors. Demonstrates the bisection behaviour
|
|
||||||
/// converges when zones are non-uniform.
|
|
||||||
#[test]
|
|
||||||
fn patch_recovers_multiple_good_middles() {
|
|
||||||
let capacity_sectors: u32 = 2048;
|
|
||||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
||||||
|
|
||||||
// Bad pattern: 1000-1024 bad, 1025-1099 good, 1100-1124 bad,
|
|
||||||
// 1125-1199 good, 1200-1224 bad, 1225-1299 good.
|
|
||||||
let mut bad_lbas = HashSet::new();
|
|
||||||
for lba in 1000..1025 {
|
|
||||||
bad_lbas.insert(lba);
|
|
||||||
}
|
|
||||||
for lba in 1100..1125 {
|
|
||||||
bad_lbas.insert(lba);
|
|
||||||
}
|
|
||||||
for lba in 1200..1225 {
|
|
||||||
bad_lbas.insert(lba);
|
|
||||||
}
|
|
||||||
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas);
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let finished = [
|
|
||||||
(0, 1000 * 2048),
|
|
||||||
(1300 * 2048, (capacity_sectors as u64 - 1300) * 2048),
|
|
||||||
];
|
|
||||||
let nontrimmed = [(1000 * 2048, 300 * 2048)];
|
|
||||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
|
||||||
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let pr = disc
|
|
||||||
.copy(&mut reader, &iso_path, &opts)
|
|
||||||
.expect("copy returns Ok");
|
|
||||||
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
|
||||||
let map = Mapfile::load(&map_path).unwrap();
|
|
||||||
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
|
|
||||||
let recovered: u64 = finished_ranges
|
|
||||||
.iter()
|
|
||||||
.map(|&(pos, sz)| {
|
|
||||||
let start = pos.max(1000 * 2048);
|
|
||||||
let end = (pos + sz).min(1300 * 2048);
|
|
||||||
end.saturating_sub(start)
|
|
||||||
})
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(&map_path);
|
|
||||||
|
|
||||||
// Three good middles of 75 sectors each = 225 good sectors in the
|
|
||||||
// bad range. Total bad = 75. So we want at least most of 225 sectors
|
|
||||||
// (= 460800 bytes) to be Finished after patch.
|
|
||||||
let target = 200 * 2048; // be generous — anything over 200 sectors is convincing
|
|
||||||
assert!(
|
|
||||||
recovered >= target,
|
|
||||||
"size-aware skip should find most of the 3 good middles. \
|
|
||||||
Recovered {} bytes; expected ≥ {}. bytes_good={} bytes_total={}",
|
|
||||||
recovered,
|
|
||||||
target,
|
|
||||||
pr.bytes_good,
|
|
||||||
pr.bytes_total,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 0.18 Pass N pipeline split: exercises the new producer/consumer
|
|
||||||
/// path end-to-end on a synthetic patterned reader. Bad range layout
|
|
||||||
/// is small (5 bad LBAs surrounded by good middle) so the producer
|
|
||||||
/// emits a mix of `Recovered` and `NonTrimmed` items and the consumer
|
|
||||||
/// thread must apply both kinds. Verifies:
|
|
||||||
///
|
|
||||||
/// - `bytes_good` advances (good sectors flow producer→consumer→file
|
|
||||||
/// →mapfile with the data preserved).
|
|
||||||
/// - The recovered LBAs end up Finished; the bad LBAs end up NonTrimmed
|
|
||||||
/// (NOT Unreadable — promotion to Unreadable is the orchestrator's job
|
|
||||||
/// after the final pass).
|
|
||||||
/// - Bytes written at the recovered offsets match what the producer
|
|
||||||
/// read from the patterned source (proves the channel hand-off
|
|
||||||
/// didn't drop or reorder buffers, and the consumer's seek+write
|
|
||||||
/// landed at the right offsets).
|
|
||||||
#[test]
|
|
||||||
fn patch_pipeline_split_recovers_and_records_correctly() {
|
|
||||||
let capacity_sectors: u32 = 512;
|
|
||||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
||||||
|
|
||||||
// Layout: LBAs 200-204 inclusive are bad (5 sectors), 205-249 good.
|
|
||||||
// The pre-existing range is LBAs 200-249 NonTrimmed (100 KB).
|
|
||||||
let mut bad_lbas = HashSet::new();
|
|
||||||
for lba in 200..205 {
|
|
||||||
bad_lbas.insert(lba);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas.clone());
|
|
||||||
let disc = synthetic_disc(capacity_sectors);
|
|
||||||
|
|
||||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
|
||||||
let iso_path = tmp.path().to_path_buf();
|
|
||||||
drop(tmp);
|
|
||||||
|
|
||||||
let finished = [
|
|
||||||
(0, 200 * 2048),
|
|
||||||
(250 * 2048, (capacity_sectors as u64 - 250) * 2048),
|
|
||||||
];
|
|
||||||
let nontrimmed = [(200 * 2048, 50 * 2048)];
|
|
||||||
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
|
|
||||||
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let pr = disc
|
|
||||||
.copy(&mut reader, &iso_path, &opts)
|
|
||||||
.expect("copy returns Ok");
|
|
||||||
|
|
||||||
// Bytes_good_total should advance — the good LBAs in the bad range
|
|
||||||
// (205-249, 45 sectors) are all reachable via per-sector retry.
|
|
||||||
// Initial bytes_good = 200 * 2048 + (512-250) * 2048 = 462 sectors.
|
|
||||||
// After patch, bytes_good should be ≥ 462 + 45 = 507 sectors worth.
|
|
||||||
let initial_good_sectors: u64 = 200 + (capacity_sectors as u64 - 250);
|
|
||||||
let min_expected_good_bytes = (initial_good_sectors + 30) * 2048;
|
|
||||||
assert!(
|
|
||||||
pr.bytes_good >= min_expected_good_bytes,
|
|
||||||
"patch should have recovered most good LBAs in the bad range via the pipeline. \
|
|
||||||
bytes_good={} (expected ≥ {}); bytes_total={}",
|
|
||||||
pr.bytes_good,
|
|
||||||
min_expected_good_bytes,
|
|
||||||
pr.bytes_total,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify the mapfile records: every good LBA is Finished, every
|
|
||||||
// bad LBA is NonTrimmed (not Finished).
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
|
||||||
let map = Mapfile::load(&map_path).unwrap();
|
|
||||||
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
|
|
||||||
let in_finished = |lba: u32| -> bool {
|
|
||||||
let pos = lba as u64 * 2048;
|
|
||||||
finished_ranges
|
|
||||||
.iter()
|
|
||||||
.any(|&(p, sz)| pos >= p && pos < p + sz)
|
|
||||||
};
|
|
||||||
|
|
||||||
for lba in 205..250 {
|
|
||||||
assert!(
|
|
||||||
in_finished(lba),
|
|
||||||
"good LBA {lba} should be Finished after pipeline patch run"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for lba in 200..205 {
|
|
||||||
assert!(
|
|
||||||
!in_finished(lba),
|
|
||||||
"bad LBA {lba} should NOT be Finished after pipeline patch run"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the consumer wrote the producer's bytes at the right
|
|
||||||
// offsets. PatternedSectorReader fills each sector with `(lba & 0xff)
|
|
||||||
// as u8` — picking LBA 220 (well inside the recovered region) gives
|
|
||||||
// a clean signature byte to check.
|
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
|
||||||
let mut iso = std::fs::File::open(&iso_path).unwrap();
|
|
||||||
iso.seek(SeekFrom::Start(220 * 2048)).unwrap();
|
|
||||||
let mut sector = [0u8; 2048];
|
|
||||||
iso.read_exact(&mut sector).unwrap();
|
|
||||||
let expected_byte = (220u32 & 0xff) as u8;
|
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(&map_path);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
sector.iter().all(|&b| b == expected_byte),
|
|
||||||
"consumer should have written PatternedSectorReader's pattern \
|
|
||||||
(byte {expected_byte:#x} for LBA 220) to the recovered offset; \
|
|
||||||
got first 8 bytes = {:?}",
|
|
||||||
§or[..8]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,902 +0,0 @@
|
|||||||
//! Pass-N (`Disc::patch`) read-error handler — A/B golden fixture.
|
|
||||||
//!
|
|
||||||
//! Background (2026-05-13, v0.20.8 release bundle planning):
|
|
||||||
//!
|
|
||||||
//! `libfreemkv::disc::read_error::handle_read_error` is supposed to be
|
|
||||||
//! the single source of truth for sector-read error → recovery action
|
|
||||||
//! decisions. Pass 1 sweep routes through it. Pass N patch's
|
|
||||||
//! `handle_read_failure` (in `disc/patch.rs`) does NOT — historically
|
|
||||||
//! MEDIUM_ERROR / NOT_READY get inline handling with their own thresholds
|
|
||||||
//! (`PASSN_DAMAGE_THRESHOLD_PCT=6` vs the sweep's `12`), their own
|
|
||||||
//! damage_window (state.damage_window, separate from ReadCtx.damage_window),
|
|
||||||
//! and their own skip logic (`compute_damage_skip`, which runs AFTER
|
|
||||||
//! the failure handler and has a size-aware `range_remaining/4` cap
|
|
||||||
//! that `handle_read_error::JumpAhead` does not know about).
|
|
||||||
//!
|
|
||||||
//! This file is the A/B fixture for that unification. It pins the
|
|
||||||
//! CURRENT (pre-unification) end-to-end behavior of `Disc::patch` for
|
|
||||||
//! eight canonical damage profiles against a synthetic
|
|
||||||
//! `ScriptedSectorReader`. Each profile asserts the exact observable
|
|
||||||
//! outcome — final mapfile byte counts and outer-loop counters — so any
|
|
||||||
//! attempt to refactor the failure path either preserves the goldens or
|
|
||||||
//! the test fails loudly.
|
|
||||||
//!
|
|
||||||
//! The prompt called for "exact sequence of `ReadAction` enums per
|
|
||||||
//! LBA"; that framing doesn't fit the current architecture because
|
|
||||||
//! `handle_read_failure` produces `FailureAction`, not `ReadAction`,
|
|
||||||
//! and interleaves with `compute_damage_skip` + cursor management in
|
|
||||||
//! the outer loop. The observable contract — what `Disc::patch` does
|
|
||||||
//! to the mapfile and how many reads it performs — is the equivalent
|
|
||||||
//! invariant, captured end-to-end.
|
|
||||||
//!
|
|
||||||
//! Why we expect divergence under naïve unification (see final report
|
|
||||||
//! of the 0.20.8 unification attempt): the patch loop's skip semantics
|
|
||||||
//! live in `compute_damage_skip` POST-failure-handler, with a size-aware
|
|
||||||
//! cap that `handle_read_error` knows nothing about; routing through
|
|
||||||
//! `handle_read_error` would invert that cursor flow. The fixture stays
|
|
||||||
//! checked in regardless — it documents the contract for the next
|
|
||||||
//! refactor attempt.
|
|
||||||
|
|
||||||
use libfreemkv::ContentFormat;
|
|
||||||
use libfreemkv::Disc;
|
|
||||||
use libfreemkv::DiscFormat;
|
|
||||||
use libfreemkv::disc::CopyOptions;
|
|
||||||
use libfreemkv::disc::DiscRegion;
|
|
||||||
use libfreemkv::disc::mapfile::{Mapfile, SectorStatus};
|
|
||||||
use libfreemkv::error::{Error, Result};
|
|
||||||
use libfreemkv::scsi;
|
|
||||||
use libfreemkv::{ScsiSense, SectorSource};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
const SECTOR_SIZE: usize = 2048;
|
|
||||||
|
|
||||||
/// Per-attempt result the script can emit. `Ok` returns a deterministic
|
|
||||||
/// per-sector byte pattern (LBA mod 256 in each sector). `Err` returns
|
|
||||||
/// the SCSI sense triple supplied — the patch failure path inspects
|
|
||||||
/// `scsi_sense().sense_key` to classify (MEDIUM, NOT_READY,
|
|
||||||
/// HARDWARE, ILLEGAL_REQUEST, ABORTED_COMMAND).
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
enum ScriptStep {
|
|
||||||
Ok,
|
|
||||||
Err { sense_key: u8, asc: u8, ascq: u8 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A scripted reader. For each (lba, count) read attempt, picks the
|
|
||||||
/// step at `attempt_idx[lba]`, advances the index. If no script entry
|
|
||||||
/// exists for an LBA, defaults to `Ok` so we don't need to script
|
|
||||||
/// every sector of large ranges.
|
|
||||||
///
|
|
||||||
/// "Batch fails if ANY sector in the batch is bad" — matches real
|
|
||||||
/// drive behavior (`pass_n_size_aware_skip.rs` uses the same model).
|
|
||||||
/// For batched reads we synthesize an Err with the FIRST scripted
|
|
||||||
/// failure in the batch.
|
|
||||||
struct ScriptedSectorReader {
|
|
||||||
capacity: u32,
|
|
||||||
/// Per-LBA script of (step, then next step on retry, …). When
|
|
||||||
/// retries exhaust the script, the LAST step repeats forever.
|
|
||||||
script: std::collections::HashMap<u32, Vec<ScriptStep>>,
|
|
||||||
/// Per-LBA index into its script vec. Bumps on each read attempt
|
|
||||||
/// at that LBA.
|
|
||||||
attempt_idx: Mutex<std::collections::HashMap<u32, usize>>,
|
|
||||||
/// Full read trace: every (lba, count, result_was_ok) tuple in
|
|
||||||
/// call order. Lets the test assert that adaptive-batch dropped
|
|
||||||
/// to count=1, bisection happened, etc.
|
|
||||||
trace: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `ScriptedSectorReader` plus the handle recording its `(lba, count, ok)` trace.
|
|
||||||
type ScriptedHarness = (ScriptedSectorReader, Arc<Mutex<Vec<(u32, u16, bool)>>>);
|
|
||||||
|
|
||||||
impl ScriptedSectorReader {
|
|
||||||
fn new(capacity: u32) -> ScriptedHarness {
|
|
||||||
let trace = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
capacity,
|
|
||||||
script: std::collections::HashMap::new(),
|
|
||||||
attempt_idx: Mutex::new(std::collections::HashMap::new()),
|
|
||||||
trace: trace.clone(),
|
|
||||||
},
|
|
||||||
trace,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a single-step script for `lba`: every attempt yields `step`.
|
|
||||||
fn always(&mut self, lba: u32, step: ScriptStep) {
|
|
||||||
self.script.insert(lba, vec![step]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a multi-step script for `lba`: first attempt yields
|
|
||||||
/// `steps[0]`, second `steps[1]`, … on retry the last step repeats.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn sequence(&mut self, lba: u32, steps: Vec<ScriptStep>) {
|
|
||||||
self.script.insert(lba, steps);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn step_for(&self, lba: u32) -> ScriptStep {
|
|
||||||
let v = match self.script.get(&lba) {
|
|
||||||
Some(v) => v,
|
|
||||||
None => return ScriptStep::Ok,
|
|
||||||
};
|
|
||||||
let mut idx = self.attempt_idx.lock().unwrap();
|
|
||||||
let i = idx.entry(lba).or_insert(0);
|
|
||||||
let step = v[(*i).min(v.len() - 1)];
|
|
||||||
*i += 1;
|
|
||||||
step
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SectorSource for ScriptedSectorReader {
|
|
||||||
fn read_sectors(
|
|
||||||
&mut self,
|
|
||||||
lba: u32,
|
|
||||||
count: u16,
|
|
||||||
buf: &mut [u8],
|
|
||||||
_recovery: bool,
|
|
||||||
) -> Result<usize> {
|
|
||||||
// Look at every sector in the batch — first failure determines
|
|
||||||
// the outcome.
|
|
||||||
let mut failure: Option<(u8, u8, u8)> = None;
|
|
||||||
for offset in 0..count as u32 {
|
|
||||||
match self.step_for(lba + offset) {
|
|
||||||
ScriptStep::Ok => {}
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key,
|
|
||||||
asc,
|
|
||||||
ascq,
|
|
||||||
} => {
|
|
||||||
failure = Some((sense_key, asc, ascq));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let ok = failure.is_none();
|
|
||||||
self.trace.lock().unwrap().push((lba, count, ok));
|
|
||||||
if let Some((sense_key, asc, ascq)) = failure {
|
|
||||||
return Err(Error::ScsiError {
|
|
||||||
opcode: scsi::SCSI_READ_10,
|
|
||||||
status: scsi::SCSI_STATUS_CHECK_CONDITION,
|
|
||||||
sense: Some(ScsiSense {
|
|
||||||
sense_key,
|
|
||||||
asc,
|
|
||||||
ascq,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Per-sector LBA byte pattern.
|
|
||||||
for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() {
|
|
||||||
chunk.fill(((lba + i as u32) & 0xff) as u8);
|
|
||||||
}
|
|
||||||
Ok(buf.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn capacity_sectors(&self) -> u32 {
|
|
||||||
self.capacity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
|
||||||
Disc {
|
|
||||||
volume_id: String::new(),
|
|
||||||
meta_title: None,
|
|
||||||
format: DiscFormat::BluRay,
|
|
||||||
capacity_sectors,
|
|
||||||
capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64,
|
|
||||||
layers: 1,
|
|
||||||
titles: Vec::new(),
|
|
||||||
region: DiscRegion::Free,
|
|
||||||
aacs: None,
|
|
||||||
css: None,
|
|
||||||
encrypted: false,
|
|
||||||
aacs_error: None,
|
|
||||||
css_error: None,
|
|
||||||
content_format: ContentFormat::BdTs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prep_iso_and_mapfile(
|
|
||||||
iso_path: &std::path::Path,
|
|
||||||
total_bytes: u64,
|
|
||||||
finished_ranges: &[(u64, u64)],
|
|
||||||
nontrimmed_ranges: &[(u64, u64)],
|
|
||||||
) {
|
|
||||||
use std::fs::OpenOptions;
|
|
||||||
use std::io::{Seek, SeekFrom, Write};
|
|
||||||
let mut f = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.write(true)
|
|
||||||
.truncate(true)
|
|
||||||
.open(iso_path)
|
|
||||||
.unwrap();
|
|
||||||
f.set_len(total_bytes).unwrap();
|
|
||||||
f.seek(SeekFrom::Start(0)).unwrap();
|
|
||||||
f.write_all(&[]).unwrap();
|
|
||||||
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(iso_path);
|
|
||||||
let mut mf = Mapfile::create(&map_path, total_bytes, "test").unwrap();
|
|
||||||
for &(pos, size) in finished_ranges {
|
|
||||||
mf.record(pos, size, SectorStatus::Finished).unwrap();
|
|
||||||
}
|
|
||||||
for &(pos, size) in nontrimmed_ranges {
|
|
||||||
mf.record(pos, size, SectorStatus::NonTrimmed).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Observable outcome of a patch run. Goldens for each profile pin
|
|
||||||
/// these exact values.
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
|
||||||
struct Golden {
|
|
||||||
/// `bytes_good` at end of patch.
|
|
||||||
bytes_good: u64,
|
|
||||||
/// `bytes_unreadable` at end.
|
|
||||||
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
|
|
||||||
/// retries via future tuning doesn't fail the test spuriously).
|
|
||||||
max_reads: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Common helper: prep ISO + mapfile, run `disc.copy(multipass)`,
|
|
||||||
/// return (PatchOutcome ↔ CopyResult, final-map stats, trace length).
|
|
||||||
fn run_profile(
|
|
||||||
profile_name: &str,
|
|
||||||
capacity_sectors: u32,
|
|
||||||
nontrimmed: &[(u64, u64)],
|
|
||||||
finished: &[(u64, u64)],
|
|
||||||
scripted: ScriptedSectorReader,
|
|
||||||
trace: Arc<Mutex<Vec<(u32, u16, bool)>>>,
|
|
||||||
) -> (
|
|
||||||
libfreemkv::disc::CopyResult,
|
|
||||||
libfreemkv::disc::mapfile::MapStats,
|
|
||||||
usize,
|
|
||||||
) {
|
|
||||||
let total_bytes: u64 = 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);
|
|
||||||
|
|
||||||
prep_iso_and_mapfile(&iso_path, total_bytes, finished, nontrimmed);
|
|
||||||
|
|
||||||
let opts = CopyOptions {
|
|
||||||
decrypt: false,
|
|
||||||
multipass: true,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut reader = scripted;
|
|
||||||
let pr = disc
|
|
||||||
.copy(&mut reader, &iso_path, &opts)
|
|
||||||
.unwrap_or_else(|e| panic!("[{profile_name}] disc.copy returned Err: {e:?}"));
|
|
||||||
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
|
||||||
let map = Mapfile::load(&map_path).unwrap();
|
|
||||||
let stats = map.stats();
|
|
||||||
|
|
||||||
let trace_len = trace.lock().unwrap().len();
|
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(&map_path);
|
|
||||||
|
|
||||||
(pr, stats, trace_len)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────── Profile 1: CLEAN ────────────────────────────
|
|
||||||
//
|
|
||||||
// The NonTrimmed range has zero scripted failures — every read succeeds.
|
|
||||||
// Patch should march through the range and mark it Finished. Validates
|
|
||||||
// the happy-path side of the failure-handler dispatch (it shouldn't
|
|
||||||
// fire at all).
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_01_clean_all_recoverable() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
// No scripted errors → all reads succeed.
|
|
||||||
|
|
||||||
let nontrimmed = [(100 * 2048, 16 * 2048)]; // 16-sector NonTrimmed range
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(116 * 2048, (capacity_sectors as u64 - 116) * 2048),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (pr, stats, trace_len) = run_profile(
|
|
||||||
"01_clean",
|
|
||||||
capacity_sectors,
|
|
||||||
&nontrimmed,
|
|
||||||
&finished,
|
|
||||||
reader,
|
|
||||||
trace,
|
|
||||||
);
|
|
||||||
|
|
||||||
let expected = Golden {
|
|
||||||
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");
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_unreadable, expected.bytes_unreadable,
|
|
||||||
"01_clean bytes_unreadable"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_pending, expected.bytes_pending,
|
|
||||||
"01_clean bytes_pending"
|
|
||||||
);
|
|
||||||
assert!(!pr.halted, "01_clean halted");
|
|
||||||
assert!(
|
|
||||||
trace_len <= expected.max_reads,
|
|
||||||
"01_clean trace_len={trace_len} exceeds bound {}",
|
|
||||||
expected.max_reads
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────── Profile 2: ALL MEDIUM ───────────────────────
|
|
||||||
//
|
|
||||||
// Every LBA in the NonTrimmed range returns MEDIUM_ERROR every attempt.
|
|
||||||
// Adaptive-batch drops to count=1 on first batch failure, then each
|
|
||||||
// single-sector read fails → consecutive_failures climbs, damage_window
|
|
||||||
// fills, compute_damage_skip fires, MAX_SKIPS_PER_RANGE caps the work,
|
|
||||||
// remaining bytes stay NonTrimmed (NEVER marked Unreadable inside a
|
|
||||||
// single pass — 2026-05-11 design call).
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_02_all_medium_error() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
for lba in 100..116 {
|
|
||||||
reader.always(
|
|
||||||
lba,
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let nontrimmed = [(100 * 2048, 16 * 2048)];
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(116 * 2048, (capacity_sectors as u64 - 116) * 2048),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (pr, stats, trace_len) = run_profile(
|
|
||||||
"02_all_medium",
|
|
||||||
capacity_sectors,
|
|
||||||
&nontrimmed,
|
|
||||||
&finished,
|
|
||||||
reader,
|
|
||||||
trace,
|
|
||||||
);
|
|
||||||
|
|
||||||
// GOLDEN: the 16-sector bad range stays NonTrimmed (bytes_pending).
|
|
||||||
// Pre-2026-05-11 patch would mark Unreadable here; current code
|
|
||||||
// preserves NonTrimmed so subsequent passes get another shot.
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_good,
|
|
||||||
(capacity_sectors as u64 - 16) * 2048,
|
|
||||||
"02_all_medium bytes_good"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_unreadable, 0,
|
|
||||||
"02_all_medium bytes_unreadable (must NOT be marked terminal in one pass)"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_pending,
|
|
||||||
16 * 2048,
|
|
||||||
"02_all_medium bytes_pending (NonTrimmed retained across passes)"
|
|
||||||
);
|
|
||||||
assert!(!pr.halted, "02_all_medium halted");
|
|
||||||
// Upper bound: every sector probed individually + a few batch-drop
|
|
||||||
// and skip-escalation attempts, PLUS scatter-recovery on each hard
|
|
||||||
// single sector (up to SCATTER_MAX_ATTEMPTS fresh tries, each a
|
|
||||||
// recalibration read + a re-read = +6 reads/sector). Still strictly
|
|
||||||
// bounded — the guard exists to catch an UNBOUNDED retry loop, which
|
|
||||||
// would be in the hundreds.
|
|
||||||
assert!(
|
|
||||||
trace_len <= 200,
|
|
||||||
"02_all_medium trace_len={trace_len} exceeds 200"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────── Profile 3: ALTERNATING GOOD/BAD ───────────────────
|
|
||||||
//
|
|
||||||
// LBAs 100, 102, 104, ... bad; odd LBAs good. Validates that good
|
|
||||||
// sectors interleaved with bad get recovered individually after the
|
|
||||||
// adaptive split (batch-fail → count=1 → per-sector probe).
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_03_alternating_good_bad() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
for lba in (100..116).step_by(2) {
|
|
||||||
reader.always(
|
|
||||||
lba,
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let nontrimmed = [(100 * 2048, 16 * 2048)];
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(116 * 2048, (capacity_sectors as u64 - 116) * 2048),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (pr, stats, trace_len) = run_profile(
|
|
||||||
"03_alternating",
|
|
||||||
capacity_sectors,
|
|
||||||
&nontrimmed,
|
|
||||||
&finished,
|
|
||||||
reader,
|
|
||||||
trace,
|
|
||||||
);
|
|
||||||
|
|
||||||
// GOLDEN: 8 good sectors interleaved should mostly be Finished;
|
|
||||||
// 8 bad stay NonTrimmed. Allow 2 sectors of slop for the actual
|
|
||||||
// bisect cursor advance — converging on alternating bad/good in
|
|
||||||
// a single pass isn't always exact at boundaries with the
|
|
||||||
// size-aware skip cap.
|
|
||||||
let good_total = stats.bytes_good;
|
|
||||||
let baseline_good = (capacity_sectors as u64 - 16) * 2048;
|
|
||||||
let middle_recovered = good_total - baseline_good;
|
|
||||||
assert!(
|
|
||||||
middle_recovered >= 6 * 2048,
|
|
||||||
"03_alternating recovered only {middle_recovered} bytes of 8 good sectors"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
middle_recovered <= 9 * 2048,
|
|
||||||
"03_alternating recovered MORE than scripted good sectors: {middle_recovered}"
|
|
||||||
);
|
|
||||||
assert_eq!(stats.bytes_unreadable, 0, "03_alternating bytes_unreadable");
|
|
||||||
// Remaining must be NonTrimmed (pending), not lost.
|
|
||||||
assert!(
|
|
||||||
stats.bytes_pending > 0,
|
|
||||||
"03_alternating expected NonTrimmed remainder, got bytes_pending=0"
|
|
||||||
);
|
|
||||||
assert!(!pr.halted, "03_alternating halted");
|
|
||||||
// Efficiency guard (catches runaway, not the tier-2 roster). The 8
|
|
||||||
// permanently-bad sectors are now additionally probed by the tier-2
|
|
||||||
// marginal specialists (SlowSpin/FuaRetry/SlowFua/CachePrime/Oscillate/
|
|
||||||
// SpeedSweep) before being left NonTrimmed — bounded, finite extra reads.
|
|
||||||
assert!(
|
|
||||||
trace_len <= 220,
|
|
||||||
"03_alternating trace_len={trace_len} exceeds 220"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────── Profile 4: EDGE-BAD (size-aware-skip canon) ───────
|
|
||||||
//
|
|
||||||
// Bad at start (100..104), good middle (104..112), bad at end (112..116).
|
|
||||||
// This is the size-aware-skip canonical case. The middle good sectors
|
|
||||||
// MUST be recovered — pre-fix patch would skip-escalate across the
|
|
||||||
// whole range and miss them.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_04_edge_bad_good_middle() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
for lba in 100..104 {
|
|
||||||
reader.always(
|
|
||||||
lba,
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for lba in 112..116 {
|
|
||||||
reader.always(
|
|
||||||
lba,
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let nontrimmed = [(100 * 2048, 16 * 2048)];
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(116 * 2048, (capacity_sectors as u64 - 116) * 2048),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (pr, stats, trace_len) = run_profile(
|
|
||||||
"04_edge_bad",
|
|
||||||
capacity_sectors,
|
|
||||||
&nontrimmed,
|
|
||||||
&finished,
|
|
||||||
reader,
|
|
||||||
trace,
|
|
||||||
);
|
|
||||||
|
|
||||||
// GOLDEN: the 8 good middle sectors should land Finished (allowing
|
|
||||||
// 2 sectors of bisection slop at boundaries).
|
|
||||||
let middle_recovered = stats.bytes_good - (capacity_sectors as u64 - 16) * 2048;
|
|
||||||
assert!(
|
|
||||||
middle_recovered >= 6 * 2048,
|
|
||||||
"04_edge_bad recovered only {middle_recovered} bytes of 8 good middle sectors"
|
|
||||||
);
|
|
||||||
assert_eq!(stats.bytes_unreadable, 0, "04_edge_bad bytes_unreadable");
|
|
||||||
assert!(
|
|
||||||
stats.bytes_pending > 0,
|
|
||||||
"04_edge_bad bytes_pending expected > 0"
|
|
||||||
);
|
|
||||||
assert!(!pr.halted, "04_edge_bad halted");
|
|
||||||
assert!(
|
|
||||||
trace_len <= 120,
|
|
||||||
"04_edge_bad trace_len={trace_len} exceeds 120"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────── Profile 5: SINGLE BAD SECTOR ──────────────────────
|
|
||||||
//
|
|
||||||
// 1 bad sector in the middle of an otherwise good 16-sector NonTrimmed
|
|
||||||
// range. Validates the common "stochastic miss in Pass 1, easily picked
|
|
||||||
// up in Pass N" scenario.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_05_single_bad_sector() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
reader.always(
|
|
||||||
108,
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let nontrimmed = [(100 * 2048, 16 * 2048)];
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(116 * 2048, (capacity_sectors as u64 - 116) * 2048),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (pr, stats, trace_len) = run_profile(
|
|
||||||
"05_single_bad",
|
|
||||||
capacity_sectors,
|
|
||||||
&nontrimmed,
|
|
||||||
&finished,
|
|
||||||
reader,
|
|
||||||
trace,
|
|
||||||
);
|
|
||||||
|
|
||||||
// GOLDEN: 15 of 16 sectors recovered. 1 sector stays NonTrimmed
|
|
||||||
// (NOT Unreadable — same multi-pass tolerance principle).
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_good,
|
|
||||||
(capacity_sectors as u64 - 1) * 2048,
|
|
||||||
"05_single_bad bytes_good"
|
|
||||||
);
|
|
||||||
assert_eq!(stats.bytes_unreadable, 0, "05_single_bad bytes_unreadable");
|
|
||||||
assert_eq!(stats.bytes_pending, 2048, "05_single_bad bytes_pending");
|
|
||||||
assert!(!pr.halted, "05_single_bad halted");
|
|
||||||
assert!(
|
|
||||||
trace_len <= 80,
|
|
||||||
"05_single_bad trace_len={trace_len} exceeds 80"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────── Profile 6: DEEP PIT ───────────────────────────────
|
|
||||||
//
|
|
||||||
// A contiguous 8-sector bad pit in the middle of a wider 24-sector
|
|
||||||
// NonTrimmed range. Tests the damage-window threshold + size-aware-skip
|
|
||||||
// converging on the actual pit boundaries instead of bailing on
|
|
||||||
// MAX_SKIPS_PER_RANGE.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_06_deep_pit() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
for lba in 108..116 {
|
|
||||||
reader.always(
|
|
||||||
lba,
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 24 sectors NonTrimmed: 100..108 good, 108..116 BAD, 116..124 good.
|
|
||||||
let nontrimmed = [(100 * 2048, 24 * 2048)];
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(124 * 2048, (capacity_sectors as u64 - 124) * 2048),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (pr, stats, trace_len) = run_profile(
|
|
||||||
"06_deep_pit",
|
|
||||||
capacity_sectors,
|
|
||||||
&nontrimmed,
|
|
||||||
&finished,
|
|
||||||
reader,
|
|
||||||
trace,
|
|
||||||
);
|
|
||||||
|
|
||||||
// GOLDEN: 16 good (8 on each side of the pit) recovered, 8 bad
|
|
||||||
// stay NonTrimmed.
|
|
||||||
let recovered_in_range = stats.bytes_good - (capacity_sectors as u64 - 24) * 2048;
|
|
||||||
assert!(
|
|
||||||
recovered_in_range >= 14 * 2048,
|
|
||||||
"06_deep_pit recovered only {recovered_in_range} bytes of 16 good sectors"
|
|
||||||
);
|
|
||||||
assert_eq!(stats.bytes_unreadable, 0, "06_deep_pit bytes_unreadable");
|
|
||||||
assert!(
|
|
||||||
stats.bytes_pending > 0,
|
|
||||||
"06_deep_pit bytes_pending expected > 0"
|
|
||||||
);
|
|
||||||
assert!(!pr.halted, "06_deep_pit halted");
|
|
||||||
// Bounded as in profile 02: the deep pit's hard single sectors each get
|
|
||||||
// scatter-recovery (up to SCATTER_MAX_ATTEMPTS recalibrate + re-read
|
|
||||||
// tries) on top of the baseline probe/skip walk. Still bounded — a
|
|
||||||
// runaway loop would be in the hundreds.
|
|
||||||
assert!(
|
|
||||||
trace_len <= 180,
|
|
||||||
"06_deep_pit trace_len={trace_len} exceeds 180"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────── Profile 7: MEDIUM-THEN-GOOD ───────────────────────
|
|
||||||
//
|
|
||||||
// First N attempts at each bad LBA fail with MEDIUM_ERROR, then succeed.
|
|
||||||
// Tests whether patch's retry semantics revisit failed sectors. Current
|
|
||||||
// patch dispatches NonTrimmed on first failure and ADVANCES the cursor
|
|
||||||
// — it does NOT retry the same LBA inside one pass for MEDIUM_ERROR
|
|
||||||
// (only NOT_READY retries in-place). So the goldens here are: bad
|
|
||||||
// sectors stay NonTrimmed in this pass (the recovery would happen in a
|
|
||||||
// subsequent pass, which this single-pass fixture does not run).
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_07_medium_then_good() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
// Sectors 105..110: fail twice, then succeed.
|
|
||||||
for lba in 105..110 {
|
|
||||||
reader.sequence(
|
|
||||||
lba,
|
|
||||||
vec![
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
ScriptStep::Ok,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let nontrimmed = [(100 * 2048, 16 * 2048)];
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(116 * 2048, (capacity_sectors as u64 - 116) * 2048),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (pr, stats, trace_len) = run_profile(
|
|
||||||
"07_medium_then_good",
|
|
||||||
capacity_sectors,
|
|
||||||
&nontrimmed,
|
|
||||||
&finished,
|
|
||||||
reader,
|
|
||||||
trace,
|
|
||||||
);
|
|
||||||
|
|
||||||
// GOLDEN (handler-chain engine): with the script "fail, fail, ok" per bad
|
|
||||||
// sector, each bad sector needs three reads to recover. The chain re-reads a
|
|
||||||
// sector across successive handlers — linear reverse/forward (each narrows a
|
|
||||||
// failed batch to per-sector reads) then bisect — so every sector in the
|
|
||||||
// cluster is read enough times to consume its two failing steps and reach
|
|
||||||
// the Ok step WITHIN one pass. So all 256 sectors recover here; none defer.
|
|
||||||
// (The old batch-halving loop reached only 254; the chain is strictly
|
|
||||||
// better because more handlers re-touch each sector.)
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_good,
|
|
||||||
256 * 2048,
|
|
||||||
"07_medium_then_good bytes_good (handler chain re-reads each sector \
|
|
||||||
across handlers, consuming the fail,fail,ok script for the whole cluster)"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_unreadable, 0,
|
|
||||||
"07_medium_then_good bytes_unreadable (NonTrimmed, never terminal in one pass)"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_pending, 0,
|
|
||||||
"07_medium_then_good bytes_pending (whole cluster recovered in one pass)"
|
|
||||||
);
|
|
||||||
assert!(!pr.halted, "07_medium_then_good halted");
|
|
||||||
assert!(
|
|
||||||
trace_len <= 100,
|
|
||||||
"07_medium_then_good trace_len={trace_len} exceeds 100"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ───────────────────── Profile 8: BATCHED-FAIL ONLY ──────────────────────
|
|
||||||
//
|
|
||||||
// LBA 108 fails on BATCH reads (any batch including it) but succeeds
|
|
||||||
// individually. Models a marginal sector that the drive can ECC-recover
|
|
||||||
// when read alone but not at multi-sector throughput. Validates that
|
|
||||||
// adaptive batch's drop-to-count=1 retries the same starting position
|
|
||||||
// and rescues the data.
|
|
||||||
//
|
|
||||||
// Implementation note: the scripted reader marks the entire batch failed
|
|
||||||
// on any failed sector. We can't easily differentiate "single vs batch"
|
|
||||||
// without bigger plumbing — so this profile uses a script that fails
|
|
||||||
// once then succeeds on retry at the same LBA, simulating "drive
|
|
||||||
// recovered after retry."
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_08_batch_fail_singles_ok() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
// Sector 108: fail on first call (which is the batch read), succeed
|
|
||||||
// on second call (the drop-to-count=1 retry at the same position).
|
|
||||||
reader.sequence(
|
|
||||||
108,
|
|
||||||
vec![
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x00,
|
|
||||||
},
|
|
||||||
ScriptStep::Ok,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
let nontrimmed = [(100 * 2048, 16 * 2048)];
|
|
||||||
let finished = [
|
|
||||||
(0, 100 * 2048),
|
|
||||||
(116 * 2048, (capacity_sectors as u64 - 116) * 2048),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (pr, stats, trace_len) = run_profile(
|
|
||||||
"08_batch_fail",
|
|
||||||
capacity_sectors,
|
|
||||||
&nontrimmed,
|
|
||||||
&finished,
|
|
||||||
reader,
|
|
||||||
trace,
|
|
||||||
);
|
|
||||||
|
|
||||||
// GOLDEN: the second attempt succeeds → all 16 sectors recovered.
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_good,
|
|
||||||
capacity_sectors as u64 * 2048,
|
|
||||||
"08_batch_fail bytes_good — second attempt should recover"
|
|
||||||
);
|
|
||||||
assert_eq!(stats.bytes_unreadable, 0, "08_batch_fail bytes_unreadable");
|
|
||||||
assert_eq!(stats.bytes_pending, 0, "08_batch_fail bytes_pending");
|
|
||||||
assert!(!pr.halted, "08_batch_fail halted");
|
|
||||||
assert!(
|
|
||||||
trace_len <= 80,
|
|
||||||
"08_batch_fail trace_len={trace_len} exceeds 80"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// 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`:
|
|
||||||
//
|
|
||||||
// - 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.
|
|
||||||
//
|
|
||||||
// - 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.
|
|
||||||
|
|
||||||
// ──────── Handler chain recovers re-readable sectors inside a bad block ────────
|
|
||||||
//
|
|
||||||
// A bad range holds one genuinely-dead sector surrounded by readable ones. The
|
|
||||||
// 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.)
|
|
||||||
//
|
|
||||||
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
|
|
||||||
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() {
|
|
||||||
let capacity_sectors: u32 = 256;
|
|
||||||
let (mut reader, trace) = ScriptedSectorReader::new(capacity_sectors);
|
|
||||||
// One bad sector at LBA 130 — inside the LOW 32-sector block of the range.
|
|
||||||
reader.always(
|
|
||||||
130,
|
|
||||||
ScriptStep::Err {
|
|
||||||
sense_key: 3,
|
|
||||||
asc: 0x11,
|
|
||||||
ascq: 0x05,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
// 64-sector NonTrimmed range [128,192); everything else already Finished.
|
|
||||||
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,
|
|
||||||
fast_capture: true,
|
|
||||||
};
|
|
||||||
disc.patch(&mut reader, &iso_path, &opts)
|
|
||||||
.expect("fast-capture patch must not error");
|
|
||||||
|
|
||||||
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
|
|
||||||
let stats = Mapfile::load(&map_path).unwrap().stats();
|
|
||||||
|
|
||||||
// The clean sectors of [128,192) all recover; only the one always-dead
|
|
||||||
// sector (LBA 130) stays NonTrimmed — NOT Unreadable. The chain narrows the
|
|
||||||
// failed batch to per-sector reads, so 63 of the 64 range sectors come back.
|
|
||||||
// Conservation: 255 good + 1 still-pending = the full 256, nothing lost.
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_unreadable, 0,
|
|
||||||
"recovery must never mark Unreadable in a pass"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_pending, 2048,
|
|
||||||
"only the single always-dead sector (LBA 130) stays NonTrimmed"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
stats.bytes_good,
|
|
||||||
255 * 2048,
|
|
||||||
"every sector except the one dead LBA is recovered"
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = trace; // read trace retained by the fixture; no ordering assertion here
|
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&iso_path);
|
|
||||||
let _ = std::fs::remove_file(&map_path);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! Tests for the `libfreemkv::scan_iso` entry point — the file-backed scan seam
|
||||||
|
//! that replaced consumers hand-rolling `FileSectorSource::open` +
|
||||||
|
//! `capacity_sectors` + `Disc::scan_image`.
|
||||||
|
//!
|
||||||
|
//! Uses a minimal synthetic UDF image (the same byte-level fixture the
|
||||||
|
//! `disc_tests.rs` `scan_image` tests build, but materialised to a real file on
|
||||||
|
//! disk so the file-backed `FileSectorSource` path is exercised end to end).
|
||||||
|
|
||||||
|
use libfreemkv::{Disc, ScanOptions, SectorSource};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
const SECTOR_SIZE: usize = 2048;
|
||||||
|
|
||||||
|
// ── Minimal UDF sector builders (mirrors disc_tests.rs) ─────────────────────
|
||||||
|
|
||||||
|
fn make_avdp_sector(vds_lba: u32) -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; SECTOR_SIZE];
|
||||||
|
s[0..2].copy_from_slice(&2u16.to_le_bytes());
|
||||||
|
s[16..20].copy_from_slice(&vds_lba.to_le_bytes());
|
||||||
|
s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_pvd_sector(volume_id: &str) -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; SECTOR_SIZE];
|
||||||
|
s[0..2].copy_from_slice(&1u16.to_le_bytes());
|
||||||
|
if !volume_id.is_empty() {
|
||||||
|
let id_bytes = volume_id.as_bytes();
|
||||||
|
s[24] = 8;
|
||||||
|
let copy_len = id_bytes.len().min(30);
|
||||||
|
s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]);
|
||||||
|
s[55] = (1 + copy_len) as u8;
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_partition_desc(partition_start: u32) -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; SECTOR_SIZE];
|
||||||
|
s[0..2].copy_from_slice(&5u16.to_le_bytes());
|
||||||
|
s[188..192].copy_from_slice(&partition_start.to_le_bytes());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_lvd_sector_simple() -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; SECTOR_SIZE];
|
||||||
|
s[0..2].copy_from_slice(&6u16.to_le_bytes());
|
||||||
|
s[268..272].copy_from_slice(&1u32.to_le_bytes());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_terminator() -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; SECTOR_SIZE];
|
||||||
|
s[0..2].copy_from_slice(&8u16.to_le_bytes());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_fsd_sector(root_meta_lba: u32) -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; SECTOR_SIZE];
|
||||||
|
s[0..2].copy_from_slice(&256u16.to_le_bytes());
|
||||||
|
s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
|
||||||
|
s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_dir_icb(data_meta_lba: u32, data_len: u32) -> Vec<u8> {
|
||||||
|
let mut s = vec![0u8; SECTOR_SIZE];
|
||||||
|
s[0..2].copy_from_slice(&266u16.to_le_bytes());
|
||||||
|
s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes());
|
||||||
|
s[208..212].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
s[212..216].copy_from_slice(&8u32.to_le_bytes());
|
||||||
|
s[216..220].copy_from_slice(&data_len.to_le_bytes());
|
||||||
|
s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_parent_fid() -> Vec<u8> {
|
||||||
|
let fid_len = (38 + 3) & !3;
|
||||||
|
let mut fid = vec![0u8; fid_len];
|
||||||
|
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
|
||||||
|
fid[18] = 0x08;
|
||||||
|
fid[19] = 0;
|
||||||
|
fid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the minimal UDF image as an LBA→sector map (empty root directory).
|
||||||
|
fn minimal_udf_sectors() -> BTreeMap<u32, Vec<u8>> {
|
||||||
|
let partition_start: u32 = 512;
|
||||||
|
let mut sectors: BTreeMap<u32, Vec<u8>> = BTreeMap::new();
|
||||||
|
sectors.insert(256, make_avdp_sector(32));
|
||||||
|
sectors.insert(32, make_pvd_sector("TEST_DISC"));
|
||||||
|
sectors.insert(33, make_partition_desc(partition_start));
|
||||||
|
sectors.insert(34, make_lvd_sector_simple());
|
||||||
|
sectors.insert(35, make_terminator());
|
||||||
|
sectors.insert(partition_start, make_fsd_sector(1));
|
||||||
|
|
||||||
|
let parent_fid = make_parent_fid();
|
||||||
|
let dir_data_len = parent_fid.len() as u32;
|
||||||
|
sectors.insert(partition_start + 1, make_dir_icb(2, dir_data_len));
|
||||||
|
let mut sector = vec![0u8; SECTOR_SIZE];
|
||||||
|
sector[..parent_fid.len()].copy_from_slice(&parent_fid);
|
||||||
|
sectors.insert(partition_start + 2, sector);
|
||||||
|
sectors
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materialise an LBA→sector map to a real ISO file (zero-filled gaps) and
|
||||||
|
/// return its path (kept alive by the returned tempfile handle).
|
||||||
|
fn write_iso(sectors: &BTreeMap<u32, Vec<u8>>) -> tempfile::NamedTempFile {
|
||||||
|
let max_lba = *sectors.keys().max().unwrap();
|
||||||
|
let mut image = vec![0u8; (max_lba as usize + 1) * SECTOR_SIZE];
|
||||||
|
for (&lba, data) in sectors {
|
||||||
|
let off = lba as usize * SECTOR_SIZE;
|
||||||
|
image[off..off + SECTOR_SIZE].copy_from_slice(data);
|
||||||
|
}
|
||||||
|
let mut tmp = tempfile::Builder::new()
|
||||||
|
.suffix(".iso")
|
||||||
|
.tempfile()
|
||||||
|
.expect("tempfile create");
|
||||||
|
tmp.write_all(&image).expect("write iso");
|
||||||
|
tmp.flush().expect("flush iso");
|
||||||
|
tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_iso_matches_manual_scan_image_path() {
|
||||||
|
let sectors = minimal_udf_sectors();
|
||||||
|
let expected_capacity = *sectors.keys().max().unwrap() + 1;
|
||||||
|
let tmp = write_iso(§ors);
|
||||||
|
|
||||||
|
// The new entry point.
|
||||||
|
let (disc, mut reader) =
|
||||||
|
libfreemkv::scan_iso(tmp.path(), ScanOptions::default()).expect("scan_iso succeeds");
|
||||||
|
|
||||||
|
// Parity with the old hand-rolled triple: open a fresh reader and run the
|
||||||
|
// exact composition scan_iso encapsulates. The resulting Disc must match.
|
||||||
|
let mut manual_reader = libfreemkv::FileSectorSource::open(tmp.path()).expect("manual open");
|
||||||
|
let manual_capacity = manual_reader.capacity_sectors();
|
||||||
|
let manual = Disc::scan_image(&mut manual_reader, manual_capacity, &ScanOptions::default())
|
||||||
|
.expect("manual scan_image succeeds");
|
||||||
|
|
||||||
|
assert_eq!(disc.capacity_sectors, manual.capacity_sectors, "capacity");
|
||||||
|
assert_eq!(disc.titles.len(), manual.titles.len(), "title count");
|
||||||
|
assert_eq!(disc.encrypted, manual.encrypted, "encrypted flag");
|
||||||
|
assert_eq!(disc.format, manual.format, "disc format");
|
||||||
|
|
||||||
|
// Independent expectations (not parity against a re-run of the same
|
||||||
|
// composition): the scanned Disc must match KNOWN properties of the fixture
|
||||||
|
// itself — its capacity (max LBA + 1), its PVD volume id ("TEST_DISC"), and
|
||||||
|
// that a UDF with no /AACS directory is unencrypted. These would fail even if
|
||||||
|
// scan_iso and the manual path drifted together.
|
||||||
|
assert_eq!(disc.capacity_sectors, expected_capacity, "capacity value");
|
||||||
|
assert_eq!(
|
||||||
|
disc.volume_id, "TEST_DISC",
|
||||||
|
"PVD volume id from the fixture"
|
||||||
|
);
|
||||||
|
assert!(!disc.encrypted, "minimal UDF (no /AACS) is not encrypted");
|
||||||
|
|
||||||
|
// The returned reader is usable: correct capacity and a real read of sector
|
||||||
|
// 256 (the AVDP) returns the bytes we wrote — proves it is not consumed /
|
||||||
|
// exhausted by the scan.
|
||||||
|
assert_eq!(
|
||||||
|
reader.capacity_sectors(),
|
||||||
|
expected_capacity,
|
||||||
|
"reader capacity"
|
||||||
|
);
|
||||||
|
let mut buf = vec![0u8; SECTOR_SIZE];
|
||||||
|
let n = reader
|
||||||
|
.read_sectors(256, 1, &mut buf, false)
|
||||||
|
.expect("read AVDP sector");
|
||||||
|
assert_eq!(n, SECTOR_SIZE);
|
||||||
|
assert_eq!(&buf[..], §ors[&256][..], "AVDP sector bytes round-trip");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_iso_propagates_open_error() {
|
||||||
|
// A path that does not exist must surface an Err (not a panic) — kills a
|
||||||
|
// mutant that ignores the open failure.
|
||||||
|
let missing = std::path::Path::new("/nonexistent/does-not-exist.iso");
|
||||||
|
let result = libfreemkv::scan_iso(missing, ScanOptions::default());
|
||||||
|
assert!(result.is_err(), "missing file must error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_iso_propagates_scan_error() {
|
||||||
|
// A readable file with no valid UDF (no AVDP at sector 256) must surface the
|
||||||
|
// scan failure — kills a mutant that swallows the scan_image error.
|
||||||
|
let mut tmp = tempfile::Builder::new()
|
||||||
|
.suffix(".iso")
|
||||||
|
.tempfile()
|
||||||
|
.expect("tempfile create");
|
||||||
|
tmp.write_all(&vec![0u8; 8 * SECTOR_SIZE]).expect("write");
|
||||||
|
tmp.flush().expect("flush");
|
||||||
|
let result = libfreemkv::scan_iso(tmp.path(), ScanOptions::default());
|
||||||
|
assert!(result.is_err(), "non-UDF image must error");
|
||||||
|
}
|
||||||
+5
-5
@@ -591,7 +591,7 @@ fn meta_all_stream_types() {
|
|||||||
fn mkvstream_write_finish() {
|
fn mkvstream_write_finish() {
|
||||||
let dt = sample_disc_title();
|
let dt = sample_disc_title();
|
||||||
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
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
|
// Write some fake PES frames (they won't produce valid MKV content
|
||||||
// since there is no real codec data, but it should not panic)
|
// 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() {
|
fn mkvstream_meta_sets_title() {
|
||||||
let dt = sample_disc_title();
|
let dt = sample_disc_title();
|
||||||
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> = Box::new(Cursor::new(Vec::new()));
|
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);
|
let info = PesStream::info(&stream);
|
||||||
assert_eq!(info.playlist, "Test Movie");
|
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 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
|
// Write PES frames targeting the audio track
|
||||||
for i in 0..10u8 {
|
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 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);
|
let info = PesStream::info(&stream);
|
||||||
assert_eq!(info.streams.len(), 5, "all 5 streams should be preserved");
|
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> =
|
let writer: Box<dyn libfreemkv::mux::WriteSeek + Send> =
|
||||||
Box::new(SharedWriter(output2.clone()));
|
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.
|
// Write the ES data (SPS+PPS+IDR) as a keyframe PES frame.
|
||||||
let frame1 = libfreemkv::pes::PesFrame {
|
let frame1 = libfreemkv::pes::PesFrame {
|
||||||
|
|||||||
Reference in New Issue
Block a user