Compare commits

..
49 Commits
Author SHA1 Message Date
Matthew Jackson b65124728c v1.7.2: bump version (freemkv-unlock git-pinned for the tag) 2026-09-18 18:37:21 +00:00
Matthew Jackson 10d073286c changelog: 1.7.2 — record the SET data-phase unlock fix + READ CAPACITY/empty-ISO hardening 2026-09-18 06:55:00 -07:00
Matthew Jackson 89fd1a6e4d merge: record v1.7.1 main into dev (dev already at 1.7.1; keep path deps) 2026-09-17 23:08:09 -07:00
Matthew Jackson 5c9e2fefe5 comment-guard: trim over-cap comment blocks to caps (comments only, no code change)
Bring every flagged comment block within its cap (inline // <=3 lines,
/// item-doc prose <=8): tighten wording, or convert an over-long inline
block that documents the item below it into a /// doc, preserving all
technical facts. No code, string literals, or logic changed.
2026-09-17 22:47:32 -07:00
Matthew Jackson 041ac5876f test: make case-collision test host-FS-independent
Probe dir_is_case_insensitive against the real target volume by creating
the dir first. Previously the test probed a not-yet-created dir, which
returns a conservative true; that disagreed with extract_tree's own
post-mkdir probe on a case-sensitive host (Linux CI), panicking the test.
Both branches (collision on insensitive, clean coexist on sensitive) still
verified.
2026-09-17 22:37:17 -07:00
Matthew Jackson e0875b630c docs: fix broken intra-doc links
- decrypt.rs: three refs to the #[cfg(test)]-only decrypt_sectors_mapped
  wrapper can't resolve in a non-test doc build; de-link to inline code
  (keeps the name, no broken link).
- Fix DecryptingSectorSource::with_content_ranges link to its full
  crate::sector path so it resolves.
2026-09-17 22:37:17 -07:00
Matthew Jackson d5902ff9f9 deps: sha1 0.10→0.11 (collapse RustCrypto digest/block-buffer/crypto-common dup); drop unused rand 2026-09-17 22:18:07 -07:00
Matthew Jackson 01cd85f514 changelog: open 1.7.2 — UNRELEASED (unified release; firmware ABI v2 in freemkv-unlock) 2026-09-17 14:35:17 -07:00
Matthew Jackson 6de61484a3 docs: point the freemkv-unlock path-dep note at release-orchestrate.yml (release.sh is gone) 2026-09-17 14:24:49 -07:00
Matthew Jackson 764519de43 aacs/disc: test coverage for VID key-derivation + handshake route selection; diag: print unknown mkb_version 2026-09-15 20:37:18 -07:00
Matthew Jackson 2fb7d78b29 libfreemkv: retry READ CAPACITY for ISO sizing; hard-error if unavailable
READ CAPACITY is the only authoritative whole-disc size for an image
(the UDF partition is a subset — measured 288 sectors / ~576 KB short on
a real BD, which would truncate the backup anchor). Its field failures
are sporadic (5/5 reliable on the target drive), so retry it a few times
rather than substituting a short value. On persistent failure the shared
scan/identify path stays lenient (capacity 0; MKV reads by extent) and
image_read_sectors() returns EmptyImage so an ISO fails cleanly instead
of writing a truncated or empty file.
2026-09-15 16:45:48 -07:00
Matthew Jackson 18024ea5f5 scsi/macos: fix io_service_t leak, honor bsd_name in fallback, fix FFI doc
shim_list_drives: reorder the drain loop condition so IOIteratorNext is
not called on the exit iteration, releasing every obtained io_service_t.

shim_open_exclusive: the fallback matcher ignored the requested bsd_name
and could open a different drive; iterate and accept only the candidate
whose IOMedia BSD name matches, else fail. Correct the misleading da_hold
TOCTOU comment.
2026-09-15 14:40:54 -07:00
Matthew Jackson 2287bb4449 labels/pes/disc: overflow + robustness fixes
class_reader: checked pc + size add (guards 32-bit overflow) and remove
the dead BadInstruction variant.

criterion: dedup duplicate stream numbers instead of emitting a number
twice. deluxe: continue past a bad studio attribute instead of aborting
the whole parse. bdmt: treat mixed-content XML (embedded markup) as XML
so it is dropped, without over-rejecting a bare '<' in prose.

pes: grow the frame buffer incrementally instead of an eager
MAX_FRAME_SIZE allocation. extract: give the case-probe a unique random
suffix so concurrent probes cannot collide (TOCTOU-safe).
2026-09-15 14:40:47 -07:00
Matthew Jackson 013cc3ce8d net/error/hex: NAT64 SSRF arm, Dir Display arms, reject leading +
network: add the NAT64 (64:ff9b::/96) arm to the IP classifier -
extract the embedded IPv4 from the last 32 bits and re-check it, matching
the keysources canonical copy, so a NAT64-wrapped private/special
address is blocked like the bare IPv4.

error: add Display arms for DirInsufficientSpace (E9027), DirNameCollision
(E9028), DirWriteFailed (E9029) so their fields render (codes-only, like
the sibling Dir variants).

hex: reject a leading + in the hex int parser.
2026-09-15 14:40:40 -07:00
Matthew Jackson ab2416d07a aacs: propagate MKB walk errors, add bounds/truncation guards, fix FMTS carry
inf: read_mkb_from_drive used .is_ok() after the first pack, silently
truncating the MKB on a mid-walk transport error; propagate the error
instead. Add checked offset guards (94/117) and reject a truncated title
table (161) like the key list already does.

segment: the FMTS key-range carry assumed 2048-aligned segment starts;
use the floor-difference of the actual LBAs so a non-2048-aligned
segment carries correctly.

Annotate css::keyless::recover_title_key as a test-only helper and
correct the clpi_audit dedup-by-PID doc.
2026-09-15 14:40:33 -07:00
Matthew Jackson b735fb3730 mux/codec: PTS carry, buffer cap, EOS core drop, PPS-by-id, dead code
lpcm: carry last PTS + DTS fallback (add last_pts_ns) and propagate
pes.discontinuity instead of hardcoding false, matching adts/flac/
mpegaudio.

pesbuf: add an internal MAX_BUFFERED_BYTES cap (defense-in-depth like
reorder.rs's MAX_GOP) so a misbehaving caller can't grow it unbounded.

dts: drop a truncated trailing core at end-of-stream instead of emitting
it, while keeping genuine trailing extensions.

hevc: resolve num_extra_slice_header_bits from the PPS the slice
references (slice_pic_parameter_set_id), not the last-active PPS.

crc: remove the dead, misnamed crc8_atm (impl was CRC-8/SMBUS, no
callers). Add the missing drop-gate mismatch test; fix the startcode
read_bits contract doc and the h264 SEI comment.
2026-09-15 14:40:26 -07:00
Matthew Jackson f1b7a73b93 mux: harden MKV child loops, read real mp4 sample rate, fix pre-video PTS origin
mkvstream: add the consumed > remaining => MkvSourceInvalid guard (as
BLOCK_GROUP already had) to the INFO/TRACKS/TrackEntry/Video/Audio child
loops, and route the DURATION/SAMPLING_FREQUENCY float reads through the
checked_size cap instead of truncating cs as usize.

mp4/read: audio sample_rate was taken from the mdhd timescale, ignoring
the AudioSampleEntry 16.16 samplerate at the stsd body; read the real
field and fall back to timescale only when it is 0. Drop the unreachable
MAX_TRACKS break (the box scan is already capped) for a debug_assert.

tsmux + m2ts_mux: PTS origin was seeded on video only, so audio
preceding the first video (or an audio-only stream) rebased onto its own
PTS and lost A/V offset; seed the origin from the first frame of any
kind.
2026-09-15 14:40:18 -07:00
Matthew Jackson fbe91895c4 mux: fix filtered-export drop gate, log skipped key-map, restore json fields
demux_sink: dropped_total() folded drops across ALL tracks while
frames_mapped now counts only persisted frames, so a filtered
(sub://audio://) multi-clip export could false-trip SeamPlanDroppedMost /
SinkWroteNothing. Compare drops and mapped frames over the same
persisted-track set via TimelineContinuity::dropped_for; update the
frames_mapped doc to the persisted-only semantics.

resolve: resolve_mux_key_map(...).ok() swallowed the error silently
(TrueHD channel correction skipped with no trace); log it like the
sibling reopen() fix before skipping.

meta_sink: TitleProfile::from_title dropped json:// stream fields
(pid, color/aspect/dimensions/interlaced/mvc, audio secondary/
sample_rate/purpose, subtitle pid/descriptive_service); restore them at
the json document level.
2026-09-15 14:40:09 -07:00
Matthew Jackson 679894e5ea aacs/disc/drive: fix bus-key gate credit, shift guard, tray-unlock log
disc/encrypt: on the disc-keyed cert route, drive_unlocked was derived
from is_drive_unlocker(matched), which is provably always false -
run_bus only ever dispatches the disc unlockers (AACS/DVD), never a
firmware/drive unlocker. Hardcode false with an honest comment; the
genuine drive-unlock case (drive_unlocked, no read_data_key) is the
OEM-VID short-circuit and the bus-key gate already credits it. Gate the
now test-only is_drive_unlocker behind cfg(test) and add a regression
test through resolve_vid_only.

aacs/derive: resolve_dk_node looped 0..u_mask_shift shifting 1u32 << b,
which panics/wraps when the disc/keydb-controlled u_mask_shift is >= 32.
Cap the search at the 32 real bit positions.

drive: unlock_tray dropped its SCSI result with let _; warn! on failure
so a stuck medium-removal PREVENT lock is visible. Fix the stale
probe_disc doc (now a no-op; calibration moved into the unlocker).
2026-09-15 14:39:53 -07:00
Matthew Jackson b7914c5995 decrypt: honor with_content_ranges in the mapped AACS read path
DecryptingSectorSource::with_content_ranges was a silent no-op: the
proactive AACS key_map path early-returned before consulting the content
extents, and decrypt_span ignored its _content argument. A whole-disc
reader (sweep/patch) that installed both a key map and content ranges
therefore decrypted/verified clear UDF/BDMV-nav units, and its
misaligned filesystem reads tripped the AACS unit-alignment gate.

Thread the content extents through decrypt_sectors_mapped_in_content ->
decrypt_span -> apply_aacs_map, skipping any unit whose LBA falls outside
every content range (never decrypted, verified, or counted as loss), and
gate the alignment check on span_touches_content so out-of-content
filesystem reads pass through untouched.

Also drop the redundant is_multiple_of clause in the prefetched
unit-tail guard (remaining > 0 is already guaranteed).
2026-09-15 14:39:31 -07:00
Matthew Jackson 747aeba4a4 error: drop premature E7031 KeyServiceConfig variant
Reverts the cross-repo E7031 addition: it forced enumerating the code across
freemkv test_support + freemkv-i18n's 29 locales for a LOW config-fault nicety.
keysources config faults keep borrowing the transient key-service code until a
coordinated i18n release; decrypt_bus-pub and the SSRF-parity additions stay.
2026-09-15 13:46:06 -07:00
Matthew Jackson 034a8b145b fix: cross-repo integration — widen decrypt_bus, SSRF parity, KeyServiceConfig
- aacs/content: widen decrypt_bus to pub so freemkv-drivetest can invert
  AACS 2.0 bus encryption out-of-tree (was pub(crate), broke that build).
- mux/network: port the 6to4 (2002::/16) and Teredo (2001:0000::/32)
  tunnel-decode arms and the 198.18.0.0/15 + 192.0.0.0/24 blocks from the
  canonical keysources SSRF classifier so both cover the same set.
- error: add distinct KeyServiceConfig variant (E_KEY_SERVICE_CONFIG 7031)
  for the standing key-service misconfiguration fault, separate from the
  transient KeyServiceUnavailable; keysources' online.rs adopts it later.
2026-09-15 13:36:47 -07:00
Matthew Jackson 6720ccdf8b style: cargo fmt the audit changes 2026-09-15 11:02:53 -07:00
Matthew Jackson 24ab408559 test/labels: cover dirimage nesting cap, long_ad dir, paramount feature hint
- dirimage: add a MAX_DEPTH nesting-cap test that walks a real
  deeper-than-cap directory chain and expects DirImageTooLarge.
- udf: add a long_ad (ICB flags = 1) directory read test — the LBA sits
  at AD offset 4, not 12 like extended_ad.
- labels/paramount: derive the feature hint's id and filename from one
  parsed number (canonical 5-digit NNNNN.mpls) so they can't disagree,
  the same fix already applied to fox, and add a hint-parse test.
2026-09-15 11:02:04 -07:00
Matthew Jackson 6e427e2eac test(scsi/linux): cover the fd-reopen path helpers
Add unit tests for to_c_path (single trailing NUL, path bytes preserved
for libc::open) and resolve_to_sg (sg passthrough plus the no-filename
and non-sr/sg fallbacks that need no sysfs) — the helpers the open /
drive_has_disc / recovery reopen path relies on. Verified compiling for
x86_64-unknown-linux-gnu (this module is Linux-only).
2026-09-15 10:58:38 -07:00
Matthew Jackson e130528fea test: cover drive tray/speed rejection, bdnav Call ops, mkv interlace patch
- drive: SET CD SPEED and PREVENT/ALLOW MEDIUM REMOVAL are best-effort; a
  drive rejection must warn, not fail the rip. New test drives a failing
  transport through set_speed/lock_tray/unlock_tray.
- bdnav: CallObject/CallTitle follow the same play path as JumpObject/
  JumpTitle for feature resolution; add tests for both.
- mkv: FlagInterlaced is corrected at finish() to the whole-stream scan
  majority; add a test that a progressive leader on an interlaced feature
  is patched from progressive to interlaced.
2026-09-15 10:56:26 -07:00
Matthew Jackson 0de2e598ef dvdnav(test): cover Vm set-ops (incl divide/mod by zero) and First-Play SetSystem
Add a direct Vm::set test exercising mov/add/sub/mul/div/mod/and/or/xor
and the divide- and modulo-by-zero guards (register left intact, no
panic) plus an unmodelled-op no-op, and a First-Play test that a
SetSystem instruction keeps resolution running so a following JumpTT
still resolves.
2026-09-15 10:51:23 -07:00
Matthew Jackson d0a5a90261 cleanup: remove dead MOBJ cmd cap, add early-break and doc clarifications
- bdnav/mobj: drop the unreachable MAX_CMDS cap (num_cmds is a u16 and the
  span is already bounded against the input) and its flagged NOTE.
- drive/wait_ready: break out immediately on a dead bus (transport
  failure) instead of polling a phantom for the full ~30 s; add a test.
- css/is_scrambled_pack: document that the 0x11 stream_id offset assumes
  zero pack stuffing (true for DVD-Video VOB packs) and why a stuffed pack
  degrades safely.
- scsi/macos_shim: document why releasing g_handle_lock before da_hold is
  TOCTOU-safe (da_hold re-validates the handle under the lock).
2026-09-15 10:49:16 -07:00
Matthew Jackson be296b3901 mux(timeline): tag provisionals with a monotonic epoch counter
passive_offset tagged a provisional offset with epoch_offsets.len() as
the epoch identity, but that length is capped at MAX_EPOCHS. Past the cap
the ring's remove+push leaves len() constant, so a provisional captured
then compared equal to the current identity forever and never retired,
drifting that track off every other one for the rest of the title. Add a
monotonic epoch_seq bumped once per open_epoch and tag provisionals with
it. Add a past-the-cap retirement test.
2026-09-15 10:45:35 -07:00
Matthew Jackson b30b06e04e mpls/ifo: fix STN gate off-by-one and pin the PGC palette chroma order
mpls: the STN table gate required item.len() > STN_OFFSET + 16, one byte
more than the 16-byte header actually spans, skipping a first play item
whose STN header ends exactly at the item boundary. Use >=, with a
boundary test.

ifo: the subtitle palette layout [padding, Y, Cr, Cb] (Cr BEFORE Cb) was
correct but under-documented; harden the comment against a future
byte-swap and add a test that cross-checks the parse against the DvdSub
ycbcr_to_rgb consumer so the two read Cr/Cb the same way.
2026-09-15 10:42:37 -07:00
Matthew Jackson e6d86b3582 labels: fix fox hint field disagreement and deluxe bare studio anchor
fox: playlist_id and filename were derived independently from the raw id,
so a 5-digit id above u16::MAX left playlist_id None while the filename
stayed Some — a half-hint naming different things. Derive both from one
parsed number via a feature_hint() helper with the canonical NNNNN.mpls
filename, and test that the fields agree.

deluxe: parse_studio_attr anchored on a bare find("studio"), which also
matched substudio=/studioId= and the word 'studio' inside another
attribute's value, then read a later attribute's quotes as the studio.
Require a word boundary before it and '=' after, and add anchoring tests.
2026-09-15 10:38:40 -07:00
Matthew Jackson 2a701a217a dirimage: refuse a read that begins past the image's capacity
read_sectors fabricated zeros and reported success for any LBA, including
one at or beyond the image's own capacity — an oversized/crafted offset
that a real file/ISO source rejects. Refuse a read whose START is past
capacity (as read_exact does past EOF) while still zero-padding a batch
that starts in range and overshoots the end, which the mux's final batch
and the FS scan's fixed-LBA probes rely on. Split the read-range test to
pin both behaviours.
2026-09-15 10:33:26 -07:00
Matthew Jackson 90e8e99152 mux(resolve): surface the reopen error instead of swallowing it
The TrueHD channel-correction probe's reopen closure returned Option<S>
via .ok(), collapsing a real io::Error into an indistinguishable None.
Make reopen return io::Result<S> and log the actual error on the failure
arm so a failed re-open is diagnosable, not silent.
2026-09-15 10:33:26 -07:00
Matthew Jackson 1edacf294f unlock: derive unlocker names/classification from the real unlockers
unlocker_names() was a hand-maintained list that could drift from the
unlockers that actually dispatch, and the cert route hardcoded
drive_unlocked:false. Factor the firmware and disc unlocker sets into
single constructors, derive unlocker_names() and a new is_drive_unlocker()
from their real .name(), and set HandshakeResult.drive_unlocked from
whichever unlocker claimed the drive so the bus-key gate credits a drive
unlock even on the cert dispatch path. Add derivation and classification
tests.
2026-09-15 10:27:09 -07:00
Matthew Jackson 7350577d91 disc(extract): fold host-path collisions by the target volume's case rule
The extract collision check always folded names to lowercase, so two
disc files differing only by case (Movie/movie) aborted the extract even
on a case-sensitive volume where they are distinct, coexisting files.
Probe the real target directory's case-sensitivity and fold only when the
host would (APFS/NTFS), defaulting to case-insensitive when the probe
can't run. Update the case-collision test to track the real volume, and
drop a dead duplicate pack-start write in the CSS cross-contamination test.
2026-09-15 10:24:37 -07:00
Matthew Jackson b985618fb5 mux(dts): continue the timeline for a PTS-less PES instead of resetting
When a PTS-less PES arrived with pending_pts invalidated (e.g. after a
forced flush), the fallback fell straight to 0, snapping the timeline
backwards. Add continuation_base_ns() to reach for the projected next-AU
start, then the last PES front, before conceding 0, and stop stashing a
drained buffer's stale front mark as pending_pts. Add a fallback test.
2026-09-15 10:22:04 -07:00
Matthew Jackson d3c4ce4a60 udf: read every FID extent of a directory, honouring the AD chain
read_directory read only the FIRST allocation descriptor of an out-of-
line directory, so a FID list spanning multiple extents was truncated at
the first boundary, and a type-3 continuation descriptor was mis-read as
FID data. Walk the whole descriptor list (types 0/1/2 as allocated FID
extents with the 2-bit type masked out of the length; type 3 followed as
a continuation block, bounded by MAX_AD_BLOCKS), gathering every extent
into one buffer while tracking the declared FID-list length separately
from the sector-rounded buffer. Bound the FID walk and the name read by
that declared length, and read a FID whose 38-byte header ends exactly at
the boundary (<=, not <) while still rejecting one whose name spills past
it. Add a multi-extent directory test.
2026-09-15 10:18:51 -07:00
Matthew Jackson f4a48480df mux(h264): gate open-GOP promotion on all-intra slices and base view
The open-GOP keyframe promotion trusted only the first slice's coding
type and wasn't gated on the base view. A picture whose first slice is I
but whose later slices are P/B is not a clean random-access point, and an
intra MVC dependent view is never an independent anchor. Track every VCL
slice's coding type (all_vcl_intra) and require a real VCL slice, then
promote only when all slices are intra AND !mvc. Add mixed-slice and MVC
non-promotion tests.
2026-09-15 10:06:20 -07:00
Matthew Jackson aad821c8a2 dirimage: count FID-list sectors in the metadata ceiling
The MAX_META_BYTES guard multiplied (dir_count + file_count) by the
sector size, counting one File Entry per node but omitting each
directory's FID-list data sectors. A wide, shallow tree's directory data
can dwarf the per-node entries, so an oversized folder could slip past
the guard and be materialized in full. Gate on metadata_block_count()
instead, which already sums File Entries and FID-list sectors, and add a
test proving the FID-list sectors are counted.
2026-09-15 10:04:12 -07:00
Matthew Jackson 959bba919d mux(demux): count only persisted frames toward frames_mapped
frames_mapped incremented for every mapped frame, including kind-filtered
ones that write to no track file (e.g. video during an audio:// export).
That inflated the denominator so a seam plan dropping every persisted
frame could ship zero-byte files at exit 0. Move the increment inside the
write block so it counts persisted frames only, and add a test where more
frames are seam-dropped than persisted -> SeamPlanDroppedMost fires.
2026-09-15 10:02:53 -07:00
Matthew Jackson ff3e46a560 error: give ImageTruncated a Display arm carrying have/want
ImageTruncated fell through to the bare-code catch-all, dropping the
have/want byte lengths a truncated-resume report needs. Add an explicit
arm emitting them in the identifier-free E{code} have/want form.
2026-09-15 10:01:14 -07:00
Matthew Jackson 14f97aacd6 aacs: fall through a primary DiscRead to the DUPLICATE copy
read_first propagated every non-UdfNotFound error immediately, so a
DiscRead on the primary Unit_Key_RO.inf aborted instead of trying the
/AACS/DUPLICATE/ backup that AACS ships for exactly this case. Treat a
DiscRead as retriable like an absent candidate: continue to the next
path, remembering the error, and only propagate it once every candidate
has been tried (still more informative than a bare AacsNoKeys). Add
fall-through and propagate-after-exhaustion tests.
2026-09-15 10:01:14 -07:00
Matthew Jackson 5c911d1573 mp4: reject hostile 64-bit largesize in find_boxes_capped
find_boxes_capped used plain `pos + box_size` on an attacker-controlled
64-bit largesize cast to usize. A value near usize::MAX at a nonzero pos
wraps past the length guard, producing a release panic on the payload
slice or an infinite loop on the advance. Mirror read_moov: compute the
end once with checked_add, reject on overflow or overrun, and reuse it
for the guard, slice, and advance. Add the hostile-largesize test.
2026-09-15 09:59:35 -07:00
Matthew Jackson cdc1d17993 v1.7.1: bump version (freemkv-unlock git-pinned for the tag) 2026-09-14 03:34:47 +00:00
Matthew Jackson 7b4a0b31d5 docs(changelog): 1.7.1 release notes (LibreDrive unlock fix) 2026-09-13 20:02:14 -07:00
Matthew Jackson a85764da38 ci: bump actions/checkout v4->v7 in identity-guard (dependabot) 2026-09-14 01:30:58 +00:00
Matthew Jackson 5af336776c chore: bump version to 1.7.1 (unified release lockstep) 2026-09-14 01:22:18 +00:00
Matthew Jackson 86c2c3c501 Add Sponsor badge to README 2026-09-06 23:29:33 +00:00
Matthew Jackson c280478ef9 Add FUNDING.yml 2026-09-06 23:25:41 +00:00
64 changed files with 4364 additions and 488 deletions
+1
View File
@@ -0,0 +1 @@
github: MattJackson
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Reject banned author/committer emails
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [1.7.2] — 2026-09-18
### Fixed
- ISO imaging: the READ CAPACITY (disc-size) query is now retried, with a UDF partition-size fallback, and `image_read_sectors()` hard-errors (`EmptyImage`) when the size is unavailable instead of writing a silent 0-byte ISO reported as success. Fixes an intermittent empty ISO caused by a transient capacity-query transport failure (shared CLI/autorip engine).
### Changed
- Unified release with freemkv-unlock 1.7.2 (firmware ABI v2).
## [1.7.1] — 2026-09-14
### Changed
- Unified release with freemkv-unlock 1.7.1 (LibreDrive profile-match fix). No functional changes to this crate.
## [1.7.0] — 2026-09-02
### Fixed
Generated
+37 -71
View File
@@ -10,7 +10,7 @@ checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.1",
"cpufeatures",
]
[[package]]
@@ -40,15 +40,6 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
@@ -87,7 +78,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"cpufeatures",
"rand_core",
]
@@ -97,7 +88,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"crypto-common",
"inout",
]
@@ -113,15 +104,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -174,16 +156,6 @@ version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
@@ -193,25 +165,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"block-buffer",
"const-oid",
"crypto-common 0.2.2",
"crypto-common",
]
[[package]]
@@ -259,7 +221,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.0"
version = "1.7.1"
dependencies = [
"aes",
"base64",
@@ -271,6 +233,7 @@ dependencies = [
"sha1",
"sha2",
"tracing",
"zeroize",
]
[[package]]
@@ -288,16 +251,6 @@ dependencies = [
"windows-result",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@@ -364,7 +317,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.7.0"
version = "1.7.1"
dependencies = [
"aes",
"base64",
@@ -373,7 +326,6 @@ dependencies = [
"libc",
"loom",
"memchr",
"rand",
"rayon",
"roxmltree",
"serde",
@@ -627,13 +579,13 @@ dependencies = [
[[package]]
name = "sha1"
version = "0.10.7"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
"cpufeatures",
"digest",
]
[[package]]
@@ -643,8 +595,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
"cpufeatures",
"digest",
]
[[package]]
@@ -803,12 +755,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -833,6 +779,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zip"
version = "8.6.0"
@@ -873,12 +839,12 @@ dependencies = [
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.0"
version = "1.7.1"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.0"
version = "1.7.1"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.0"
version = "1.7.1"
+7 -8
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.7.0"
version = "1.7.2"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -42,7 +42,6 @@ rip = [
"dep:sha1",
"dep:aes",
"dep:freemkv-unlock",
"dep:rand",
"dep:zip",
"dep:base64",
"dep:roxmltree",
@@ -54,13 +53,13 @@ rip = [
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
sha1 = { version = "0.10", optional = true }
sha1 = { version = "0.11", optional = true }
aes = { version = "0.9", optional = true }
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.0", optional = true }
rand = { version = "0.10", optional = true }
# Interim path dep for local cross-repo dev; `release-orchestrate.yml` re-pins
# this to `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` in the tagged commit
# (so the released tag resolves freemkv-unlock from git, not a sibling path) and
# restores the path dep on the branch tip.
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.7.2", optional = true }
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
base64 = { version = "0.23", optional = true }
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
+2
View File
@@ -1,3 +1,5 @@
[![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-ea4aaa?logo=github-sponsors)](https://github.com/sponsors/MattJackson)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![CI](https://github.com/freemkv/libfreemkv/actions/workflows/ci.yml/badge.svg)](https://github.com/freemkv/libfreemkv/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/freemkv/libfreemkv/branch/dev/graph/badge.svg)](https://codecov.io/gh/freemkv/libfreemkv)
+109 -1
View File
@@ -258,7 +258,12 @@ pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector.
pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
///
/// Widened from `pub(crate)` to `pub` (behavior unchanged) so an out-of-tree
/// diagnostic tool can invert AACS 2.0 bus encryption on sampled units after an
/// OEM host-cert AKE yields the Read Data Key — the same de-bus step the rip
/// path applies internally.
pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
// Expand the key schedule ONCE for the whole unit: `read_data_key` is
// loop-invariant, but per-sector `aes_cbc_decrypt` rebuilt it 3x per unit
// (~29M redundant expansions over a 90GB read) on the decrypt hot path.
@@ -275,6 +280,25 @@ pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
}
}
/// Test-only exact inverse of [`decrypt_bus`]: apply AACS 2.0 bus ENCRYPTION to
/// an aligned unit in place — per-sector AES-CBC-encrypt bytes 16..2048 of each
/// 2048-byte sector under `read_data_key`, leaving the first 16 bytes of every
/// sector clear (they are plaintext on the wire, as `decrypt_bus` relies on).
/// Used to build a bus-encrypted fixture that `decrypt_bus` must recover, and by
/// the `decrypt.rs` end-to-end tests to model the drive's forward transform.
#[cfg(test)]
pub(crate) fn encrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
if sector_start + SECTOR_BYTES > unit.len() {
break;
}
crate::aacs::crypto::aes_cbc_encrypt(
read_data_key,
&mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
);
}
}
#[cfg(test)]
mod tests {
use super::super::crypto::aes_ecb_decrypt;
@@ -1066,6 +1090,90 @@ mod tests {
);
}
// Full round trip through the SHARED `encrypt_bus` seam (the exact inverse
// used by the decrypt.rs end-to-end tests): clear -> bus-encrypt -> bus-
// decrypt must recover byte-for-byte, and the payload must actually change.
#[test]
fn encrypt_bus_is_the_exact_inverse_of_decrypt_bus() {
let rdk = [0x3Cu8; 16];
let original = clear_unit();
let mut unit = original.clone();
encrypt_bus(&mut unit, &rdk);
assert_ne!(
&unit[16..SECTOR_BYTES],
&original[16..SECTOR_BYTES],
"bus encryption must encipher the per-sector payload"
);
decrypt_bus(&mut unit, &rdk);
assert_eq!(
unit, original,
"bus encrypt/decrypt must round-trip exactly"
);
}
// Bus encryption covers ONLY bytes 16..2048 of each 2048-byte sector; the
// first 16 bytes are the plaintext seed and `decrypt_bus` must never touch
// them. Pins the "skip first 16 bytes" contract per sector.
#[test]
fn decrypt_bus_leaves_each_sector_seed_untouched() {
let rdk = [0x6Du8; 16];
let mut unit = clear_unit();
encrypt_bus(&mut unit, &rdk);
// Snapshot each sector's 16-byte seed as it arrives from the drive.
let seeds: Vec<[u8; 16]> = (0..ALIGNED_UNIT_LEN)
.step_by(SECTOR_BYTES)
.map(|s| unit[s..s + 16].try_into().unwrap())
.collect();
decrypt_bus(&mut unit, &rdk);
for (i, s) in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES).enumerate() {
assert_eq!(
&unit[s..s + 16],
&seeds[i],
"decrypt_bus must leave sector {i}'s 16-byte seed byte-for-byte"
);
}
}
// A wrong read_data_key must NOT recover the bus-encrypted region — the
// ground truth that the key genuinely drives the transform.
#[test]
fn decrypt_bus_wrong_read_data_key_does_not_recover() {
let original = clear_unit();
let mut unit = original.clone();
encrypt_bus(&mut unit, &[0x91u8; 16]);
decrypt_bus(&mut unit, &[0x22u8; 16]); // wrong rdk
assert_ne!(
&unit[16..SECTOR_BYTES],
&original[16..SECTOR_BYTES],
"a wrong read_data_key must not recover the bus-encrypted region"
);
}
// A buffer whose length is not a whole number of sectors: `decrypt_bus`
// processes only the WHOLE sectors and leaves the partial trailing sector
// byte-for-byte (the `sector_start + SECTOR_BYTES > len` break).
#[test]
fn decrypt_bus_skips_a_partial_trailing_sector() {
let rdk = [0x5Cu8; 16];
let mut full = clear_unit();
encrypt_bus(&mut full, &rdk);
// Two whole sectors + a 100-byte fragment of the third.
let mut buf = full[..2 * SECTOR_BYTES + 100].to_vec();
let tail_before = buf[2 * SECTOR_BYTES..].to_vec();
decrypt_bus(&mut buf, &rdk);
let clear = clear_unit();
assert_eq!(
buf[..2 * SECTOR_BYTES],
clear[..2 * SECTOR_BYTES],
"the two whole sectors must bus-decrypt back to clear"
);
assert_eq!(
buf[2 * SECTOR_BYTES..],
tail_before[..],
"the partial trailing sector must be left untouched"
);
}
// ── CBC decrypt: first-block uses fixed AACS IV ────────────────────────
// The published `iv0` bytes (`[C]` §2.1.2), INDEPENDENT of `crypto::AACS_IV`.
+268 -1
View File
@@ -306,7 +306,10 @@ pub(crate) fn resolve_dk_node(
uv: u32,
u_mask_shift: u8,
) -> Option<DeviceKey> {
for b in 0..u_mask_shift {
// `u_mask_shift` is disc/keydb-controlled: `1u32 << b` with b >= 32 panics
// (debug) / wraps (release), so cap the search at the 32 real u32 bit positions
// (same >= 32 guard the mask shifts above use) — a bad disc can't trip the shift.
for b in 0..u_mask_shift.min(32) {
let dk = DeviceKey {
key: *key,
node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16,
@@ -730,6 +733,258 @@ mod resolve_candidate_tests {
"PK chain recovers the title key"
);
}
// VUK relation `Kvu = AES-128D(Km, IDv) XOR IDv` ([PR]/[BD] §3.3), anchored
// to the FIPS-197 AES-128 known-answer vector so the test pins BOTH the AES
// primitive and the trailing XOR (a dropped XOR or encrypt-swap changes it).
#[test]
fn derive_vuk_matches_the_fips197_aes_decrypt_xor_relation() {
use crate::aacs::crypto::aes_ecb_decrypt;
// FIPS-197 Appendix example: AES-128D(key, ciphertext) == plaintext.
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d,
0x0e, 0x0f,
];
let ciphertext: [u8; 16] = [
0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4,
0xc5, 0x5a,
];
let plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
0xee, 0xff,
];
// The known AES-128 decrypt result — pins the primitive derive_vuk builds on.
assert_eq!(
aes_ecb_decrypt(&key, &ciphertext),
plaintext,
"FIPS-197 AES-128 decrypt vector"
);
// Treating `key` as the Media Key and `ciphertext` as the Volume ID, the
// VUK is the known plaintext XOR the Volume ID.
let mut expected = plaintext;
for i in 0..16 {
expected[i] ^= ciphertext[i];
}
assert_eq!(
derive_vuk(&key, &ciphertext),
expected,
"VUK = AES-128D(Km, IDv) XOR IDv against a known AES vector"
);
// And it is genuinely XOR: a Volume ID sharing set bits with the decrypt
// must CLEAR them, which an OR could never do.
let mk = [0xFFu8; 16];
let vid = [0xABu8; 16];
let mut want = aes_ecb_decrypt(&mk, &vid);
for i in 0..16 {
want[i] ^= vid[i];
}
assert_eq!(derive_vuk(&mk, &vid), want, "XOR, not OR, of IDv");
}
/// A single-CPS `Unit_Key_RO.inf` in the real on-disc shape (Bruges/Titanic:
/// one encrypted title key at offset 0x50) parses to exactly one key numbered
/// CPS unit 1, and `derive_unit_keys` unwraps it with the VUK.
#[test]
fn single_cps_inf_parses_one_key_at_offset_0x50_and_boils_it() {
// uk_pos = 0x20; first (only) key at uk_pos + 48 = 0x50.
let mut inf = vec![0u8; 0x50 + 16];
inf[0..4].copy_from_slice(&0x20u32.to_be_bytes());
inf[0x20..0x22].copy_from_slice(&1u16.to_be_bytes()); // num_unit_keys = 1
let enc = [0x9Au8; 16];
inf[0x50..0x60].copy_from_slice(&enc);
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).expect("valid single-CPS inf");
assert_eq!(
ukf.encrypted_keys,
vec![(1u32, enc)],
"one CPS unit, numbered 1, read from offset 0x50"
);
let vuk = [0x5Cu8; 16];
let boiled = derive_unit_keys(&ukf, &vuk);
assert_eq!(
boiled,
vec![(1u32, decrypt_unit_key(&vuk, &enc))],
"the single unit key is the VUK-decrypt of its slot, keyed by CPS 1"
);
}
/// Build an `n`-key `Unit_Key_RO.inf` at a chosen stride, each slot filled
/// with a distinct byte so a mis-strided read is visible.
fn multi_cps_inf(n: usize, stride: usize) -> (Vec<u8>, Vec<[u8; 16]>) {
let uk_pos = 0x20usize;
let key0 = uk_pos + 48;
let total = key0 + (n.saturating_sub(1)) * stride + 16;
let mut inf = vec![0u8; total];
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
let mut encs = Vec::new();
for i in 0..n {
let k = [(0x10 + i as u8); 16];
let o = key0 + i * stride;
inf[o..o + 16].copy_from_slice(&k);
encs.push(k);
}
(inf, encs)
}
/// A MULTI-CPS inf yields one key per CPS unit, numbered 1..=n in on-disc
/// order, and each boils to the VUK-decrypt of its own slot — the map cannot
/// drift or renumber.
#[test]
fn multi_cps_inf_numbers_each_unit_and_maps_to_its_slot() {
let (inf, encs) = multi_cps_inf(4, 48); // V10 stride
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).expect("valid multi-CPS inf");
assert_eq!(ukf.encrypted_keys.len(), 4, "one key per CPS unit");
let cps: Vec<u32> = ukf.encrypted_keys.iter().map(|(c, _)| *c).collect();
assert_eq!(cps, vec![1, 2, 3, 4], "CPS units numbered 1..=n in order");
let vuk = [0x33u8; 16];
let boiled = derive_unit_keys(&ukf, &vuk);
for (i, (num, key)) in boiled.iter().enumerate() {
assert_eq!(*num, (i + 1) as u32, "CPS number is the 1-based slot index");
assert_eq!(
*key,
decrypt_unit_key(&vuk, &encs[i]),
"each unit key is the VUK-decrypt of its own encrypted slot"
);
}
}
/// The stride `parse_unit_key_ro` walks is driven by the AACS generation:
/// V10 reads 48-byte spacing, V20/V21 read 64-byte spacing. Same buffer, two
/// versions, DIFFERENT second key — the V10-vs-2.x distinction the on-disc
/// layout hinges on.
#[test]
fn parse_unit_key_ro_stride_follows_the_aacs_version() {
// Two keys spaced at the V20 (64-byte) stride. Key 0 is shared (both
// strides read +48); key 1 sits at +64, which a V10 parse (+48) misses.
let uk_pos = 0x20usize;
let key0 = uk_pos + 48;
let v10_key1 = key0 + 48;
let v20_key1 = key0 + 64;
let mut inf = vec![0u8; v20_key1 + 16];
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
inf[uk_pos..uk_pos + 2].copy_from_slice(&2u16.to_be_bytes());
inf[key0..key0 + 16].fill(0xA0);
inf[v10_key1..v10_key1 + 16].fill(0x10);
inf[v20_key1..v20_key1 + 16].fill(0x20);
let v10 = parse_unit_key_ro(&inf, AacsVersion::V10).expect("v10");
let v20 = parse_unit_key_ro(&inf, AacsVersion::V20).expect("v20");
let v21 = parse_unit_key_ro(&inf, AacsVersion::V21).expect("v21");
assert_eq!(v10.encrypted_keys[0].1, [0xA0; 16], "key 0 shared");
assert_eq!(v20.encrypted_keys[0].1, [0xA0; 16]);
assert_eq!(v10.encrypted_keys[1].1, [0x10; 16], "V10 reads +48");
assert_eq!(v20.encrypted_keys[1].1, [0x20; 16], "V20 reads +64");
assert_eq!(
v21.encrypted_keys[1].1, [0x20; 16],
"V21 shares the V20 64-byte stride"
);
assert_ne!(v10.encrypted_keys[1].1, v20.encrypted_keys[1].1);
}
/// `resolve_candidate` derives the inf parse stride from the disc's OWN MKB
/// (`mkb_type(mkb).generation()`), NOT a fixed default: a V20 (Category C)
/// MKB boils the second unit key at the 64-byte stride, while an absent MKB
/// falls back to V10's 48-byte stride.
#[test]
fn resolve_candidate_boils_at_the_mkb_declared_stride() {
// A two-key inf whose 2nd key differs by stride (see the parse test).
let uk_pos = 0x20usize;
let key0 = uk_pos + 48;
let v10_key1 = key0 + 48;
let v20_key1 = key0 + 64;
let mut inf = vec![0u8; v20_key1 + 16];
inf[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
inf[uk_pos..uk_pos + 2].copy_from_slice(&2u16.to_be_bytes());
inf[key0..key0 + 16].fill(0xA0);
inf[v10_key1..v10_key1 + 16].fill(0x10);
inf[v20_key1..v20_key1 + 16].fill(0x20);
let vuk = Vuk([0x44u8; 16]);
// A minimal Category-C 2.0 Type-and-Version record → generation V20.
let v20_mkb: [u8; 12] = [
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
assert_eq!(
mkb_type(&v20_mkb).map(|t| t.generation()),
Some(AacsVersion::V20),
"fixture check: this MKB declares AACS 2.0"
);
let r20 = resolve_candidate(&KeyCandidate::Vuk(vuk), &v20_mkb, &inf, None)
.expect("V20 MKB boils the inf");
assert_eq!(
r20.unit_keys[1].1,
decrypt_unit_key(&vuk.0, &[0x20; 16]),
"the V20 MKB drives the 64-byte stride, reading the 2nd key at +64"
);
// No MKB → mkb_type is None → the V10 48-byte fallback stride.
let r10 = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None)
.expect("absent MKB falls back to V10");
assert_eq!(
r10.unit_keys[1].1,
decrypt_unit_key(&vuk.0, &[0x10; 16]),
"an absent MKB falls back to the V10 48-byte stride, reading +48"
);
assert_ne!(r20.unit_keys[1].1, r10.unit_keys[1].1);
}
/// PIN: a `Mk` candidate is PURE DERIVATION — `resolve_candidate` does NOT
/// validate the Media Key against the MKB. A WRONG MK (one the MKB's verify
/// record would reject) still boils a full set of unit keys, from the VUK
/// that wrong MK produces. This documents the "computed-but-wrong-UK" case:
/// the caller's sample/`is_clean_ts` step, not this function, is the gate.
#[test]
fn resolve_candidate_mk_does_not_validate_the_media_key_against_the_mkb() {
// An MKB carrying a 0x86 verify record built for a DIFFERENT media key,
// so `km_verifies` would reject the candidate below.
let real_km = [0xC3u8; 16];
let mut vd = [0x11u8; 16];
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
let mk_dv = aes_ecb_encrypt(&real_km, &vd);
let mut mkb = vec![
0x10, 0x00, 0x00, 0x0C, 0x48, 0x14, 0x10, 0x03, 0x00, 0x00, 0x00, 0x4D,
];
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
mkb.extend_from_slice(&mk_dv);
// The MK we hand in is NOT the one the verify record was built for.
let wrong_mk = MediaKey([0x77u8; 16]);
assert!(
!probe::km_verifies(&mkb, &wrong_mk.0),
"fixture check: the wrong MK must fail the MKB verify record"
);
let vid = Vid([0x42u8; 16]);
let enc = [0x9Au8; 16];
let inf = synth_inf(std::slice::from_ref(&enc));
let r = resolve_candidate(&KeyCandidate::Mk(wrong_mk), &mkb, &inf, Some(vid))
.expect("resolve_candidate boils regardless of MK-vs-MKB validity");
// The chain is derived straight from the (wrong) MK, no gate applied.
let expected_vuk = derive_vuk(&wrong_mk.0, &vid.0);
assert_eq!(
r.vuk,
Some(Vuk(expected_vuk)),
"VUK is derived from the given MK"
);
assert_eq!(
r.mk,
Some(wrong_mk),
"the candidate MK is carried through unchecked"
);
assert_eq!(
r.unit_keys,
vec![(1u32, decrypt_unit_key(&expected_vuk, &enc))],
"unit keys are boiled from the wrong VUK — derivation does not validate"
);
}
}
// Device-key POSITION recovery and the MKB probe accessors. No published AACS
@@ -910,6 +1165,18 @@ mod position_recovery_tests {
);
}
// `u_mask_shift` is a disc/keydb-controlled u8; a value >= 32 would drive
// `1u32 << b` past a u32's width and panic (debug) / wrap (release). The `.min(32)`
// guard must bound the search so a hostile disc can never trip the shift.
#[test]
fn resolve_dk_node_does_not_shift_past_u32_on_a_huge_u_mask_shift() {
// Empty MKB → the inner derive returns None every iteration, so the loop runs
// its full range; with u_mask_shift = 200 the unguarded loop panics at b = 32.
let dk = resolve_dk_node(&[], &[0u8; 16], 0, 200)
.expect("must fall back to the node itself, not panic on the shift");
assert_eq!(dk.u_mask_shift, 200);
}
// probe::mkb_mk_dv feeds km_verifies for reproduction harnesses; a fixed
// block would "verify" against a record no disc carries, and always-None
// would make every verification report "unverifiable".
+117 -14
View File
@@ -92,7 +92,7 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
// Key storage offset
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if uk_pos + 2 > data.len() {
if uk_pos.checked_add(2)? > data.len() {
return None;
}
@@ -114,8 +114,8 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
let stride = version.unit_key_stride();
// Validate size
let keys_start = uk_pos + 48; // first key at uk_pos + 48
if keys_start + 16 > data.len() {
let keys_start = uk_pos.checked_add(48)?; // first key at uk_pos + 48
if keys_start.checked_add(16)? > data.len() {
return None;
}
@@ -160,10 +160,14 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
for i in 0..num_titles {
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
if off + 2 <= data.len() {
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
title_cps_unit.push(to_key_idx(cps));
// A declared title whose entry runs past the buffer means the .inf
// is truncated — reject rather than silently returning a short
// title→CPS map (mirrors the key-list truncation check above).
if off + 2 > data.len() {
return None;
}
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
title_cps_unit.push(to_key_idx(cps));
}
}
@@ -333,14 +337,13 @@ pub fn read_mkb_from_drive(
cdb[5] = (pack & 0xFF) as u8;
let mut buf = vec![0u8; 32772];
if session
.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
.is_ok()
{
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if len > 2 && len - 2 <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len - 2]);
}
// A transport error on a non-first pack must PROPAGATE: swallowing it
// (the prior `.is_ok()`) silently truncated the MKB and returned the
// partial data as Ok, corrupting the root of the whole AACS ladder.
session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
if len > 2 && len - 2 <= 32768 {
mkb.extend_from_slice(&buf[4..4 + len - 2]);
}
}
@@ -832,4 +835,104 @@ mod read_mkb_tests {
"an unreadable MKB must surface as an error, not an empty MKB"
);
}
/// A transport failure on a NON-first pack must ALSO propagate. Before the
/// fix the per-pack loop tested `.is_ok()` and dropped the error, so a
/// mid-walk failure silently TRUNCATED the MKB and returned the partial
/// pack-0 data as Ok. Pack 0 succeeds, pack 1 errors → the whole read errors.
#[test]
fn read_mkb_from_drive_propagates_a_mid_walk_pack_failure() {
struct FlakyDrive;
impl ScsiTransport for FlakyDrive {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
if pack == 0 {
// Honest first pack: 64 payload bytes, TWO packs declared so
// the loop goes on to request pack 1.
let body = [0x5Au8; 64];
data[0..2].copy_from_slice(&((body.len() + 2) as u16).to_be_bytes());
data[2] = 0x00;
data[3] = 2;
data[4..4 + body.len()].copy_from_slice(&body);
Ok(ScsiResult {
status: 0,
bytes_transferred: 4 + body.len(),
sense: [0u8; 32],
})
} else {
// Pack 1 read fails mid-walk.
Err(crate::error::Error::ScsiError {
opcode: SCSI_READ_DISC_STRUCTURE,
status: 0x02,
sense: None,
})
}
}
}
assert!(
read_mkb_from_drive(&mut FlakyDrive).is_err(),
"a transport error on pack 1 must surface, not truncate the MKB to pack 0"
);
}
}
#[cfg(test)]
mod unit_key_ro_tests {
use super::*;
/// Finding 17: a Unit_Key_RO.inf that parses its key list fully but declares
/// more title entries than the buffer can hold is TRUNCATED and must be
/// rejected. Pre-fix the loop silently skipped the missing entries and
/// returned `Some` with a short title→CPS map (mirrors the key-list check).
#[test]
fn parse_unit_key_ro_rejects_truncated_title_table() {
let uk_pos = 26usize;
let mut data = vec![0u8; 90];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes()); // key storage @26
data[24..26].copy_from_slice(&20u16.to_be_bytes()); // num_titles=20 (needs bytes to 106)
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes()); // num_uk=1
// One present key at uk_pos+48 = 74..90 (V10 48-byte stride) so the key
// list parses fully; only the title table overruns the 90-byte buffer.
data[74..90].copy_from_slice(&[0xAB; 16]);
assert!(
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
"a title table that runs past the buffer is truncated → reject"
);
}
/// Fixture guard for the test above: the SAME buffer with an honest title
/// count parses to `Some`, proving it is only the truncation that is rejected
/// (not some unrelated malformation).
#[test]
fn parse_unit_key_ro_accepts_the_same_buffer_with_an_honest_title_count() {
let uk_pos = 26usize;
let mut data = vec![0u8; 90];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[24..26].copy_from_slice(&15u16.to_be_bytes()); // 15 titles: last entry ends at 88 <= 90
data[uk_pos..uk_pos + 2].copy_from_slice(&1u16.to_be_bytes());
data[74..90].copy_from_slice(&[0xAB; 16]);
let ukf = parse_unit_key_ro(&data, AacsVersion::V10).expect("fits → Some");
assert_eq!(ukf.encrypted_keys.len(), 1);
assert_eq!(ukf.title_cps_unit.len(), 2 + 15); // first_play + top_menu + 15 titles
}
/// Finding 15: a malformed key-storage offset pointing far past the buffer
/// must be rejected, never indexed. The `checked_add` guards also stop the
/// offset arithmetic (`uk_pos + 2`, `uk_pos + 48`) from overflowing `usize`
/// on 32-bit targets rather than panicking.
#[test]
fn parse_unit_key_ro_rejects_out_of_range_key_offset() {
let mut data = vec![0u8; 32];
data[0..4].copy_from_slice(&u32::MAX.to_be_bytes()); // uk_pos = 0xFFFFFFFF
assert!(
parse_unit_key_ro(&data, AacsVersion::V10).is_none(),
"a key-storage offset past the buffer must not be indexed"
);
}
}
+105
View File
@@ -399,6 +399,29 @@ mod tests {
assert_eq!(mkb_type_raw(&[]), None);
}
// A genuinely-old AACS 1.0 Blu-ray really does carry MKB version 1 (the "50
// First Dates" disc, MKB 12628 bytes, is a real example). `mkb_version` must
// read that `00 00 00 01` verbatim — never reject/clamp a low value as garbage.
#[test]
fn mkb_version_reads_a_genuine_version_one_prerecorded_mkb() {
// Realistic early-BD layout: Type-and-Version (type 0x00041003, v1)
// followed by another framed record, then the end marker.
let mut mkb = type_and_version(MKB_TYPE_4_PRERECORDED, 1);
mkb.extend(rec(REC_MEDIA_KEY_DATA, &[0x5A; 32]));
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
assert_eq!(
mkb_version(&mkb),
Some(1),
"version 1 is a real, valid MKB version and must be read verbatim"
);
assert_eq!(
mkb_type(&mkb),
Some(MkbType::Prerecorded),
"AACS 1.0 pre-recorded Blu-ray type must still decode alongside v1"
);
assert_eq!(mkb_is_uhd(&mkb), Some(false), "a v1 BD is not UHD");
}
#[test]
fn trim_mkb_keeps_only_the_framed_records() {
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 1);
@@ -495,4 +518,86 @@ mod tests {
);
assert_eq!(mkb_version(&mkb), Some(7));
}
// Every MkbType category the Type-and-Version field can name must decode to
// its variant, and an unknown value must round-trip verbatim through
// `Other(raw)` — the categorisation the stride/generation dispatch keys on.
#[test]
fn mkb_type_classifies_every_category_byte() {
let cases: &[(u32, MkbType)] = &[
(MKB_TYPE_3_RECORDABLE, MkbType::Recordable),
(MKB_TYPE_4_PRERECORDED, MkbType::Prerecorded),
(MKB_TYPE_10_CLASS_II, MkbType::ClassII),
(MKB_20_CATEGORY_C, MkbType::CategoryC20),
(MKB_21_CATEGORY_C, MkbType::CategoryC21),
];
for &(raw, expected) in cases {
assert_eq!(
MkbType::from_raw(raw),
expected,
"raw {raw:#010x} must decode to {expected:?}"
);
// …and through the byte-level path a real MKB takes.
let mut mkb = type_and_version(raw, 1);
mkb.extend([0, 0, 0, 0]);
assert_eq!(mkb_type(&mkb), Some(expected));
}
// An unrecognised MKBType is preserved, not coerced to a known variant.
const UNKNOWN: u32 = 0x1234_5678;
assert_eq!(MkbType::from_raw(UNKNOWN), MkbType::Other(UNKNOWN));
assert!(!matches!(
MkbType::from_raw(UNKNOWN),
MkbType::CategoryC20 | MkbType::CategoryC21
));
// No Type-and-Version record at all → no classification (not a default).
assert_eq!(mkb_type(&[]), None);
assert_eq!(mkb_type_raw(&[]), None);
}
// generation()/major()/is_uhd()/unit_key_stride() must agree per category:
// only Category C is UHD (2.x, 64-byte stride), and only CategoryC21 is V21.
#[test]
fn mkb_generation_stride_and_major_per_category() {
// Category C 2.1 → V21, UHD, major 2, 64-byte stride.
assert_eq!(MkbType::CategoryC21.generation(), AacsVersion::V21);
assert!(MkbType::CategoryC21.is_uhd());
// Category C 2.0 → V20, UHD, major 2, 64-byte stride.
assert_eq!(MkbType::CategoryC20.generation(), AacsVersion::V20);
assert!(MkbType::CategoryC20.is_uhd());
// Everything else → V10 (BD), NOT UHD.
for t in [
MkbType::Recordable,
MkbType::Prerecorded,
MkbType::ClassII,
MkbType::Other(0xDEAD_BEEF),
] {
assert_eq!(t.generation(), AacsVersion::V10, "{t:?} is AACS 1.0");
assert!(!t.is_uhd(), "{t:?} is not UHD");
}
// Stride and major are the two consumers of the generation enum.
assert_eq!(AacsVersion::V10.unit_key_stride(), 48);
assert_eq!(AacsVersion::V20.unit_key_stride(), 64);
assert_eq!(AacsVersion::V21.unit_key_stride(), 64);
assert_eq!(AacsVersion::V10.major(), AACS_MAJOR_BD);
assert_eq!(AacsVersion::V20.major(), AACS_MAJOR_UHD);
assert_eq!(AacsVersion::V21.major(), AACS_MAJOR_UHD);
}
// from_major is the inverse the keysource stride dispatch relies on: ONLY
// the BD major (1) is V10; every other integer takes the V20 64-byte stride.
#[test]
fn aacs_version_from_major_only_bd_is_v10() {
assert_eq!(AacsVersion::from_major(AACS_MAJOR_BD), AacsVersion::V10);
assert_eq!(AacsVersion::from_major(AACS_MAJOR_UHD), AacsVersion::V20);
// Unknown majors are NOT V10 — they must not take the 48-byte stride.
for m in [0u8, 3, 4, 255] {
assert_eq!(
AacsVersion::from_major(m),
AacsVersion::V20,
"major {m} must not select the V10 stride"
);
assert_eq!(AacsVersion::from_major(m).unit_key_stride(), 64);
}
}
}
+72 -4
View File
@@ -105,22 +105,33 @@ pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String>
v
}
// Walk an AACS role's candidate paths, returning the first that reads.
// A missing candidate is skipped; any other error (e.g. a real `DiscRead`)
// propagates; `AacsNoKeys` only when every candidate was absent.
/// Walk an AACS role's candidate paths, returning the first that reads.
///
/// AACS ships a `/AACS/DUPLICATE/` copy of every managed file so a bad primary
/// read can fall through to the backup. A missing candidate (`UdfNotFound`) and a
/// failed read (`DiscRead`) are both retriable: the walk moves on rather than
/// aborting. After all candidates are tried, a remembered `DiscRead` propagates
/// (more informative than `AacsNoKeys`); if every candidate was merely absent, the
/// result is `AacsNoKeys`. Any other error is a hard failure (parse, corruption)
/// and propagates immediately.
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>>,
{
let mut deferred: Option<crate::error::Error> = None;
for path in candidates {
match read(path.as_ref()) {
Ok(buf) => return Ok(buf),
Err(crate::error::Error::UdfNotFound { .. }) => continue,
Err(e @ crate::error::Error::DiscRead { .. }) => {
deferred = Some(e);
continue;
}
Err(e) => return Err(e),
}
}
Err(crate::error::Error::AacsNoKeys)
Err(deferred.unwrap_or(crate::error::Error::AacsNoKeys))
}
// The module structure IS the public API — consumers import from the owning module
@@ -149,6 +160,63 @@ mod tests {
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
}
// AACS `/AACS/DUPLICATE/` redundancy: a `DiscRead` on the primary managed
// file must fall through to the backup copy, not abort the whole read.
#[test]
fn read_first_falls_through_a_primary_disc_read_to_the_duplicate() {
use crate::error::Error;
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
let out = super::read_first(&candidates, |p| {
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
Ok(vec![0xAA, 0xC5])
} else {
Err(Error::DiscRead {
sector: 42,
status: Some(0x02),
sense: None,
})
}
});
assert_eq!(
out.unwrap(),
vec![0xAA, 0xC5],
"a primary DiscRead must fall through to the DUPLICATE copy"
);
}
// When every candidate fails to read, the remembered `DiscRead` propagates
// (more informative than a bare `AacsNoKeys`); a purely absent set yields
// `AacsNoKeys`.
#[test]
fn read_first_propagates_disc_read_only_after_all_candidates_fail() {
use crate::error::Error;
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
let err = super::read_first(&candidates, |p| {
if p.contains("DUPLICATE") {
Err(Error::UdfNotFound { path: p.into() })
} else {
Err(Error::DiscRead {
sector: 7,
status: None,
sense: None,
})
}
})
.unwrap_err();
assert!(
matches!(err, Error::DiscRead { sector: 7, .. }),
"the primary DiscRead must propagate once the DUPLICATE is absent, got {err:?}"
);
let all_absent =
super::read_first(&candidates, |p| Err(Error::UdfNotFound { path: p.into() }))
.unwrap_err();
assert!(
matches!(all_absent, Error::AacsNoKeys),
"an entirely absent candidate set yields AacsNoKeys, got {all_absent:?}"
);
}
#[test]
fn version_strides_are_reexported_and_distinct() {
// V10 (48) vs V20/V21 (64) stride is the load-bearing distinction;
+30 -3
View File
@@ -178,9 +178,12 @@ pub fn fmts_key_ranges(
) else {
continue;
};
if b >= a
&& (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64
{
// start_byte = start_spn*192 is generally NOT 2048-aligned, so the LBA carry
// is floor((end_byte-1)/2048) - floor(start_byte/2048); the naive (end_byte-1
// -start_byte)/2048 matches `b-a` only when sector-aligned, else wrongly drops the non-aligned segment back to the Unit Key.
let sector = crate::consts::SECTOR_BYTES as u64;
let expected_delta = (end_byte - 1) / sector - start_byte / sector;
if b >= a && (b - a) as u64 == expected_delta {
ranges.push((a, b + 1, index_to_key_idx(s.index)));
}
}
@@ -260,6 +263,30 @@ mod tests {
);
}
#[test]
fn fmts_key_ranges_carries_a_non_2048_aligned_segment() {
use crate::disc::Extent;
let extents = vec![Extent {
start_lba: 1000,
sector_count: 1_000_000,
}];
// start_spn=1 → start_byte=192 (NOT 2048-aligned); end_spn=10 → end_byte-1=2111.
// Correct carry floor(2111/2048)-floor(192/2048)=1 maps to LBA 1000..=1001; the
// old aligned (2111-192)/2048=0 mismatched b-a=1 and DROPPED it to the Unit Key (empty map).
let segs = vec![Segment {
index: 5,
start_spn: 1,
end_spn: 10,
}];
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert_eq!(
ranges.len(),
1,
"a non-2048-aligned segment must still carry, not be dropped"
);
assert_eq!(ranges[0], (1000, 1002, 5));
}
#[test]
fn fmts_key_ranges_skips_inverted_segment_without_underflow() {
use crate::disc::Extent;
+8 -9
View File
@@ -30,10 +30,12 @@ pub(crate) struct MovieObject {
}
const CMD_LEN: usize = 12;
/// Sanity caps (real discs are far under these; some densely-branched
/// dispatchers run several thousand commands in one object).
/// Sanity cap on object count (real discs are far under this; some densely-
/// branched dispatchers run several thousand commands in one object). There is
/// deliberately NO command-count cap: `num_cmds` is a u16 (≤ 65535) and each
/// object's command span is bounded against the input length below, so a
/// separate cap would be dead code.
const MAX_OBJECTS: usize = 4096;
const MAX_CMDS: usize = 200_000;
fn be_u16(d: &[u8], o: usize) -> Option<u16> {
Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?]))
@@ -72,9 +74,6 @@ pub(crate) fn parse(d: &[u8]) -> Option<Vec<MovieObject>> {
// Object header: flags(1) + reserved(1) + num_cmds(u16).
let num_cmds = be_u16(d, off + 2)? as usize;
off = off.checked_add(4)?;
if num_cmds > MAX_CMDS {
return None;
}
let span = num_cmds.checked_mul(CMD_LEN)?;
let end = off.checked_add(span)?;
if end > d.len() {
@@ -156,7 +155,7 @@ pub(crate) mod tests {
);
}
// NOTE: no "rejects_cmd_count_over_the_cap" test — `num_cmds` is a u16
// field (max 65535), always < MAX_CMDS, so that branch is unreachable via
// honest bytes; a test for it would be tautological. Flagged, not faked.
// No command-count cap exists to test: `num_cmds` is a u16 (max 65535) and
// each object's span is bounded against the input length, so the former
// MAX_CMDS check was dead code and was removed rather than left flagged.
}
+32
View File
@@ -280,6 +280,16 @@ mod tests {
// sub_grp=JUMP(1), branch_opt=JUMP_TITLE(1), imm dst.
cmd((1 << 5) | 1, 0x81, 0, 0, title as u32, 0)
}
fn call_object(obj: u16) -> [u8; 12] {
// sub_grp=JUMP(1), branch_opt=CALL_OBJECT(0x02), imm dst. For play-path
// resolution a Call follows the same target as a Jump (the return
// address is not modelled).
cmd((1 << 5) | 1, 0x82, 0, 0, obj as u32, 0)
}
fn call_title(title: u16) -> [u8; 12] {
// sub_grp=JUMP(1), branch_opt=CALL_TITLE(0x03), imm dst.
cmd((1 << 5) | 1, 0x83, 0, 0, title as u32, 0)
}
fn set_move_gpr(reg: u16, imm: u16) -> [u8; 12] {
// grp=SET(2), sub_grp=SET(0), set_opt=MOVE(1); dst=reg (GPR), imm src.
// op_cnt=2, imm_op2=1 (src immediate), imm_op1=0 (dst is a register).
@@ -317,6 +327,28 @@ mod tests {
assert_eq!(resolve(&index, &mobjs, &|id| id == 1), Some(1));
}
#[test]
fn call_object_follows_the_play_path_like_jump_object() {
// First-Play HDMV obj0 → CallObject 1 → PlayPL 11. CallObject follows
// the same target as JumpObject for feature resolution.
let d = build(&[&[call_object(1)], &[play_pl(11)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
assert_eq!(resolve(&index, &mobjs, &|id| id == 11), Some(11));
}
#[test]
fn call_title_resolves_through_the_index_like_jump_title() {
// CallTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42).
let d = build(&[&[call_title(1)], &[play_pl(42)]]);
let mobjs = mobj::parse(&d).unwrap();
let index = idx(
PlaybackObj::Hdmv { id_ref: 0 },
vec![PlaybackObj::Hdmv { id_ref: 1 }],
);
assert_eq!(resolve(&index, &mobjs, &|id| id == 42), Some(42));
}
#[test]
fn abstains_when_first_play_jumps_to_bdj_title() {
// Computed-title-number shape: First-Play computes a title number in a
+9 -5
View File
@@ -143,12 +143,16 @@ fn recover_title_key_from_plain(
}
}
/// Recover the CSS title key from a scrambled sector using a known plaintext
/// for the encrypted region.
/// Recover the CSS title key from a scrambled sector via known plaintext.
///
/// `plain` is the expected plaintext at byte 0x80 (at least 10 bytes).
/// Returns the recovered key only if it actually descrambles the sector back
/// to `plain` — guarding against the rare spurious LFSR-seed match.
/// `plain` is the expected plaintext at byte 0x80 (≥10 bytes); returns the key only
/// if it descrambles the sector back to `plain`, guarding a rare spurious LFSR match.
///
/// TEST-ONLY: the production crack path uses [`crack_title_key`], which derives
/// its own crib. `recover_title_key` is a known-plaintext helper exercised only
/// by this module's unit tests and `tests/crypto_tests.rs`. It stays `pub` so
/// that integration test (a separate crate linking the non-`cfg(test)` build)
/// can reach it — do NOT gate it behind `#[cfg(test)]`, which would break it.
pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
if sector.len() < SECTOR_BYTES || plain.len() < 10 {
return None;
+8 -8
View File
@@ -379,15 +379,15 @@ pub fn has_scramble_flag_bits(sector: &[u8]) -> bool {
/// untouched, so this signature survives scrambling.
pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
/// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED
/// test the crack scan uses for its `saw_scrambled` flag, and the same gate
/// [`descramble_sector`] / [`descramble_region`] use.
/// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED gate the
/// crack scan's `saw_scrambled` flag and [`descramble_sector`]/[`descramble_region`] share.
///
/// Requires BOTH the MPEG-PS pack-start code AND the 0x14 scramble bits —
/// [`has_scramble_flag_bits`] alone is not enough. Also excludes
/// structural/nav `stream_id`s at offset 0x11 (`0xBB`/`0xBE`/`0xBF`) that CSS
/// never scrambles, so a decrypted HD-DVD's RDI nav packs can't falsely trip
/// it. See docs/css-mod.md — `is_scrambled_pack`.
/// Requires BOTH the MPEG-PS pack-start code AND the 0x14 scramble bits
/// ([`has_scramble_flag_bits`] alone is not enough), and excludes structural/nav
/// `stream_id`s at 0x11 (`0xBB`/`0xBE`/`0xBF`) that CSS never scrambles so a decrypted
/// HD-DVD's RDI nav packs can't falsely trip it. 0x11 holds the `stream_id` only for
/// zero-stuffing headers (always true for DVD-Video VOB packs); a stuffed pack reads
/// 0xFF there, harmless. See docs/css-mod.md — `is_scrambled_pack`.
pub fn is_scrambled_pack(sector: &[u8]) -> bool {
use crate::consts::pes_stream_id::{PADDING_STREAM, PRIVATE_STREAM_2, SYSTEM_HEADER};
sector.len() >= 2048
+191 -12
View File
@@ -258,7 +258,7 @@ impl AacsKeyMap {
/// group's variant and are omitted entirely. `extents` are the title's clip
/// extents; `unit_sectors` is the AACS aligned-unit size (3 sectors). A map
/// with no forensic range returns `extents` unchanged, and a kept unit is
/// always one [`decrypt_sectors_mapped`] would open. See docs/decrypt.md.
/// always one `decrypt_sectors_mapped` would open. See docs/decrypt.md.
pub fn read_plan(
&self,
extents: &[crate::disc::Extent],
@@ -325,14 +325,50 @@ impl AacsKeyMap {
/// Decrypt `buf` with a resolved AACS key map. Thin wrapper over
/// [`decrypt_span`] — the map is the AACS scheme's input, not a second
/// orchestrator.
// Content-less convenience form used only by tests (production reads always go
// through `decrypt_sectors_mapped_in_content`, which honours the content extents).
#[cfg(test)]
pub(crate) fn decrypt_sectors_mapped(
buf: &mut [u8],
keys: &DecryptKeys,
base_lba: u32,
map: &AacsKeyMap,
) -> Result<(), crate::error::Error> {
decrypt_sectors_mapped_in_content(buf, keys, base_lba, map, None)
}
/// `decrypt_sectors_mapped` restricted to the disc's encrypted-content extents.
/// `content` is the sorted/merged `(start_lba, sector_count)` content map: an
/// aligned unit whose absolute LBA falls in NO content range is clear
/// (UDF filesystem / BDMV nav) and is passed through untouched — never decrypted,
/// verified, or counted as loss. `None` means "the caller only reads encrypted
/// content", so every unit is treated as content (the legacy behaviour). This is
/// how [`crate::sector::DecryptingSectorSource::with_content_ranges`] honours its contract.
pub(crate) fn decrypt_sectors_mapped_in_content(
buf: &mut [u8],
keys: &DecryptKeys,
base_lba: u32,
map: &AacsKeyMap,
content: Option<&[(u32, u32)]>,
) -> Result<(), crate::error::Error> {
let mut keys = keys.clone();
decrypt_span(buf, &mut keys, base_lba, Some(map), None).map(|_| ())
decrypt_span(
buf,
&mut keys,
base_lba,
Some(map),
content.map(|r| (base_lba, r)),
)
.map(|_| ())
}
/// Is `lba` inside any `(start_lba, sector_count)` content range? Used to gate the
/// mapped decrypt so clear filesystem/nav units outside every encrypted-content
/// extent are passed through untouched.
fn lba_in_content_ranges(lba: u32, ranges: &[(u32, u32)]) -> bool {
ranges
.iter()
.any(|&(start, count)| lba >= start && (lba - start) < count)
}
// AACS scheme step: apply `map`'s per-unit keys to `buf`, in-place — no key
@@ -343,6 +379,7 @@ fn apply_aacs_map(
keys: &DecryptKeys,
base_lba: u32,
map: &AacsKeyMap,
content: Option<&[(u32, u32)]>,
) -> Result<(), crate::error::Error> {
let (unit_keys, rdk, format) = match keys {
DecryptKeys::Aacs {
@@ -373,11 +410,19 @@ fn apply_aacs_map(
let verify_failed = std::sync::atomic::AtomicBool::new(false);
let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| {
let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors);
// Content-extent gate: a unit outside the disc's encrypted-content ranges is
// clear filesystem / BDMV nav — pass it through untouched, never decrypting,
// verifying, or counting it as loss (the `with_content_ranges` contract).
if let Some(ranges) = content
&& !lba_in_content_ranges(unit_lba, ranges)
{
return;
}
if chunk.len() != unit_len {
// Trailing partial unit: normally a genuinely-clear tail, left as-is. But
// one that is BOTH inside a mapped range AND flagged encrypted is a CBC
// fragment we cannot decrypt — fail loud instead of shipping it as clear.
let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors);
if map.entry_for(unit_lba).is_some()
&& aacs::content::aacs_unit_seed_encrypted(chunk, format)
{
@@ -385,7 +430,6 @@ fn apply_aacs_map(
}
return;
}
let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors);
// No range covers this LBA — expected for clear filesystem/nav, but an
// ENCRYPTED unit outside every range is an orphan clip we cannot key;
// emitting it verbatim would ship ciphertext as clear content.
@@ -450,7 +494,7 @@ fn apply_aacs_map(
/// For CSS: descrambles per 2048-byte sector, self-cracking the title key
/// from the data. For `None`: a no-op. For AACS: **always** returns
/// `Err(DecryptFailed)` — AACS decrypts exclusively through the resolved key
/// map ([`decrypt_sectors_mapped`]); reaching this arm with AACS keys means a
/// map (`decrypt_sectors_mapped`); reaching this arm with AACS keys means a
/// reader was built without installing its map (a bug). `unit_key_idx` is a
/// legacy parameter, ignored. See docs/decrypt.md for full detail.
pub fn decrypt_sectors(
@@ -464,11 +508,12 @@ pub fn decrypt_sectors(
/// Legacy alias of [`decrypt_sectors`]. Under the keymap-only model AACS decrypts
/// EXCLUSIVELY through the resolved key map (`decrypt_sectors_mapped`), so there is
/// no per-unit content-extent gate here any more: the AACS arm fails loud and the
/// CSS / `None` arm self-gates on its per-sector scramble flag. `base_lba` and
/// `content_ranges` are therefore inert — retained only so the wrapper signature
/// stays stable for the `DecryptingSectorSource` dispatch. Prefer
/// [`decrypt_sectors`] in new code.
/// no per-unit content-extent gate on THIS map-less path: the AACS arm fails loud
/// and the CSS / `None` arm self-gates on its per-sector scramble flag. `base_lba`
/// and `content_ranges` are therefore inert here — retained only so the wrapper
/// signature stays stable for the `DecryptingSectorSource` dispatch. The mapped
/// AACS path honours content extents via
/// [`decrypt_sectors_mapped_in_content`]. Prefer [`decrypt_sectors`] in new code.
pub fn decrypt_sectors_in_content(
buf: &mut [u8],
keys: &mut DecryptKeys,
@@ -488,7 +533,7 @@ fn decrypt_span(
keys: &mut DecryptKeys,
base_lba: u32,
map: Option<&AacsKeyMap>,
_content: Option<(u32, &[(u32, u32)])>,
content: Option<(u32, &[(u32, u32)])>,
) -> Result<usize, crate::error::Error> {
let dropped: usize = match keys {
DecryptKeys::None => 0,
@@ -499,7 +544,9 @@ fn decrypt_span(
let Some(map) = map else {
return Err(crate::error::Error::DecryptFailed);
};
apply_aacs_map(buf, keys, base_lba, map)?;
// Honour the content-extent gate when present: units outside the disc's
// encrypted-content ranges are clear filesystem/nav and pass through.
apply_aacs_map(buf, keys, base_lba, map, content.map(|(_, r)| r))?;
0
}
DecryptKeys::Css { title_key } => {
@@ -1067,6 +1114,66 @@ mod tests {
unit
}
// AACS 2.0 / UHD "OEM route": a drive delivers bus_encrypt(encrypt_unit(clear))
// and the mapped decrypt must strip the bus layer with the read_data_key FIRST,
// then the unit-key layer — the `if let Some(rdk)` arm no other test reaches.
#[test]
fn mapped_decrypt_removes_bus_then_unit_encryption_end_to_end() {
let unit_key = [0x5Au8; 16];
let rdk = [0x91u8; 16];
let mut clear = clear_ts_unit();
clear[0] |= 0xC0; // the encrypted flag lives in the clear seed
let mut buf = clear.clone();
// Inner layer: unit-key encryption. Outer layer: drive bus-encryption.
aacs_encrypt_unit_for_test(&mut buf, &unit_key);
aacs::content::encrypt_bus(&mut buf, &rdk);
assert!(
!aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"the delivered unit must still be scrambled before decrypt"
);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: Some(rdk),
format: crate::disc::ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]);
decrypt_sectors_mapped(&mut buf, &keys, 0, &map)
.expect("bus + unit-key decrypt must succeed");
assert_eq!(
buf, clear,
"read_data_key (bus) then unit-key must recover the plaintext exactly"
);
}
// Necessity + ordering proof: the SAME delivered bytes with NO read_data_key
// run only the unit-key decrypt over still-bus-encrypted data and must NOT
// recover clear — so the rdk arm genuinely runs, and runs BEFORE the unit key.
#[test]
fn mapped_decrypt_without_read_data_key_cannot_clear_bus_encrypted_content() {
let unit_key = [0x5Au8; 16];
let rdk = [0x91u8; 16];
let mut clear = clear_ts_unit();
clear[0] |= 0xC0;
let mut buf = clear.clone();
aacs_encrypt_unit_for_test(&mut buf, &unit_key);
aacs::content::encrypt_bus(&mut buf, &rdk);
let keys_no_rdk = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]);
// Phase::All never runs the forensic verify, so this returns Ok either
// way; the point is the BYTES are not the plaintext without the bus key.
let _ = decrypt_sectors_mapped(&mut buf, &keys_no_rdk, 0, &map);
assert_ne!(
buf, clear,
"without the read_data_key, unit-key decrypt alone cannot recover clear"
);
}
// ── FMTS phase-aware map ──────────────────────────────────────────────────
/// `entry_for` returns Some((idx, phase, range_start)) inside a range;
@@ -1415,6 +1522,78 @@ mod tests {
}
}
// `with_content_ranges` contract: an encrypted unit whose LBA is OUTSIDE the disc's
// content extents (clear filesystem / BDMV nav) must pass through untouched — never
// decrypted, verified, or counted as loss. Pre-fix the content map was ignored, so this unit was decrypted (mangled).
#[test]
fn mapped_decrypt_skips_units_outside_content_ranges() {
let unit_key = [0x33u8; 16];
let mut clear = clear_ts_unit();
clear[0] |= 0xC0; // encrypted flag lives in the clear seed
let mut ciphertext = clear.clone();
aacs_encrypt_unit_for_test(&mut ciphertext, &unit_key);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges(vec![(0, u32::MAX, 0)]);
// Unit at LBA 0 is OUTSIDE the content ranges → passed through untouched.
let mut outside = ciphertext.clone();
decrypt_sectors_mapped_in_content(&mut outside, &keys, 0, &map, Some(&[(100, 3)]))
.expect("an out-of-content unit passes through");
assert_eq!(
outside, ciphertext,
"unit outside content ranges must not be decrypted"
);
// The SAME unit at LBA 0 INSIDE the content ranges → decrypted to plaintext.
let mut inside = ciphertext.clone();
decrypt_sectors_mapped_in_content(&mut inside, &keys, 0, &map, Some(&[(0, 3)]))
.expect("an in-content unit decrypts");
assert_eq!(
inside, clear,
"unit inside content ranges must decrypt to plaintext"
);
}
// Exercise the rayon parallel branch of `apply_aacs_map`: a buffer of far more than
// `PARALLEL_MIN_UNITS` units on the multi-thread path must recover EVERY unit byte-for-
// byte — i.e. `par_chunks_mut` keeps each `idx_in_buf` correct (a race/off-by-one corrupts units).
#[test]
fn mapped_decrypt_parallel_path_recovers_every_unit() {
use crate::disc::ContentFormat;
let key = [0x77u8; 16];
let ul = aacs::content::ALIGNED_UNIT_LEN;
let usz = (ul / 2048) as u32;
let n = PARALLEL_MIN_UNITS * 4; // well past the parallel threshold
let mut clear = clear_ts_unit();
clear[0] |= 0xC0; // the CPI/encrypted flag is part of the preserved clear seed
let mut buf = vec![0u8; n * ul];
for i in 0..n {
let mut u = clear.clone();
aacs_encrypt_unit_for_test(&mut u, &key);
buf[i * ul..(i + 1) * ul].copy_from_slice(&u);
}
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges(vec![(0, (n as u32) * usz, 0)]);
decrypt_sectors_mapped(&mut buf, &keys, 0, &map)
.expect("the parallel mapped decrypt must succeed");
for i in 0..n {
assert_eq!(
&buf[i * ul..(i + 1) * ul],
clear.as_slice(),
"unit {i} must recover to the known plaintext on the parallel path"
);
}
}
// The mapped descramble indexes the committed key pool POSITIONALLY, so
// the ORDER of the `Vec<UnitKey>` a `KeySource` returns is load-bearing —
// nothing here searches the pool. See docs/decrypt.md.
+4 -2
View File
@@ -493,11 +493,13 @@ fn dump_aacs(disc: &Disc) {
// Unit_Key_RO.inf if captured. Report both: resolved count and raw len.
tracing::debug!(
target: DIAG,
"tag=aacs version={} bus_enc={} mkb_version={:?} disc_hash={} key_source={:?} \
"tag=aacs version={} bus_enc={} mkb_version={} disc_hash={} key_source={:?} \
vuk={} unit_keys_resolved={} uk_ro_bytes={} mkb_bytes={}",
a.version,
a.bus_encryption,
a.mkb_version,
a.mkb_version
.map(|v| v.to_string())
.unwrap_or_else(|| "unknown".to_string()),
a.disc_hash,
a.key_source,
a.vuk.is_some(),
+79 -3
View File
@@ -600,9 +600,10 @@ pub(super) fn plan(root: &Path) -> Result<Layout> {
if total_sectors > MAX_IMAGE_SECTORS {
return Err(Error::DirImageTooLarge);
}
// Metadata is materialized up front and held for the life of the image, so
// its size is bounded here rather than discovered when memory runs out.
let meta_bytes = (dir_count as u64 + file_count as u64).saturating_mul(SECTOR as u64);
// Metadata is materialized up front and held for the image's life, so bound its
// size here rather than at OOM. Count the FULL footprint — File Entries AND each
// directory's FID-list sectors (a wide, shallow tree's dir data can dwarf them).
let meta_bytes = metadata_block_count(&tree).saturating_mul(SECTOR as u64);
if meta_bytes > MAX_META_BYTES {
return Err(Error::DirImageTooLarge);
}
@@ -691,6 +692,57 @@ mod tests {
);
}
/// The metadata ceiling must account for each directory's FID-list sectors,
/// not just one File Entry per node: a wide directory's FID list can span
/// many sectors that the old (dir_count + file_count) count ignored, so an
/// oversized folder could slip past the guard and be materialized in full.
/// See docs/dirimage.md — metadata_ceiling_counts_fid_lists.
#[test]
fn metadata_block_count_includes_the_fid_list_sectors() {
let files: Vec<FileNode> = (0..300)
.map(|i| FileNode {
name: format!("A_REASONABLY_LONG_FILENAME_{i:04}.M2TS"),
disc_path: format!("/{i}"),
host: PathBuf::new(),
size: 0,
mtime: None,
icb_lba: 0,
unique_id: 0,
extents: Vec::new(),
})
.collect();
let root = DirNode {
name: String::new(),
icb_lba: 0,
parent_icb_lba: 0,
data_lba: 0,
data_bytes: 0,
unique_id: 0,
dirs: Vec::new(),
files,
};
let mut dir_count = 0;
let mut file_count = 0;
count_nodes(&root, &mut dir_count, &mut file_count);
// Old formula: one File Entry per node only.
let file_entry_only = dir_count as u64 + file_count as u64;
let fid_sectors = dir_bytes(&root.dirs, &root.files).div_ceil(SECTOR) as u64;
assert!(
fid_sectors > 1,
"300 files must fill more than one FID sector"
);
// 2 (FSD + Terminating Descriptor) + File Entries + FID-list sectors.
assert_eq!(
metadata_block_count(&root),
2 + file_entry_only + fid_sectors
);
assert!(
metadata_block_count(&root) > file_entry_only,
"the ceiling must count FID-list sectors the old formula omitted"
);
}
/// A zero-byte file records no allocation descriptors at all and consumes
/// no blocks.
#[test]
@@ -829,4 +881,28 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
// The nesting cap (`MAX_DEPTH`) refuses a tree deeper than the planner
// represents, before it recurses without bound. Executes `walk`'s depth
// guard on a real directory chain, not the constant.
#[test]
fn the_nesting_cap_refuses_a_tree_deeper_than_max_depth() {
let base = std::env::temp_dir().join(format!("fmkv-depth-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
// MAX_DEPTH + 2 nested levels below the root guarantees a `walk` call at
// a depth greater than MAX_DEPTH.
let mut deep = base.clone();
for _ in 0..(MAX_DEPTH as usize + 2) {
deep = deep.join("d");
}
std::fs::create_dir_all(&deep).unwrap();
let err = plan(&base).expect_err("a tree deeper than the nesting cap must be refused");
assert!(
matches!(err, Error::DirImageTooLarge),
"expected DirImageTooLarge, got {err:?}"
);
let _ = std::fs::remove_dir_all(&base);
}
}
+13
View File
@@ -259,6 +259,19 @@ impl SectorSource for DirImage {
if buf.len() < need {
return Err(Error::UdfBufferTooSmall);
}
// A read that BEGINS at/beyond the image's own capacity addresses a sector
// that cannot exist (an oversized/crafted offset): refuse it, as a real
// file/ISO source's read_exact does past EOF, not fabricated zeros+success.
if count > 0 && lba >= self.total_sectors {
return Err(Error::DiscRead {
sector: lba as u64,
status: None,
sense: None,
});
}
// A batch that starts IN range but overshoots the end is fine: this zero-pads
// its tail past capacity, which the mux's final batch and the FS scan's
// fixed-LBA probes both expect.
buf[..need].fill(0);
// Walk the request in RUNS, not sector by sector: a mux batch (8192
// sectors) almost always lands in one extent, so per-sector seek+read
+32 -7
View File
@@ -203,18 +203,43 @@ fn a_file_past_the_ad_ceiling_reads_back_as_multiple_extents() {
);
}
/// Reads outside any planned extent are zeros, not errors — a real image has
/// unrecorded sectors too, and `read_filesystem` probes fixed LBAs (256, the
/// VDS window) before it knows what is there.
/// An IN-RANGE gap (a sector below capacity that no extent covers) reads as
/// zeros, not an error — a real image has unrecorded sectors too, and
/// `read_filesystem` probes fixed LBAs (256, the VDS window) before it knows
/// what is there. But a read AT OR BEYOND the image's own capacity is out of
/// range: a real file/ISO source errors there, so DirImage must too rather than
/// fabricate zeros and let an oversized offset fill an output with nothing.
#[test]
fn gaps_and_out_of_range_reads_are_zero_filled() {
fn an_in_range_gap_reads_zeros_but_a_past_capacity_read_errors() {
let (s, _, _) = bdmv_scratch();
let mut img = DirImage::open(s.path()).unwrap();
let cap = img.capacity_sectors();
// In-range gap: sector 200 sits below the data floor and covers no file.
let mut buf = [0xAAu8; SECTOR];
let n = img.read_sectors(200, 1, &mut buf, false).unwrap();
assert_eq!(n, SECTOR);
assert!(
buf.iter().all(|&b| b == 0),
"an in-range gap reads as zeros"
);
// A read that BEGINS past the image's capacity is refused, not zero-filled.
let mut buf = [0xAAu8; SECTOR * 2];
let n = img.read_sectors(cap + 10, 2, &mut buf, false).unwrap();
assert_eq!(n, SECTOR * 2);
assert!(buf.iter().all(|&b| b == 0));
assert!(
img.read_sectors(cap + 10, 2, &mut buf, false).is_err(),
"a read that begins past capacity must be refused, not fabricated"
);
// A read that STARTS in range but overshoots the end still succeeds, tail
// zero-padded — the mux's final batch and the FS scan both rely on that.
let mut buf = vec![0xAAu8; SECTOR * 4];
let n = img.read_sectors(cap - 1, 4, &mut buf, false).unwrap();
assert_eq!(
n,
SECTOR * 4,
"an in-range start may overshoot and zero-pad"
);
}
/// Two runs over the same unchanged folder must produce the same image, byte
+203 -4
View File
@@ -103,7 +103,10 @@ impl AacsCertUnlocker<'_> {
// `session` across `scsi_mut()` + `&drive_id`, so clone the cheap identity.
let drive_id = session.drive_id.clone();
let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs);
let (_, unlock_res) = crate::unlock_bridge::run_bus(
// `matched` (the unlocker name) is intentionally unused: on this disc-keyed
// cert route it is only ever "AACS"/"DVD"/"", never a drive unlock — see the
// `drive_unlocked: false` note below.
let (_matched, unlock_res) = crate::unlock_bridge::run_bus(
session.scsi_mut(),
&drive_id,
freemkv_unlock::DiscKind::Aacs,
@@ -127,9 +130,9 @@ impl AacsCertUnlocker<'_> {
// AACS-specific "why the read_data_key read failed" diagnostic does
// not cross the seam. The bus-key gate keys off presence, not cause.
read_data_key_err: None,
// Cert route: bus encryption is removed via the read_data_key (AKE),
// NOT at the drive, so `drive_unlocked` is false — the gate credits
// this path through `read_data_key.is_some()` instead.
// Always false on the cert route: `run_bus` dispatches ONLY disc-keyed unlockers
// (AACS, DVD), so `matched` is never a firmware/drive unlocker (freemkv/LD/Renesas);
// a genuine DRIVE unlock instead sets `drive_unlocked: true` via the OEM-VID short-circuit in `do_handshake_cert` (the rdk-less case, credited by the gate like a cert `read_data_key`).
drive_unlocked: false,
})
}
@@ -831,6 +834,42 @@ mod tests {
assert_eq!(st.read_data_key, Some([0x22u8; 16]));
}
/// Finding: `is_drive_unlocker(matched)` on the cert route was provably always
/// false — `run_bus` dispatches ONLY the disc-keyed AACS/DVD unlockers, never a
/// firmware/drive unlocker — so a cert handshake's `drive_unlocked` is always
/// false and bus removal there is credited solely via `read_data_key`. The
/// GENUINE drive-unlock case (drive_unlocked:true, NO read_data_key) is the
/// OEM/VID-only path, and the bus-key gate MUST credit it rather than
/// hard-error `AacsBusKeyUnavailable`.
#[test]
fn drive_unlock_without_read_data_key_removes_bus_encryption() {
// The cert route can never observe a firmware/drive unlocker: the only names
// `run_bus` can return ("AACS"/"DVD"/"") classify as NOT a drive unlock, so
// the replaced `is_drive_unlocker(matched)` was always false.
for name in ["AACS", "DVD", ""] {
assert!(
!crate::unlock_bridge::is_drive_unlocker(name),
"the disc-keyed cert route can never credit a drive unlock ({name:?})"
);
}
// The real case: a bus-encrypted disc unlocked AT THE DRIVE (drive_unlocked)
// with no cert read_data_key must resolve OK, not AacsBusKeyUnavailable.
let (mut disc, udf) = disc_with_cert(0x01, true);
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: None,
read_data_key_err: None,
drive_unlocked: true,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
.expect("a drive-unlocked disc removes bus encryption even without a read_data_key");
assert!(st.bus_encryption);
assert_eq!(
st.read_data_key, None,
"the drive-unlock path carries no read_data_key"
);
}
/// ISO scan (handshake None) of a bus_encryption disc → Ok. Bus encryption
/// was already removed at read time; the gate must NOT fire without a
/// handshake (no UHD-ISO-mux regression).
@@ -1117,4 +1156,164 @@ mod tests {
UnlockOutcome::HandshakeRejected
);
}
// ---------------------------------------------------------------
// Tests: do_handshake_cert route selection (OEM VID vs cert)
// ---------------------------------------------------------------
/// OEM-VID short-circuit: when an unlocker stashed the disc's Volume ID at
/// `init()` (`oem_vid` is `Some`), `do_handshake_cert` returns that VID and
/// SKIPS the AACS host-cert handshake entirely — no SCSI is issued (contrast
/// the cert route, which reads the MKB). The result credits `drive_unlocked`
/// (bus encryption removed AT THE DRIVE) but carries NO `read_data_key`: the
/// AACS-2.0 "VID served, but no bus key from this path" case the bus-key gate
/// must later surface. Red-before-green: without the short-circuit the cert
/// route fires and the SCSI count is non-zero.
#[test]
fn oem_vid_short_circuits_cert_handshake_with_drive_unlocked_and_no_rdk() {
use std::sync::atomic::Ordering::SeqCst;
let scsi_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut drive = crate::drive::Drive::from_transport_for_test(Box::new(CountingTransport(
scsi_count.clone(),
)));
let vid = [0x7Au8; 16];
drive.set_oem_vid_for_test(vid);
let opts = ScanOptions::default();
let (result, err) = Disc::do_handshake_cert(&mut drive, &opts);
// Read the count BEFORE `drive` drops (Drop::cleanup issues a tray-unlock).
assert_eq!(
scsi_count.load(SeqCst),
0,
"the OEM-VID short-circuit must issue NO SCSI (the cert route is skipped)"
);
assert!(err.is_none(), "an OEM VID is a success, not an error");
let hs = result.expect("OEM VID yields a HandshakeResult");
assert_eq!(hs.volume_id, vid, "the stashed OEM VID must propagate");
assert!(
hs.drive_unlocked,
"an OEM VID means the drive is unlocked at the drive (firmware)"
);
assert_eq!(
hs.read_data_key, None,
"the OEM/VID-only path never produces a read_data_key"
);
assert_eq!(
hs.read_data_key_err, None,
"None here is 'not attempted', not a read failure — no error code"
);
}
/// When NO unlocker serves a VID (neither `oem_vid` nor a firmware
/// `unlocker_name`), `do_handshake_cert` RUNS the cert route: it reads the
/// MKB from the drive (issuing SCSI) and collects host certs. With zero certs
/// available it folds to `AacsNoHostCert` and yields no HandshakeResult — but
/// crucially the cert route DID run (SCSI issued), unlike the firmware-claimed
/// guard which issues zero. This is the counterpart to the OEM-VID and
/// firmware-claimed short-circuits.
#[test]
fn no_unlocker_runs_cert_route_and_issues_scsi() {
use std::sync::atomic::Ordering::SeqCst;
let scsi_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut drive = crate::drive::Drive::from_transport_for_test(Box::new(CountingTransport(
scsi_count.clone(),
)));
// No oem_vid, no unlocker_name → the cert route is the unlocker here.
let opts = ScanOptions::default();
let (result, err) = Disc::do_handshake_cert(&mut drive, &opts);
assert!(
scsi_count.load(SeqCst) > 0,
"the cert route must have run (MKB read issues SCSI) when no unlocker serves a VID"
);
assert!(
result.is_none(),
"with no host cert the cert route yields no VID"
);
assert!(
matches!(err, Some(Error::AacsNoHostCert { .. })),
"no host cert from any source folds to AacsNoHostCert, got {err:?}"
);
}
// ---------------------------------------------------------------
// Tests: bus_encryption_removed — the single source of truth
// ---------------------------------------------------------------
fn hs_with(drive_unlocked: bool, rdk: Option<[u8; 16]>) -> HandshakeResult {
HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: rdk,
read_data_key_err: None,
drive_unlocked,
}
}
/// `bus_encryption_removed` is the single gate. Cover every combination:
/// - not bus-encrypted → always removed (nothing to remove), regardless of
/// whether a handshake is present.
/// - bus-encrypted + no handshake (file/ISO) → removed at read time.
/// - bus-encrypted + handshake with a read_data_key → removed via AKE.
/// - bus-encrypted + handshake, drive_unlocked → removed at the drive.
/// - bus-encrypted + handshake, no RDK and not unlocked → NOT removed.
#[test]
fn bus_encryption_removed_covers_all_paths() {
// never had it: handshake state is irrelevant.
assert!(super::bus_encryption_removed(false, None));
assert!(super::bus_encryption_removed(
false,
Some(&hs_with(false, None))
));
// bus-encrypted, no handshake (file/ISO): clear at read time.
assert!(super::bus_encryption_removed(true, None));
// bus-encrypted + a cert read_data_key: removed via AKE.
assert!(super::bus_encryption_removed(
true,
Some(&hs_with(false, Some([0x22u8; 16])))
));
// bus-encrypted + drive_unlocked (no RDK): removed at the drive.
assert!(super::bus_encryption_removed(
true,
Some(&hs_with(true, None))
));
// bus-encrypted + neither: NOT removed — the wrong-keys guard.
assert!(!super::bus_encryption_removed(
true,
Some(&hs_with(false, None))
));
// Belt-and-braces: RDK present AND unlocked is still removed (OR, not XOR).
assert!(super::bus_encryption_removed(
true,
Some(&hs_with(true, Some([0x22u8; 16])))
));
}
/// A handshake that carries a VID but `read_data_key: None` must SURFACE the
/// `None` onto the state — not silently drop it or synthesize a key. On a
/// non-bus-encrypted disc (so the bus-key gate does not fire) the VID still
/// propagates while `read_data_key` stays `None`. This is the AACS-2.0
/// "VID present, no RDK" signal the downstream gate keys off.
#[test]
fn resolve_vid_only_surfaces_vid_with_absent_read_data_key() {
let (mut disc, udf) = disc_with_cert(0x00, false); // V10, bus off
let vid = [0x33u8; 16];
let hs = HandshakeResult {
volume_id: vid,
read_data_key: None,
read_data_key_err: None,
drive_unlocked: false,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
assert_eq!(
st.volume_id, vid,
"the VID must propagate even without an RDK"
);
assert_eq!(
st.read_data_key, None,
"a handshake bus_key of None must surface as None, not be dropped or faked"
);
}
}
+132 -18
View File
@@ -138,6 +138,9 @@ impl Disc {
let mut dirs: Vec<PathBuf> = Vec::new();
let mut seen_hosts: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
// The collision fold depends on the REAL target volume: fold case only
// where the host would (APFS/NTFS), never on a case-sensitive volume.
let case_insensitive = dir_is_case_insensitive(dest);
plan_tree(
reader,
&fs,
@@ -145,6 +148,7 @@ impl Disc {
Path::new(""),
"",
true,
case_insensitive,
&mut planned,
&mut dirs,
&mut seen_hosts,
@@ -350,6 +354,49 @@ impl SectorSource for Borrowed<'_> {
}
}
/// Whether `dir` lives on a case-INSENSITIVE filesystem (macOS APFS/HFS+ and
/// Windows NTFS default). Probes the real target: create a lowercase marker
/// and test whether its uppercase spelling resolves to the same file. On a
/// case-SENSITIVE volume (typical Linux ext4, or a case-sensitive APFS) two
/// disc names differing only by case are DISTINCT host files that coexist, so
/// folding them together would wrongly abort a legitimate extract. If the probe
/// can't run (e.g. a read-only dir), assume case-insensitive — the conservative
/// choice that never MISSES a real overwrite collision.
fn dir_is_case_insensitive(dir: &Path) -> bool {
// Unique per attempt: a FIXED name let concurrent extracts race — one's
// `remove_file` could delete another's marker between create and `exists()`
// (a TOCTOU flip). A per-call token keeps each probe's pair on private paths.
let token = unique_probe_token();
let lower_name = format!(".fmkv_case_probe_{token}");
let lower = dir.join(&lower_name);
if std::fs::File::create(&lower).is_err() {
return true;
}
// The upper spelling is the SAME name uppercased end-to-end (hex token
// letters `a-f` fold to `A-F`), so on a case-insensitive volume it resolves
// to the file just created and on a case-sensitive one it does not exist.
let upper = dir.join(lower_name.to_ascii_uppercase());
let insensitive = upper.exists();
let _ = std::fs::remove_file(&lower);
insensitive
}
/// A process-unique, lowercase-hex token for the case-probe filename. No `rand`
/// dependency: a monotonic counter (distinguishes concurrent same-process
/// probes) mixed with the pid and a nanosecond clock (distinguishes processes /
/// runs). Hex digits are case-foldable, which the probe relies on.
fn unique_probe_token() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{pid:x}_{nanos:x}_{n:x}")
}
/// Recursively plan the host tree: collect directories to create and files to
/// extract, sanitizing each component and detecting host-path collisions.
/// Skips the top-level `AACS/` and `CERTIFICATE/` directories (§7).
@@ -361,6 +408,7 @@ fn plan_tree(
host_rel: &Path,
disc_path: &str,
is_root: bool,
case_insensitive: bool,
files: &mut Vec<PlannedFile>,
dirs: &mut Vec<PathBuf>,
seen_hosts: &mut std::collections::HashMap<String, String>,
@@ -381,11 +429,15 @@ fn plan_tree(
let safe = sanitize_component(&entry.name)?;
let child_rel = host_rel.join(&safe);
let child_disc = format!("{disc_path}/{}", entry.name);
// Collision: two distinct disc paths → same host FILE (case-insensitive
// fold of `Movie`/`movie`, or `.partial` sharing the final-name namespace
// so `X`/`X.partial` collide too). Fold is ASCII/simple-Unicode only.
// Collision: two distinct disc paths → one host FILE. Case-insensitive folds
// `Movie`/`movie`; case-sensitive collides only on EXACT match (else names
// coexist). `.partial` shares the final-name namespace, so `X`/`X.partial` collide.
let mut register = |key: PathBuf| -> Result<()> {
let folded = key.to_string_lossy().to_lowercase();
let folded = if case_insensitive {
key.to_string_lossy().to_lowercase()
} else {
key.to_string_lossy().into_owned()
};
if let Some(prev) = seen_hosts.insert(folded, child_disc.clone())
&& prev != child_disc
{
@@ -408,6 +460,7 @@ fn plan_tree(
&child_rel,
&child_disc,
false,
case_insensitive,
files,
dirs,
seen_hosts,
@@ -1540,11 +1593,13 @@ mod tests {
assert!(matches!(err, Error::DirNameCollision { .. }));
}
// Two disc names differing only by CASE fold to one host file on macOS
// APFS / Windows NTFS — must be a collision, not a silent overwrite
// reported clean. See docs/extract.md for the failure this prevents.
// Two disc names differing only by CASE fold to one host file on a case-
// INSENSITIVE host (macOS APFS / Windows NTFS) — that must be a collision,
// not a silent overwrite reported clean. On a case-SENSITIVE volume the two
// are distinct files that coexist, so the extract must NOT abort. The
// outcome tracks the REAL target volume, tested by probing it.
#[test]
fn names_differing_only_by_case_are_a_collision() {
fn names_differing_only_by_case_collide_only_on_a_case_insensitive_host() {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
@@ -1557,10 +1612,22 @@ mod tests {
};
let mut disc = build_disc(root);
let out = TmpDir::new("case_collision");
let err = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect_err("two names that fold to one host file must collide");
assert!(matches!(err, Error::DirNameCollision { .. }), "got {err:?}");
// Create the dir before probing so the probe reflects the REAL volume:
// `dir_is_case_insensitive` returns a conservative `true` on a missing dir,
// disagreeing with `extract_tree`'s own post-mkdir probe on a case-sensitive host.
std::fs::create_dir_all(out.path()).unwrap();
let insensitive = super::dir_is_case_insensitive(out.path());
let res = clear_disc().extract_tree(&mut disc, out.path(), &ExtractOptions::default());
if insensitive {
let err = res.expect_err("two names that fold to one host file must collide");
assert!(matches!(err, Error::DirNameCollision { .. }), "got {err:?}");
} else {
let out = res.expect("distinct-case names coexist on a case-sensitive volume");
assert!(
out.files.iter().all(|f| f.complete),
"both case-distinct files must extract cleanly on a case-sensitive host"
);
}
}
// A file's in-flight `.partial` path shares the host namespace with
@@ -1884,13 +1951,10 @@ mod tests {
fn scrambled_vob(title_key: [u8; 5], marker: u8) -> (Vec<u8>, Vec<u8>) {
let seed = [0x11u8, 0x22, 0x33, 0x44, marker];
let mut plain = vec![0u8; 2048];
// The crack scan's `is_scrambled_pack` gate requires the MPEG-PS pack-
// start signature before attempting a crack; without it, `resolve_vts_key`
// falls back to `base_keys` for both groups, masking this regression.
// The crack scan's `is_scrambled_pack` gate needs the MPEG-PS pack-start
// signature (real scrambled sector = MPEG-2 PS pack) before cracking; without it
// `resolve_vts_key` falls back to `base_keys` for both groups, masking the regression.
plain[0x00..0x04].copy_from_slice(&crate::css::PACK_START);
// Pack start code: a real scrambled sector is an MPEG-2 PS pack, and
// the descrambler requires it before trusting byte 0x14.
plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
plain[0x14] = 0x10; // scramble flag
let pat: Vec<u8> = (0..8)
.map(|k| (0xA0u8.wrapping_add(k as u8) ^ marker) ^ 0x5A)
@@ -2321,4 +2385,54 @@ mod tests {
);
assert_eq!(vts_group_of("VTS_01_1.VOB").as_deref(), Some("VTS_01"));
}
/// The case-probe filename token must be UNIQUE across calls: a fixed
/// suffix let concurrent probes collide (TOCTOU). Every token from a burst
/// of calls must be distinct.
#[test]
fn probe_token_is_unique_across_calls() {
let mut seen = std::collections::HashSet::new();
for _ in 0..10_000 {
let t = unique_probe_token();
assert!(t.is_ascii(), "token must be ascii-hex, got {t:?}");
assert!(seen.insert(t.clone()), "probe token repeated: {t}");
}
}
/// Concurrency safety: many threads probing the SAME directory at once must
/// all agree on the answer and never panic — no probe's cleanup may clobber
/// another's marker (the collision a fixed name allowed). Also confirms no
/// stray `.fmkv_case_probe*` files survive the burst.
#[test]
fn concurrent_case_probes_do_not_collide() {
let tmp = TmpDir::new("case_probe_race");
std::fs::create_dir_all(tmp.path()).unwrap();
let expected = dir_is_case_insensitive(tmp.path());
let dir = tmp.path().to_path_buf();
let handles: Vec<_> = (0..8)
.map(|_| {
let dir = dir.clone();
std::thread::spawn(move || {
for _ in 0..200 {
assert_eq!(dir_is_case_insensitive(&dir), expected);
}
})
})
.collect();
for h in handles {
h.join().expect("probe thread panicked");
}
// Every probe removes its own marker, so nothing should be left behind.
let leftover: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.to_ascii_lowercase()
.starts_with(".fmkv_case_probe")
})
.collect();
assert!(leftover.is_empty(), "probe markers leaked: {leftover:?}");
}
}
+225 -11
View File
@@ -1607,17 +1607,10 @@ impl Disc {
/// Read UDF filesystem and set up buffered reader with metadata prefetched.
/// Shared setup for both identify() and scan().
fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> {
let capacity = Self::read_capacity(session).unwrap_or_else(|e| {
// A READ CAPACITY failure must not be silently treated as a 0-sector
// disc: capacity=0 skews the layer heuristic (always 1 layer) and
// title ordering. Recovery is unchanged, but surface it.
tracing::warn!(
target: "freemkv::scan",
error = %e,
"READ CAPACITY failed; treating disc capacity as 0 sectors (layer count and title ordering may be wrong)"
);
0
});
// READ CAPACITY is the sole authoritative whole-disc size (the UDF partition
// is a subset — 288 sectors short on a real BD, truncating the backup anchor).
// Its sporadic failures are ridden out by `read_capacity_retrying` (0 on hard fail).
let capacity = Self::read_capacity_retrying(session);
let batch = detect_max_batch_sectors(session.device_path());
let mut buffered = udf::BufferedSectorReader::new(session, batch);
let udf_fs = udf::read_filesystem(&mut buffered)?;
@@ -1625,6 +1618,63 @@ impl Disc {
Ok((capacity, buffered, udf_fs))
}
/// Number of READ CAPACITY attempts before giving up. The command answers
/// reliably on a healthy drive (measured 5/5 on the target); the field
/// failure this rides out — GOOD-then-empty / host_status set, no sense,
/// intermittent, succeeds on retry — is the USB/UAS transport + spin-up
/// timing signature. Six attempts covers a cold spin-up plus one USB
/// re-enumeration; needing more means a bad disc or drive, not a blip.
const READ_CAPACITY_ATTEMPTS: u32 = 6;
/// First backoff after a failed READ CAPACITY. Backoff grows exponentially
/// (double each miss) up to [`Self::READ_CAPACITY_BACKOFF_CAP`]. The floor is
/// well above one revolution — an optical drive can only usefully retry
/// about once per rotation, so sub-100ms hammering buys nothing.
const READ_CAPACITY_BACKOFF_START: std::time::Duration = std::time::Duration::from_millis(200);
/// Cap on the exponential backoff between READ CAPACITY attempts. With six
/// attempts the sleeps run 200/400/800/1600/2000 ms — a ~5s total budget,
/// comfortably inside a cold spin-up + one re-enumeration.
const READ_CAPACITY_BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(2);
/// READ CAPACITY with retry. Returns the hardware sector count, or 0 if every
/// attempt failed. It is the only authoritative whole-disc size for an image and its
/// failures are sporadic (a transient USB/UAS + spin-up fault), so ride one out with
/// exponential backoff rather than substitute a UDF partition size (a subset that
/// would truncate the image). No readiness poke: the only ready helper
/// (`Drive::wait_ready`) is a ~30s TUR loop that would blow this budget, and a cheap
/// single-shot TUR is out of scope. A final 0 keeps the shared scan/identify/MKV path
/// lenient (titles read by extent); image output makes it a hard [`Error::EmptyImage`] via `image_read_sectors`.
fn read_capacity_retrying(session: &mut Drive) -> u32 {
let mut backoff = Self::READ_CAPACITY_BACKOFF_START;
for attempt in 1..=Self::READ_CAPACITY_ATTEMPTS {
match Self::read_capacity(session) {
Ok(sectors) => return sectors,
Err(e) if attempt == Self::READ_CAPACITY_ATTEMPTS => {
tracing::warn!(
target: "freemkv::scan",
error = %e,
attempts = Self::READ_CAPACITY_ATTEMPTS,
"READ CAPACITY failed after all retries; disc capacity unavailable (image/ISO output disabled; scan/identify/MKV read by extent and continue)"
);
return 0;
}
Err(e) => {
tracing::warn!(
target: "freemkv::scan",
error = %e,
attempt,
backoff_ms = backoff.as_millis() as u64,
"READ CAPACITY failed; retrying after backoff"
);
std::thread::sleep(backoff);
backoff = (backoff * 2).min(Self::READ_CAPACITY_BACKOFF_CAP);
}
}
}
0
}
/// Scan a disc — parse filesystem, playlists, streams, and set up AACS
/// decryption. This is the main entry point; after `scan()` the Disc is
/// ready (titles populated with streams, AACS inputs captured, content
@@ -2440,6 +2490,21 @@ fn aligned_unit_keys_validate(
}
impl Disc {
/// Sector count for a whole-disc image read (`disc:// → iso://` / raw
/// image), validated non-zero.
///
/// `capacity_sectors` is 0 only when both READ CAPACITY and the UDF
/// partition-size fallback failed (see `read_udf`). Imaging paths size their
/// read domain through this accessor so that empty case is a hard
/// [`Error::EmptyImage`], not a silent 0-byte ISO reported as success;
/// scan/identify/MKV paths read the field directly and stay lenient.
pub fn image_read_sectors(&self) -> Result<u32> {
if self.capacity_sectors == 0 {
return Err(Error::EmptyImage);
}
Ok(self.capacity_sectors)
}
/// Get the resolved decryption keys for this disc.
/// Used by disc-to-ISO and other full-disc operations.
pub fn decrypt_keys(&self) -> crate::decrypt::DecryptKeys {
@@ -4763,6 +4828,155 @@ mod tests {
assert_eq!(disc_zero.capacity_gb(), 0.0);
}
#[test]
fn image_read_sectors_rejects_zero_capacity() {
// A non-zero capacity passes through unchanged — the imaging read
// domain is the disc's sector count.
let disc = Disc {
volume_id: String::new(),
meta_title: None,
format: DiscFormat::BluRay,
capacity_sectors: 12_219_392,
capacity_bytes: 12_219_392u64 * 2048,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: ContentFormat::BdTs,
};
assert_eq!(disc.image_read_sectors().expect("nonzero"), 12_219_392);
// capacity_sectors == 0 means READ CAPACITY failed and was swallowed to
// 0 during the scan. Imaging must hard-fail here, not size a 0-byte read
// domain and write an empty ISO that reports success.
let disc_zero = Disc {
capacity_sectors: 0,
capacity_bytes: 0,
..disc
};
assert!(
matches!(disc_zero.image_read_sectors(), Err(Error::EmptyImage)),
"zero capacity must be EmptyImage, got {:?}",
disc_zero.image_read_sectors()
);
}
#[test]
fn read_capacity_retrying_rides_out_a_transient_failure() {
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
// Fails the first `fail_first` attempts with an empty data phase (which
// decodes to Error::DiscCapacityMalformed), then answers a healthy READ
// CAPACITY. Models the sporadic field failure the retry loop exists for.
struct FlakyCapacity {
calls: u32,
fail_first: u32,
last_lba: u32,
}
impl ScsiTransport for FlakyCapacity {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
buf: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
// Only model READ CAPACITY; any other opcode (e.g. the ALLOW
// MEDIUM REMOVAL that Drive::drop issues) trivially succeeds.
if cdb[0] != crate::scsi::SCSI_READ_CAPACITY {
return Ok(ScsiResult {
status: 0,
sense: [0u8; 32],
bytes_transferred: 0,
});
}
self.calls += 1;
if self.calls <= self.fail_first {
// Empty data phase → Error::DiscCapacityMalformed.
return Ok(ScsiResult {
status: 0,
sense: [0u8; 32],
bytes_transferred: 0,
});
}
buf[0..4].copy_from_slice(&self.last_lba.to_be_bytes());
Ok(ScsiResult {
status: 0,
sense: [0u8; 32],
bytes_transferred: 8,
})
}
}
// Fails on the first two attempts, succeeds on the third: the retry
// yields the hardware value, not the swallowed 0. (last_lba + 1.)
let mut drive = crate::drive::Drive::from_transport_for_test(Box::new(FlakyCapacity {
calls: 0,
fail_first: 2,
last_lba: 9_997_279,
}));
assert_eq!(Disc::read_capacity_retrying(&mut drive), 9_997_280);
}
#[test]
fn read_capacity_retrying_gives_up_as_zero_and_that_blocks_imaging() {
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
// Every attempt answers GOOD with an empty data phase (malformed): the
// retry exhausts and returns 0 rather than a bogus value.
struct AlwaysEmpty;
impl ScsiTransport for AlwaysEmpty {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_buf: &mut [u8],
_timeout_ms: u32,
) -> crate::error::Result<ScsiResult> {
Ok(ScsiResult {
status: 0,
sense: [0u8; 32],
bytes_transferred: 0,
})
}
}
let mut drive = crate::drive::Drive::from_transport_for_test(Box::new(AlwaysEmpty));
let capacity = Disc::read_capacity_retrying(&mut drive);
assert_eq!(
capacity, 0,
"exhausted retries must yield 0, not a bad value"
);
// A capacity of 0 leaves the shared scan/identify/MKV path lenient but
// hard-fails image output: image_read_sectors -> EmptyImage.
let disc = Disc {
volume_id: String::new(),
meta_title: None,
format: DiscFormat::BluRay,
capacity_sectors: capacity,
capacity_bytes: capacity as u64 * 2048,
layers: 1,
titles: Vec::new(),
region: DiscRegion::Free,
aacs: None,
css: None,
encrypted: false,
aacs_error: None,
css_error: None,
content_format: ContentFormat::BdTs,
};
assert!(
matches!(disc.image_read_sectors(), Err(Error::EmptyImage)),
"capacity 0 must block imaging with EmptyImage, got {:?}",
disc.image_read_sectors()
);
}
#[test]
fn disc_title_duration_display_edge_cases() {
let mut t = DiscTitle::empty();
+88 -5
View File
@@ -198,6 +198,14 @@ impl Drive {
self.unlocker_name = Some(name.to_string());
}
/// Test-only: stash the OEM Volume ID a matching unlocker would have
/// returned at init(), so `do_handshake_cert`'s OEM-VID short-circuit
/// (skip the cert handshake, credit `drive_unlocked`) can be exercised.
#[cfg(test)]
pub(crate) fn set_oem_vid_for_test(&mut self, vid: [u8; 16]) {
self.oem_vid = Some(vid);
}
/// Get a clone of the halt flag. Set to true to interrupt Drive::read().
pub fn halt_flag(&self) -> Arc<AtomicBool> {
self.halt.clone()
@@ -305,6 +313,10 @@ impl Drive {
return Ok(());
}
Err(Error::Halted) => return Err(Error::Halted),
// A dead bus (transport failure / disconnected drive) will never spin
// up — surface it immediately instead of polling a phantom for 30 s. A
// plain not-ready TUR failure falls through and keeps the loop going.
Err(e) if e.is_scsi_transport_failure() => return Err(e),
Err(_) => {}
}
// Halt-aware backoff: the flag can also flip DURING the 500 ms
@@ -536,9 +548,11 @@ impl Drive {
r
}
/// Probe disc surface so the drive firmware learns optimal read speeds
/// per region. After this the host reads at max speed and the drive
/// manages zones internally.
/// No-op retained for API stability. Disc-speed calibration is now
/// unlocker-specific and runs inside the unlocker's `unlock()` at `init()`
/// (for every disc, including DVD), so there is nothing to do here — this
/// only emits the begin/end trace span. Kept so existing callers and the
/// `probe_disc_without_unlocker_is_ok_noop` test need not change.
pub fn probe_disc(&mut self) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::drive", phase = "probe_disc", "begin");
@@ -1021,10 +1035,21 @@ impl Drive {
0x00,
];
let mut buf = [0u8; 0];
let _ =
// Best-effort (the tray-unlock is advisory), but a failure is worth a warn!
// rather than a silent `let _`: a stuck PREVENT lock is a real symptom the
// operator otherwise never sees until the tray won't open.
if let Err(e) =
self.scsi
.as_mut()
.execute(&allow, crate::scsi::DataDirection::None, &mut buf, 5_000);
.execute(&allow, crate::scsi::DataDirection::None, &mut buf, 5_000)
{
tracing::warn!(
target: "freemkv::drive",
phase = "unlock_tray",
error_code = e.code(),
"Failed to clear the medium-removal PREVENT lock; the tray may stay locked until the drive is power-cycled."
);
}
}
/// Eject the disc tray. Unlocks first, then ejects.
@@ -2488,6 +2513,39 @@ mod command_tests {
);
}
// A dead bus (transport failure) will never spin up, so wait_ready must
// surface it AT ONCE rather than poll a phantom for the full ~30 s.
#[test]
fn wait_ready_breaks_immediately_on_a_dead_bus() {
struct DeadBus;
impl ScsiTransport for DeadBus {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
Err(Error::ScsiError {
opcode: cdb[0],
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
})
}
}
let mut d = Drive::from_transport_for_test(Box::new(DeadBus));
let t0 = std::time::Instant::now();
let r = d.wait_ready();
assert!(
matches!(&r, Err(e) if e.is_scsi_transport_failure()),
"a dead bus must surface the transport failure, not DeviceNotReady: {r:?}"
);
assert!(
t0.elapsed() < std::time::Duration::from_secs(5),
"a dead bus must break out at once, not run the ~30 s poll"
);
}
// A Stop pressed before the poll starts must be answered at once, not
// after the ~30 s poll. See docs/drive-mod.md — wait_ready/spin_cycle halt tests.
#[test]
@@ -2776,6 +2834,31 @@ mod command_tests {
assert_eq!(c[4], 0x00, "PREVENT bit clear (unlocked)");
}
// SET CD SPEED and PREVENT/ALLOW MEDIUM REMOVAL are best-effort tray/speed
// control: a drive that REJECTS them must be warned about, never fail the
// rip. Each returns () on a transport error and still issues its CDB.
#[test]
fn set_speed_and_tray_control_swallow_a_drive_rejection() {
let RecordingHarness {
drive: mut d,
cdb,
timeouts: _to,
} = recording(TransportOutcome::Scsi(0x02, None));
// None of these may panic or propagate the ScsiError.
d.set_speed(0x1234);
assert_eq!(
cdb.lock().unwrap()[0],
crate::scsi::SCSI_SET_CD_SPEED,
"SET CD SPEED still issued despite the rejection"
);
d.lock_tray();
assert_eq!(cdb.lock().unwrap()[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL);
assert_eq!(cdb.lock().unwrap()[4], 0x01, "PREVENT bit still set");
d.unlock_tray();
assert_eq!(cdb.lock().unwrap()[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL);
assert_eq!(cdb.lock().unwrap()[4], 0x00, "ALLOW bit still clear");
}
#[test]
fn eject_unlocks_then_sends_start_stop_with_loej() {
let RecordingHarness {
+69
View File
@@ -447,6 +447,75 @@ mod tests {
// ── Executor: convergence to a title ────────────────────────────────────
/// Unconditional-dispatch shape: First-Play is an unconditional `JumpTT 1`,
// Vm::set arithmetic/logic ops, including the ÷0 and mod-0 guards: a divide
// or modulo by zero must leave the register INTACT (checked_div/rem → cur),
// never panic, and an unmodelled op must be a no-op.
#[test]
fn set_ops_including_divide_and_mod_by_zero_leave_the_register_intact() {
let mut vm = Vm::new();
vm.set(0, 1, true, 42, 0); // mov
assert_eq!(vm.gprm[0], 42);
vm.set(0, 3, true, 10, 0); // add → 52
assert_eq!(vm.gprm[0], 52);
vm.set(0, 4, true, 2, 0); // sub → 50
assert_eq!(vm.gprm[0], 50);
vm.set(0, 5, true, 3, 0); // mul → 150
assert_eq!(vm.gprm[0], 150);
// mul wraps (u16), never panics.
vm.set(1, 1, true, 0xFFFF, 0);
vm.set(1, 5, true, 2, 0);
assert_eq!(vm.gprm[1], 0xFFFE, "0xFFFF * 2 wraps in u16");
vm.set(0, 6, true, 7, 0); // div → 150/7 = 21
assert_eq!(vm.gprm[0], 21);
vm.set(0, 6, true, 0, 0); // ÷0 → unchanged
assert_eq!(
vm.gprm[0], 21,
"divide by zero must leave the register intact"
);
vm.set(0, 7, true, 5, 0); // mod → 21 % 5 = 1
assert_eq!(vm.gprm[0], 1);
vm.set(0, 7, true, 0, 0); // mod 0 → unchanged
assert_eq!(
vm.gprm[0], 1,
"modulo by zero must leave the register intact"
);
vm.set(2, 1, true, 0b1100, 0);
vm.set(2, 9, true, 0b1010, 0); // and → 0b1000
assert_eq!(vm.gprm[2], 0b1000);
vm.set(2, 10, true, 0b0011, 0); // or → 0b1011
assert_eq!(vm.gprm[2], 0b1011);
vm.set(2, 11, true, 0b1111, 0); // xor → 0b0100
assert_eq!(vm.gprm[2], 0b0100);
// An unmodelled op (swap/rnd/…) leaves the register intact.
vm.set(2, 13, true, 99, 0);
assert_eq!(vm.gprm[2], 0b0100, "an unmodelled set op is a no-op");
}
// A SetSystem (SPRM write, type 2) in the First-Play list is a no-op FOR
// RESOLUTION: it must keep executing, not abort, so a following JumpTT still
// resolves. `40..` = type 2, zero compare nibble → unconditional.
#[test]
fn first_play_setsystem_is_a_noop_and_resolution_continues() {
let vmgi = build_vmgi(
&[h("4000000000000000"), h("3002000000010000")],
1,
&[(2, 1), (3, 1)],
);
assert_eq!(
resolve_from_vmg(&vmgi),
Some(ResolvedTitle {
title: 1,
vtsn: 2,
vts_ttn: 1
}),
"a SetSystem must not abort First-Play resolution"
);
}
/// and TT_SRPT maps title 1 to the feature title set (here VTS_02, title 1).
/// The resolver must return that title.
#[test]
+70
View File
@@ -1100,6 +1100,16 @@ impl std::fmt::Display for Error {
Error::DiscTitleRange { index, count } => {
write!(f, "E{}: {}/{}", self.code(), index, count)
}
Error::DirInsufficientSpace {
required,
available,
} => write!(f, "E{}: {}/{}", self.code(), required, available),
Error::DirNameCollision { host } => write!(f, "E{}: {}", self.code(), host),
// errno is Option: emit it after the code when present, else the
// bare code (mirrors NoDiscKey's empty-field handling — no dangling
// "colon space" suffix).
Error::DirWriteFailed { errno: Some(e) } => write!(f, "E{}: {}", self.code(), e),
Error::DirWriteFailed { errno: None } => write!(f, "E{}", self.code()),
Error::KeydbConnect { host } => write!(f, "E{}: {}", self.code(), host),
Error::KeydbHttp { status } => write!(f, "E{}: {}", self.code(), status),
Error::KeydbWrite { path } => write!(f, "E{}: {}", self.code(), path),
@@ -1134,6 +1144,12 @@ impl std::fmt::Display for Error {
Error::MuxHeaderBufferExceeded { bytes } => {
write!(f, "E{}: {}", self.code(), bytes)
}
// have/want are byte lengths (an identifier-free, language-neutral
// pair) — surface them so a truncated-resume report carries the
// actual mismatch, not just the bare code.
Error::ImageTruncated { have, want } => {
write!(f, "E{} {have}/{want}", self.code())
}
_ => write!(f, "E{}", self.code()),
}
}
@@ -1651,6 +1667,60 @@ mod tests {
}
}
/// The three `Dir*` variants whose fields previously fell through to the
/// bare-code `_` arm must now SURFACE their fields: `E9027` carries
/// `required/available`, `E9028` the colliding host, `E9029` the errno.
/// Red-before-green: before the arms were added each rendered only `E{code}`
/// with no field values, so these substring assertions failed.
#[test]
fn dir_space_collision_write_variants_surface_fields() {
let space = Error::DirInsufficientSpace {
required: 4096,
available: 512,
};
assert_eq!(
space.to_string(),
format!("E{}: 4096/512", E_DIR_INSUFFICIENT_SPACE)
);
let collision = Error::DirNameCollision {
host: "TITLE".into(),
};
assert_eq!(
collision.to_string(),
format!("E{}: TITLE", E_DIR_NAME_COLLISION)
);
let write_failed = Error::DirWriteFailed { errno: Some(28) };
assert_eq!(
write_failed.to_string(),
format!("E{}: 28", E_DIR_WRITE_FAILED)
);
// A None errno renders as the bare code — no dangling "colon space".
let write_none = Error::DirWriteFailed { errno: None };
assert_eq!(write_none.to_string(), format!("E{}", E_DIR_WRITE_FAILED));
}
/// `ImageTruncated` Display must carry BOTH the `have` and `want` byte
/// lengths as substrings, so a truncated-resume report shows the actual
/// mismatch rather than just the bare code.
#[test]
fn image_truncated_display_has_have_and_want() {
let e = Error::ImageTruncated {
have: 12345,
want: 67890,
};
let s = e.to_string();
assert!(
s.contains("12345"),
"have value missing from ImageTruncated display `{s}`"
);
assert!(
s.contains("67890"),
"want value missing from ImageTruncated display `{s}`"
);
}
#[test]
fn iokind_mapping_for_new_variants() {
use std::io::ErrorKind;
+29 -3
View File
@@ -51,17 +51,30 @@ pub fn parse_hex_fixed<const N: usize>(s: &str) -> Option<[u8; N]> {
/// (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()
u16::from_str_radix(hex_int_body(s)?, 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()
u32::from_str_radix(hex_int_body(s)?, 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()
u8::from_str_radix(hex_int_body(s)?, 16).ok()
}
/// Trim, strip the optional `0x`/`0X` prefix, and reject a leading `+` sign.
/// `from_str_radix` accepts a leading `+` (`+10` → 16), but hex key material
/// never carries a sign — the byte parsers already reject it via `byte()`, so
/// the integer parsers must too, or the same value parses in one path and drops
/// in another. (A bare `-` already fails on the unsigned parse.)
fn hex_int_body(s: &str) -> Option<&str> {
let body = strip_hex_prefix(s.trim());
if body.starts_with('+') {
return None;
}
Some(body)
}
/// Strip a single leading `0x` / `0X` if present (case-insensitive). Public so
@@ -132,6 +145,19 @@ mod tests {
assert_eq!(parse_hex_u16("0xzz"), None);
}
// `from_str_radix` accepts a leading `+` (`+10` → 16), but hex key material is
// never signed and the byte parsers reject it, so the integer parsers must too.
// Red-before-green: before the guard, each of these returned `Some(..)` not `None`.
#[test]
fn hex_ints_reject_leading_plus_sign() {
assert_eq!(parse_hex_u16("+10"), None);
assert_eq!(parse_hex_u16("+0010"), None);
assert_eq!(parse_hex_u32("+deadbeef"), None);
assert_eq!(parse_hex_u8("+03"), None);
// A `+` behind the prefix is rejected too (strip leaves `+10`).
assert_eq!(parse_hex_u16("0x+10"), None);
}
#[test]
fn bytes_variable_length_and_odd_rejected() {
assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));
+29 -1
View File
@@ -942,7 +942,9 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
times
};
// Extract subtitle palette at PGC offset 0xA4: 16 colors × 4 bytes [padding, Y, Cr, Cb]
// Subtitle palette at PGC offset 0xA4: 16 colors × 4 bytes [padding, Y, Cr, Cb].
// Chroma order is Cr (byte 2) BEFORE Cb (byte 3) per the DVD-Video PGC CLUT format,
// NOT Cb first; consumer `mux::codec::dvdsub::ycbcr_to_rgb` must stay in lockstep or subtitle colours channel-swap.
let palette = if pgc_offset + 0xA4 + 64 <= data.len() {
let mut colors = Vec::with_capacity(16);
for i in 0..16 {
@@ -1728,6 +1730,32 @@ mod tests {
assert!(title2.palette.is_none());
}
/// The palette byte order is [padding, Y, Cr, Cb] — chroma is Cr BEFORE Cb
/// — and the DvdSub consumer reads it the same way. Pin both together with
/// distinct Cr/Cb so a future byte-swap in either place fails here.
#[test]
fn pgc_palette_chroma_order_is_cr_then_cb_matching_the_consumer() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x03] = 0; // no cells
// Color 0: Y=0x40, Cr=0x11, Cb=0x22 — distinct so an order swap shows.
pgc[0xA4] = 0x00; // padding
pgc[0xA4 + 1] = 0x40; // Y
pgc[0xA4 + 2] = 0x11; // Cr
pgc[0xA4 + 3] = 0x22; // Cb
let title = parse_pgc(&pgc, 0, 1).unwrap();
let pal = title.palette.expect("non-empty palette");
assert_eq!(
pal[0],
[0x00, 0x40, 0x11, 0x22],
"stored as [pad, Y, Cr, Cb]"
);
// The consumer interprets index 2 as Cr and index 3 as Cb (see
// dvdsub::ycbcr_to_rgb). Cross-check the same entry both ways.
let rgb = crate::mux::codec::dvdsub::ycbcr_to_rgb(&pal[0]);
let expected = crate::mux::codec::dvdsub::ycbcr_to_rgb(&[0x00, 0x40, 0x11, 0x22]);
assert_eq!(rgb, expected, "ifo and dvdsub must read Cr/Cb the same way");
}
/// parse_pgc palette layout: each color is [padding, Y, Cr, Cb] and the
/// "non-empty" test ignores the padding byte (index 0). A palette whose
/// ONLY non-zero bytes are padding must still be treated as empty (None).
+98
View File
@@ -1304,4 +1304,102 @@ mod tests {
// Non-secret shape stays printable for diagnostics.
assert!(dbg.contains("units_len: 8"), "{dbg}");
}
/// A source whose every unit is CPI-encrypted, for exercising the count cap
/// and extent-skip logic without the clarity/CPI selection getting in the way.
struct AllEncryptedSource {
cap: u32,
}
impl crate::sector::SectorSource for AllEncryptedSource {
fn capacity_sectors(&self) -> u32 {
self.cap
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_r: bool,
) -> crate::error::Result<usize> {
let bytes = count as usize * 2048;
// 0xC0 in byte 0 of every unit → CPI-encrypted for BdTs.
buf[..bytes].fill(0xC0);
Ok(bytes)
}
}
fn title_with_extents(extents: Vec<crate::disc::Extent>) -> crate::disc::DiscTitle {
crate::disc::DiscTitle {
playlist: String::new(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
chapters: Vec::new(),
extents,
content_format: crate::disc::ContentFormat::BdTs,
codec_privates: Vec::new(),
}
}
/// `read_encrypted_units` returns AT MOST `n` units and stops as soon as it
/// has them — an all-encrypted extent must not spill every probe's worth of
/// samples when the caller asked for a few.
#[test]
fn read_encrypted_units_returns_at_most_n() {
use crate::aacs::content::{ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
let total_units = 600u32;
let ext_start = 1000u32;
let mut src = AllEncryptedSource {
cap: ext_start + total_units * ALIGNED_UNIT_SECTORS + 64,
};
let title = title_with_extents(vec![crate::disc::Extent {
start_lba: ext_start,
sector_count: total_units * ALIGNED_UNIT_SECTORS,
}]);
for n in [1usize, 3, 8] {
let samples = read_encrypted_units(&mut src, &title, n);
assert_eq!(samples.len(), n, "must return exactly the {n} requested");
for s in &samples {
assert!(aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs));
}
}
}
/// An extent too small to hold a single 3-sector aligned unit yields zero
/// units and must be SKIPPED, with sampling continuing into the next extent —
/// not abandoned, and not a panic on the empty extent.
#[test]
fn read_encrypted_units_skips_a_zero_unit_extent_and_samples_the_next() {
use crate::aacs::content::{ALIGNED_UNIT_SECTORS, aacs_unit_encrypted};
let good_units = 400u32;
let good_start = 5000u32;
let mut src = AllEncryptedSource {
cap: good_start + good_units * ALIGNED_UNIT_SECTORS + 64,
};
// Extent 0: only 2 sectors — fewer than one 3-sector aligned unit → 0 units.
// Extent 1: a normal, sampleable extent.
let title = title_with_extents(vec![
crate::disc::Extent {
start_lba: 10,
sector_count: ALIGNED_UNIT_SECTORS - 1,
},
crate::disc::Extent {
start_lba: good_start,
sector_count: good_units * ALIGNED_UNIT_SECTORS,
},
]);
let samples = read_encrypted_units(&mut src, &title, 4);
assert_eq!(
samples.len(),
4,
"the empty first extent is skipped; the second extent supplies the samples"
);
for s in &samples {
assert!(aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs));
}
}
}
+47 -5
View File
@@ -145,12 +145,23 @@ pub(crate) fn parse_bdmt_xml(xml_text: &str) -> Option<BdmtFields> {
Some((title, description, disc_set))
}
// Reject description candidates that are themselves XML fragments (e.g.
// only <di:thumbnail/> children, no prose). See docs/bdmt.md —
// looks_like_xml.
/// Reject description candidates that are themselves XML fragments (e.g.
/// only <di:thumbnail/> children, no prose) OR mixed content — prose with an
/// embedded child element, which `xml::text` returns verbatim (tags and all).
/// The old `starts_with('<')` only caught fragments that LED with a tag, so a
/// description like `Real prose <di:thumbnail/>` leaked its markup through. Any
/// `<` that begins an element / close tag / comment / PI marks the value as
/// XML-tainted; a bare `<` used as prose (e.g. `a < b`) is left alone.
/// See docs/bdmt.md — looks_like_xml.
fn looks_like_xml(s: &str) -> bool {
let t = s.trim_start();
t.starts_with('<')
let bytes = s.as_bytes();
bytes.iter().enumerate().any(|(i, &b)| {
b == b'<'
&& matches!(
bytes.get(i + 1),
Some(&c) if c.is_ascii_alphabetic() || matches!(c, b'/' | b'!' | b'?')
)
})
}
/// Try title-bearing element variants in priority order. The `xml`
@@ -418,6 +429,37 @@ mod tests {
);
}
/// Mixed content: prose LEADING with real text but carrying an embedded
/// child element. `xml::text` returns it verbatim (markup and all), so the
/// old `starts_with('<')` filter let the tags leak into the description.
/// A mixed-content description must be dropped, not surfaced with raw XML.
#[test]
fn mixed_content_description_with_embedded_tag_is_dropped() {
let xml = r#"<discInfo>
<di:name>Skyline Run</di:name>
<di:description>Intro prose <di:thumbnail href="x.jpg" /> more</di:description>
</discInfo>"#;
let (title, description, _) =
parse_bdmt_xml(xml).expect("title present so parse must succeed");
assert_eq!(title, "Skyline Run");
assert!(
description.is_none(),
"a description with embedded markup must be dropped, got {description:?}"
);
}
/// The mixed-content guard must not over-reject: a bare `<` used as prose
/// (a comparison, not a tag) is still a valid description.
#[test]
fn description_with_bare_less_than_is_not_treated_as_xml() {
let xml = r#"<discInfo>
<di:name>Math Film</di:name>
<di:description>when a < b holds</di:description>
</discInfo>"#;
let (_, description, _) = parse_bdmt_xml(xml).expect("must parse");
assert_eq!(description.as_deref(), Some("when a < b holds"));
}
#[test]
fn whitespace_in_title_is_trimmed() {
let xml = r#"<discInfo><di:name>
+32 -4
View File
@@ -26,7 +26,9 @@ pub enum Error {
BadCpTag { index: u16, tag: u8 },
BadUtf8 { index: u16 },
BadCodeAttribute,
BadInstruction { pc: usize, opcode: u8 },
// NOTE: no `BadInstruction` variant. The bytecode iterator STOPS at the
// first malformed instruction (returns `None`) rather than surfacing a
// typed error, so nothing ever constructed one — it was dead and removed.
}
// No Display/std::error::Error impl: crate-internal typed error for `match`/`?`
@@ -593,11 +595,15 @@ impl<'a> Iterator for Instructions<'a> {
let pc = self.pos;
let opcode = self.code[pc];
let size = instruction_size(self.code, pc)?;
if pc + size > self.code.len() {
// `size` can reach `usize::MAX` on a 32-bit target (saturated switch-table
// math on adversarial bytecode), so `pc + size` must be a CHECKED add: a
// wrapping add could yield a small `end` that passes the bounds check below, then slices out of range / panics.
let end = pc.checked_add(size)?;
if end > self.code.len() {
return None;
}
let operands = &self.code[pc + 1..pc + size];
self.pos = pc + size;
let operands = &self.code[pc + 1..end];
self.pos = end;
Some(Instruction {
pc,
opcode,
@@ -1160,6 +1166,28 @@ mod tests {
assert_eq!(count, 0);
}
/// A `tableswitch` whose declared jump table spans the full i32 index range
/// drives `instruction_size` to a saturated, buffer-exceeding size. `next()`
/// must add `pc + size` with a CHECKED add (no wrap on a 32-bit `usize`, no
/// panic on any target) and simply stop the walk — malformed disc bytecode
/// yields no instruction, never a panic.
#[test]
fn instructions_iter_rejects_oversized_switch_without_panicking() {
let mut code = vec![TABLESWITCH, 0, 0, 0]; // opcode + 3 pad bytes
code.extend_from_slice(&[0, 0, 0, 0]); // default offset
code.extend_from_slice(&i32::MIN.to_be_bytes()); // low
code.extend_from_slice(&i32::MAX.to_be_bytes()); // high (2^32 entries)
let attr = CodeAttribute {
max_stack: 0,
max_locals: 0,
code: &code,
};
// The size overruns the buffer, so the switch instruction is never
// yielded and iteration ends immediately — without panicking.
assert!(attr.instructions().next().is_none());
assert_eq!(attr.instructions().count(), 0);
}
#[test]
fn cp_index_extraction() {
let i = Instruction {
+3 -2
View File
@@ -1,8 +1,9 @@
//! CLPI vs MPLS cross-validation diagnostic.
//!
//! Walks per-clip CLPI program info and per-playlist MPLS STN tables,
//! normalizes streams by `(PID, language, coding_type)`, and classifies
//! each PID as CLPI-only, MPLS-only, Match, or Divergent. See
//! dedups streams by PID alone (keeping the first `(coding_type, language)`
//! seen per source), then compares those fields to classify each PID as
//! CLPI-only, MPLS-only, Match, or Divergent. See
//! docs/clpi-audit.md for the full classification rules.
//!
//! [`audit`] returns [`ClpiVsMplsAudit`]; diagnostic only, not used by
+51 -11
View File
@@ -12,7 +12,7 @@
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
/// Cheap signature check: a Criterion disc ships `streamproperties.xml`
/// inside a `/BDMV/JAR/*` archive.
@@ -96,19 +96,30 @@ fn assign_stream_numbers(
let mut audio_idx: u32 = 1;
let mut sub_idx: u32 = 1;
// Numbers already EMITTED per type. When a playbackconfig maps two distinct
// StreamInfo_IDs to the SAME number (a duplicate), the second is demoted to a
// synthesized free number, not emitted twice; the HashSet keeps the skip loop O(1) across the full 65535-stream space.
let mut used_audio: HashSet<u16> = HashSet::new();
let mut used_sub: HashSet<u16> = HashSet::new();
let mut out = Vec::with_capacity(infos.len());
for info in infos {
let n = match stream_map.get(&info.id).copied() {
Some(n) if n != 0 => n,
let (idx, taken, used) = match info.stream_type {
StreamLabelType::Audio => (&mut audio_idx, &taken_audio, &mut used_audio),
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub, &mut used_sub),
};
// A map value of 0 is unmatchable (apply_labels is 1-based); treat it as
// unmapped. A mapped number already claimed by an earlier stream of the
// same type is a duplicate and is likewise demoted to synthesis.
let mapped = stream_map.get(&info.id).copied().filter(|&n| n != 0);
let n = match mapped {
Some(n) if !used.contains(&n) => n,
_ => {
let (idx, taken) = match info.stream_type {
StreamLabelType::Audio => (&mut audio_idx, &taken_audio),
StreamLabelType::Subtitle => (&mut sub_idx, &taken_sub),
};
// Advance past any number already claimed via the map. The
// counter strictly increases and NUMBER_SPACE_END is fixed, so
// this terminates in at most 65535 steps for any input.
while *idx < NUMBER_SPACE_END && taken.contains(&(*idx as u16)) {
// Advance past any number already claimed via the map OR already
// emitted (dedup). The counter strictly increases and NUMBER_SPACE_END
// is fixed, so this terminates in at most 65535 steps for any input.
while *idx < NUMBER_SPACE_END
&& (taken.contains(&(*idx as u16)) || used.contains(&(*idx as u16)))
{
*idx += 1;
}
if *idx >= NUMBER_SPACE_END {
@@ -127,6 +138,7 @@ fn assign_stream_numbers(
n
}
};
used.insert(n);
out.push(n);
}
Some(out)
@@ -308,6 +320,34 @@ mod tests {
assert_eq!(sorted.len(), 3, "stream numbers must be unique");
}
/// Duplicate stream numbers in playbackconfig: two distinct StreamInfo_IDs
/// mapped to the SAME number must not both emit it. The first keeps the
/// claimed number; the second is demoted to a synthesized free slot so the
/// output stays collision-free (pre-fix: both landed on the same number).
#[test]
fn duplicate_mapped_numbers_are_deduped_not_emitted_twice() {
let mut map = HashMap::new();
map.insert("a0".to_string(), 1u16);
map.insert("a1".to_string(), 1u16); // duplicate claim of 1
let infos = vec![
info("a0", StreamLabelType::Audio),
info("a1", StreamLabelType::Audio),
info("a2", StreamLabelType::Audio), // unmapped → fallback
];
let nums = assign_stream_numbers(&infos, &map).expect("numbering space not exhausted");
// a0 keeps 1; a1's duplicate 1 is demoted (skips the taken 1 → 2);
// a2 synthesizes the next free number (3). All distinct.
assert_eq!(nums[0], 1);
assert_ne!(
nums[1], nums[0],
"duplicate must not reuse the claimed number"
);
let mut sorted = nums.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 3, "all stream numbers must be unique");
}
#[test]
fn map_fully_drives_numbers_when_complete() {
let mut map = HashMap::new();
+75 -11
View File
@@ -145,18 +145,45 @@ fn detect_studio(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<String> {
fn parse_studio_attr(xml: &[u8]) -> Option<String> {
const MAX_STUDIO_LEN: usize = 32;
let text = std::str::from_utf8(xml).ok()?;
let anchor = text.find("studio")?;
let after_key = &text[anchor + "studio".len()..];
// Skip whitespace / '=' up to the opening quote.
let open = after_key.find(['"', '\''])?;
let quote = after_key.as_bytes()[open];
let rest = &after_key[open + 1..];
let close = rest.find(quote as char)?;
let val = rest[..close].trim();
if val.is_empty() || val.len() > MAX_STUDIO_LEN {
return None;
let bytes = text.as_bytes();
// Anchor `studio` as a WHOLE attribute name, not a bare substring: the old
// `find("studio")` also fired on `substudio=`, `studioId=`, or "studio" inside
// another attribute's value. Require a word boundary before it and an `=` (after optional whitespace) immediately after.
let mut from = 0;
while let Some(rel) = text[from..].find("studio") {
let start = from + rel;
let end = start + "studio".len();
from = start + 1;
let boundary =
start == 0 || matches!(bytes[start - 1], b' ' | b'\t' | b'\r' | b'\n' | b'<' | b'/');
if !boundary {
continue;
}
let after_eq = text[end..].trim_start();
let Some(after_eq) = after_eq.strip_prefix('=') else {
continue;
};
let after_eq = after_eq.trim_start();
let Some(quote) = after_eq.as_bytes().first().copied() else {
continue;
};
if quote != b'"' && quote != b'\'' {
continue;
}
let rest = &after_eq[1..];
// An unterminated quote on THIS candidate must not abort the whole scan
// (the old `?` did): skip the bad item and keep looking so a later,
// well-formed `studio="..."` is still found.
let Some(close) = rest.find(quote as char) else {
continue;
};
let val = rest[..close].trim();
if val.is_empty() || val.len() > MAX_STUDIO_LEN {
return None;
}
return Some(val.to_ascii_lowercase());
}
Some(val.to_ascii_lowercase())
None
}
/// One identified master enum class.
@@ -2960,4 +2987,41 @@ mod tests {
assert_eq!(parse_studio_attr(b"studio=\"\""), None);
assert_eq!(parse_studio_attr(&[0xff, 0xfe, 0x00]), None);
}
/// The `studio` anchor is a WHOLE attribute name, not a bare substring: a
/// longer attribute that merely CONTAINS "studio", or the word inside some
/// other attribute's value, must not be read as the studio — and the real
/// `studio="..."` on the same tag must still win.
#[test]
fn studio_anchor_is_a_whole_attribute_not_a_substring() {
// `substudio` / `studioId` are not `studio`.
assert_eq!(parse_studio_attr(b"<C substudio=\"nope\">"), None);
assert_eq!(parse_studio_attr(b"<C studioId=\"nope\">"), None);
// "studio" inside another attribute's value must be skipped, and the
// real attribute later on the tag must be found.
assert_eq!(
parse_studio_attr(b"<C title=\"a studio film\" studio=\"fox\">").as_deref(),
Some("fox"),
"the word 'studio' in a value must not steal a later attribute's quotes"
);
// A `studio`-containing value with no real attribute anywhere → None,
// not the value's own quotes.
assert_eq!(parse_studio_attr(b"<C title=\"studio ghibli\">"), None);
}
/// A malformed `studio=` candidate whose quote is never closed must be
/// SKIPPED, not abort the scan: a later well-formed `studio="..."` still
/// resolves. Pre-fix the `?` on the missing close-quote returned None for the
/// whole document, dropping the real studio.
#[test]
fn unterminated_studio_quote_is_skipped_and_a_later_studio_resolves() {
// First `studio="` opens a double-quoted value with NO other `"` until
// the second attribute; only the single-quoted `studio='uni'` is valid.
let xml = b"<C studio=\"unclosed studio='uni'>";
assert_eq!(
parse_studio_attr(xml).as_deref(),
Some("uni"),
"a bad studio candidate must be skipped, not abort the whole scan"
);
}
}
+29 -4
View File
@@ -66,13 +66,25 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
// Surface the feature playlist's authoring id (e.g. "00800") so title
// selection can prefer the disc's own feature over a size-inflated decoy —
// the same signal `paramount::parse` provides.
result.feature_playlist = feature_playlist_id(text).map(|id| super::FeaturePlaylistHint {
playlist_id: id.parse::<u16>().ok(),
filename: Some(format!("{id}.mpls")),
});
result.feature_playlist = feature_hint(text);
Some(result)
}
/// The feature playlist hint for a `dcx.xml` doc. Derives the numeric id and the
/// filename from ONE parsed number so they can never disagree: `format!("{id}")`
/// on a 5-digit id above u16::MAX (65536..=99999) left playlist_id `None` while
/// the filename stayed `Some`, a half-hint whose two fields named different
/// things. Require a valid u16 playlist number, then format the canonical
/// 5-digit `NNNNN.mpls` from it.
pub(crate) fn feature_hint(text: &str) -> Option<super::FeaturePlaylistHint> {
let id = feature_playlist_id(text)?;
let playlist_id = id.parse::<u16>().ok()?;
Some(super::FeaturePlaylistHint {
playlist_id: Some(playlist_id),
filename: Some(format!("{playlist_id:05}.mpls")),
})
}
// Build stream labels from a `dcx.xml` doc; split out from `parse` for unit
// testing. Scoped to the richest `<playlist name="feature">` element — see
// docs/fox.md for why merging playlists is wrong.
@@ -325,6 +337,19 @@ mod tests {
);
}
/// The feature hint's numeric id and filename are derived from ONE parsed
/// number, so they always name the same playlist — the id parses to 800 and
/// the filename is the canonical 5-digit `00800.mpls`, never a half-hint.
#[test]
fn feature_hint_id_and_filename_agree() {
let h = feature_hint(FOX_DCX_SAMPLE).expect("the sample has a feature id");
assert_eq!(h.playlist_id, Some(800));
assert_eq!(h.filename.as_deref(), Some("00800.mpls"));
assert!(!h.is_empty());
// The two fields point at the same playlist.
assert!(h.matches(800, "00800.mpls"));
}
/// Full real-disc parse: the 00800 audio table. Eleven tracks, id order =
/// STN slot, and slot 2 (`eng rnib`) is the descriptive/narration track.
#[test]
+34 -15
View File
@@ -40,28 +40,31 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
return None;
}
// Surface the feature playlist's identity so title selection can prefer
// it over a size-inflated decoy. The id is 5-digit zero-padded on BD
// (matching `NNNNN.mpls`); keep only digits to resist a stray quote/space.
let feature_playlist = super::xml::attr(&feature, "id").and_then(|id| {
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return None;
}
Some(super::FeaturePlaylistHint {
playlist_id: digits.parse::<u16>().ok(),
filename: Some(format!("{digits}.mpls")),
})
});
// High confidence: this format is fully structured and we extract
// every field whose meaning the corpus establishes. "Documented" would
// be the wrong word — see the module note; nothing about it is.
let mut result = ParseResult::high(labels);
result.feature_playlist = feature_playlist;
result.feature_playlist = feature_hint(&feature);
Some(result)
}
/// The feature playlist hint for a `playlists.xml` `<playlist>` element. Surface
/// its identity so title selection can prefer it over a size-inflated decoy.
/// Derive the numeric id and the filename from ONE parsed number so they cannot
/// disagree: keep only the digits (resisting a stray quote/space), require a
/// valid u16 playlist number, and format the canonical 5-digit `NNNNN.mpls`.
/// (`format!("{digits}.mpls")` on a 5-digit id above u16::MAX left playlist_id
/// None while the filename stayed Some — a half-hint.)
fn feature_hint(feature: &str) -> Option<super::FeaturePlaylistHint> {
let id = super::xml::attr(feature, "id")?;
let digits: String = id.chars().filter(|c| c.is_ascii_digit()).collect();
let playlist_id = digits.parse::<u16>().ok()?;
Some(super::FeaturePlaylistHint {
playlist_id: Some(playlist_id),
filename: Some(format!("{playlist_id:05}.mpls")),
})
}
// One cell of the `forced_sub` CSV. Reads like a boolean but is an
// enumeration; see docs/paramount.md for the corpus measurements behind
// each value. Unrecognised cells map to `None` — the conservative direction.
@@ -716,6 +719,22 @@ mod tests {
assert!(find_feature_playlist("<root />").is_none());
}
// The feature hint's id and filename are derived from ONE parsed number, so
// they always name the same playlist (canonical 5-digit NNNNN.mpls), and a
// playlist with no numeric id yields no hint rather than a half-hint.
#[test]
fn feature_hint_id_and_filename_agree() {
let feature = r#"<playlist name="Feature" id="00222" duration="7000" />"#;
let h = feature_hint(feature).expect("a numeric id yields a hint");
assert_eq!(h.playlist_id, Some(222));
assert_eq!(h.filename.as_deref(), Some("00222.mpls"));
assert!(h.matches(222, "00222.mpls"), "the two fields agree");
// No id / non-numeric id → no hint (not a filename-only half-hint).
assert!(feature_hint(r#"<playlist name="Feature" />"#).is_none());
assert!(feature_hint(r#"<playlist id="menu" />"#).is_none());
}
// Spec: on a tie in audio-slot count, the FIRST playlist wins (see
// docs/paramount.md). Mutation: `count > best_aud_count` ->
// `count >= best_aud_count` lets a later tie silently displace it.
+19 -1
View File
@@ -154,7 +154,10 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
// UO_mask_table(8) + misc flags(1) + still_mode(1) + still_time(2), then
// STN_table starting at STN_OFFSET.
const STN_OFFSET: usize = 32;
if item_idx == 0 && item.len() > STN_OFFSET + 16 {
// `>=`, not `>`: the 16-byte STN header spans item[STN_OFFSET..STN_OFFSET+16], so
// item.len() == STN_OFFSET + 16 already holds every header byte. Strict `>` demanded
// one extra byte, skipping a play item whose STN header ends exactly at the boundary (stream-less STN table).
if item_idx == 0 && item.len() >= STN_OFFSET + 16 {
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
let n_video = item[STN_OFFSET + 4] as usize;
let n_audio = item[STN_OFFSET + 5] as usize;
@@ -735,6 +738,21 @@ mod tests {
assert!(!s.secondary);
}
// The STN gate is inclusive of the boundary: a first play item whose STN table is
// JUST the 16-byte header (no stream entries) has item.len() == STN_OFFSET(32)+16 ==
// 48 exactly, and must be parsed, not skipped by a strict `>` demanding one extra byte.
#[test]
fn stn_header_ending_exactly_at_the_item_boundary_is_parsed() {
let data = build_mpls(&[(b"00001", 1, 0, 9000000)], (0, 0, 0, 0, 0, 0, 0, 0), &[]);
// The first play item is exactly the header length (32 + 16).
let playlist = parse(&data).expect("a boundary-length STN header must parse");
assert_eq!(playlist.play_items.len(), 1);
assert!(
playlist.streams.is_empty(),
"no stream entries were declared at the boundary"
);
}
#[test]
fn parse_invalid_magic() {
let mut data = build_mpls(&[(b"00001", 1, 0, 9000000)], (0, 0, 0, 0, 0, 0, 0, 0), &[]);
-36
View File
@@ -44,24 +44,6 @@ pub(crate) fn crc16_mlp(data: &[u8]) -> u16 {
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::*;
@@ -106,22 +88,4 @@ mod tests {
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);
}
}
+38
View File
@@ -199,6 +199,44 @@ mod tests {
assert_eq!(t.dropped_frames(), TRACK_VERDICT_MIN_AUS * 3);
}
/// Drop-gate mismatch: the two counters disagree. A MAJORITY of the track's
/// AUs are dropped (>50%), yet only a MINORITY are individually verified
/// undecodable — the rest are collateral. The poison gate keys on the
/// VERIFIED subset, so this track must survive. The collateral bulk is
/// recorded FIRST, so if the gate mistakenly judged on the raw `dropped`
/// count it would poison as soon as a verified drop pushed it past the
/// minimum; keying on `verified_dropped` it never does.
#[test]
fn a_verified_minority_amid_a_dropped_majority_does_not_poison() {
let mut t = DropTally::new("test");
let kept = TRACK_VERDICT_MIN_AUS; // 200 good
let collateral = TRACK_VERDICT_MIN_AUS * 4; // 800 collateral-bad
let verified = TRACK_VERDICT_MIN_AUS / 2; // 100 verified-bad
for _ in 0..kept {
t.record_kept();
}
for _ in 0..collateral {
t.record_collateral_drop(0, 1000, 512, "resync-forward");
}
for _ in 0..verified {
t.record_drop(0, 1000, 512, "crc");
assert!(
!t.is_poisoned(),
"verified drops are a minority — the dropped majority must not poison"
);
}
let total = kept + collateral + verified;
assert!(
t.dropped_frames() > total / 2,
"a majority of AUs were dropped ({} of {total})",
t.dropped_frames()
);
assert!(
!t.is_poisoned(),
"poison keys on the verified minority, not the dropped majority"
);
}
#[test]
fn collateral_drops_never_poison_the_track() {
// A TrueHD resync-forward run collaterally drops a long burst of AUs, but
+119 -10
View File
@@ -136,6 +136,23 @@ impl DtsParser {
base
}
/// Best "most recent known base" (ns) for a PES that carries no PTS. Never
/// resets the timeline to 0 while any prior base survives: prefer the
/// current AU's captured base, then the projected next-AU start, then the
/// last PES front. `pending_pts` going `PTS_UNSET` (e.g. after a forced
/// flush) used to fall straight to 0, jumping the timeline backwards.
fn continuation_base_ns(&self) -> i64 {
if self.pending_pts >= 0 {
self.pending_pts
} else if self.next_pts_ns != PTS_UNSET {
self.next_pts_ns
} else if self.last_front_pts != PTS_UNSET {
self.last_front_pts
} else {
0
}
}
/// Drop `n` bytes from the front, rebasing attribution onto the new front.
fn drain_front(&mut self, n: usize) {
self.acc.drain(n);
@@ -197,11 +214,7 @@ impl CodecParser for DtsParser {
// timeline to 0 — continue from the most recent known base.
let pts_ns = super::pesbuf::PesFacts::of(pes)
.presentation_ns()
.unwrap_or(if self.pending_pts >= 0 {
self.pending_pts
} else {
0
});
.unwrap_or_else(|| self.continuation_base_ns());
// Blu-ray DTS-HD MA/HRA: core frame + extension substreams (lossless data)
// arrive in SEPARATE, later PES packets, so assemble core-to-next-core
@@ -303,10 +316,14 @@ impl CodecParser for DtsParser {
let au_src = self.front_source();
self.emit_or_drop(au, au_pts, dur_ns, au_src, &mut frames);
self.drain_front(au_end);
// After draining, the marker covering the new front (if any) carries
// the next AU's PTS; `pending_pts` is only the fallback when no
// marker survives. Track it so the fallback stays sensible.
self.pending_pts = self.front_pts();
// After draining, the mark over the new front carries the next AU's PTS;
// `pending_pts` is the fallback when none survives. A fully drained buffer
// has no live front, so its offset-0 mark is the just-emitted AU's — invalidate rather than keep that stale PTS as a fallback for a later PTS-less PES.
self.pending_pts = if self.acc.is_empty() {
PTS_UNSET
} else {
self.front_pts()
};
if forced {
// Safety-valve flush: the next AU's real core PES hasn't arrived,
// so invalidate the PTS rather than inherit this non-core PES's.
@@ -353,9 +370,13 @@ impl DtsParser {
self.acc.clear();
return Vec::new();
}
// A trailing core sync at EOS opens a NEW access unit that never completed
// (a complete next core would have closed this AU during parse()), so it is
// truncated — drop it rather than splice onto the final AU; real trailing extensions are kept.
let emit_end = final_au_end(self.acc.as_slice(), core_size);
// The final AU's PTS is the PES covering the buffer front (its core's
// PES). Fall back to pending_pts, clamping the sentinel to 0.
let au = self.acc.as_slice().to_vec();
let au = self.acc.as_slice()[..emit_end].to_vec();
let dur_ns = dts_core_duration_ns(&au) as i64;
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
let src = self.front_source();
@@ -366,6 +387,38 @@ impl DtsParser {
}
}
/// Offset where the FINAL access unit ends at end-of-stream, dropping a
/// truncated trailing core. Trailing DTS-HD extension substreams belong to this
/// AU and are skipped precisely (kept); a trailing CORE sync — complete or
/// truncated — begins a NEW AU that never closed during `parse()`, so the AU
/// ends there. A tail too short to identify, or non-sync garbage, is kept with
/// the AU. See docs/dts.md.
fn final_au_end(buf: &[u8], core_size: usize) -> usize {
let mut pos = core_size;
loop {
if buf.len().saturating_sub(pos) < SYNCWORD_BYTES {
// Fewer than a syncword of trailing bytes: nothing identifiable —
// keep them with this AU (the same 3-byte-tail tolerance as parse).
return buf.len();
}
if buf[pos..].starts_with(&DTS_HD_EXT_SYNC) {
match exss_frame_size(&buf[pos..]) {
// A fully-buffered extension substream belongs to this AU.
Some(sz) if sz >= SYNCWORD_BYTES && buf.len() >= pos + sz => pos += sz,
// Truncated/unsizeable extension at EOS: still part of this AU.
_ => return buf.len(),
}
} else if buf[pos..].starts_with(&DTS_CORE_SYNC) {
// A trailing core (truncated — a complete one would have closed the
// AU during parse) is a new AU that never completed: drop it.
return pos;
} else {
// Non-sync garbage after the core is not a further AU; keep it.
return buf.len();
}
}
}
fn find_sync(data: &[u8], pattern: &[u8; 4]) -> Option<usize> {
if data.len() < 4 {
return None;
@@ -692,6 +745,37 @@ mod tests {
}
}
/// A PTS-less PES must continue the timeline from the most recent known base,
/// never snap it back to 0. `pending_pts` going PTS_UNSET (e.g. after a forced
/// flush) previously fell straight to 0; the fallback must instead reach for
/// the projected next-AU start, then the last PES front.
#[test]
fn continuation_base_prefers_a_known_projection_over_zero() {
let mut parser = DtsParser::new();
// Fresh current-AU base wins.
parser.pending_pts = 90_000;
parser.next_pts_ns = 200_000;
parser.last_front_pts = 100_000;
assert_eq!(parser.continuation_base_ns(), 90_000);
// pending_pts invalidated (post-forced-flush): fall to the projection,
// NOT to 0.
parser.pending_pts = PTS_UNSET;
assert_eq!(
parser.continuation_base_ns(),
200_000,
"an unset pending_pts must continue from the projected next-AU start"
);
// No projection either: the last PES front, still not 0.
parser.next_pts_ns = PTS_UNSET;
assert_eq!(parser.continuation_base_ns(), 100_000);
// Nothing known at all: only then is 0 the honest answer.
parser.last_front_pts = PTS_UNSET;
assert_eq!(parser.continuation_base_ns(), 0);
}
fn make_dts_core(size: usize) -> Vec<u8> {
let fsize = size - 1;
let mut data = vec![0u8; size];
@@ -870,6 +954,31 @@ mod tests {
assert_eq!(tail[0].data.len(), 512);
}
#[test]
fn truncated_trailing_core_is_dropped_at_eos() {
// A complete core is held awaiting a next core, but the stream cuts off mid-way
// into that next core (only sync + 2 bytes arrived — too few to size, so held not
// emitted during parse). At EOS that truncated trailing core is DROPPED, not spliced onto the final AU: the emitted unit is exactly the complete 512-byte core.
let mut parser = DtsParser::new();
let mut stream = make_dts_core(512);
// Truncated trailing core: sync + 2 bytes (< CORE_HEADER_MIN_BYTES).
stream.extend_from_slice(&DTS_CORE_SYNC);
stream.extend_from_slice(&[0x00, 0x00]);
assert!(
parser.parse(&make_pes(stream, Some(90000))).is_empty(),
"core held awaiting next core; truncated tail cannot close it"
);
let tail = parser.flush();
assert_eq!(tail.len(), 1, "exactly the one complete AU is emitted");
assert_eq!(
tail[0].data.len(),
512,
"the truncated trailing core is dropped, not appended to the AU"
);
assert_eq!(&tail[0].data[0..4], &DTS_CORE_SYNC);
}
#[test]
fn parse_frame_spanning_two_pes() {
let mut parser = DtsParser::new();
+66 -11
View File
@@ -208,6 +208,11 @@ impl CodecParser for H264Parser {
let mut keyframe = false;
// Picture coding type, MEASURED from the first coded slice's header.
let mut coding_type: Option<CodingType> = None;
// Open-GOP promotion needs EVERY VCL slice intra, not just the first: a picture
// whose first slice is I but later slices are P/B is not a clean random-access
// point. `all_vcl_intra` holds only while every slice header parses AND codes as I/SI; `saw_vcl` guards against promoting a param-set-only access unit.
let mut all_vcl_intra = true;
let mut saw_vcl = false;
// Did this access unit already carry each param-set type in-band?
let mut emitted_sps = false;
let mut emitted_pps = false;
@@ -243,16 +248,24 @@ impl CodecParser for H264Parser {
if nal_type == NAL_SLICE_IDR {
keyframe = true;
}
// Measure coding type from the first slice header (§7.3.3:
// first_mb_in_slice, slice_type, both ue(v)) after unescaping
// EBSP (§7.3.1) — large first_mb_in_slice needs escaped 0x00 0x00.
if (nal_type == NAL_SLICE_NON_IDR || nal_type == NAL_SLICE_IDR)
&& coding_type.is_none()
{
// Measure coding type from each slice header (§7.3.3: first_mb_in_slice,
// slice_type, both ue(v)) after unescaping EBSP (§7.3.1) — large
// first_mb_in_slice needs escaped 0x00 0x00. The FIRST slice sets the picture's `coding_type`; EVERY slice feeds `all_vcl_intra` for the open-GOP promotion below.
if nal_type == NAL_SLICE_NON_IDR || nal_type == NAL_SLICE_IDR {
saw_vcl = true;
let header = unescape_ebsp_prefix(&nal[1..]);
let mut br = BitReader::new(&header);
if let (Some(_first_mb), Some(slice_type)) = (br.read_ue(), br.read_ue()) {
coding_type = h264_slice_coding_type(slice_type);
let ct = h264_slice_coding_type(slice_type);
if coding_type.is_none() {
coding_type = ct;
}
if ct != Some(CodingType::I) {
all_vcl_intra = false;
}
} else {
// An unparseable slice header cannot be proven intra.
all_vcl_intra = false;
}
}
// A NAL longer than u32::MAX can't be length-prefixed in the
@@ -271,10 +284,10 @@ impl CodecParser for H264Parser {
return Vec::new();
}
// Open-GOP resync anchor: BD titles use open GOPs whose random-access
// point is a non-IDR I-frame, not always SEI-tagged. Without treating it
// as a keyframe, the resync gate (`mux/resync.rs`) can miss it and drop frames to EOF.
if coding_type == Some(CodingType::I) {
// Open-GOP resync anchor: BD titles use open GOPs whose random-access point is a
// non-IDR I-frame. H.264 signals it with a recovery_point SEI, which this parser
// does NOT read — it infers the anchor from the slices. Without treating it as a keyframe the resync gate (`mux/resync.rs`) can miss it and drop frames to EOF. Promote only when EVERY VCL slice is intra (a picture with a P/B slice is not a random-access point) and only on the base view (`!mvc`): a dependent MVC view is never an independent anchor.
if saw_vcl && all_vcl_intra && !mvc {
keyframe = true;
}
@@ -603,6 +616,48 @@ mod tests {
);
}
// A picture whose FIRST slice is I but whose LATER slice is P/B is not a
// clean random-access point: the open-GOP promotion must require EVERY VCL
// slice to be intra, not trust the first slice alone.
#[test]
fn a_mixed_slice_picture_with_a_p_slice_is_not_promoted() {
// AU with two coded slices: slice 1 is I (0x88, slice_type=7), slice 2
// is P (0xC0, slice_type=0). Both NAL type 1, nal_ref_idc 3 -> 0x61.
let au = vec![
0x00, 0x00, 0x01, 0x61, 0x88, // I slice
0x00, 0x00, 0x01, 0x61, 0xC0, // P slice
0x00,
];
let mut parser = H264Parser::new();
let frames = parser.parse(&make_pes(au, Some(0)));
assert_eq!(frames.len(), 1, "one PES access unit → one frame");
assert_eq!(
frames[0].coding.map(|c| c.coding_type()),
Some(CodingType::I),
"the picture's coding type is still the first slice's (I)",
);
assert!(
!frames[0].keyframe,
"a picture with a P slice is not a random-access point and must not promote",
);
}
// MVC dependent-view (Blu-ray 3D right-eye) passthrough: an intra dependent
// view is not an independent random-access anchor, so it must NOT be
// promoted to a keyframe even when every slice is intra.
#[test]
fn an_intra_mvc_dependent_view_is_not_promoted() {
// Single non-IDR I slice, as in open_gop_intra_access_unit_is_a_keyframe.
let au = vec![0x00, 0x00, 0x01, 0x61, 0x88, 0x00];
let mut parser = H264Parser::new().with_mvc_passthrough(true);
let frames = parser.parse(&make_pes(au, Some(0)));
assert_eq!(frames.len(), 1);
assert!(
!frames[0].keyframe,
"an MVC dependent view is never an independent anchor, intra or not",
);
}
// The slice-header parse must remove emulation-prevention bytes first
// (ISO/IEC 14496-10 §7.3.1/§7.4.1) before decoding ue(v) fields.
// See docs/h264.md — slice_header_parse_removes_emulation_prevention_bytes.
+99 -13
View File
@@ -60,10 +60,24 @@ fn hevc_slice_coding_type(slice_type: u32) -> Option<CodingType> {
}
}
// Measures the coding type from the FIRST coded slice of an access unit
// (H.265 §7.3.6.1). `None` for a non-first slice or on truncation — never a
// guess. See docs/hevc.md — hevc_first_slice_coding_type.
fn hevc_first_slice_coding_type(nal: &[u8], nal_type: u8, num_extra: u32) -> Option<CodingType> {
// The `pps_pic_parameter_set_id` (first ue(v)) of a PPS NAL — the key under
// which each PPS is stored so a slice's referenced PPS is resolved by its own
// id. `None` if the PPS is too short. See docs/hevc.md.
fn hevc_pps_id(pps_nal: &[u8]) -> Option<u32> {
BitReader::new(pps_nal.get(2..)?).read_ue()
}
/// Measures the coding type from the FIRST coded slice of an access unit
/// (H.265 §7.3.6.1). `None` for a non-first slice or on truncation — never a
/// guess. `resolve_num_extra` maps the slice's OWN `slice_pic_parameter_set_id`
/// to that PPS's `num_extra_slice_header_bits`, so the `slice_type` bit offset is
/// taken from the PPS the slice references — not merely the last-active one. See
/// docs/hevc.md — hevc_first_slice_coding_type.
fn hevc_first_slice_coding_type(
nal: &[u8],
nal_type: u8,
resolve_num_extra: impl Fn(u32) -> Option<u32>,
) -> Option<CodingType> {
let mut br = BitReader::new(nal.get(2..)?); // RBSP after the 2-byte NAL header
if br.read_bit()? != 1 {
return None; // not the first slice segment of the picture
@@ -71,7 +85,11 @@ fn hevc_first_slice_coding_type(nal: &[u8], nal_type: u8, num_extra: u32) -> Opt
if (NAL_BLA_W_LP..=NAL_RSV_IRAP_VCL23).contains(&nal_type) {
br.skip_bits(1)?; // no_output_of_prior_pics_flag (IRAP only)
}
br.read_ue()?; // slice_pic_parameter_set_id
let pps_id = br.read_ue()?; // slice_pic_parameter_set_id
// Resolve num_extra from the PPS this slice REFERENCES; a stream with
// multiple PPS can point a slice at one whose num_extra differs, which would
// shift the slice_type offset if the last-active PPS were assumed instead.
let num_extra = resolve_num_extra(pps_id)?;
// First slice → no slice_segment_address and dependent_slice_segment_flag is
// 0, so slice_type follows the reserved bits directly.
br.skip_bits(num_extra)?; // slice_reserved_flag[i]
@@ -94,6 +112,11 @@ pub struct HevcParser {
cur_vps: Option<Vec<u8>>,
cur_sps: Option<Vec<u8>>,
cur_pps: Option<Vec<u8>>,
/// Every PPS seen, keyed by its `pps_pic_parameter_set_id`, so a slice's
/// coding type is measured with the `num_extra_slice_header_bits` of the PPS
/// it REFERENCES rather than the last-active one (multiple PPS with differing
/// values would otherwise shift the slice_type bit offset).
pps_by_id: std::collections::HashMap<u32, Vec<u8>>,
// Splice-aware CRA→BLA rewrite for a non-seamless BD clip boundary (first
// CRA_NUT -> BLA_W_LP so NoRaslOutput discards dangling RASL). Armed by
// `mark_clip_boundary` AND PTS-backstep auto-detect in `parse` — not dead code.
@@ -184,6 +207,7 @@ impl HevcParser {
cur_vps: None,
cur_sps: None,
cur_pps: None,
pps_by_id: std::collections::HashMap::new(),
pending_clip_boundary: false,
high_pts: None,
pts_wrap_offset: 0,
@@ -422,17 +446,19 @@ impl CodecParser for HevcParser {
// Measure coding type from the first coded slice (VCL NAL
// 0..=31), only once the active PPS is known so the bit
// offset to `slice_type` is exact; else decline (`None`), never guess.
if coding_type.is_none()
&& nal_type <= NAL_VCL_MAX
&& let Some(num_extra) = self
.cur_pps
.as_deref()
.and_then(hevc_num_extra_slice_header_bits)
{
if coding_type.is_none() && nal_type <= NAL_VCL_MAX {
// Resolve num_extra from the PPS the slice REFERENCES (by
// its slice_pic_parameter_set_id), not the last-active PPS.
let pps_by_id = &self.pps_by_id;
coding_type = hevc_first_slice_coding_type(
&data[nal_start..end],
nal_type,
num_extra,
|pps_id| {
pps_by_id
.get(&pps_id)
.map(Vec::as_slice)
.and_then(hevc_num_extra_slice_header_bits)
},
);
}
@@ -454,6 +480,12 @@ impl CodecParser for HevcParser {
)
}
NAL_PPS => {
// Store the PPS under its own id so a later slice's
// coding type resolves num_extra from the PPS it
// references, not merely the last-active one.
if let Some(id) = hevc_pps_id(&data[nal_start..end]) {
self.pps_by_id.insert(id, data[nal_start..end].to_vec());
}
emitted_pps |= handle_param_set(
&mut self.pps,
&mut self.cur_pps,
@@ -1318,6 +1350,60 @@ mod tests {
);
}
/// A slice must be measured with the num_extra_slice_header_bits of the PPS
/// it REFERENCES (slice_pic_parameter_set_id), not the last-active PPS. Two
/// PPS with different num_extra: the slice points at the FIRST while the
/// second is active. Assuming the active PPS reads slice_type at the wrong
/// offset (None here); resolving by the slice's own pps id reads it right.
#[test]
#[allow(clippy::unusual_byte_groupings)]
fn slice_coding_type_uses_the_pps_the_slice_references_not_the_active_one() {
use super::super::coding::CodingType;
// PPS id=0, num_extra=0 → RBSP byte 0xC0 (pps_id '1', sps_id '1', 00, 000).
let pps0 = 0xC0u8;
// PPS id=1, num_extra=3 → RBSP 0101_0001 1000_0000: pps_id ue '010'=1,
// sps_id '1'=0, 00, num_extra '011'=3.
let pps1 = [0x51u8, 0x80];
// Raw NAL (header + body, NO start code) for the direct-parse helpers.
let raw_pps = |body: &[u8]| {
let mut v = hevc_nal_header(NAL_PPS).to_vec();
v.extend_from_slice(body);
v
};
assert_eq!(hevc_num_extra_slice_header_bits(&raw_pps(&[pps0])), Some(0));
assert_eq!(hevc_num_extra_slice_header_bits(&raw_pps(&pps1)), Some(3));
assert_eq!(hevc_pps_id(&raw_pps(&[pps0])), Some(0));
assert_eq!(hevc_pps_id(&raw_pps(&pps1)), Some(1));
// TRAIL_R slice referencing pps_id=0: first_slice=1, pps_id '1'=0,
// slice_type ue '011'=2 (I) directly (num_extra of PPS 0 is 0). 0xD8.
let slice = 0xD8u8;
// AU order: PPS0, PPS1 (so cur_pps = PPS1, num_extra=3), then the slice
// that references PPS0 (num_extra=0).
let mut data = nal_bytes(NAL_PPS, &[pps0]);
data.extend_from_slice(&nal_bytes(NAL_PPS, &pps1));
data.extend_from_slice(&nal_bytes(1, &[slice]));
let frames = HevcParser::new().parse(&make_pes(data, Some(0)));
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].coding.expect("PictureInfo").coding_type(),
CodingType::I,
"resolved via the slice's own pps id (PPS0, num_extra=0) → slice_type 2 (I); \
assuming the active PPS1 (num_extra=3) would misread the offset"
);
}
/// Build an Annex-B NAL (start code + 2-byte header + body bytes).
fn nal_bytes(nal_type: u8, body: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01];
v.extend_from_slice(&hevc_nal_header(nal_type));
v.extend_from_slice(body);
v
}
#[test]
fn hevc_populates_measured_coding_type_and_source() {
use super::super::coding::CodingType;
+45 -3
View File
@@ -19,6 +19,11 @@ pub struct LpcmParser {
/// `true` for BD-TS LPCM (header still present), `false` for DVD-PS LPCM
/// (header already removed by `PsDemuxer`).
strip_header: bool,
/// 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 ADTS/FLAC/MPEG-audio parsers
/// and preserving A/V sync.
last_pts_ns: i64,
}
impl Default for LpcmParser {
@@ -30,7 +35,10 @@ impl Default for LpcmParser {
impl LpcmParser {
/// BD-TS LPCM parser: strips the 4-byte BD LPCM header from each PES.
pub fn new() -> Self {
Self { strip_header: true }
Self {
strip_header: true,
last_pts_ns: 0,
}
}
/// DVD-PS LPCM parser: `PsDemuxer` already stripped the private sub-header,
@@ -38,6 +46,7 @@ impl LpcmParser {
pub fn new_dvd() -> Self {
Self {
strip_header: false,
last_pts_ns: 0,
}
}
}
@@ -53,9 +62,14 @@ impl CodecParser for LpcmParser {
if pes.data.len() <= offset {
return Vec::new();
}
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
let pts_ns = pes
.pts
.or(pes.dts)
.map(pts_to_ns)
.unwrap_or(self.last_pts_ns);
self.last_pts_ns = pts_ns;
vec![Frame {
discontinuity: false,
discontinuity: pes.discontinuity,
coding: None,
source: super::pesbuf::PesFacts::of(pes).source,
pts_ns,
@@ -261,6 +275,34 @@ mod tests {
assert_eq!(f[0].pts_ns, 0);
}
#[test]
fn pts_less_pes_carries_last_timestamp_and_propagates_discontinuity() {
// 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 — and the
// emitted unit must reflect the PES discontinuity flag, not a hardcoded false. Mirrors the adts/flac guards.
let mut parser = LpcmParser::new();
let header = vec![0x00, 0x01, 0x00, 0b1001_0001];
let pcm = vec![0x11, 0x22, 0x33, 0x44];
let mut data = header;
data.extend_from_slice(&pcm);
// Prime last_pts_ns with a real timestamp.
parser.parse(&make_pes(data.clone(), Some(90_000)));
// Next PES has no PTS and is flagged discontinuous.
let mut pes = make_pes(data, None);
pes.discontinuity = true;
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(
frames[0].pts_ns, 1_000_000_000,
"carried forward, not reset to 0"
);
assert!(
frames[0].discontinuity,
"PES discontinuity propagates into the access unit"
);
}
// The text-based guard in codec/mod.rs can't see writes via `facts.source`;
// only a runtime check proves an emitted frame carries the byte it came
// from, needed for placing a track by byte instead of timestamp inference.
+47
View File
@@ -71,6 +71,16 @@ pub(crate) fn facts_for(marks: &[(usize, PesFacts)], off: usize) -> PesFacts {
found
}
/// Internal hard cap on buffered bytes — defense-in-depth, mirroring
/// reorder.rs's `MAX_GOP_BYTES`/`MAX_GOP_FRAMES`. Even a caller that never
/// drains (a misbehaving or crafted-input codec) cannot grow this buffer
/// without bound: a push past the cap drops the OLDEST bytes to make room,
/// rebasing marks through `drain` so attribution of the surviving bytes stays
/// correct. Real callers cap their own access units far below this (DTS at 64
/// KiB), so this only ever fires on pathological input. Bounding the bytes also
/// bounds the mark count, since every mark is anchored to at least one byte.
const MAX_BUFFERED_BYTES: usize = 16 * 1024 * 1024;
/// Bytes accumulated across PES packets, each byte attributable to the packet
/// that carried it.
pub(crate) struct PesBuf {
@@ -97,6 +107,7 @@ impl PesBuf {
}
self.marks.push_back((self.buf.len(), PesFacts::of(pes)));
self.buf.extend_from_slice(&pes.data);
self.enforce_cap();
}
/// Append a payload under facts the caller resolved — for a parser that
@@ -108,6 +119,17 @@ impl PesBuf {
}
self.marks.push_back((self.buf.len(), facts));
self.buf.extend_from_slice(data);
self.enforce_cap();
}
/// Enforce [`MAX_BUFFERED_BYTES`] by dropping the oldest bytes when a push
/// overflows it. `drain` rebases the marks, so the surviving bytes keep
/// correct attribution and the mark count stays bounded with the bytes.
fn enforce_cap(&mut self) {
if self.buf.len() > MAX_BUFFERED_BYTES {
let overflow = self.buf.len() - MAX_BUFFERED_BYTES;
self.drain(overflow);
}
}
// The facts of the PES that carried the byte at `off`: the last mark at or
@@ -304,6 +326,31 @@ mod tests {
assert_eq!(b.front(), PesFacts::of(&p));
}
/// Defense-in-depth: a caller that pushes without ever draining must not
/// grow the buffer — or its marks — without bound. Past MAX_BUFFERED_BYTES
/// the oldest bytes are dropped, and the mark count is bounded with them.
#[test]
fn a_caller_that_never_drains_cannot_grow_the_buffer_without_bound() {
let mut b = PesBuf::with_capacity(64);
let chunk = vec![0u8; 1024 * 1024]; // 1 MiB per push
// Push well past the 16 MiB cap (24 MiB total) without ever draining.
for i in 0..24u64 {
b.push(&pes(&chunk, Some(90_000 + i as i64), Some(i * 1000)));
}
assert!(
b.len() <= MAX_BUFFERED_BYTES,
"buffer stayed capped at {MAX_BUFFERED_BYTES}, got {}",
b.len()
);
// Marks are bounded with the bytes: at most one per surviving MiB (+1
// for the retained covering mark), never one per push.
assert!(
b.mark_count() <= MAX_BUFFERED_BYTES / (1024 * 1024) + 2,
"mark count stayed bounded, got {}",
b.mark_count()
);
}
/// Over-draining is clamped rather than panicking: a parser that
/// mis-sizes a unit must not take the process down.
#[test]
+5 -1
View File
@@ -63,7 +63,11 @@ impl<'a> BitReader<'a> {
Some(b as u32)
}
/// Read `n` bits, MSB-first, into the low bits of a `u32`.
/// Read `n` bits, MSB-first, into the low `n` bits of a `u32`. Returns
/// `None` if fewer than `n` bits remain (the same exhaustion contract as
/// [`read_bit`](Self::read_bit)), leaving the reader's position unspecified.
/// `n` must be `<= 32`; a larger `n` shifts the earliest bits out of the
/// `u32`, keeping only the last 32. `read_bits(0)` yields `Some(0)`.
pub fn read_bits(&mut self, n: u32) -> Option<u32> {
let mut v = 0u32;
for _ in 0..n {
+180 -11
View File
@@ -645,14 +645,15 @@ pub struct DemuxSink {
ref_first_pts_ns: Option<i64>,
timeline: TimelineContinuity,
finished: bool,
/// Frames the timeline PLACED (not necessarily persisted).
/// Frames actually PERSISTED to a track file — the drop gate's denominator.
///
/// A frame for a track this export filters out still flows through
/// `write()` and still counts here, so this is "the timeline placed
/// something" rather than "a file received bytes". That is the right
/// denominator for the drop gate below — it asks whether placement worked
/// at all — but it is deliberately NOT a per-track written count, so a
/// single lost track among many is not visible to it.
/// Counted in `write()` only inside the `Some(TrackOut)` arm, so a frame for
/// a track this export filters out (e.g. video during an `audio://` export)
/// flows through the timeline but does NOT count here. The gate in `finish()`
/// pairs this with `timeline.dropped_for(persisted_tracks)` so numerator and
/// denominator cover the SAME set of tracks: were it "frames the timeline
/// placed", a filtered-out track's clip-join drops would measure against a
/// count that never included them, wrongly tripping `SeamPlanDroppedMost`/`SinkWroteNothing`.
frames_mapped: u64,
}
@@ -867,7 +868,6 @@ impl Stream for DemuxSink {
) else {
return Ok(());
};
self.frames_mapped = self.frames_mapped.saturating_add(1);
if drives {
// Delay reference: recorded here, not in the track's `TrackOut`, so
// it survives the `audio://` / `sub://` kind filter dropping the
@@ -875,6 +875,10 @@ impl Stream for DemuxSink {
self.ref_first_pts_ns.get_or_insert(pts);
}
if let Some(Some(t)) = self.tracks.get_mut(frame.track) {
// Denominator = persisted frames only. A kind-filtered frame (video
// in an `audio://` export) maps but writes no file; counting it masks
// a seam plan that dropped every *persisted* frame → zero-byte exit 0.
self.frames_mapped = self.frames_mapped.saturating_add(1);
t.first_pts_ns.get_or_insert(pts);
t.writer.write_frame(&mut t.w, frame, pts)?;
}
@@ -886,9 +890,16 @@ impl Stream for DemuxSink {
return Ok(());
}
self.finished = true;
// Same reporting as the MKV muxer's finish: an unexpected drop volume is
// how a demux quietly comes up short, so surface it here too.
let seam_dropped = self.timeline.dropped_total();
// Same as the MKV muxer's finish: surface unexpected drop volume. Count
// drops ONLY for persisted tracks (matches the `frames_mapped` denominator;
// see its doc for why filtered exports must not fold non-persisted drops in).
let persisted_tracks: Vec<usize> = self
.tracks
.iter()
.enumerate()
.filter_map(|(i, slot)| slot.as_ref().map(|_| i))
.collect();
let seam_dropped = self.timeline.dropped_for(&persisted_tracks);
// Frames arrived and all were dropped: previously finished cleanly as
// zero-byte track files. Keyed on frames-offered so a legitimately empty
// sink (chapters-only export, absent track class) still writes none.
@@ -1704,6 +1715,164 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A demux export where the seam plan drops MORE frames than it persists
/// must report SeamPlanDroppedMost — even though at least one frame did
/// persist (frames_mapped > 0). The denominator counts persisted frames
/// only, so a mostly-emptied export can no longer slip past as success.
/// See docs/demux-sink.md — a_demux_export_the_seam_plan_mostly_emptied_fails.
#[test]
fn a_demux_export_the_seam_plan_mostly_emptied_fails() {
let dir = tempdir();
let mut title = title_with(vec![video_stream(Codec::H264)], vec![None]);
title.clips = vec![
crate::disc::Clip {
feed_span: None,
clip_id: "00000".into(),
in_time: 100 * 45_000,
out_time: 200 * 45_000,
duration_secs: 100.0,
source_packets: 0,
},
crate::disc::Clip {
feed_span: None,
clip_id: "00001".into(),
in_time: 200 * 45_000,
out_time: 300 * 45_000,
duration_secs: 100.0,
source_packets: 0,
},
];
let mut sink = DemuxSink::create(&dir, &title, &DemuxOptions::default()).unwrap();
// Two frames before the first IN mark (dropped) …
for (i, pts) in [0i64, 1_000_000_000].iter().enumerate() {
let f = PesFrame {
coding: None,
source: None,
track: 0,
pts: *pts,
keyframe: true,
data: vec![0x00, 0x00, 0x00, 0x01, 0x09, 0x10, i as u8],
duration_ns: None,
};
let _ = Stream::write(&mut sink, &f);
}
// … and one frame inside the first clip (150 s, in ns) that DOES persist,
// so frames_mapped > 0 while seam_dropped (2) still exceeds it (1).
let inside = PesFrame {
coding: None,
source: None,
track: 0,
pts: 150_000_000_000,
keyframe: true,
data: vec![0x00, 0x00, 0x00, 0x01, 0x09, 0x10, 0x42],
duration_ns: None,
};
Stream::write(&mut sink, &inside).unwrap();
let err = Stream::finish(&mut sink).expect_err("a mostly-emptied export must fail");
assert_eq!(
crate::error::error_code(&err),
Some(crate::error::E_SEAM_PLAN_DROPPED_MOST),
"more frames dropped than persisted must report SeamPlanDroppedMost"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// A FILTERED export (`audio://`) must NOT trip the drop gates on drops that
/// belong to a track it never persists. The video track is dropped hard at
/// the clip join (2 frames, all outside the marks), while the persisted audio
/// track loses nothing. `dropped_total()` would fold the video drops (2) into
/// the numerator and, exceeding the persisted count (1), wrongly report
/// SeamPlanDroppedMost; `dropped_for(persisted_tracks)` sees 0 and passes.
/// See docs/demux-sink.md — a_filtered_export_ignores_drops_on_filtered_tracks.
#[test]
fn a_filtered_export_ignores_drops_on_non_persisted_tracks() {
let dir = tempdir();
let mut title = title_with(
vec![video_stream(Codec::H264), audio_stream(Codec::Ac3, "eng")],
vec![None, None],
);
title.clips = vec![
crate::disc::Clip {
feed_span: None,
clip_id: "00000".into(),
in_time: 100 * 45_000,
out_time: 200 * 45_000,
duration_secs: 100.0,
source_packets: 0,
},
crate::disc::Clip {
feed_span: None,
clip_id: "00001".into(),
in_time: 200 * 45_000,
out_time: 300 * 45_000,
duration_secs: 100.0,
source_packets: 0,
},
];
// `audio://`: only the audio track is persisted; video is filtered out.
let opts = DemuxOptions {
base: "Filt".to_string(),
kind_filter: Some(TrackKind::Audio),
export_chapters: false,
..Default::default()
};
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
assert!(
sink.tracks[0].is_none() && sink.tracks[1].is_some(),
"audio:// must not persist the video track"
);
// Two VIDEO frames before the first IN mark: the plan drops both, but the
// video track has no file — these drops are not this export's shortfall.
for (i, pts) in [0i64, 1_000_000_000].iter().enumerate() {
let f = PesFrame {
coding: None,
source: None,
track: 0,
pts: *pts,
keyframe: true,
data: vec![0x00, 0x00, 0x00, 0x01, 0x09, 0x10, i as u8],
duration_ns: None,
};
let _ = Stream::write(&mut sink, &f);
}
// One AUDIO frame inside the first clip (150 s): placed and persisted.
Stream::write(
&mut sink,
&PesFrame {
coding: None,
source: None,
track: 1,
pts: 150_000_000_000,
keyframe: true,
data: vec![0x0B, 0x77],
duration_ns: None,
},
)
.unwrap();
// Gate must PASS: the only drops were on the filtered-out video track.
Stream::finish(&mut sink)
.expect("a filtered export must not fail on drops it never persisted");
// And the audio file was actually written with its payload.
let wrote_audio = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.any(|e| {
e.path().extension().map(|x| x == "ac3").unwrap_or(false)
&& std::fs::metadata(e.path())
.map(|m| m.len() > 0)
.unwrap_or(false)
});
assert!(
wrote_audio,
"the persisted audio track must have received bytes"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sink_read_returns_write_only() {
let dir = tempdir();
+52 -3
View File
@@ -232,7 +232,10 @@ impl<W: Write> M2tsMux<W> {
if self.audio.is_none() {
return Ok(());
}
let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ false);
// Audio seeds the shared origin too. Video-only seeding made a leading audio
// frame fall back to `base = unwrap_or(raw_90k)` (its OWN pts), collapsing all
// pre-video audio onto PTS 0; `get_or_insert` still lets the FIRST frame fix it.
let pts_90k = self.base_relative_pts(pts_ns, /* may_seed_base */ true);
let pes = build_audio_pes(pts_90k, data);
self.write_pes(PID_AUDIO, &pes, None, false)
}
@@ -243,8 +246,9 @@ impl<W: Write> M2tsMux<W> {
self.out.flush()
}
// PTS (ns) -> 90 kHz ticks rebased on the stream origin, seeded ONLY by
// the first video frame. See docs/m2ts-mux.md#base_relative_pts.
// PTS (ns) -> 90 kHz ticks rebased on the stream origin, seeded by the FIRST
// frame of any kind (video or audio) when `may_seed_base`. See
// docs/m2ts-mux.md#base_relative_pts.
fn base_relative_pts(&mut self, pts_ns: i64, may_seed_base: bool) -> u64 {
let raw_90k = if pts_ns > 0 {
// Widen to u128 so adversarial timestamps can't overflow the
@@ -1423,6 +1427,51 @@ mod tests {
assert_eq!(pts, 0, "negative pts_ns encodes PTS 0");
}
#[test]
fn pre_video_audio_frames_keep_their_spacing() {
// Two audio frames before any video. The FIRST (audio) seeds the shared origin,
// so the second keeps its 1s offset. OLD code seeded only on video → both fell
// back to `base = unwrap_or(raw_90k)` (own pts), collapsing onto PTS 0, losing 1s.
let decode_audio_pts = |pkt: &[u8]| -> u64 {
let afc = (pkt[3] >> 4) & 0x03;
let pes_start = if afc & 0b10 != 0 {
4 + 1 + pkt[4] as usize // adaptation field present (stuffing)
} else {
4
};
let pes = &pkt[pes_start..];
assert_eq!(&pes[0..3], &[0x00, 0x00, 0x01], "PES start code");
assert!(pes[7] & 0x80 != 0, "the PES header carries a PTS");
((((pes[9] >> 1) & 0x07) as u64) << 30)
| ((pes[10] as u64) << 22)
| (((pes[11] >> 1) as u64) << 15)
| ((pes[12] as u64) << 7)
| ((pes[13] >> 1) as u64)
};
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = M2tsMux::new(&mut sink);
mux.set_audio(AudioCodec::Ac3);
mux.write_audio(0, &[0x0B, 0x77, 0x00, 0x00]).unwrap();
mux.write_audio(1_000_000_000, &[0x0B, 0x77, 0x00, 0x01])
.unwrap();
mux.finish().unwrap();
}
let pkts = find_all_pkts(&sink, PID_AUDIO, true);
assert_eq!(pkts.len(), 2, "one PUSI packet per small audio frame");
assert_eq!(
decode_audio_pts(pkts[0]),
0,
"the first (audio) frame seeds the origin → 0"
);
assert_eq!(
decode_audio_pts(pkts[1]),
90_000,
"the second audio frame (1s later) must keep its spacing, not collapse to 0"
);
}
// ── audio without configured track ────────────────────────────────────
#[test]
+157
View File
@@ -109,11 +109,18 @@ impl Stream for ChaptersSink {
// See docs/meta-sink.md — title_json schema.
pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value {
use serde_json::json;
// `index`/`is_main` are disc-level (title's position in the sorted list, whether
// it's the selected main feature). A `json://` sink sees ONE `DiscTitle` with no
// disc view, so both are fixed `0`/`false`; authoritative values live on `DiscProfile::from_disc`.
let profile = TitleProfile::from_title(title, 0, false);
// Serializing a plain-scalar struct is infallible; fall back to an empty
// object rather than panic if that ever changes (the create() path then
// surfaces the empty doc as a NoMetadata error).
let mut doc = serde_json::to_value(&profile).unwrap_or_else(|_| json!({}));
// Restore per-stream detail the flat `TitleProfile` VIEW omits (pid, colour
// signalling, sample rate, raw purpose/qualifier, etc.). Rather than widen that
// shared type, enrich straight from the source Title, in `from_title`'s kind order.
enrich_streams(&mut doc, title);
doc["format"] = json!(format!("{:?}", title.content_format));
doc["playlist_id"] = json!(title.playlist_id);
doc["clips"] = json!(
@@ -138,6 +145,79 @@ pub(crate) fn title_json(title: &DiscTitle) -> serde_json::Value {
doc
}
/// Compact id for an audio stream's editorial purpose (no localized prose —
/// the app maps it to display text). Mirrors the `Codec::id()` convention.
fn purpose_id(p: crate::disc::LabelPurpose) -> &'static str {
use crate::disc::LabelPurpose::*;
match p {
Normal => "normal",
Commentary => "commentary",
Descriptive => "descriptive",
Score => "score",
Ime => "ime",
}
}
/// Merge the per-stream fields `TitleProfile` drops back into the `json://`
/// document, straight from the source Title. Walks `title.streams` in declared
/// order with a per-kind cursor so each raw stream lines up with the profile's
/// `video`/`audio`/`subtitles` array element it produced.
fn enrich_streams(doc: &mut serde_json::Value, title: &DiscTitle) {
use crate::disc::Stream;
use serde_json::json;
let (mut vi, mut ai, mut si) = (0usize, 0usize, 0usize);
for s in &title.streams {
match s {
Stream::Video(v) => {
let e = &mut doc["video"][vi];
e["pid"] = json!(v.pid);
e["color_space"] = json!(v.color_space.id());
e["display_aspect"] = match v.display_aspect {
Some((n, d)) => json!([n, d]),
None => json!(null),
};
e["measured_cicp"] = match v.measured_cicp {
Some(c) => json!({
"matrix": c.matrix,
"transfer": c.transfer,
"primaries": c.primaries,
"range": c.range,
}),
None => json!(null),
};
match v.resolution.pixels() {
Some((w, h)) => {
e["width"] = json!(w);
e["height"] = json!(h);
}
None => {
e["width"] = json!(null);
e["height"] = json!(null);
}
}
e["interlaced"] = json!(v.resolution.is_interlaced());
e["mvc"] = json!(v.is_mvc_dependent());
vi += 1;
}
Stream::Audio(a) => {
let e = &mut doc["audio"][ai];
e["pid"] = json!(a.pid);
e["secondary"] = json!(a.secondary);
e["sample_rate"] = json!(a.sample_rate.to_string());
e["purpose"] = json!(purpose_id(a.purpose));
ai += 1;
}
Stream::Subtitle(sub) => {
let e = &mut doc["subtitles"][si];
e["pid"] = json!(sub.pid);
e["descriptive_service"] =
json!(sub.qualifier == crate::disc::LabelQualifier::DescriptiveService);
si += 1;
}
}
}
}
/// `json://` sink: writes the title's structured metadata at construction; the
/// PES stream is ignored.
pub struct JsonSink {
@@ -298,6 +378,83 @@ mod tests {
assert!(vid["default"].as_bool().unwrap());
}
/// The json:// document must carry the per-stream detail the normalized
/// `TitleProfile` drops: video pid/color_space/measured_cicp/aspect/w/h/
/// interlaced/mvc, audio pid/secondary/sample_rate/purpose, subtitle pid/
/// descriptive_service. Before the fix these were absent (profile had no such
/// fields), so each index read would be `Null` and every assertion below fail.
#[test]
fn json_restores_dropped_stream_fields_from_title() {
use crate::disc::{
AudioChannels, AudioStream, Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat,
LabelPurpose, LabelQualifier, MeasuredCicp, Resolution, SampleRate,
Stream as DiscStream, SubtitleStream, VideoStream,
};
use serde_json::json;
let mut t = DiscTitle::empty();
t.streams = vec![
DiscStream::Video(VideoStream {
pid: 0x1011,
codec: Codec::Mpeg2,
resolution: Resolution::R576i, // interlaced, 720x576
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt470bg,
display_aspect: Some((16, 9)),
secondary: false,
label: String::new(),
measured_cicp: Some(MeasuredCicp {
matrix: 5,
transfer: 6,
primaries: 5,
range: 1,
}),
}),
DiscStream::Audio(AudioStream {
pid: 0x1100,
codec: Codec::Ac3,
channels: AudioChannels::Stereo,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: true,
purpose: LabelPurpose::Commentary,
label: String::new(),
}),
DiscStream::Subtitle(SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: LabelQualifier::DescriptiveService,
codec_data: None,
}),
];
let v = title_json(&t);
let vid = &v["video"][0];
assert_eq!(vid["pid"], 0x1011);
assert_eq!(vid["color_space"], "bt470bg");
assert_eq!(vid["display_aspect"], json!([16, 9]));
assert_eq!(vid["measured_cicp"]["matrix"], 5);
assert_eq!(vid["measured_cicp"]["transfer"], 6);
assert_eq!(vid["measured_cicp"]["primaries"], 5);
assert_eq!(vid["measured_cicp"]["range"], 1);
assert_eq!(vid["width"], 720);
assert_eq!(vid["height"], 576);
assert_eq!(vid["interlaced"], true);
assert_eq!(vid["mvc"], false);
let a = &v["audio"][0];
assert_eq!(a["pid"], 0x1100);
assert_eq!(a["secondary"], true);
assert_eq!(a["sample_rate"], "48kHz");
assert_eq!(a["purpose"], "commentary");
let s = &v["subtitles"][0];
assert_eq!(s["pid"], 0x1200);
assert_eq!(s["descriptive_service"], true);
}
fn temp_path(name: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
+60
View File
@@ -2481,6 +2481,66 @@ mod tests {
);
}
/// FlagInterlaced is written up-front from the FIRST picture, then corrected
/// at finish() to the WHOLE-STREAM scan majority. A progressive leader on an
/// interlaced feature must not misrepresent the title: the header byte is
/// rewritten from progressive (2) to interlaced (1).
#[test]
fn flag_interlaced_is_patched_to_the_whole_stream_scan_majority() {
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
// make_video_track() writes the header PROGRESSIVE (interlaced=false).
let mut muxer = MkvMuxer::new(
SharedWriter(shared.clone()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
// One progressive leader picture, then three interlaced → majority
// interlaced (2:1 the other way would stay progressive on a tie).
muxer
.write_frame_at(
0,
0,
true,
&[0x01, 0x02, 0x03],
None,
None,
None,
Some(true),
)
.unwrap();
for (i, pts) in [40_000_000i64, 80_000_000, 120_000_000].iter().enumerate() {
muxer
.write_frame_at(
0,
*pts,
false,
&[0x04, 0x05, i as u8],
None,
None,
None,
Some(false),
)
.unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// FlagInterlaced is written by hand as id(0x9A) size(0x81) value.
let fi = data
.windows(2)
.position(|w| w == [ebml::FLAG_INTERLACED as u8, 0x81])
.expect("FlagInterlaced element present");
assert_eq!(
data[fi + 2],
ebml::INTERLACED_INTERLACED as u8,
"the up-front progressive flag must be patched to interlaced by the majority"
);
}
#[test]
fn mkv_write_frame_creates_cluster() {
let buf = Cursor::new(Vec::new());
+73 -7
View File
@@ -34,6 +34,8 @@ const MAX_CODEC_PRIVATE: u64 = 16 * 1024 * 1024;
const MAX_STRING_LEN: u64 = 64 * 1024;
/// EBML unsigned-int elements are at most 8 bytes wide.
const MAX_UINT_LEN: u64 = 8;
/// EBML float elements are 4 or 8 bytes wide.
const MAX_FLOAT_LEN: u64 = 8;
/// Reject an untrusted element size that exceeds `cap` before it is used
/// to allocate or read. Returns the size as `usize` when within bounds.
@@ -56,6 +58,13 @@ fn read_string_bounded(r: &mut impl Read, size: u64) -> io::Result<String> {
ebml::read_string_val(r, checked_size(size, MAX_STRING_LEN)?)
}
/// Read a bounded EBML float element (4- or 8-byte), reusing the same
/// `checked_size` cap as the uint/string readers instead of a raw `as usize`
/// truncation of an untrusted element size.
fn read_float_bounded(r: &mut impl Read, size: u64) -> io::Result<f64> {
ebml::read_float_val(r, checked_size(size, MAX_FLOAT_LEN)?)
}
use crate::disc::*;
use std::io::{self, Read};
@@ -939,10 +948,17 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
if cs == u64::MAX {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
remaining = remaining.saturating_sub(hlen as u64 + cs);
// A child whose header+body exceeds bytes left in the parent is
// malformed — reject rather than saturating `remaining` to 0
// (mirrors the BLOCK_GROUP child-loop guard).
let consumed = (hlen as u64).saturating_add(cs);
if consumed > remaining {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
remaining -= consumed;
match cid {
ebml::TIMESTAMP_SCALE => ts_scale = read_uint_bounded(r, cs)?,
ebml::DURATION => duration_ticks = ebml::read_float_val(r, cs as usize)?,
ebml::DURATION => duration_ticks = read_float_bounded(r, cs)?,
ebml::TITLE => title = read_string_bounded(r, cs)?,
_ => {
skip_bytes(r, cs)?;
@@ -961,7 +977,13 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
if cs == u64::MAX {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
remaining = remaining.saturating_sub(hlen as u64 + cs);
// Reject a child that overruns the TRACKS body rather than
// saturating `remaining` to 0 (same guard as BLOCK_GROUP).
let consumed = (hlen as u64).saturating_add(cs);
if consumed > remaining {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
remaining -= consumed;
if cid == ebml::TRACK_ENTRY {
let (stream, tnum, cp, default_dur) = parse_track(r, cs)?;
if let Some(s) = stream {
@@ -1055,7 +1077,13 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result<ParsedTrack> {
if cs == u64::MAX {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
remaining = remaining.saturating_sub(hlen as u64 + cs);
// Reject a child that overruns the TrackEntry body rather than
// saturating `remaining` to 0 (same guard as BLOCK_GROUP).
let consumed = (hlen as u64).saturating_add(cs);
if consumed > remaining {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
remaining -= consumed;
match cid {
ebml::TRACK_NUMBER => {
// Reject a TRACK_NUMBER above u16::MAX rather than truncating
@@ -1089,7 +1117,13 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result<ParsedTrack> {
if vs == u64::MAX {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
vrem = vrem.saturating_sub(vhlen as u64 + vs);
// Reject a child overrunning the Video body (same guard as
// BLOCK_GROUP) rather than saturating `vrem` to 0.
let consumed = (vhlen as u64).saturating_add(vs);
if consumed > vrem {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
vrem -= consumed;
if vid == ebml::PIXEL_HEIGHT {
ph = read_uint_bounded(r, vs)? as u32;
} else {
@@ -1104,9 +1138,15 @@ fn parse_track(r: &mut impl Read, size: u64) -> io::Result<ParsedTrack> {
if as_ == u64::MAX {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
arem = arem.saturating_sub(ahlen as u64 + as_);
// Reject a child overrunning the Audio body (same guard as
// BLOCK_GROUP) rather than saturating `arem` to 0.
let consumed = (ahlen as u64).saturating_add(as_);
if consumed > arem {
return Err(crate::error::Error::MkvSourceInvalid.into());
}
arem -= consumed;
match aid {
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
ebml::SAMPLING_FREQUENCY => sr = read_float_bounded(r, as_)?,
// Clamp instead of `as u8`: a CHANNELS value that's a multiple of
// 256 would truncate to 0 (invalid) on a bare cast; saturate to
// u8::MAX so an absurd count degrades to "many", never to 0.
@@ -2911,6 +2951,32 @@ mod tests {
assert_eq!(stream.info().duration_secs, 5.0);
}
#[test]
fn info_child_size_exceeding_remaining_is_rejected() {
// Crafted INFO whose sole child (TITLE) declares a 1000-byte body while the INFO
// parent is sized to just the child header, so header+body overruns `remaining`.
// Old code saturated it to 0 and read the oversized child (EOF/garbage); the guard rejects as MkvSourceInvalid.
let mut info = Vec::new();
ebml::write_id(&mut info, ebml::TITLE).unwrap();
ebml::write_size(&mut info, 1000).unwrap(); // declares 1000 bytes, provides none
let mut out = Vec::new();
ebml::write_id(&mut out, ebml::EBML).unwrap();
ebml::write_size(&mut out, 0).unwrap();
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
ebml::write_unknown_size(&mut out).unwrap();
ebml::write_id(&mut out, ebml::INFO).unwrap();
// Parent INFO sized to ONLY the child header — its child then overruns it.
ebml::write_size(&mut out, info.len() as u64).unwrap();
out.extend_from_slice(&info);
let e = open_err(MkvStream::open(Cursor::new(out)));
assert!(
is_mkv_source_invalid(&e),
"a child body larger than the INFO parent's remaining must be rejected"
);
}
#[test]
fn missing_ebml_header_is_rejected() {
// A stream whose first element is not the EBML header (0x1A45DFA3) is
+131 -9
View File
@@ -103,9 +103,10 @@ impl<R: Read + Seek> Mp4Reader<R> {
// (8-byte) trak headers can't force the scan to materialize a Vec far
// larger than the moov payload before the per-track cap below ever runs.
for trak in find_boxes_capped(&moov, b"trak", MAX_TRACKS) {
if track_idx >= MAX_TRACKS {
break; // bound track count so the per-track PID can't overflow u16
}
// `find_boxes_capped` yields at most MAX_TRACKS matches, so `track_idx`
// never reaches MAX_TRACKS here (old runtime `break` was dead). Assert the
// invariant instead so the per-track PID stays within u16 without dead flow.
debug_assert!(track_idx < MAX_TRACKS, "trak scan exceeded MAX_TRACKS");
let Some(mdia) = find_box(trak, b"mdia") else {
tracing::warn!(track = track_idx, "mp4: trak has no mdia, dropping track");
continue;
@@ -134,6 +135,7 @@ impl<R: Read + Seek> Mp4Reader<R> {
height,
config,
channels,
sample_rate: entry_sample_rate,
}) = parse_stsd(stsd)
else {
tracing::warn!(
@@ -164,7 +166,13 @@ impl<R: Read + Seek> Mp4Reader<R> {
// `as u8` (a crafted 256 would alias to 0/Mono).
channels: AudioChannels::from_count(channels.min(u8::MAX as u16) as u8),
language: language.clone().unwrap_or_else(|| "und".into()),
sample_rate: SampleRate::from_hz(timescale),
// Prefer the AudioSampleEntry's own samplerate; the mdhd media
// timescale is only a fallback when the entry omits it (0).
sample_rate: SampleRate::from_hz(if entry_sample_rate != 0 {
entry_sample_rate
} else {
timescale
}),
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
@@ -461,13 +469,17 @@ fn find_boxes_capped<'a>(payload: &'a [u8], want: &[u8; 4], cap: usize) -> Vec<&
0 => (payload.len() - pos, 8usize),
n => (n, 8usize),
};
if box_size < header_len || pos + box_size > payload.len() {
break;
}
// checked_add: `box_size` is an attacker-controlled 64-bit largesize cast to
// usize; near usize::MAX at nonzero `pos`, plain `pos + box_size` wraps past the
// guard (release panic on slice / infinite advance). Compute `end` once, reuse it.
let end = match pos.checked_add(box_size) {
Some(end) if box_size >= header_len && end <= payload.len() => end,
_ => break,
};
if &bt == want {
out.push(&payload[pos + header_len..pos + box_size]);
out.push(&payload[pos + header_len..end]);
}
pos += box_size;
pos = end;
}
out
}
@@ -664,6 +676,10 @@ struct StsdInfo {
height: u16,
config: Option<Vec<u8>>,
channels: u16,
/// Integer sample rate (Hz) from the AudioSampleEntry 16.16 samplerate
/// field (high 16 bits). `0` when absent/video — the caller then falls
/// back to the mdhd media timescale.
sample_rate: u32,
}
/// stsd → codec + dimensions + codec_private + channel count (first entry).
@@ -711,12 +727,21 @@ fn parse_stsd(b: &[u8]) -> Option<StsdInfo> {
height,
config,
channels: 0,
sample_rate: 0,
})
} else {
// AudioSampleEntry header is 28 bytes, then child boxes. AAC (mp4a) carries
// its AudioSpecificConfig in an `esds` box — the MKV CodecPrivate for
// A_AAC. AC-3/DTS are self-describing in-band (None).
let channels = if body.len() >= 28 { be16(body, 16) } else { 2 };
// Real audio sample rate: AudioSampleEntry 16.16 samplerate field at body
// offset 24 (ISO/IEC 14496-12 §12.2.3), integer rate = high 16 bits. The mdhd
// media timescale is only a fallback (USUALLY the rate, but need not be).
let sample_rate = if body.len() >= 28 {
be16(body, 24) as u32
} else {
0
};
let config = if matches!(codec, Codec::Aac) && body.len() >= 28 {
find_box(&body[28..], b"esds").and_then(parse_esds_asc)
} else {
@@ -727,6 +752,7 @@ fn parse_stsd(b: &[u8]) -> Option<StsdInfo> {
height: 0,
config,
channels,
sample_rate,
})
}
}
@@ -2226,6 +2252,78 @@ mod tests {
);
}
/// Regression: audio sample rate must come from the AudioSampleEntry 16.16
/// samplerate field, NOT the mdhd media timescale. Here the two DIFFER
/// (mdhd timescale = 90_000, AudioSampleEntry samplerate = 48_000); the
/// entry value must win. Old code read the rate from the timescale, so a
/// 90 kHz mdhd yielded SampleRate::Unknown, dropping the real 48 kHz.
#[test]
fn audio_sample_entry_samplerate_wins_over_mdhd_timescale() {
use std::io::Cursor;
let mdhd = {
// v0: version+flags(4) creation(4) modification(4) timescale(4) duration(4).
let mut p = vec![0u8; 24];
p[12..16].copy_from_slice(&90_000u32.to_be_bytes()); // NOT the audio rate
mp4_box(b"mdhd", &p)
};
let hdlr = {
let mut p = vec![0u8; 12];
p[8..12].copy_from_slice(b"soun");
mp4_box(b"hdlr", &p)
};
// Full AudioSampleEntry: samplerate 48_000 in the 16.16 field at body 24.
let stsd = mp4_box(b"stsd", &stsd_with(b"ac-3", &audio_entry(6, 0, &[])));
// stsz: sample_size=10, count=1 (one 10-byte sample).
let stsz = {
let mut p = vec![0u8, 0, 0, 0];
p.extend_from_slice(&10u32.to_be_bytes());
p.extend_from_slice(&1u32.to_be_bytes());
mp4_box(b"stsz", &p)
};
let stco = {
let mut p = vec![0u8, 0, 0, 0];
p.extend_from_slice(&1u32.to_be_bytes()); // count
p.extend_from_slice(&0u32.to_be_bytes()); // chunk offset 0
mp4_box(b"stco", &p)
};
let stsc = {
let mut p = vec![0u8, 0, 0, 0];
p.extend_from_slice(&1u32.to_be_bytes()); // count
p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
mp4_box(b"stsc", &p)
};
let stts = {
let mut p = vec![0u8, 0, 0, 0];
p.extend_from_slice(&1u32.to_be_bytes()); // entry_count
p.extend_from_slice(&1u32.to_be_bytes()); // sample_count
p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta
mp4_box(b"stts", &p)
};
let mut stbl = Vec::new();
for b in [&stsd, &stsz, &stco, &stsc, &stts] {
stbl.extend_from_slice(b);
}
let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl));
let mut mdia = Vec::new();
mdia.extend_from_slice(&mdhd);
mdia.extend_from_slice(&hdlr);
mdia.extend_from_slice(&minf);
let trak = mp4_box(b"trak", &mp4_box(b"mdia", &mdia));
let moov = mp4_box(b"moov", &trak);
let rd = Mp4Reader::from_reader(Cursor::new(moov), "sr".into()).unwrap();
match &rd.info().streams[0] {
DiscStream::Audio(a) => assert_eq!(
a.sample_rate,
SampleRate::S48,
"the AudioSampleEntry 48 kHz must win over the 90 kHz mdhd timescale"
),
other => panic!("expected an audio stream, got {other:?}"),
}
}
// ============================================================
// Edit lists — ISO/IEC 14496-12 §8.6.5 (`edts`) / §8.6.6 (`elst`).
// ============================================================
@@ -3053,6 +3151,30 @@ mod tests {
);
}
/// A crafted 64-bit `largesize` near u64::MAX at a nonzero `pos` must not
/// wrap `pos + box_size` past the length guard: the scan must reject it and
/// return without a release panic (slice) or an infinite loop (advance).
/// See docs/mp4-read.md — find_boxes_capped_rejects_a_hostile_largesize.
#[test]
fn find_boxes_capped_rejects_a_hostile_largesize_near_u64_max() {
let mut payload = Vec::new();
// A first, valid empty box so `pos` is nonzero when the hostile box is
// reached — that is what makes `pos + box_size` wrap rather than merely
// overshoot the length.
payload.extend_from_slice(&mp4_box(b"free", &[]));
// size32 == 1 selects the 16-byte largesize header; largesize = u64::MAX-3
// so `pos + box_size` overflows usize on a 64-bit target.
payload.extend_from_slice(&[0, 0, 0, 1, b't', b'e', b's', b't']);
payload.extend_from_slice(&(u64::MAX - 3).to_be_bytes());
// Must terminate without panicking and without matching the hostile box.
let out = find_boxes_capped(&payload, b"test", 8);
assert!(
out.is_empty(),
"a box whose largesize wraps the position must be rejected, not matched"
);
assert!(find_box(&payload, b"test").is_none());
}
// An `stsc` entry naming a `first_chunk` beyond what `stco` declares must
// be clamped, not index `spc` past its length and panic.
// See docs/mp4-read.md — sample_offsets_clamps_an_stsc_run.
+102 -2
View File
@@ -31,21 +31,46 @@ pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool {
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40)
// "this network" 0.0.0.0/8
|| o[0] == 0
// benchmarking 198.18.0.0/15 (RFC 2544) — 198.18.x and 198.19.x
// (the /15 second octet is 18 with the low bit free, i.e. 18|19).
|| (o[0] == 198 && (o[1] & 0xfe) == 18)
// IETF protocol assignments 192.0.0.0/24 (RFC 6890), which
// includes 192.0.0.170/171 (NAT64/DNS64 discovery).
|| (o[0] == 192 && o[1] == 0 && o[2] == 0)
// Class E reserved 240.0.0.0/4
|| o[0] >= 240
}
IpAddr::V6(v6) => {
let seg = v6.segments();
// 6to4 (2002::/16) embeds an IPv4 in segments[1..3]; Teredo (2001:0000::/32)
// embeds the client IPv4 in the last two segments, each XOR 0xffff. Re-check
// both as their embedded IPv4 or an internal target slips through the tunnel.
let sixtofour = (seg[0] == 0x2002)
.then(|| std::net::Ipv4Addr::from(((seg[1] as u32) << 16) | (seg[2] as u32)));
let teredo = (seg[0] == 0x2001 && seg[1] == 0x0000).then(|| {
std::net::Ipv4Addr::from(
(((seg[6] ^ 0xffff) as u32) << 16) | ((seg[7] ^ 0xffff) as u32),
)
});
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) embeds the IPv4 in
// the last 32 bits (segments[6..8]); re-check it too so an internal
// target does not slip through a NAT64 translator.
let nat64 = (seg[0] == 0x0064 && seg[1] == 0xff9b)
.then(|| std::net::Ipv4Addr::from(((seg[6] as u32) << 16) | (seg[7] as u32)));
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
// unique-local fc00::/7
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|| (seg[0] & 0xfe00) == 0xfc00
// link-local fe80::/10
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|| (seg[0] & 0xffc0) == 0xfe80
// IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x);
// to_ipv4() returns Some for both forms — re-check as IPv4 so an
// IPv4-mapped private/loopback address can't bypass the block above.
|| v6.to_ipv4().map(|m| is_blocked_ip(IpAddr::V4(m))) == Some(true)
|| sixtofour.is_some_and(|v4| is_blocked_ip(IpAddr::V4(v4)))
|| teredo.is_some_and(|v4| is_blocked_ip(IpAddr::V4(v4)))
|| nat64.is_some_and(|v4| is_blocked_ip(IpAddr::V4(v4)))
}
}
}
@@ -303,6 +328,81 @@ mod tests {
}
}
// Benchmarking 198.18.0.0/15 (RFC 2544) and IETF protocol assignments
// 192.0.0.0/24 (RFC 6890, incl. the 192.0.0.170/171 NAT64/DNS64 anycast)
// are non-public and must be blocked outbound — parity with keysources.
#[test]
fn ssrf_guard_blocks_benchmarking_and_protocol_assignment_ranges() {
use std::net::Ipv4Addr;
let v4 = |a, b, c, d| IpAddr::V4(Ipv4Addr::new(a, b, c, d));
// 198.18.0.0/15 spans 198.18.x AND 198.19.x — both octets blocked.
assert!(is_blocked_ip(v4(198, 18, 0, 1)));
assert!(is_blocked_ip(v4(198, 18, 255, 255)));
assert!(is_blocked_ip(v4(198, 19, 0, 1)));
assert!(is_blocked_ip(v4(198, 19, 200, 5)));
// 198.17.x and 198.20.x are OUTSIDE the /15 — must stay allowed.
assert!(!is_blocked_ip(v4(198, 17, 0, 1)));
assert!(!is_blocked_ip(v4(198, 20, 0, 1)));
// 192.0.0.0/24, including 192.0.0.170 / 192.0.0.171.
assert!(is_blocked_ip(v4(192, 0, 0, 0)));
assert!(is_blocked_ip(v4(192, 0, 0, 170)));
assert!(is_blocked_ip(v4(192, 0, 0, 171)));
assert!(is_blocked_ip(v4(192, 0, 0, 255)));
// The adjacent 192.0.1.0 is a different block — not covered here.
assert!(!is_blocked_ip(v4(192, 0, 1, 1)));
}
// 6to4 (2002::/16) and Teredo (2001:0000::/32) tunnel an IPv4 inside an
// IPv6 address; the guard must decode and re-check that embedded IPv4 or an
// internal target slips through the tunnel — parity with keysources.
#[test]
fn ssrf_guard_blocks_embedded_ipv4_via_6to4_and_teredo() {
use std::net::Ipv6Addr;
// 6to4 for 127.0.0.1: 2002:7f00:0001:: (embedded in segments[1..3]).
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
0x2002, 0x7f00, 0x0001, 0, 0, 0, 0, 0
))));
// 6to4 for 169.254.169.254 (cloud metadata): 2002:a9fe:a9fe::.
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
0x2002, 0xa9fe, 0xa9fe, 0, 0, 0, 0, 0
))));
// Teredo for 127.0.0.1: client IPv4 lives in the last two segments XOR
// 0xffff, so 0x7f00^0xffff=0x80ff and 0x0001^0xffff=0xfffe.
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
0x2001, 0x0000, 0, 0, 0, 0, 0x80ff, 0xfffe
))));
// A 6to4 wrapping a PUBLIC IPv4 (8.8.8.8 → 2002:0808:0808::) is allowed.
assert!(!is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
0x2002, 0x0808, 0x0808, 0, 0, 0, 0, 0
))));
}
/// The NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) embeds an IPv4 in its
/// last 32 bits; the guard must decode and re-check that embedded IPv4 so an
/// internal target does not slip through a NAT64 translator — parity with
/// keysources. A NAT64 address embedding a private/special IPv4 must classify
/// exactly as that IPv4 does directly.
#[test]
fn ssrf_guard_blocks_embedded_ipv4_via_nat64() {
use std::net::{Ipv4Addr, Ipv6Addr};
// NAT64 for 169.254.169.254 (cloud metadata): 64:ff9b::a9fe:a9fe — the
// embedded IPv4 is link-local, so both forms must be blocked identically.
assert!(is_blocked_ip(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))));
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0xa9fe, 0xa9fe
))));
// NAT64 for 127.0.0.1: 64:ff9b::7f00:0001 — embedded loopback, blocked.
assert!(is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0x7f00, 0x0001
))));
// NAT64 wrapping a PUBLIC IPv4 (8.8.8.8 → 64:ff9b::0808:0808) is allowed,
// exactly as 8.8.8.8 itself is.
assert!(!is_blocked_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_blocked_ip(IpAddr::V6(Ipv6Addr::new(
0x0064, 0xff9b, 0, 0, 0, 0, 0x0808, 0x0808
))));
}
/// The public `connect` must refuse a loopback target with the typed
/// `NetworkAddrBlocked` error (PermissionDenied) rather than attempting
/// the TCP connect — this is the rebinding TOCTOU close at the connect.
+24 -6
View File
@@ -376,7 +376,10 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
let stream = image_input(
reader,
opts,
move || crate::io::file_sector_source::FileSectorSource::open(&probe_path).ok(),
move || {
crate::io::file_sector_source::FileSectorSource::open(&probe_path)
.map_err(|e| -> io::Error { e.into() })
},
false,
)?;
Ok(Box::new(stream))
@@ -391,7 +394,10 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
let stream = image_input(
reader,
opts,
move || crate::dirimage::DirImage::open(&probe_path).ok(),
move || {
crate::dirimage::DirImage::open(&probe_path)
.map_err(|e| -> io::Error { e.into() })
},
true,
)?;
Ok(Box::new(stream))
@@ -445,7 +451,7 @@ fn image_input<S, F>(
) -> io::Result<PipelinedPesStream>
where
S: SectorSource + Send + 'static,
F: FnOnce() -> Option<S>,
F: FnOnce() -> io::Result<S>,
{
let capacity = reader.capacity_sectors();
let mut disc =
@@ -510,7 +516,7 @@ where
// probing decrypted units via a fresh reader; skipped in --raw (no point).
if !opts.raw {
match reopen() {
Some(mut probe) => {
Ok(mut probe) => {
// The probe decrypts the title head, so it needs the same
// key map the mux read installs below, or an AACS source
// fails loud on the first unit. `.ok()`: non-fatal.
@@ -526,6 +532,16 @@ where
// Bounded, file-backed probe; no live drive here.
None,
)
// Non-fatal: a failed key-map resolution leaves the probe without
// FMTS ranges, so TrueHD channel correction is skipped (MPLS 7.1/Atmos
// stays understated as 5.1). Log the actual error (parity with the sibling re-open failure below), not a bare `.ok()`.
.inspect_err(|e| {
tracing::debug!(
target: "mux",
error = %e,
"TrueHD channel-correction key-map resolution failed; skipping"
);
})
.ok()
.map(std::sync::Arc::new),
_ => None,
@@ -536,11 +552,13 @@ where
}
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
}
None => {
Err(e) => {
// Non-fatal: a failed re-open leaves MPLS 7.1/Atmos counts
// uncorrected (as 5.1). Log so it's diagnosable, not silent.
// uncorrected (as 5.1). Log the actual error so it's
// diagnosable, not swallowed by a bare `.ok()` -> None.
tracing::debug!(
target: "mux",
error = %e,
"TrueHD channel-correction probe re-open failed"
);
}
+79 -6
View File
@@ -178,7 +178,6 @@ impl SeamPlan {
/// How many frames this track has had dropped for falling outside every
/// clip's marks.
#[cfg(test)]
pub(crate) fn dropped_for(&self, track: usize) -> u64 {
self.dropped.get(track).copied().unwrap_or(0)
}
@@ -433,10 +432,18 @@ pub(crate) struct TimelineContinuity {
// (distinct from the shared frontier). See
// docs/mux-timeline.md#timelinecontinuitylast_raw_ns.
last_raw_ns: Vec<Option<i64>>,
// Per-track provisional offset for frames arriving before the video
// frame that opens their epoch. Never written to offset_ns/high_ns and
// never retires an epoch. See docs/mux-timeline.md#timelinecontinuityprovisional.
provisional: Vec<Option<(usize, i64)>>,
/// Per-track provisional offset for frames arriving before the video
/// frame that opens their epoch, tagged with the epoch sequence it was
/// captured in (see `epoch_seq`). Never written to offset_ns/high_ns and
/// never retires an epoch. See docs/mux-timeline.md#timelinecontinuityprovisional.
provisional: Vec<Option<(u64, i64)>>,
/// Monotonic epoch counter, bumped once per `open_epoch`. Used as the epoch
/// IDENTITY a provisional is tagged with, instead of `epoch_offsets.len()`:
/// that length is capped at MAX_EPOCHS, so past the cap it stops changing and
/// a provisional captured then would compare equal forever and never retire —
/// outliving its epoch and mis-offsetting later passive frames. A monotonic
/// counter always advances. (Same root as the disc.rs slice-identity finding.)
epoch_seq: u64,
}
/// Most epochs retained for straggler resolution. A title has a handful; this
@@ -453,6 +460,7 @@ impl TimelineContinuity {
prev_offset_ns: 0,
high_ns: None,
seams: None,
epoch_seq: 0,
}
}
@@ -477,6 +485,7 @@ impl TimelineContinuity {
prev_offset_ns: 0,
high_ns: None,
seams,
epoch_seq: 0,
}
}
@@ -486,6 +495,21 @@ impl TimelineContinuity {
self.seams.as_ref().map_or(0, |p| p.dropped_total())
}
/// Frames dropped for falling outside the playlist's clip marks, counted
/// ONLY over `tracks` — the numerator/denominator alignment a filtered sink
/// needs. A `demux://` variant that persists a subset of tracks (e.g.
/// `audio://`) counts its written denominator over just those tracks, so the
/// drop count it gates on must cover the SAME set: a video track dropped at a
/// clip join during an `audio://` export is not evidence the audio files came
/// up short. Zero without a seam plan.
pub(crate) fn dropped_for(&self, tracks: &[usize]) -> u64 {
self.seams.as_ref().map_or(0, |p| {
tracks
.iter()
.fold(0u64, |a, &t| a.saturating_add(p.dropped_for(t)))
})
}
// Map a raw PES PTS onto the output timeline, or `None` to drop the frame
// (only ever happens under a SeamPlan). See docs/mux-timeline.md#timelinecontinuitymap.
pub(crate) fn map(
@@ -527,7 +551,9 @@ impl TimelineContinuity {
self.provisional.resize(track + 1, None);
}
let prev_raw = self.last_raw_ns[track].replace(raw_pts_ns);
let retired = self.epoch_offsets.len();
// Monotonic epoch identity, NOT epoch_offsets.len() (which is capped at
// MAX_EPOCHS and would stop distinguishing epochs past the cap).
let retired = self.epoch_seq;
// A provisional only survives until the video opens the epoch for real.
if let Some((taken_at, _)) = self.provisional[track]
@@ -564,6 +590,9 @@ impl TimelineContinuity {
self.epoch_offsets.remove(0);
}
self.epoch_offsets.push((self.offset_ns, high));
// Advance the monotonic epoch identity so any provisional captured in the
// just-closed epoch retires, regardless of the ring buffer's length cap.
self.epoch_seq = self.epoch_seq.saturating_add(1);
let bump = high
.saturating_sub(mapped_now)
.saturating_add(DISCONTINUITY_GAP_NS);
@@ -2060,6 +2089,50 @@ mod tests {
);
}
// A provisional must retire even PAST the epoch-history cap. Epoch identity
// was `epoch_offsets.len()`, which pins at MAX_EPOCHS once the ring is full,
// so a provisional captured then compared equal forever and never retired —
// drifting that track away from every other for the rest of the title. The
// monotonic `epoch_seq` keeps distinguishing epochs past the cap.
#[test]
fn a_provisional_retires_even_past_the_epoch_history_cap() {
let mut tc = TimelineContinuity::new();
// Open well more than MAX_EPOCHS epochs so the ring is full and its
// length can no longer tell one epoch from the next.
for _ in 0..(MAX_EPOCHS + 5) {
for i in 0..=5 {
adj_video(&mut tc, i * S);
}
adj_video(&mut tc, 0); // big backstep → open_epoch
}
assert_eq!(
tc.epoch_offsets.len(),
MAX_EPOCHS,
"the epoch history ring is full and its length is pinned at the cap"
);
// A passive frame arrives before its epoch's video and takes a provisional.
for i in 0..=5 {
adj_video(&mut tc, i * S);
}
adj_other(&mut tc, 599 * S); // establish this track's prev raw
adj_other(&mut tc, 0); // big backstep → provisional
assert!(
tc.provisional[1].is_some(),
"an early frame past the cap still takes a provisional"
);
// The video opens the next epoch. epoch_offsets.len() does NOT change
// here (remove+push at the cap), so only the monotonic counter marks the
// transition — and the provisional must retire on it.
adj_video(&mut tc, 0);
adj_other(&mut tc, S);
assert!(
tc.provisional[1].is_none(),
"the provisional must retire once the epoch is real, even past MAX_EPOCHS"
);
}
// MEASURED on a real HD-DVD title: a straggler must be judged against
// its OWN epoch's end, not the current frontier. See
// docs/mux-timeline.md#a_straggler_is_judged_against_its_own_epochs_end_not_the_frontier.
+62 -34
View File
@@ -48,9 +48,10 @@ pub struct TsMuxer<W: Write> {
/// for why NAL-ness is derived from this rather than tracked
/// separately.
video_codec: Vec<Codec>,
/// Global PTS origin (nanoseconds), seeded by the FIRST video frame so
/// the audio/video offset is preserved. Frames that arrive before it
/// is set saturate to 0.
/// Global PTS origin (nanoseconds), seeded by the FIRST frame of any kind
/// (video or audio) so a single fixed origin rebases every frame and the
/// audio/video offset is preserved. A frame earlier than the origin (e.g.
/// audio reordered before the seeding frame) saturates to 0.
base_pts_ns: Option<i64>,
/// Count of PES frames actually emitted (a frame dropped as non-key
/// before the first keyframe does NOT count). `finish()` returns
@@ -157,13 +158,10 @@ impl<W: Write> TsMuxer<W> {
return Ok(());
}
// Seed the global PTS origin from the FIRST video frame only, so the
// audio/video offset is preserved. A leading audio frame must not
// pull the base up and collapse the first video IDR to t=0.
if is_video {
self.base_pts_ns.get_or_insert(pts_ns);
}
let base = self.base_pts_ns.unwrap_or(pts_ns);
// Seed the global PTS origin from the FIRST frame of ANY kind, so one fixed
// origin rebases every frame and the a/v offset is preserved. Video-ONLY seeding
// made pre-video audio fall back to `base = unwrap_or(pts_ns)` (own pts) → PTS 0, spacing lost.
let base = *self.base_pts_ns.get_or_insert(pts_ns);
let pts_ns = pts_ns.saturating_sub(base);
// NAL video (HEVC/H.264): convert length-prefixed NALUs to Annex B and prepend
@@ -733,17 +731,37 @@ mod tests {
| ((pts[4] >> 1) as u64)
}
/// Decode the 33-bit PTS from every PTS-bearing PUSI packet on `pid`, in
/// order — one per single-PES access unit. Continuation PES (no PTS flag)
/// are skipped so only real per-frame timestamps are returned.
fn all_pts_90k(packets: &[TsPacket], pid: u16) -> Vec<u64> {
packets
.iter()
.filter(|p| p.pid == pid && p.pusi)
.filter(|p| p.payload.len() >= 14 && p.payload[7] & 0x80 != 0)
.map(|p| {
let pts = &p.payload[9..14];
((((pts[0] >> 1) & 0x07) as u64) << 30)
| ((pts[1] as u64) << 22)
| (((pts[2] >> 1) as u64) << 15)
| ((pts[3] as u64) << 7)
| ((pts[4] >> 1) as u64)
})
.collect()
}
#[test]
fn av_offset_preserved_with_audio_before_first_video() {
// Audio at t=0 arrives before the first video keyframe at t=1s; the base
// must seed from video, not both collapsing to 0.
// Audio at t=0 before the first video keyframe at t=1s. The FIRST frame of any
// kind fixes the origin, so audio becomes origin (PTS 0), video lands 1s=90_000
// ticks later. OLD video-only seeding made both fall back to own pts → collapse to 0, offset lost.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]);
// Audio frame first, at PTS 0.
// Audio frame first, at PTS 0 — this seeds the origin.
mux.write_frame(1, 0, false, &[0x0B, 0x77, 0x00, 0x00])
.unwrap();
// Video keyframe at PTS 1s — seeds the base.
// Video keyframe at PTS 1s — 1s after the origin.
let idr = fake_hevc_nal(19, 100);
mux.write_frame(0, 1_000_000_000, true, &idr).unwrap();
mux.finish().unwrap();
@@ -751,13 +769,15 @@ mod tests {
let packets = parse_bd_ts(&sink);
let video_pts = first_pts_90k(&packets, VIDEO_PID);
let audio_pts = first_pts_90k(&packets, AUDIO_PID);
// Video keyframe is the base ⇒ its relative PTS is 0.
assert_eq!(video_pts, 0, "video keyframe seeds the base at t=0");
// Audio arrived 1s earlier ⇒ saturates to 0, NOT lifted past video.
assert_eq!(audio_pts, 0, "earlier audio saturates to 0");
assert!(
audio_pts <= video_pts,
"audio must not be pulled ahead of the video base"
// The leading audio frame is the origin ⇒ its relative PTS is 0.
assert_eq!(
audio_pts, 0,
"the first (audio) frame seeds the origin at 0"
);
// Video is 1s after the origin ⇒ the audio→video offset is preserved.
assert_eq!(
video_pts, 90_000,
"video 1s after the origin must stay 90_000 ticks ahead, not collapse to 0"
);
}
@@ -1068,42 +1088,50 @@ mod tests {
#[test]
fn negative_relative_pts_saturates_to_zero() {
// A frame earlier than the base (negative relative PTS) must encode
// PTS 0, never an underflowed huge value. Audio at t=0 before a
// video keyframe at t=2s: base=video, audio relative = -2s → 0.
// A frame earlier than the origin (negative relative PTS) must encode PTS 0, not
// an underflowed huge value. Video keyframe at t=2s seeds the origin; a later
// audio frame at t=0 is 2s BEFORE it → -2s, which must floor to 0.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID, AUDIO_PID]);
mux.write_frame(1, 0, false, &[0x0B, 0x77, 0x00, 0x00])
.unwrap();
let idr = fake_hevc_nal(19, 50);
mux.write_frame(0, 2_000_000_000, true, &idr).unwrap();
// Audio a full 2s before the origin — must saturate, not wrap.
mux.write_frame(1, 0, false, &[0x0B, 0x77, 0x00, 0x00])
.unwrap();
mux.finish().unwrap();
}
let packets = parse_bd_ts(&sink);
assert_eq!(
first_pts_90k(&packets, AUDIO_PID),
0,
"earlier audio saturates to 0"
"audio before the origin saturates to 0"
);
}
#[test]
fn no_base_seeded_by_audio_only_stream() {
// Audio-only never seeds base_pts_ns; each frame rebases to itself via
// unwrap_or(pts_ns), proving audio can't corrupt later A/V offsets.
fn audio_only_stream_preserves_frame_spacing() {
// Regression: an audio-only stream (no video to seed the origin) must keep its
// frame spacing. The FIRST audio frame seeds the origin; later frames rebase on
// it. OLD video-only seeding made EVERY frame fall back to own pts → all on PTS 0.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = TsMuxer::new(&mut sink, &[AUDIO_PID]);
// First audio frame at 5s.
mux.write_frame(0, 5_000_000_000, false, &[0x01, 0x02])
.unwrap();
// Second frame 1s later — must land 90_000 ticks after the first.
mux.write_frame(0, 6_000_000_000, false, &[0x03, 0x04])
.unwrap();
mux.finish().unwrap();
}
let packets = parse_bd_ts(&sink);
// With no video base, base = unwrap_or(pts_ns) = this frame's pts,
// so relative PTS is 0.
assert_eq!(first_pts_90k(&packets, AUDIO_PID), 0);
let pts = all_pts_90k(&packets, AUDIO_PID);
assert_eq!(pts.len(), 2, "two audio frames → two PTS-bearing PES");
assert_eq!(pts[0], 0, "the first audio frame is the origin → 0");
assert_eq!(
pts[1], 90_000,
"the second frame (1s later) must keep its 1s spacing, not collapse to 0"
);
}
#[test]
+61 -2
View File
@@ -141,8 +141,19 @@ impl PesFrame {
if len > MAX_FRAME_SIZE {
return Err(crate::error::Error::PesFrameTooLarge { size: len }.into());
}
let mut data = vec![0u8; len];
r.read_exact(&mut data)?;
// Grow the buffer INCREMENTALLY (not `vec![0u8; len]`): `len` is attacker-controlled
// up to MAX_FRAME_SIZE (256 MiB), so a truncated stream claiming a huge frame must not
// pre-allocate the max; a bounded scratch chunk tracks delivered bytes, short reads still surface UnexpectedEof, valid frames assemble byte-for-byte.
const GROW_CHUNK: usize = 1024 * 1024; // 1 MiB scratch, heap-allocated
let mut data: Vec<u8> = Vec::with_capacity(len.min(GROW_CHUNK));
let mut chunk = vec![0u8; GROW_CHUNK.min(len).max(1)];
let mut remaining = len;
while remaining > 0 {
let want = remaining.min(chunk.len());
r.read_exact(&mut chunk[..want])?;
data.extend_from_slice(&chunk[..want]);
remaining -= want;
}
Ok(Some(Self {
track,
pts,
@@ -780,4 +791,52 @@ mod tests {
cs.write(&f2).unwrap();
assert_eq!(cs.bytes_written(), 5, "must accumulate 3+2=5 bytes");
}
/// A frame larger than the incremental read chunk (1 MiB) must assemble
/// byte-for-byte across chunk boundaries — the grow-as-you-read path must
/// not drop, duplicate, or corrupt bytes where one chunk ends and the next
/// begins. Uses a position-dependent pattern so any boundary slip shows up.
#[test]
fn large_multi_chunk_frame_assembles_correctly() {
// 2 MiB + a tail, so at least three read chunks are exercised.
let size = 2 * 1024 * 1024 + 12_345;
let payload: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
let frame = PesFrame {
coding: None,
source: None,
track: 7,
pts: 42,
keyframe: true,
data: payload.clone(),
duration_ns: Some(5),
};
let mut buf = Vec::new();
frame.serialize(&mut buf).expect("serialize large frame");
let mut cursor = std::io::Cursor::new(buf);
let got = PesFrame::deserialize(&mut cursor)
.expect("deserialize")
.expect("frame present");
assert_eq!(got.data.len(), payload.len(), "length must survive");
assert_eq!(got.data, payload, "every byte must survive across chunks");
assert!(PesFrame::deserialize(&mut cursor).unwrap().is_none());
}
/// A header claiming a large frame with the data truncated must surface as
/// UnexpectedEof (not silently succeed) — and, crucially, the incremental
/// reader must not pre-allocate the full declared length before failing.
#[test]
fn truncated_large_frame_body_errors() {
// Header advertises a 4 MiB frame, but only 10 payload bytes follow.
let declared = 4 * 1024 * 1024u32;
let mut buf = Vec::new();
buf.push(0u8); // track
buf.extend_from_slice(&0i64.to_le_bytes()); // pts
buf.push(0u8); // keyframe
buf.extend_from_slice(&u64::MAX.to_le_bytes()); // duration None sentinel
buf.extend_from_slice(&declared.to_le_bytes()); // len
buf.extend_from_slice(&[0xAB; 10]); // far fewer than declared
let mut cursor = std::io::Cursor::new(buf);
let err = PesFrame::deserialize(&mut cursor).expect_err("truncated body must error");
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
}
+39
View File
@@ -726,4 +726,43 @@ mod raw_command_cdb_guard_tests {
);
}
}
// ── fd-reopen path helpers (used by open / drive_has_disc / recovery) ──
/// `to_c_path` must NUL-terminate exactly once and preserve the path bytes,
/// or `libc::open` in the reopen path reads past the buffer or opens a
/// truncated device name.
#[test]
fn to_c_path_nul_terminates_and_preserves_the_bytes() {
let c = SgIoTransport::to_c_path(Path::new("/dev/sg3"));
assert_eq!(c.last(), Some(&0u8), "must end in a NUL for libc::open");
assert_eq!(&c[..c.len() - 1], b"/dev/sg3", "path bytes preserved");
assert_eq!(
c.iter().filter(|&&b| b == 0).count(),
1,
"exactly one NUL, at the end"
);
}
/// `resolve_to_sg` passes an sg node through unchanged, and falls back to the
/// original path when there is no filename or the node is neither sr nor sg —
/// the branches that need no sysfs.
#[test]
fn resolve_to_sg_passes_sg_through_and_falls_back_otherwise() {
assert_eq!(
SgIoTransport::resolve_to_sg(Path::new("/dev/sg7")),
Path::new("/dev/sg7"),
"an sg node is already resolved"
);
assert_eq!(
SgIoTransport::resolve_to_sg(Path::new("/")),
Path::new("/"),
"a path with no filename falls back to itself"
);
assert_eq!(
SgIoTransport::resolve_to_sg(Path::new("/dev/foo")),
Path::new("/dev/foo"),
"a non-sr, non-sg node is returned unchanged"
);
}
}
+41 -6
View File
@@ -458,13 +458,31 @@ int shim_open_exclusive(const char *bsd_name) {
svc = find_bdsvc_from_iomedia(mp, bsd_name);
}
if (!svc) {
// IOServiceMatching returns NULL on allocation failure. Both other call
// sites in this file check it; this one did not, and
// IOServiceGetMatchingService with a NULL matching dictionary is
// undefined (it consumes the reference it is given).
// Last-resort fallback: enumerate IOBDServices directly. This must still
// HONOR the requested bsd_name — grabbing IOServiceGetMatchingService's
// first/arbitrary match would open the wrong drive on a multi-drive host.
// So iterate and accept only the service whose IOMedia BSD name equals the
// requested one; if none matches, leave svc == 0 and fail below.
//
// IOServiceMatching returns NULL on allocation failure (checked here, as
// the other call sites do); IOServiceGetMatchingServices consumes the
// matching-dictionary reference whether it succeeds or fails.
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
if (matching) {
svc = IOServiceGetMatchingService(mp, matching);
io_iterator_t iter;
if (IOServiceGetMatchingServices(mp, matching, &iter) == KERN_SUCCESS) {
io_service_t cand;
while ((cand = IOIteratorNext(iter)) != 0) {
char name[64];
if (bdsvc_to_bsd_name(cand, name, sizeof(name))
&& strcmp(name, bsd_name) == 0) {
svc = cand; // retained; released after the plug-in is built
break;
}
IOObjectRelease(cand);
}
IOObjectRelease(iter);
}
}
}
if (!svc) {
@@ -523,6 +541,17 @@ int shim_open_exclusive(const char *bsd_name) {
// Claim the disk now that we hold the drive, so DA can't remount it during
// the read. Best-effort: a failed claim does not fail the open (we already
// have exclusive SCSI access and the disc is unmounted).
//
// TOCTOU note: the lock is released above, so a concurrent shim_close could
// tear g_handle down in the gap before da_hold runs. That is SAFE, not a
// use-after-free: da_hold re-takes g_handle_lock and builds its
// DiskArbitration state (da_queue / da_session / da_disk) from scratch,
// entirely under the lock and keyed only off its bsd_name argument — it never
// dereferences a g_handle field it read before acquiring the lock, so a
// teardown in the gap just leaves fresh state to overwrite. If any of those
// allocations fails it returns early (0 → no claim). The lock is deliberately
// NOT held across da_hold's ~5 s self-locking claim wait, which would
// serialize every open behind it.
da_hold(bsd_name);
return 0;
@@ -661,7 +690,13 @@ int shim_list_drives(ShimDriveInfo *out, int max_entries) {
int count = 0;
io_service_t svc;
while ((svc = IOIteratorNext(iter)) != 0 && count < max_entries) {
// Test count BEFORE calling IOIteratorNext: each IOIteratorNext returns a
// retained io_service_t the caller must release. With the count check second,
// the iteration that fills the last slot would call IOIteratorNext once more
// (obtaining, and then leaking, one extra service) before the loop exits.
// Short-circuiting on count first means no unreleased service is ever obtained;
// any entries left unvisited in the iterator are freed by IOObjectRelease(iter).
while (count < max_entries && (svc = IOIteratorNext(iter)) != 0) {
ShimDriveInfo *info = &out[count];
memset(info, 0, sizeof(*info));
+102 -16
View File
@@ -103,6 +103,25 @@ pub struct DecryptingSectorSource<S: SectorSource> {
key_map: Option<Arc<crate::decrypt::AacsKeyMap>>,
}
/// Does the sector span `[lba, lba+count)` intersect any encrypted-content range?
/// `None` content (the mux reads title extents only) means "always content" so the
/// AACS unit-alignment gate stays enforced. When a content map IS set (whole-disc
/// readers), a span touching NO content range is clear filesystem/nav and must be
/// exempt from the alignment gate — it will pass through undecrypted.
fn span_touches_content(content: Option<&[(u32, u32)]>, lba: u32, count: u16) -> bool {
match content {
None => true,
Some(ranges) => {
let end = lba as u64 + count as u64;
ranges.iter().any(|&(start, cnt)| {
let rs = start as u64;
let re = rs + cnt as u64;
(lba as u64) < re && rs < end
})
}
}
}
impl<S: SectorSource> DecryptingSectorSource<S> {
/// Wrap `inner` with the given keys. For an AACS source, install a key map
/// via [`with_key_map`](Self::with_key_map) before reading — AACS decrypts
@@ -217,10 +236,17 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
recovery: bool,
fua: bool,
) -> Result<usize> {
// Defense-in-depth: AACS units are 3-sector aligned; misalignment would
// silently mis-decrypt, so reject loud (DecryptFailed) before reading.
// Gated relative to `unit_base` (per-extent), not raw `lba % 3`.
// Content-extent map (whole-disc readers): units outside the encrypted
// extents are clear filesystem / nav and pass through untouched. Cheap Arc
// bump; frees the &self borrow so we can decrypt against `&mut buf`.
let content = self.content_ranges.clone();
let content_ref = content.as_deref();
// Defense-in-depth: AACS units are 3-sector aligned; misalignment silently mis-
// decrypts, so reject loud (DecryptFailed) before reading — gated vs `unit_base`
// (not raw `lba % 3`), only when the span TOUCHES content (else UDF/nav passes through).
if matches!(self.keys, DecryptKeys::Aacs { .. })
&& span_touches_content(content_ref, lba, count)
&& !crate::aacs::content::is_unit_aligned(lba, self.unit_base)
{
return Err(crate::error::Error::DecryptFailed);
@@ -229,23 +255,23 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
.inner
.read_sectors_fua(lba, count, buf, recovery, fua)?;
// Proactive map path (storm-free mux): keys were resolved per unit up
// front, so decrypt with the mapped key and trust it, no per-unit
// `is_clean` check. A resolver gap fails loud; bad TS passes through.
// Proactive map path (storm-free mux): keys were resolved per unit up front,
// so decrypt with the mapped key and trust it (no per-unit `is_clean`). A
// resolver gap fails loud; bad TS and units outside content extents pass through.
if let Some(map) = self.key_map.clone() {
crate::decrypt::decrypt_sectors_mapped(&mut buf[..n], &self.keys, lba, &map)?;
crate::decrypt::decrypt_sectors_mapped_in_content(
&mut buf[..n],
&self.keys,
lba,
&map,
content_ref,
)?;
return Ok(n);
}
// Decrypt `buf` in place (None / CSS / AACS); with a content map, units
// outside the encrypted extents pass through untouched. A can't-decrypt
// fails loud; broken-TS output is the muxer's concern, not a read failure.
let content = self.content_ranges.clone(); // cheap Arc bump; frees the &self borrow
let content_ref = content.as_deref();
// No map installed: CSS self-descramble / clear pass-through. A can't-
// decrypt (misalignment, or mapless AACS reaching here — a bug) fails
// loud; otherwise units pass through, same as above.
// No map installed: `decrypt_buf` does CSS self-descramble / clear pass-through
// (units outside the content extents pass through untouched). A can't-decrypt
// (misalignment, or mapless AACS here — a bug) fails loud; broken TS is the muxer's concern.
Self::decrypt_buf(&mut buf[..n], &mut self.keys, lba, content_ref)?;
Ok(n)
}
@@ -773,4 +799,64 @@ mod tests {
.expect_err("AACS decorator with no key map must fail loud");
assert_eq!(err.code(), crate::error::Error::DecryptFailed.code());
}
// `with_content_ranges` contract: an encrypted unit whose LBA is OUTSIDE the
// disc's content extents passes through untouched (never decrypted). Before the
// fix the mapped path ignored the content map, so this unit was decrypted.
#[test]
fn content_ranges_pass_through_units_outside_encrypted_extents() {
let key = [0x5Au8; 16];
let unit = encrypt_aacs_unit(&key);
let ciphertext = unit.clone();
let src = FixedUnit { unit };
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let map = std::sync::Arc::new(crate::decrypt::AacsKeyMap::from_ranges(vec![(
0,
u32::MAX,
0,
)]));
// Content covers a DIFFERENT extent (LBA 300..303); LBA 0 is outside it.
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(300u32, 3u32)].into_boxed_slice());
let mut dec = DecryptingSectorSource::new(src, keys)
.with_key_map(map)
.with_content_ranges(ranges);
let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN];
let n = dec.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(n, crate::aacs::content::ALIGNED_UNIT_LEN);
assert_eq!(
buf, ciphertext,
"a unit outside the content extents must pass through untouched"
);
}
// With content ranges set, a NON-aligned read that touches no content extent is
// clear filesystem — it must NOT trip the AACS unit-alignment gate (which would
// otherwise fail loud for any AACS read at a misaligned LBA).
#[test]
fn content_ranges_exempt_out_of_content_read_from_alignment_gate() {
let src = PatternedSource { capacity: 16 };
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let map = std::sync::Arc::new(crate::decrypt::AacsKeyMap::from_ranges(vec![(300, 303, 0)]));
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(300u32, 3u32)].into_boxed_slice());
let mut dec = DecryptingSectorSource::new(src, keys)
.with_key_map(map)
.with_content_ranges(ranges);
let mut buf = vec![0u8; 2048];
// LBA 1 is misaligned AND outside content → passes through, no DecryptFailed.
let n = dec
.read_sectors(1, 1, &mut buf, false)
.expect("a misaligned clear read outside content must not be gated");
assert_eq!(n, 2048);
let mut expected = vec![0u8; 2048];
PatternedSource::fill(1, 1, &mut expected);
assert_eq!(buf, expected, "clear out-of-content bytes pass through");
}
}
+4 -6
View File
@@ -157,12 +157,10 @@ impl PrefetchedSectorSource {
offset = 0;
continue;
}
// AACS units are SECTOR_ALIGNMENT (3) sectors; decrypt only
// processes full units, leaving a short tail encrypted. If the
// tail can't fill a unit, error instead of emitting encrypted bytes.
if !remaining.is_multiple_of(unit_align as u32)
&& remaining < unit_align as u32
{
// AACS units are SECTOR_ALIGNMENT (3) sectors; decrypt processes only
// full units, so a tail below one unit can't decrypt — error, don't emit
// encrypted bytes. (`remaining > 0` above, so `< align` IS the short tail; `!is_multiple_of` was redundant.)
if remaining < unit_align as u32 {
let _ = tx.send(Err(crate::error::Error::ExtentNotUnitAligned.into()));
return;
}
+126
View File
@@ -386,6 +386,22 @@ impl DiscSession {
key_fetch,
}
}
// Test-only: build a session that OWNS a (mock-backed) `Drive` so the
// stage/into_reader/into_drive lifecycle can be exercised (unreachable via
// `from_parts_for_test`, which holds no drive). See docs/session.md.
#[cfg(test)]
pub(crate) fn from_drive_for_test(drive: Drive) -> DiscSession {
let device = drive.device_path().to_string();
DiscSession {
drive: Some(drive),
device,
spec: KeySpec::default(),
disc: None,
reader: None,
key_fetch: None,
}
}
}
/// Scan an ISO image's structure from a file path, returning the scanned
@@ -860,4 +876,114 @@ mod tests {
"expected DeviceNotReady, got {err:?}"
);
}
// ── DiscSession drive lifecycle: stage / into_reader / into_drive ─────────
/// A transport that tolerates any command (the drive's Drop runs a
/// tray-unlock through it). Returns a benign GOOD status.
struct NoopTransport;
impl crate::scsi::ScsiTransport for NoopTransport {
fn execute(
&mut self,
_cdb: &[u8],
_direction: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<crate::scsi::ScsiResult> {
Ok(crate::scsi::ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
fn session_with_drive() -> DiscSession {
DiscSession::from_drive_for_test(Drive::from_transport_for_test(Box::new(NoopTransport)))
}
/// `stage_drive_as_reader` moves the owned `Drive` into the `reader` slot:
/// afterward the reader is staged (`into_reader` is `Some`) and the drive
/// slot is emptied. The cached `device_path` survives the move.
#[test]
fn stage_drive_as_reader_moves_drive_into_reader_slot() {
let mut session = session_with_drive();
assert_eq!(session.device_path(), "test");
session.stage_drive_as_reader();
// device_path still resolves after the drive has moved out.
assert_eq!(
session.device_path(),
"test",
"device_path is cached and survives the drive move"
);
assert!(
session.into_reader().is_some(),
"the drive must be staged as the reader"
);
}
/// A staged drive left the drive slot: `into_drive` then errors cleanly with
/// `DeviceNotReady` (mutually exclusive with `into_reader`, which holds it).
#[test]
fn into_drive_errors_after_staging_moved_the_drive_out() {
let mut session = session_with_drive();
session.stage_drive_as_reader();
// `Drive` isn't `Debug`, so match on the result rather than `expect_err`.
assert!(
matches!(session.into_drive(), Err(Error::DeviceNotReady { .. })),
"into_drive after staging must error with DeviceNotReady, not return a drive"
);
}
/// The two consuming exits are mutually exclusive. An UNSTAGED session hands
/// the drive out via `into_drive` (and has no staged reader); a STAGED
/// session hands the reader out via `into_reader` (and has no drive).
#[test]
fn into_drive_and_into_reader_are_mutually_exclusive() {
// Unstaged: the drive is available; the reader is not.
let unstaged = session_with_drive();
assert!(
unstaged.into_reader().is_none(),
"an unstaged session has no reader to hand out"
);
let unstaged = session_with_drive();
assert!(
unstaged.into_drive().is_ok(),
"an unstaged session hands the drive out"
);
// Staged: the reader is available; the drive is not.
let mut staged = session_with_drive();
staged.stage_drive_as_reader();
assert!(
staged.into_reader().is_some(),
"a staged session hands the reader out"
);
}
/// `stage_drive_as_reader` is a no-op when the session holds no drive
/// (already staged or moved out): the reader slot stays empty.
#[test]
fn stage_drive_as_reader_is_noop_without_a_drive() {
let mut session = DiscSession::from_parts_for_test(None, None, None);
session.stage_drive_as_reader();
assert!(
session.into_reader().is_none(),
"staging with no drive leaves the reader slot empty"
);
}
/// A double stage is idempotent: the first moves the drive into the reader,
/// the second is a no-op (drive slot already empty), and the single staged
/// reader remains available.
#[test]
fn stage_drive_as_reader_is_idempotent() {
let mut session = session_with_drive();
session.stage_drive_as_reader();
session.stage_drive_as_reader();
assert!(
session.into_reader().is_some(),
"the reader staged by the first call survives a redundant second call"
);
}
}
+195 -53
View File
@@ -1125,73 +1125,142 @@ fn read_directory(
}
(icb[ad_off..ad_off + l_ad].to_vec(), l_ad as u32)
} else {
// Out-of-line: read the FIRST allocation descriptor. short_ad/long_ad both
// start with 4-byte length + 4-byte LBA; extended_ad's lb_num is at offset
// 12. Other flag values fall back to short_ad, matching `read_icb_extents`.
let lba_at = if ad_type == 2 {
ad_off + 12
} else {
ad_off + 4
// Out-of-line: the FID list may span MULTIPLE ADs (only extent_type 0 is FID data);
// reading just the first AD truncated dirs whose FIDs cross an extent boundary, or
// misread a sparse/type-3 descriptor's (length,LBA) as FIDs. Walk the list, gathering every extent's sectors into one buffer, following type-3 continuations like `read_icb_extents`.
let ad_size: usize = match ad_type {
0 => 8, // short_ad
1 => 16, // long_ad
2 => 20, // extended_ad
// Reserved (4/14.6.8); fall back to the short_ad stride rather than
// fail the whole title, matching `read_icb_extents`.
_ => 8,
};
if lba_at + 4 > icb.len() {
// The ICB's own allocation-descriptor field must fit inside its 2048-byte
// sector: a disc-controlled L_EA large enough to push the first
// descriptor past the sector end is corruption, not an out-of-bounds read.
if l_ad > 0 && ad_off.saturating_add(ad_size) > icb.len() {
return Err(Error::DiscRead {
sector: icb_abs as u64,
status: None,
sense: None,
});
}
let ad_len = u32::from_le_bytes([
icb[ad_off],
icb[ad_off + 1],
icb[ad_off + 2],
icb[ad_off + 3],
]) & 0x3FFF_FFFF;
let ad_pos = u32::from_le_bytes([
icb[lba_at],
icb[lba_at + 1],
icb[lba_at + 2],
icb[lba_at + 3],
]);
let mut dir_data: Vec<u8> = Vec::new();
// Declared FID-list byte length (sum of the recorded extents' lengths),
// kept apart from the sector-rounded buffer so `size` and the FID-loop
// bound stay the honest declared length, never the padded buffer.
let mut total_len: u32 = 0;
let mut block = icb;
let mut ad_start = ad_off;
let mut ad_bytes = l_ad;
const MAX_AD_BLOCKS: usize = 256;
'chain: for _ in 0..MAX_AD_BLOCKS {
let avail = block.len().saturating_sub(ad_start);
let num = ad_bytes.min(avail) / ad_size;
let mut next_block: Option<u32> = None;
// Reject oversized dirs before allocating: ad_len is a disc-controlled
// 30-bit length, so corruption could force a ~1 GiB alloc (amplified by
// recursion). 1 MiB cap still covers a large STREAM/ dir's .m2ts FIDs.
if ad_len > MAX_DIR_BYTES {
return Err(Error::DiscRead {
sector: meta_start as u64,
status: None,
sense: None,
});
}
for i in 0..num {
let off = ad_start + i * ad_size;
if off + ad_size > block.len() {
break;
}
let raw_len = u32::from_le_bytes([
block[off],
block[off + 1],
block[off + 2],
block[off + 3],
]);
let extent_type = raw_len >> 30;
let data_len = raw_len & 0x3FFF_FFFF;
// Extended ADs place the extent LBA after three length fields.
let lba_off = if ad_size == 20 { off + 12 } else { off + 4 };
let data_lba = u32::from_le_bytes([
block[lba_off],
block[lba_off + 1],
block[lba_off + 2],
block[lba_off + 3],
]);
// Read directory data
let dir_abs = meta_start.checked_add(ad_pos).ok_or(Error::DiscRead {
sector: meta_start as u64,
status: None,
sense: None,
})?;
let sector_count = ad_len.div_ceil(2048);
let mut dir_data = vec![0u8; sector_count as usize * 2048];
for i in 0..sector_count {
let abs = dir_abs.checked_add(i).ok_or(Error::DiscRead {
sector: dir_abs as u64,
status: None,
sense: None,
})?;
read_sector(
reader,
abs,
&mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048],
)?;
if extent_type == 3 {
// Continuation (ECMA-167 4/14.14.1.1 type 3): the REST of the ADs live
// in the block at data_lba, NOT FID data — reading its (length,LBA) as an
// extent (the old first-AD-only bug) enumerates an unrelated sector. Follow the pointer instead (bounded by MAX_AD_BLOCKS).
if data_len > 0 {
next_block = Some(data_lba);
}
break;
}
// Types 0/1/2 are allocated extents whose sectors hold FID data
// (the 2-bit extent type is masked out of `data_len` above). A
// zero-length descriptor terminates the AD list.
if data_len == 0 {
break 'chain;
}
// Bound the cumulative allocation before growing: `data_len` is a
// disc-controlled 30-bit value, and multiple extents could
// otherwise sum to a huge buffer.
let sector_count = data_len.div_ceil(2048);
let add = sector_count as usize * 2048;
if dir_data.len().saturating_add(add) > MAX_DIR_BYTES as usize {
return Err(Error::DiscRead {
sector: meta_start as u64,
status: None,
sense: None,
});
}
let dir_abs = meta_start.checked_add(data_lba).ok_or(Error::DiscRead {
sector: meta_start as u64,
status: None,
sense: None,
})?;
let base = dir_data.len();
dir_data.resize(base + add, 0);
for s in 0..sector_count {
let abs = dir_abs.checked_add(s).ok_or(Error::DiscRead {
sector: dir_abs as u64,
status: None,
sense: None,
})?;
let o = base + s as usize * 2048;
read_sector(reader, abs, &mut dir_data[o..o + 2048])?;
}
total_len = total_len.saturating_add(data_len);
}
match next_block {
Some(cont_lba) => {
let abs = meta_start.checked_add(cont_lba).ok_or(Error::DiscRead {
sector: meta_start as u64,
status: None,
sense: None,
})?;
read_sector(reader, abs, &mut block)?;
// A continuation block leads with a 24-byte Allocation Extent
// Descriptor (tag 258); its L_AD is at offset 20 and real ADs
// start at offset 24.
let aed_l_ad =
u32::from_le_bytes([block[20], block[21], block[22], block[23]]) as usize;
ad_start = 24;
ad_bytes = aed_l_ad.min(block.len().saturating_sub(24));
}
None => break,
}
}
(dir_data, ad_len)
(dir_data, total_len)
};
// Parse File Identifier Descriptors
// Parse File Identifier Descriptors. The declared FID-list length — never
// the sector-rounded buffer — bounds the walk: trailing padding past it is
// not directory content and must not be read as FIDs.
let dir_end = dir_data.len().min(ad_len as usize);
let mut entries = Vec::new();
let mut pos = 0;
while pos + 38 < dir_data.len().min(ad_len as usize) {
// `<=`, not `<`: a FID's fixed 38-byte header ending exactly at the declared boundary
// (pos + 38 == dir_end) is fully present and must be read (strict `<` dropped it). The
// name may run past the header but is bounded against `dir_end` below, so a FID whose header fits but whose NAME spills past the length is still rejected.
while pos + 38 <= dir_end {
let fid_tag = u16::from_le_bytes([dir_data[pos], dir_data[pos + 1]]);
if fid_tag != 257 {
break;
@@ -1220,7 +1289,7 @@ fn read_directory(
if !is_parent && !is_deleted && l_fi > 0 {
let name_start = pos + 38 + l_iu;
let name_end = name_start + l_fi;
if name_end > dir_data.len() {
if name_end > dir_end {
break;
}
let entry_name = parse_udf_name(&dir_data[name_start..name_end]);
@@ -4355,6 +4424,52 @@ mod tests {
assert_eq!(parsed.entries[0].size, 1024);
}
#[test]
fn read_directory_reads_fids_spanning_multiple_allocation_descriptors() {
// A directory's FID list can span MORE than one AD (ECMA-167 4/14.14.1); reading
// only the first AD truncated it at the extent boundary, so every recorded extent
// must be gathered and walked. Extent 0 (sector 60) is packed to exactly 2048 bytes so the walk flows into extent 1 (sector 61), where the second file's FID lives.
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0); // parent (..), 40 bytes
// Pad FIRST.CLPI's implementation-use area so e0 ends exactly at 2048.
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 1959);
assert_eq!(e0.len(), 2048, "extent 0 must fill its sector exactly");
let mut sec0 = [0u8; 2048];
sec0.copy_from_slice(&e0);
let mut e1 = Vec::new();
push_fid_iu(&mut e1, "SECOND.CLPI", 8, false, false, 0);
let mut sec1 = [0u8; 2048];
sec1[..e1.len()].copy_from_slice(&e1);
// Two short_ads: (recorded, len=2048, lba=60) then (recorded, len, lba=61).
let mut body = Vec::new();
body.extend_from_slice(&2048u32.to_le_bytes());
body.extend_from_slice(&60u32.to_le_bytes());
body.extend_from_slice(&(e1.len() as u32).to_le_bytes());
body.extend_from_slice(&61u32.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
reader.put(61, sec1);
reader.put(7, build_efe_icb(11, 2048, 0));
reader.put(8, build_efe_icb(22, 2048, 0));
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
.expect("a multi-extent directory must be readable");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string(), "SECOND.CLPI".to_string()],
"the FID in the SECOND allocation extent must be read, not truncated away"
);
assert_eq!(
parsed.size,
2048 + e1.len() as u64,
"the reported size is the sum of both recorded extents' declared lengths"
);
}
#[test]
fn read_directory_reads_an_extended_ad_directorys_extent_location() {
// ECMA-167 4/14.14.3 extended_ad (20B) keeps extent_location at offset 12, NOT offset 4
@@ -4382,6 +4497,33 @@ mod tests {
assert_eq!(child_names(&parsed), vec!["INDEX.BDMV".to_string()]);
}
#[test]
fn read_directory_reads_a_long_ad_directorys_extent() {
// ICB Tag flags = 1 selects long_ad (16 bytes): extent_length(4) +
// extent_location{ lba(4) + part_ref(2) } + impl_use(6). The LBA is at offset 4
// (like short_ad), NOT offset 12 like extended_ad — the wrong offset walks an empty sector and the directory comes back empty.
let mut fids = Vec::new();
push_fid_iu(&mut fids, "", 5, true, true, 0);
push_fid_iu(&mut fids, "INDEX.BDMV", 7, false, false, 0);
let mut dir = [0u8; 2048];
dir[..fids.len()].copy_from_slice(&fids);
let mut long_ad = [0u8; 16];
long_ad[0..4].copy_from_slice(&(fids.len() as u32).to_le_bytes()); // extent_length
long_ad[4..8].copy_from_slice(&60u32.to_le_bytes()); // extent_location lba
// long_ad[8..10] = partition_ref, long_ad[10..16] = implementation_use (0).
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(1, &long_ad));
reader.put(60, dir);
reader.put(7, build_efe_icb(1024, 1024, 0));
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
.expect("a long_ad directory must be readable");
assert_eq!(child_names(&parsed), vec!["INDEX.BDMV".to_string()]);
assert_eq!(parsed.size, fids.len() as u64);
}
#[test]
fn read_directory_masks_the_extent_type_bits_out_of_the_ad_length() {
// ECMA-167 4/14.14.1.1: a short_ad's head 32-bit field is a 30-bit length plus a 2-bit
+83 -9
View File
@@ -126,12 +126,43 @@ pub(crate) fn run_features(
scsi: &mut dyn crate::scsi::ScsiTransport,
drive_id: &crate::identity::DriveId,
) -> Dispatch {
let unlockers: Vec<Box<dyn fu::Unlocker>> = vec![
run(firmware_unlockers(), scsi, drive_id, fu::DiscKind::Unknown)
}
/// The FIRMWARE/drive unlockers, in dispatch order. These key off the DRIVE and
/// remove bus encryption AT THE DRIVE, so a VID from one of them means the drive
/// is already unlocked (see `is_drive_unlocker`). Single constructor so both the
/// dispatch and the name/classification helpers share one source of truth.
fn firmware_unlockers() -> Vec<Box<dyn fu::Unlocker>> {
vec![
Box::new(fu::FreemkvUnlocker::new()),
Box::new(fu::LdUnlocker::new()),
Box::new(fu::Renesas::new()),
];
run(unlockers, scsi, drive_id, fu::DiscKind::Unknown)
]
}
// The DISC-keyed unlockers, in dispatch order. `host_certs` are injected into
// the AACS unlocker here (the one place certs enter); pass an empty slice when
// only the names are wanted.
fn disc_unlockers(host_certs: Vec<fu::HostCert>) -> Vec<Box<dyn fu::Unlocker>> {
vec![
Box::new(fu::AacsUnlocker::new(host_certs)),
Box::new(fu::DvdUnlocker::new()),
]
}
/// Whether `name` (a matched unlocker's `.name()`) is a firmware/drive unlocker,
/// i.e. one whose success means bus encryption is already removed at the drive.
/// Derived from the real unlocker set, never a hardcoded name list.
///
/// Test-only: the disc-keyed cert route (`run_bus`) can only match the disc
/// unlockers (AACS/DVD), so no production path classifies a `matched` name here —
/// the AACS cert handshake credits bus removal via its `read_data_key`, and a
/// genuine firmware drive-unlock is surfaced by the OEM-VID short-circuit. Retained
/// so the disjointness invariant stays under test.
#[cfg(test)]
pub(crate) fn is_drive_unlocker(name: &str) -> bool {
firmware_unlockers().iter().any(|u| u.name() == name)
}
/// Content: remove BUS ENCRYPTION for the mounted disc via the DISC-keyed
@@ -144,16 +175,18 @@ pub(crate) fn run_bus(
kind: fu::DiscKind,
host_certs: &[fu::HostCert],
) -> Dispatch {
let unlockers: Vec<Box<dyn fu::Unlocker>> = vec![
Box::new(fu::AacsUnlocker::new(host_certs.to_vec())),
Box::new(fu::DvdUnlocker::new()),
];
run(unlockers, scsi, drive_id, kind)
run(disc_unlockers(host_certs.to_vec()), scsi, drive_id, kind)
}
// The unlocker names, in dispatch order, for the user-facing unlocker matrix.
// Derived from the real unlocker instances' `.name()` — firmware set then disc
// set — so the matrix can never drift from what actually dispatches.
pub(crate) fn unlocker_names() -> Vec<&'static str> {
vec!["freemkv", "LD", "Renesas", "AACS", "DVD"]
firmware_unlockers()
.iter()
.chain(disc_unlockers(Vec::new()).iter())
.map(|u| u.name())
.collect()
}
#[cfg(test)]
@@ -185,6 +218,47 @@ mod tests {
.expect_err("error path")
}
/// The user-facing unlocker matrix must be DERIVED from the real unlocker
/// instances, never a hand-maintained list that can silently drift. Pin the
/// dispatch order (firmware set, then disc set) and cross-check that every
/// name comes from an actual `.name()`.
#[test]
fn unlocker_names_are_derived_from_the_real_unlockers() {
assert_eq!(
unlocker_names(),
vec!["freemkv", "LD", "Renesas", "AACS", "DVD"],
"the matrix is the firmware set then the disc set, in dispatch order"
);
let derived: Vec<&str> = firmware_unlockers()
.iter()
.chain(disc_unlockers(Vec::new()).iter())
.map(|u| u.name())
.collect();
assert_eq!(unlocker_names(), derived);
}
// `is_drive_unlocker` classifies a matched unlocker name against the real
// firmware set: firmware unlockers unlock AT THE DRIVE (true), disc-keyed
// ones do not (false), and an empty/unknown name is not a drive unlock.
#[test]
fn is_drive_unlocker_credits_only_the_firmware_set() {
for u in firmware_unlockers() {
assert!(
is_drive_unlocker(u.name()),
"{} is a firmware/drive unlocker",
u.name()
);
}
for u in disc_unlockers(Vec::new()) {
assert!(
!is_drive_unlocker(u.name()),
"{} is disc-keyed, not a drive unlock",
u.name()
);
}
assert!(!is_drive_unlocker(""), "no match is not a drive unlock");
}
/// A CHECK CONDITION carrying sense crosses the seam with status + parsed
/// sense intact, so the unlock crate's ILLEGAL_REQUEST wedge guard can fire.
#[test]