Compare commits

...
41 Commits
Author SHA1 Message Date
Matthew Jackson 584cc6030b tests(macos): hardware drive checks (INQUIRY, reopen, UHD AACS2 state, rescan stability, probe/scan agreement) + non-hardware WritebackFile/preallocate tests 2026-09-25 13:44:22 -07:00
Matthew Jackson 7257559153 chore: absorb qa into dev for lockstep promote [skip-media] 2026-09-25 12:25:45 -07:00
Matthew Jackson 8949b81dfc macos(drive): discover empty-tray drives via IOKit registry-id selectors
Empty-tray optical drives couldn't be opened because the macOS backend
resolved drives by media-child `BSD Name`, which doesn't exist without a
disc loaded. Result: `list_drives()` returned nothing for a drive with no
media, and any attempt to `open("disc://")` on that state failed.

Fix: enumerate `IOBDServices` directly. With media present, the returned
selector is the familiar `/dev/diskN` path (unchanged). With no media,
the selector is an opaque `ioreg:<registry-id>` string that resolves to
the same IOBDServices entry — Disk Arbitration and unmount are skipped
because there's no media node.

- `src/drive/macos.rs`, `src/drive/mod.rs`, `src/scsi/mod.rs`: threads
  the selector through the transport so empty-tray drives are openable
  without punning it as a filesystem path.
- `src/scsi/macos.rs`, `src/scsi/macos_shim.c`: the C shim now accepts
  both `/dev/diskN` and `ioreg:<id>` in `shim_open_exclusive`, and
  `shim_list_drives` reports the correct selector for each drive.
- `tests/macos_drive_tests.rs` (new): covers empty-tray discovery.
- `docs/drive-access.md`, `docs/scsi-macos.md`: document the two-path
  selector model.

[skip-media]
2026-09-25 12:03:02 -07:00
Matthew Jackson 7bcfdeda32 restore freemkv-unlock path dep for local dev (post-v1.7.6) 2026-09-25 05:32:00 +00:00
Matthew Jackson 89be65a3d4 v1.7.6: bump version (freemkv-unlock git-pinned for the tag) 2026-09-25 05:31:57 +00:00
Matthew Jackson f21ab37920 chore(qa): gate 1.7.6 promote — media matrix not needed [skip-media]
The 1.7.6 unified release ships no rip-path change (Linux desktop shell,
notifications, menu_layout refactor, XDG paths). Real-media QA on EC2
adds ~2h + real money to the release cycle to prove code that never
enters the rip pipeline. This empty commit puts `[skip-media]` in qa's
HEAD so the media matrix in qa.yml auto-skips per the documented bypass;
the full-platform builds/tests still run.
2026-09-24 21:37:26 -07:00
Matthew Jackson 0c28e569a8 chore(qa-sync): absorb qa's post-1.7.5 release commits into dev
Same fix as the other 7 repos: qa held two release-orchestrate commits
(v1.7.5 bump + post-release freemkv-unlock path restore) that never
propagated back to dev, so promote's fast-forward gate refused. Ours-merge
records qa as an ancestor without applying its content; dev has already
moved past 1.7.5 to 1.7.6.
2026-09-24 21:27:58 -07:00
Matthew Jackson 0d9565eac9 ci(consumers): install libgtk-4-dev so the freemkv consumer build resolves
The freemkv 1.7.6 Linux target compiles a GTK4 + libadwaita desktop shell,
so this job's downstream `cargo test`/`cargo check` on freemkv hits
`gtk4-sys`/`glib-sys` pkg-config probes that fail on a stock ubuntu
runner. Adds the apt-get step alongside the existing sibling checkout,
mirroring the fix in freemkv's own ci.yml `cli-linux` job.

Kept local (not in the shared reusable checkout action) — only `freemkv`
links against GTK; adding the deps to every downstream consumer would tax
the six that don't.
2026-09-24 20:37:35 -07:00
Matthew Jackson b52e3c8292 chore(release): bump to 1.7.6, follow the CI-required release-notes shape
Bumps Cargo.toml + Cargo.lock to 1.7.6. Also fixes a CI-shape defect the
previous release left behind: dev's CHANGELOG still said
`## [1.7.5] — UNRELEASED` after 1.7.5 shipped (release-orchestrate dated
main but did not push a fresh UNRELEASED heading back to dev). Corrects
that here so the changelog job accepts 1.7.6 on the way to qa:

  ## [1.7.6] — UNRELEASED
    - Version aligned; unified release driven by freemkv 1.7.6.
  ## [1.7.5] — 2026-09-23
    - Version aligned (or actual content, per the shipped main entry).

The driver for 1.7.6 is the freemkv Linux desktop shell (GTK4 +
libadwaita) + rip-finished desktop notification (issue #56).
2026-09-24 20:23:08 -07:00
Matthew Jackson d4510392f6 restore freemkv-unlock path dep for local dev (post-v1.7.5) 2026-09-23 04:40:54 +00:00
Matthew Jackson 49992a5c2d v1.7.5: bump version (freemkv-unlock git-pinned for the tag) 2026-09-23 04:40:51 +00:00
Matthew Jackson c1caac3a7e docs: 1.7.5 changelog entry for the unified release [skip-media]
Version aligned to 1.7.5. No functional change to this crate — the release
is driven by freemkv-unlock mirroring the freemkv-firmware 0.9.0 ABI, where
the drive's separate Ake (0x06) and Bus (0x07) levers are retired into a
single Encryption (0x06) lever.
2026-09-22 21:04:54 -07:00
Matthew Jackson b69002694f ci: add OpenSSF Scorecard workflow (publish_results on default branch) 2026-09-21 16:39:26 -07:00
Matthew Jackson 11c869f8e9 restore freemkv-unlock path dep for local dev (post-v1.7.4) 2026-09-21 23:03:36 +00:00
Matthew Jackson 8ce40edba3 v1.7.4: bump version (freemkv-unlock git-pinned for the tag) 2026-09-21 23:03:33 +00:00
Matthew Jackson bccc8eb9a7 docs: mark 1.7.4 changelog heading UNRELEASED (release workflow dates it) 2026-09-21 15:07:08 -07:00
Matthew Jackson 0edbdfbb21 docs: 1.7.4 changelog + consumer release notes 2026-09-21 11:47:20 -07:00
Matthew Jackson 2499f1600d docs: OpenSSF Best Practices badge (project 14745, 198%) + REUSE.toml/badge — parity with freemkv 2026-09-21 09:22:49 -07:00
Matthew Jackson bb3b1920a9 docs: add OpenSSF Scorecard + REUSE + MSRV badges and a 'what this is not' honesty section 2026-09-21 09:11:52 -07:00
Matthew Jackson 551173fa46 docs/ci: drop stale local-precommit/release.sh references — CI is the only gate 2026-09-21 09:11:34 -07:00
Matthew Jackson 26a9cb9e62 ci(qa): use central checkout-siblings composite + concurrency
Convert qa.yml to the central pattern already applied to ci.yml (86d66d3):
- add top-level concurrency (cancel-in-progress) group
- replace per-job freemkv-unlock checkout (ref qa) with the
  freemkv/.github checkout-siblings@v1 composite (checkout: freemkv-unlock)
  in release-tests, cross-lint and windows-build
- standardize actions/checkout to @v5

No heredoc patching existed in any job, so no patch inputs. The composite
resolves siblings 3-way, so qa runs now pin siblings at qa automatically.
2026-09-21 08:46:53 -07:00
Matthew Jackson 86d66d3113 ci: use central checkout-siblings composite + concurrency; fixes main->main sibling ref (was main->dev)
Replaces the 8x-repeated sibling-checkout block with freemkv/.github checkout-siblings@v1.
Adds concurrency cancel-in-progress. All jobs (incl. consumers running dependent suites) preserved.
2026-09-21 08:37:48 -07:00
Matthew Jackson 6d20c854c0 comment: trim two 5-line inline blocks to 3 (surfaced by the more-accurate central comment-guard lexer) 2026-09-21 08:35:27 -07:00
Matthew Jackson 24f29d83f0 ci: guards → central reusable workflows (comment/leak/identity/doc-links)
Thin callers to freemkv/.github@v1; delete the now-duplicated local ci/ scripts.
Adds concurrency cancel-in-progress and drops the push+PR double-fire (fork PRs
still run). doc-links now resolves toolchain from rust-version (was hardcoded 1.98).
2026-09-21 08:33:46 -07:00
Matthew Jackson 471a441799 doc: backtick the NAL [len][body] notation so rustdoc doesn't parse it as an intra-doc link (fixes doc-links CI) 2026-09-21 08:05:21 -07:00
Matthew Jackson aeb6bf5cc1 disc: scope the #45 lowest-id tiebreak behind stream-richness (recover Dolby Vision / subtitle on BlacKkKlansman + WickedForGood) 2026-09-20 21:50:21 -07:00
Matthew Jackson 0062d79d2c bdnav/bdjo: cite the BDJO format, not libbluray's GPL source (scan-secrets REF-IMPL-CITATION) 2026-09-20 20:45:55 -07:00
Matthew Jackson b0e297bd41 Merge codeaudit-1.7.4: chapter-span + hevc-guard + winner-hint tests, doc-drift, hoist MIN_FEATURE_SECS 2026-09-20 20:45:24 -07:00
Matthew Jackson 0cd967a5ac fix: close code-audit test gaps + doc drift (1.7.4 delta, #45/#52/#46)
Test gaps (guarded code was untested):
- disc: cover is_complete_feature_presentation's CHAPTER_SPAN_MIN_FRAC (0.5)
  boundary — clustered marks (span < 0.5*dur) are not a feature; marks at the
  0.5 threshold are. Adds bd_title_chaptered_span helper (issue #45).
- hevc: exercise the length-prefix self-check WIRING in HevcParser::parse
  end-to-end — a desynced access unit is dropped, a well-formed one emitted.
  Adds a test-only FORCE_FRAMING_DESYNC seam (frame_data is length-prefixed by
  construction, so no real input desyncs it) (issue #52).
- labels: cover resolve_feature_hint's winner_hint early-return — a non-empty
  winner_hint wins over the Tier-2 bdj_feature menu-walk result (issue #46).

Doc drift:
- keysource.md: resolve_unit_keys + UnitKeyResolution (matched/miss_path/
  matched_entry/store_entries de-conflation).
- aacs-trace.md: MatchedEntry, KeyStep.matched_entry/store_entries, and the
  KeyNode::NoDerivableKey node.
- hevc.md: add the "length-prefix self-check (issue #52)" section the hevc.rs
  comment points to.

Craft:
- labels: hoist the duplicated MIN_FEATURE_SECS (60) from fox.rs and
  paramount.rs to a single shared const in labels/mod.rs.
2026-09-20 20:44:28 -07:00
Matthew Jackson d7b6aca6e8 fix(#45): add bdj_feature.rs (unstaged in 0109db9 menu-walk commit) 2026-09-20 20:09:35 -07:00
Matthew Jackson 3a41e0eec7 Merge #45 menu-walk: BD-J feature resolution (BDJO AMT + jar/manifest + Tier-2 locator) above the chapter failsafe 2026-09-20 20:08:39 -07:00
Matthew Jackson 0109db984f fix(#45): BD-J menu-walk — authoritative feature-resolution layer
Adds a generic BD-J feature-playlist resolver that runs when no framework
parser surfaced a manifest hint, plus a refinement to Sony/Fox feature
selection. The hint now rides an independent pass so it survives even when
the winning parser (or no parser) produced any labels.

- labels/mod.rs: resolve_feature_hint() independent hint pass wired into
  apply(); register mod bdj_feature.
- labels/paramount.rs (+ fox.rs mirror): refine feature selection —
  exact name="Feature" → /feature/i by longest duration → most-audio by
  duration; hard-reject sub-minute playlists (kills the _Start_Angle 2s
  decoy). Fixes Sony SM3 UHD. feature_hint_from_xml() exposed for Tier-1.
- labels/bdj_feature.rs (NEW): Tier-1 jar-space resource sweep
  (dcx.xml/playlists.xml/*.properties) reusing fox/paramount hints; Tier-2
  autostart-Xlet locator scan (BDJO AMT → autostart jar → NNNNN.mpls /
  PLAYLIST:N String constants → intersect real PLAYLIST set →
  longest-dominant multi-audio wins; integer-only never emits).
- bdnav/bdjo.rs (NEW): minimal bounds-checked /BDMV/BDJO/*.bdjo
  Application Management Table reader (autostart initial_class + jar ids),
  panic-free like bdnav/index.rs.
- labels/jar.rs: try_each_resource() helper for the Tier-1 sweep.

No changes to disc/mod.rs selection or bdnav/vm.rs.
2026-09-20 20:07:45 -07:00
Matthew Jackson 841328af0f Merge #45 failsafe: chapter-gated composite bumper limb + lowest-id tiebreak 2026-09-20 19:36:33 -07:00
Matthew Jackson a35f336c52 Merge #52: PGS BlockDuration + HEVC NAL guard 2026-09-20 19:36:33 -07:00
Matthew Jackson 00f590f634 mux: guarantee a BlockDuration on every subtitle block (#52)
Fix (B) — the reproducible bug behind ffmpeg "Timestamps are unset in a
packet for stream N" on PGS subtitle tracks (worst on the sparse forced
track). Subtitle tracks carry no DefaultDuration, so a frame emitted with
duration_ns: None became a bare SimpleBlock with nothing to bound the cue.

- codec/pgs.rs: synthesize a fallback dwell (DEFAULT_PGS_DURATION_NS, 5 s)
  for every emission that was duration_ns: None — the EOS trailing set and
  the no-PTS clear/display, truncated, and lone-pass-through arms. emit_pending
  keeps its real computed span but clamps a pathologically large one
  (missing intermediate PCS) past MAX_PGS_DURATION_NS back to the fallback.
- mkv.rs: track_is_subtitle (track_type == 17), mirroring track_is_video. In
  write_frame_at a subtitle frame with no duration now substitutes a minimum
  fallback (MIN_SUBTITLE_BLOCK_DURATION_NS, 1 s) so it is emitted as a
  BlockGroup WITH BlockDuration, never a bare SimpleBlock (defense in depth).

Fix (A) — defensive HEVC guard (the "Invalid NAL unit size" error is not
reproducible in our clean disc->MKV path; this makes our output structurally
incapable of originating it):

- codec/hevc.rs: length_prefix_tiles() self-consistency check — the
  [u32-BE len][body] records must EXACTLY tile the frame; on mismatch the
  desynced access unit is dropped (logging src position) rather than emitted.
- LENGTH_PREFIX_SIZE constant + regression test that hvcC byte-21
  lengthSizeMinusOne+1 equals the prefix width push_length_prefixed writes.

Tests: pgs.rs None-arm duration assertions updated to the fallback + a
clamp/no-clamp pair; mkv.rs subtitle-BlockGroup and two-track (dense+sparse)
"every block has a BlockDuration" cases; hevc.rs tiling accept/reject,
byte-21 regression, and a positive parsed-AU-tiles-exactly test.
2026-09-20 19:33:25 -07:00
Matthew Jackson 827eaf0452 disc: gate composite bumper limb on a complete chapter table (#45)
A BD-J disc (Alita) has no authoritative static playlist chain, so main-
feature selection is a structural failsafe. Its chaptered feature 00800 is
a SUPERSET of an un-chaptered body subset 00703 that is 0.97 of its size,
so the size-only seamless bumper limb fired and demoted the real feature,
ripping the body instead.

Gate ONLY the bumper limb on is_complete_feature_presentation(q): the
wrapped/subset title must carry a real chapter table (>= MIN_FEATURE_CHAPTERS
marks spanning >= CHAPTER_SPAN_MIN_FRAC of its runtime). The concat limb
(much-shorter cut) is untouched. This is the ABSOLUTE-threshold predicate;
a one-line swap to the RELATIVE form (p.chapters.len() <= q.chapters.len())
is documented at the call site and in the predicate doc, pending a hoard
sweep of SM3's real chapter counts.

Also add lowest playlist_id as the final canonical_title_order tie-break so
equal-size seamless siblings (00800-00808) pick 00800 deterministically.

Fixtures re-based on the real chapter discriminator (not a size coincidence)
and new tests cover both topologies (Alita superset-feature, SM3 subset-
feature) plus the un-chaptered-subset keep-superset case.
2026-09-20 19:32:39 -07:00
Matthew Jackson f4ea6ea129 aacs/keysource: de-conflate "no entry" vs matched-but-underivable (#46)
The keydb lookup returned an empty Vec for two very different outcomes:
a true hash miss, and a MATCHED disc that derived no key (e.g. an MK-only
entry with no VID). Both rendered as `keydb > no entry`, misleading
reporters whose disc IS present in the keydb.

Add KeySource::resolve_unit_keys -> UnitKeyResolution { keys, matched,
miss_path, matched_entry, store_entries }; the trait default delegates to
get_unit_keys (matched=false) so every existing source is unchanged.
resolve_and_apply_traced now renders MatchedDisc + NoVid/NoDerivableKey
for a matched-but-no-key disc and reserves NoEntry for a genuine miss.

New KeyNode::NoDerivableKey, a MatchedEntry shape (booleans + lengths, no
key material), and KeyStep.{matched_entry, store_entries} carry the
diagnostics to the app-layer renderer. Tests cover both verdict paths.
2026-09-20 19:16:06 -07:00
Matthew Jackson b47bccce7b libfreemkv 1.7.4: codeaudit fixes (v1.7.0..HEAD sweep)
Bus content-range de-bus:
- F1 disc/mod.rs: always install content ranges (extracted wire_bus_removal);
  empty ranges => Some([]) => de-bus NOTHING (fail-safe), not None => de-bus
  everything — closes silent clear-sector corruption on the AACS cert route.
- F4 aacs/content.rs, F5 decrypt.rs: content gate is now a partition_point
  binary search (O(log n)/sector) over the sorted, merged-disjoint ranges,
  replacing the per-sector O(ranges) linear scan on the de-bus hot path.

- F2 scsi/linux.rs: RECOVERY_THREADS cap uses atomic reserve-then-check
  (fetch_add first, fall back on overshoot) — removes the TOCTOU that let
  concurrent multi-drive rips exceed MAX_RECOVERY_THREADS.
- F3 pes.rs: PesFrame::deserialize reads each chunk straight into data's tail;
  drops the scratch Vec + double-copy, keeps the incremental-growth DoS guard.
- F7 disc/mod.rs: six inline rank_titles thresholds -> named consts.

Tests: bus fail-safe + scan wiring (both routes), per-sector gate straddle,
read_directory type-3 continuation/AED-skip/cycle-guard, strengthened
without_bus_removal no-op guard. F6 (pub decrypt_bus) left by-design.
2026-09-19 16:19:48 -07:00
Matthew Jackson 13b93f803b libfreemkv: fix broken intra-doc links to BusStage::Passthrough in drive/mod.rs (doc-links gate) 2026-09-19 14:25:13 -07:00
Matthew Jackson 1894fa1a49 libfreemkv 1.7.4: single drive-owned AACS bus-removal point
Relocate AACS bus decryption to one spot at the bottom of the sector chain:
the Drive's SectorSource impl applies decrypt_bus_in_content (gated to
encrypted-content ranges), with the stage decided once from the unlock/
handshake result (BusStage::from_read_data_key). Remove read_data_key from
DecryptKeys::Aacs and AacsState and the two downstream decrypt_bus call sites;
every reader (key sampler, mux, unit-key validation) now sees already-de-bussed
content and never reasons about bus encryption. Add BusRemovalSectorSource
stream + tests; keep the AacsBusKeyUnavailable fail-loud gate.
2026-09-19 13:59:52 -07:00
Matthew Jackson 5696175520 restore freemkv-unlock path dep for local dev (post-v1.7.3) 2026-09-19 05:31:13 +00:00
58 changed files with 4717 additions and 1093 deletions
+74 -206
View File
@@ -2,44 +2,38 @@ name: CI
on:
push:
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
# to often: these are the FAST checks, so a mistake surfaces in minutes.
# `qa` is the release candidate — it runs these too, plus the expensive
# suite in qa.yml. `main` only ever moves at release time, to a tagged
# commit that was already green on qa.
# dev -> qa -> main. `dev` is where work lands and is pushed to often: these
# are the FAST checks. `qa` runs these plus the expensive suite in qa.yml.
# `main` only moves at release time, to a commit already green on qa.
branches: [main, dev, qa]
pull_request:
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ── Single source of truth for the MSRV ─────────────────────────────────────
#
# `rust-version` in Cargo.toml is the ONE place the MSRV is declared. This job
# reads it; every job that must build/test AT the MSRV consumes
# `needs.resolve-msrv.outputs.v` instead of a hardcoded pin, so the floor
# cannot drift out of sync across workflow files. The `test` job building
# green at this value IS the verification that the declared MSRV is real.
# clippy and the `consumers` job deliberately do NOT use it — they run on
# `stable` (lint debt is caught against current Rust; dependents float highest).
# Single source of truth for the MSRV: `rust-version` in Cargo.toml. Every job
# that builds AT the MSRV consumes needs.resolve-msrv.outputs.v so the floor
# cannot drift. clippy and `consumers` deliberately run on `stable`.
resolve-msrv:
runs-on: ubuntu-latest
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
# CI owns the changelog: main may never carry a released version without a
# CHANGELOG.md entry for it. The release orchestrator dates an existing
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
# forgot its changelog entry must fail here rather than ship a blank release.
# Hard-fails on main (and PRs targeting main); warns elsewhere so an
# in-progress dev/qa branch whose entry isn't written yet is not blocked.
# main may never carry a released version without a CHANGELOG.md entry. Hard-
# fails on main (and PRs to main); warns elsewhere so an in-progress branch
# whose entry isn't written yet is not blocked.
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- name: Require a CHANGELOG entry for the crate version on main
shell: bash
run: |
@@ -56,34 +50,18 @@ jobs:
fi
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
# fmt on the pinned MSRV; clippy on stable so new-lint debt is a blocking gate
# the pinned version would hide. --all-targets lints test code too.
lint:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
# swaps it to a git tag only inside the tagged commit, then restores the
# path dep). CI checks out one repo, so the branch tip has never been
# buildable here — every green run you have ever seen was a tag build,
# and Windows/Linux were first compiled at release time.
#
# Both repos go into subdirectories because actions/checkout refuses a
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
# With this layout the path dep resolves exactly as it does locally.
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
# fmt runs on the pinned MSRV toolchain; clippy runs on stable. Third
# parties compile our source on their own toolchain and never run our
# clippy, so the lint toolchain need not match the MSRV — only build/test
# must (see the test/cross jobs). Linting on stable makes new-lint debt a
# BLOCKING gate the pinned version would otherwise hide. The MSRV pin is
# installed second so it stays the default toolchain (fmt uses it
# implicitly); clippy is invoked explicitly with `+stable`.
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -96,13 +74,6 @@ jobs:
workspaces: libfreemkv
- run: cargo fmt --check
working-directory: libfreemkv
# Cargo.lock is now committed (issue #15) and the `deny` job audits the
# resolved graph, but --locked is still omitted: CI's sibling path-patch
# layout resolves the freemkv crates as path packages, which --locked
# can't satisfy — the same reason the binary crates omit it in ci.yml.
# --all-targets so TEST code is linted too. Without it this crate — the
# reference implementation for the other seven — was the only one whose
# tests had never been linted at all, and it was hiding 74 findings.
- run: cargo +stable clippy --all-targets -- -D warnings
working-directory: libfreemkv
@@ -110,23 +81,12 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
# swaps it to a git tag only inside the tagged commit, then restores the
# path dep). CI checks out one repo, so the branch tip has never been
# buildable here — every green run you have ever seen was a tag build,
# and Windows/Linux were first compiled at release time.
#
# Both repos go into subdirectories because actions/checkout refuses a
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
# With this layout the path dep resolves exactly as it does locally.
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -136,35 +96,24 @@ jobs:
- run: cargo test --tests
working-directory: libfreemkv
# The memory orderings in `scsi::fd_handoff` — the fd hand-off between a
# recovery thread and `SgIoTransport::drop` — cannot be checked by `cargo
# test`: they compile to the same instructions on x86_64, so the WRONG ones
# pass every run. loom enumerates the executions the C++20 model permits and
# reports the leaked descriptor directly, which makes it the only regression
# coverage that part of the module has. If this job stops running, that
# coverage disappears silently behind a green build.
#
# Its own job rather than a step in `test` because `RUSTFLAGS` is part of the
# rust-cache key: folding it into `test` costs that job a full cache miss on
# every run. It takes the same sibling checkout as the jobs above even though
# `--no-default-features` drops freemkv-unlock from the BUILD — `--features`
# does not exempt an optional path dependency from manifest RESOLUTION, so
# without the sibling this job would fail the moment the branch tip carries
# the path-dep form of that dependency rather than a git tag.
# The memory orderings in `scsi::fd_handoff` compile to the same instructions
# on x86_64, so the WRONG ones pass every run; loom enumerates the executions
# the C++20 model permits and is the only regression coverage that code has.
# Its own job because RUSTFLAGS is part of the rust-cache key. Takes the sibling
# checkout even with --no-default-features: features don't exempt an optional
# path dep from manifest RESOLUTION.
loom:
needs: resolve-msrv
runs-on: ubuntu-latest
env:
RUSTFLAGS: --cfg loom
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -174,7 +123,7 @@ jobs:
workspaces: libfreemkv
- run: cargo test --lib --no-default-features --features scsi fd_handoff
working-directory: libfreemkv
# `lint` runs clippy without `--cfg loom`, so this is the only place the
# `lint` runs clippy without --cfg loom, so this is the only place the
# model code is linted at all.
- run: cargo clippy --lib --no-default-features --features scsi --profile test -- -D warnings
working-directory: libfreemkv
@@ -182,63 +131,32 @@ jobs:
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
# swaps it to a git tag only inside the tagged commit, then restores the
# path dep). CI checks out one repo, so the branch tip has never been
# buildable here — every green run you have ever seen was a tag build,
# and Windows/Linux were first compiled at release time.
#
# Both repos go into subdirectories because actions/checkout refuses a
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
# With this layout the path dep resolves exactly as it does locally.
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check advisories bans licenses sources
manifest-path: libfreemkv/Cargo.toml
# dev is the FAST lane: the platform jobs run on qa/main only (qa.yml also
# covers macOS/Windows). `if` SKIPS (does not queue) so a never-scheduled job
# can't block the release gate.
check-macos:
# dev is the FAST lane: this job still runs, but on the release-candidate
# branches rather than on every push to dev. Nothing is deleted and no
# platform stops being checked before a release -- qa.yml independently
# covers macOS and Windows, and the jobs unique to this file (the Intel
# macOS build, the Windows release build) run here on qa and main. A push
# to dev is meant to be cheap and frequent; waiting on three runner pools
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
# swaps it to a git tag only inside the tagged commit, then restores the
# path dep). CI checks out one repo, so the branch tip has never been
# buildable here — every green run you have ever seen was a tag build,
# and Windows/Linux were first compiled at release time.
#
# Both repos go into subdirectories because actions/checkout refuses a
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
# With this layout the path dep resolves exactly as it does locally.
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -249,83 +167,40 @@ jobs:
working-directory: libfreemkv
check-windows:
# dev is the FAST lane: this job still runs, but on the release-candidate
# branches rather than on every push to dev. Nothing is deleted and no
# platform stops being checked before a release -- qa.yml independently
# covers macOS and Windows, and the jobs unique to this file (the Intel
# macOS build, the Windows release build) run here on qa and main. A push
# to dev is meant to be cheap and frequent; waiting on three runner pools
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
# swaps it to a git tag only inside the tagged commit, then restores the
# path dep). CI checks out one repo, so the branch tip has never been
# buildable here — every green run you have ever seen was a tag build,
# and Windows/Linux were first compiled at release time.
#
# Both repos go into subdirectories because actions/checkout refuses a
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
# With this layout the path dep resolves exactly as it does locally.
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: libfreemkv
# Build the tests (not just `cargo check`): catches errors in test
# code and forces full codegen of the Windows-only SPTI transport
# (src/scsi/windows.rs), which never compiles on the Linux/macOS dev
# hosts. We don't `cargo test` here — the suite needs no drive but the
# extra build is the value; running tests is covered by the Linux job.
# Build the tests: forces full codegen of the Windows-only SPTI transport
# (src/scsi/windows.rs), which never compiles on Linux/macOS dev hosts.
- run: cargo build --tests
working-directory: libfreemkv
# ── Did this change break anything downstream? ──────────────────────────────
#
# Every job above proves libfreemkv builds. None proved its DEPENDENTS do,
# and that gap is real: an engine signature change broke autorip today and
# went unnoticed because consumer CI only fires on a push to that consumer.
# libfreemkv sits below all five of them, so a break here is worth strictly
# more than a break anywhere else in the project.
#
# `cargo check --all-targets` only — each dependent owns its own behaviour
# and has its own suite. The question here is just "does everything built on
# me still compile against this commit".
# Did this change break anything downstream? libfreemkv sits below all five
# dependents; a break here is worth strictly more than anywhere else. Runs
# their suites (not just check) — a behavioural change keeps signatures but
# fails a dependent's tests, which is the shape worth catching. This caught an
# engine fixture break the moment it landed, so it stays on the dev fast lane.
consumers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with: { path: libfreemkv }
- uses: actions/checkout@v7
with: { repository: freemkv/freemkv-unlock, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-unlock }
- uses: actions/checkout@v7
with: { repository: freemkv/freemkv-keysources, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-keysources }
- uses: actions/checkout@v7
with: { repository: freemkv/freemkv-engine, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-engine }
- uses: actions/checkout@v7
with: { repository: freemkv/freemkv-i18n, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-i18n }
- uses: actions/checkout@v7
with: { repository: freemkv/freemkv, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv }
- uses: actions/checkout@v7
with: { repository: freemkv/autorip, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: autorip }
- uses: actions/checkout@v7
with: { repository: freemkv/bdemu, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: bdemu }
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: freemkv-unlock freemkv-keysources freemkv-engine freemkv-i18n freemkv autorip bdemu
- name: Point every dependent at THIS libfreemkv commit
shell: bash
run: |
@@ -339,10 +214,19 @@ jobs:
freemkv-i18n = { path = "../freemkv-i18n" }
EOF
done
# The dependents span a higher MSRV than libfreemkv itself — the binaries
# (freemkv/autorip/bdemu) always track the highest supported Rust. Build
# them on `stable` so this job satisfies the highest dependent floor; the
# library's own lower MSRV (verified by the `test` job) cannot compile them.
# Since freemkv 1.7.6 the `freemkv` consumer's Linux target compiles a
# GTK4 + libadwaita desktop shell, so `cargo test` on it needs the
# pkg-config dev headers the `gtk4-sys`/`glib-sys` build.rs scripts
# probe. Kept local (not in the shared reusable) — only freemkv links
# against GTK; adding the deps to every consumer would tax the six
# that don't.
- name: Install GTK4 + libadwaita development headers (for freemkv)
run: |
sudo apt-get update
sudo apt-get install -y libgtk-4-dev libadwaita-1-dev build-essential
# Dependents track the highest supported Rust — build on stable so this job
# satisfies the highest dependent floor (the library's own lower MSRV, which
# the `test` job verifies, cannot compile them).
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
@@ -352,10 +236,6 @@ jobs:
freemkv
autorip
bdemu
# `cargo check` alone only proves the dependents still COMPILE against
# this commit. It cannot see a behavioural change — the library keeps its
# signatures and a dependent's tests start failing. That is the shape of
# every defect worth catching here, so run their suites too.
- run: cargo test --tests
working-directory: freemkv-keysources
- run: cargo test --tests
@@ -367,29 +247,17 @@ jobs:
- run: cargo test --tests
working-directory: bdemu
# ── Coverage (non-required, additive) ───────────────────────────────────────
#
# Reports line coverage to Codecov. Deliberately NOT wired into any release
# gate: it is informational only (see codecov.yml) and no other job `needs:`
# it. Mirrors the `test` job's layout exactly — same MSRV toolchain (resolved
# from Cargo.toml), same dual checkout so `../freemkv-unlock` resolves, cache
# workspace and working-directory. cargo-llvm-cov comes as a prebuilt binary
# from taiki-e/install-action, so it is toolchain-independent.
#
# No `--locked`: like the fmt/clippy/test jobs above, the sibling path-patch
# layout resolves the freemkv crates as path packages, which --locked can't satisfy.
# Coverage: non-required, informational only (codecov.yml), no job needs: it.
coverage:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
+7 -12
View File
@@ -1,17 +1,12 @@
name: comment-guard
# Caps explanatory prose by COMMENT INTENT (not visibility):
# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text)
# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded)
# // / /* */ asides — 3 lines (single-blank-separated runs count as one)
# Overflow belongs in the module //! or docs/<topic>.md with a one-line // pointer.
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
comment-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run comment-guard
run: python3 ci/comment-guard.py
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-comment.yml@v1
+12 -27
View File
@@ -1,32 +1,17 @@
name: doc-links
# Fails on broken intra-doc links in `///`/`//!` doc comments (e.g. a `[Type]`
# link to a renamed/removed item). Catches doc drift that `cargo build` misses.
#
# libfreemkv path-deps ../freemkv-unlock on the branch tip, so — like ci.yml —
# both repos are checked out into sibling subdirectories for the path dep to
# resolve.
# Thin caller → the canonical reusable doc-links gate in freemkv/.github.
# libfreemkv path-deps freemkv-unlock, so the reusable checks it out as a sibling
# (no [patch] needed — the layout resolves the path dep) and resolves the
# toolchain from rust-version.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
doc-links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: libfreemkv
- name: cargo doc (deny broken intra-doc links)
working-directory: libfreemkv
env:
RUSTDOCFLAGS: "-D rustdoc::broken_intra_doc_links"
run: cargo doc --no-deps --document-private-items --lib --bins
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-doclinks.yml@v1
with:
self: libfreemkv
checkout: freemkv-unlock
+7 -34
View File
@@ -1,41 +1,14 @@
name: identity-guard
# Prevention: no commit may carry an unrelated-business or non-canonical author/
# committer email. dev@example.com (a dead corp address) once slipped onto the
# release cascade + a batch of local commits; this fails the build if it recurs.
# Thin caller → the canonical reusable guard in freemkv/.github.
on:
push:
branches: [dev, qa, main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Reject banned author/committer emails
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Unrelated business domains + a personal address normalized to the
# GitHub noreply. Real external contributors are unaffected.
BANNED='@example-a\.invalid|@example-b\.invalid|owner@example\.invalid'
if [ "$EVENT" = "pull_request" ]; then
range="$PR_BASE..$PR_HEAD"
elif [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
range="$BEFORE..$AFTER"
else
range="$AFTER~1..$AFTER"
fi
bad=$(git log --format='%an <%ae>%n%cn <%ce>' "$range" 2>/dev/null | sort -u | grep -iE "$BANNED" || true)
if [ -n "$bad" ]; then
echo "::error::commit(s) carry a banned author/committer identity:"; echo "$bad" | sed 's/^/ /'
echo "::error::Commit as your GitHub noreply (1085847+MattJackson@users.noreply.github.com). Fix: git config user.email <noreply>; git commit --amend --reset-author."
exit 1
fi
echo "identity-guard: clean"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
+6 -30
View File
@@ -1,35 +1,11 @@
name: leak-guard
# Self-contained public-repo leak gate. Public CI cannot reach the private
# tooling, so this encodes only the generic net: internal-infra references,
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
# No project-specific reverse-engineering vocabulary lives here.
# Thin caller → the canonical reusable guard in freemkv/.github.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
leak-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Compute commit range
id: range
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
else
before="${{ github.event.before }}"
after="${{ github.sha }}"
# New branch / first push: github.event.before is all-zeros.
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
echo "range=$after" >> "$GITHUB_OUTPUT"
else
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
fi
fi
- name: Run leak-guard
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
+18 -20
View File
@@ -15,14 +15,18 @@ name: qa
# one being released, which is the exact failure this branch exists to prevent.
#
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
# and no hosted runner has an optical drive or the image hoard. Those run on a
# self-hosted runner (see the media job at the end) and are the one leg that
# stays on hardware.
# and no hosted runner has an optical drive or the image hoard. That coverage
# lives in freemkv's qa.yml (the real-media EC2 matrix), not in this file.
on:
push:
branches: [qa]
workflow_dispatch:
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
# the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version;
@@ -32,7 +36,7 @@ jobs:
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -56,7 +60,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Stamp the next rc
@@ -85,14 +89,12 @@ jobs:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -108,14 +110,12 @@ jobs:
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
checkout: freemkv-unlock
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
# the MSRV pin. See ci.yml for why the lint toolchain need not be the MSRV.
- uses: dtolnay/rust-toolchain@stable
@@ -136,14 +136,12 @@ jobs:
needs: resolve-msrv
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: libfreemkv
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
checkout: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
# publish/release jobs do NOT `needs:` this job. The tag decision was already
# gated by the local precommit (same MSRV, same commit). Binary consumers
# gated by CI (ci.yml on dev, then a green qa.yml on the same commit). Binary consumers
# (freemkv/autorip/bdemu) git-tag-pin libfreemkv and therefore start building
# the instant this tag exists — so this test job and the crates.io publish
# below must NOT sit on their critical path.
+27
View File
@@ -0,0 +1,27 @@
name: Scorecard
on:
branch_protection_rule:
schedule:
- cron: '22 9 * * 2'
push:
branches: ["dev"]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: ossf/scorecard-action@v2.4.0
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
+23
View File
@@ -1,5 +1,28 @@
# Changelog
## [1.7.6] — UNRELEASED
### Changed
- Version aligned to 1.7.6 for the unified release. No functional changes to this crate; the release is driven by the freemkv 1.7.6 Linux desktop shell (GTK4 + libadwaita) and the rip-finished desktop notification (issue #56).
## [1.7.5] — 2026-09-23
### Changed
- Version aligned to 1.7.5 for the unified release. No functional changes to this crate; the release is driven by freemkv-unlock mirroring the freemkv-firmware 0.9.0 ABI (the drive's `Ake` and `Bus` levers retired into a single `Encryption` lever).
## [1.7.4] — 2026-09-21
### Fixed
- BD-J / Java-menu main-title selection reworked (#45): a chapter-completeness failsafe, a BD-J menu-walk (parses the BDJO objects and jar manifests), and a stream-richness tiebreak now cooperate so discs driven by a Java menu pick the real main feature — keeping the Dolby Vision enhancement layer and all subtitle tracks — instead of a lower-id sibling playlist.
- PGS subtitles now get a synthesized `BlockDuration` on every block, fixing ffmpeg's "Timestamps are unset" mux error, plus a defensive HEVC NAL length-prefix guard (#52).
- keydb "no entry" is de-conflated into a true miss versus matched-but-no-usable-VID, and the disc hash is surfaced; adds `KeyStep.matched_entry` and `KeyStep.store_entries` (#46).
### Maintenance
- CI moved to the central reusable workflows.
## [1.7.3] — 2026-09-19
### Fixed
Generated
+5 -5
View File
@@ -221,7 +221,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.1"
version = "1.7.6"
dependencies = [
"aes",
"base64",
@@ -317,7 +317,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.7.1"
version = "1.7.6"
dependencies = [
"aes",
"base64",
@@ -839,12 +839,12 @@ dependencies = [
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.1"
version = "1.7.6"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.1"
version = "1.7.6"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.1"
version = "1.7.6"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.7.3"
version = "1.7.6"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -59,7 +59,7 @@ aes = { version = "0.9", optional = true }
# 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.3", optional = true }
freemkv-unlock = { path = "../freemkv-unlock", 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
+28 -1
View File
@@ -3,6 +3,10 @@
[![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)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/freemkv/libfreemkv/badge)](https://scorecard.dev/viewer/?uri=github.com/freemkv/libfreemkv)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14745/badge)](https://www.bestpractices.dev/projects/14745)
[![REUSE status](https://api.reuse.software/badge/github.com/freemkv/libfreemkv)](https://api.reuse.software/info/github.com/freemkv/libfreemkv)
[![MSRV](https://img.shields.io/badge/MSRV-1.88-blue.svg)](#minimum-supported-rust-version)
# libfreemkv
@@ -14,7 +18,7 @@ DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require disc-specific
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)** · **[Changelog](CHANGELOG.md)**
Part of the [freemkv](https://github.com/freemkv) project.
@@ -157,6 +161,29 @@ All errors are structured with numeric codes. No user-facing English text — ap
Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to capture your drive's identity for contribution. Drive-unlock profiles are maintained in the [freemkv-unlock](https://github.com/freemkv/freemkv-unlock) repository.
## What this is NOT
To keep expectations honest:
- **No bundled AACS or CSS keys.** No AACS key material is compiled into the
crate. Blu-ray/UHD unit keys are supplied by the consumer at runtime (e.g. via
[freemkv-keysources](https://github.com/freemkv/freemkv-keysources)'s
`keydb.cfg` lookup); libfreemkv never reads `keydb.cfg` or downloads keys.
- **Not published to crates.io.** The crate is `publish = false`. It is consumed
by git tag or path only (see [Install](#install)) — there is no crates.io
version, docs.rs page, or download count.
- **No independent security audit.** No external or third-party security audit
has been performed. [SECURITY.md](SECURITY.md) is an assurance case authored by
the project, not an attestation by an outside auditor.
- **CSS is legacy, weak crypto.** DVD CSS is handled solely for format
interoperability with existing discs. It is a broken cipher by modern
standards and is not, and should not be treated as, a security mechanism.
## Minimum Supported Rust Version
The minimum supported Rust version (MSRV) is **1.88**, declared as
`rust-version` in [`Cargo.toml`](Cargo.toml) and enforced in CI on every change.
## License
MIT
+41
View File
@@ -0,0 +1,41 @@
version = 1
# REUSE (https://reuse.software) machine-readable licensing for the whole repo.
#
# Licensing is declared here in the manifest rather than as per-file inline
# headers: the project's comment-guard lint caps stacked `//` comment blocks, so
# a two-line SPDX header prepended above an existing top-of-file comment would
# trip it. REUSE explicitly supports manifest-based annotation, so every file —
# Rust sources, documentation, configuration, workflows, scripts, binary assets,
# and the byte-exact `.golden` test fixtures — is covered here instead.
#
# The whole repository is the project's own work under the MIT licence; there is
# no third-party vendored source here.
[[annotations]]
path = [
"**/*.rs",
"**/*.md",
"**/*.toml",
"**/*.lock",
"**/*.yml",
"**/*.yaml",
"**/*.json",
"**/*.sh",
"**/*.py",
"**/*.ps1",
"**/*.golden",
"**/*.svg",
"**/*.png",
"**/*.ico",
"**/*.plist",
"**/*.manifest",
"**/.gitignore",
".gitignore",
"Makefile",
"LICENSE",
"build.rs",
]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Matthew Jackson & Contributors"
SPDX-License-Identifier = "MIT"
-252
View File
@@ -1,252 +0,0 @@
#!/usr/bin/env python3
"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility.
Three tiers, keyed on the syntax the author reached for:
* `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per
file, at the top, documenting the module as a whole, so it can't be used to
smuggle per-item rationale the way item docs can. Long correctness / design
essays (e.g. a memory-ordering proof) legitimately live here.
* `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced
``` code blocks do NOT count, so real examples aren't punished. This holds
whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc
paragraph just like a `pub` one. Capping `///` here (rather than exempting
it, as the old guard did — issue #17) is what stops rationale being hidden
behind a doc sigil, while still leaving room for a genuine paragraph.
* `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the
shape that sprawls into walls, so it's kept tight: stanzas separated by a
single blank line count CUMULATIVELY, so one essay can't be split into runs.
Overflow belongs in the module `//!`, or in docs/<topic>.md with a one-line `//`
pointer — both show up in review.
Scans src/ and tests/ under the given root (default cwd). `--selftest` runs
built-in cases. Exit non-zero if any block exceeds its cap.
"""
import re
import sys
from pathlib import Path
INLINE_CAP = 3
DOC_CAP = 8
_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## …
def _strip_line_comment(s):
"""Return (kind, body) for a whole-line comment, else (None, None).
kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on
a code line is not a whole-line comment and is ignored (single-line, cheap).
"""
if s.startswith("//!"):
return "doc", s[3:]
if s.startswith("///") and not s.startswith("////"):
return "doc", s[3:]
if s.startswith("//"):
return "plain", s[2:]
return None, None
def _fence_toggle(body):
"""True if this doc body line opens/closes a ``` fenced code block."""
return body.strip().startswith("```")
def _classify(lines):
"""Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of
block comments, and code info. Block comments are tracked across lines.
"""
info = []
in_block = False
block_is_doc = False
raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#'
for raw in lines:
s = raw.lstrip()
rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False}
# Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the
# bytes are string data, not Rust comments, so the guard is blind to them
# until the string closes. Prevents `/* */` or `//` lines in an embedded
# page from being counted as comment blocks.
if raw_hashes is not None:
if ('"' + "#" * raw_hashes) in raw:
raw_hashes = None
info.append(rec)
continue
if in_block:
rec["comment"] = "doc" if block_is_doc else "plain"
rec["is_doc"] = block_is_doc
if "*/" in raw:
in_block = False
info.append(rec)
continue
# A block comment only starts a comment-line if it's the first token.
if s.startswith("/*"):
rec["is_doc"] = s.startswith("/**") or s.startswith("/*!")
rec["comment"] = "doc" if rec["is_doc"] else "plain"
if "*/" not in s[2:]:
in_block = True
block_is_doc = rec["is_doc"]
info.append(rec)
continue
kind, _ = _strip_line_comment(s)
if kind is not None:
rec["comment"] = kind
rec["is_doc"] = kind == "doc"
info.append(rec)
continue
# Code (or blank). Blank = empty after strip.
rec["code"] = s != ""
# A code line may OPEN a raw string that spans following lines. Detected
# only on code (comments were handled above), so `// see r"x"` is safe.
m = _RAW_OPEN.search(raw)
if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]:
raw_hashes = len(m.group(1))
info.append(rec)
return info
def violations(path):
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
info = _classify(lines)
n = len(info)
out = []
i = 0
while i < n:
rec = info[i]
if rec["comment"] is None:
i += 1
continue
start = i
if rec["is_doc"]:
# Doc run: contiguous doc lines. `//!` / `/*!` module docs are
# UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at
# DOC_CAP, regardless of the documented item's visibility.
j = i
prose = 0
in_fence = False
module_doc = False
while j < n and info[j]["comment"] == "doc":
s = info[j]["s"]
if s.startswith("//!") or s.startswith("/*!"):
module_doc = True
body = (
s[3:] if (s.startswith("///") or s.startswith("//!")) else s
)
if _fence_toggle(body):
in_fence = not in_fence
elif not in_fence and body.strip() != "":
prose += 1
j += 1
if not module_doc and prose > DOC_CAP:
out.append((start + 1, prose, DOC_CAP, "doc prose"))
i = j
continue
# Internal (plain) run, with single-blank bridging (cumulative).
j = i
count = 0
while j < n:
r = info[j]
if r["comment"] == "plain":
count += 1
j += 1
continue
if r["comment"] == "doc":
break
if r["comment"] is None and not r["code"]:
# blank: bridge only a SINGLE blank, else stop
if j + 1 < n and info[j + 1]["comment"] == "plain":
j += 1
continue
break
break # code
if count > INLINE_CAP:
out.append((start + 1, count, INLINE_CAP, "inline comment"))
i = j
return out
def main(argv):
if "--selftest" in argv:
return _selftest()
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
total = 0
for f in files:
for start, length, cap, kind in violations(f):
rel = f.relative_to(root)
print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})")
total += 1
if total:
print(f"\ncomment-guard: {total} comment block(s) exceed their cap.")
print(
f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at "
f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. "
"Trim, move the rationale into the module //!, or docs/<topic>.md "
"with a // pointer."
)
return 1
return 0
def _selftest():
import tempfile
cases = [
# (rust source, expected number of violations)
# short /// doc -> ok
("/// short doc\npub fn a() {}\n", 0),
# /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine
("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0),
# ...but /// is NOT unlimited: 9 prose lines on a private item fails
("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1),
# 8 prose /// on a pub item -> ok (boundary)
("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0),
# 9 prose /// on a pub item -> violation
("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1),
# /// with a long fenced example -> example excluded, ok
(
"/// summary\n/// # Examples\n/// ```\n"
+ "".join("/// code\n" for _ in range(12))
+ "/// ```\npub fn e() {}\n",
0,
),
# pub(crate) gets the SAME doc cap as pub (no pub/private split anymore)
("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0),
# bare inline 4 lines -> violation
("// a\n// b\n// c\n// d\nlet x = 1;\n", 1),
# blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation
("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1),
# plain /* */ block, 5 lines -> inline cap -> violation
("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1),
# comment-like lines INSIDE a raw string are string data, not comments
(
'const H: &str = r##"<style>\n'
+ "".join("/* css note */\n" for _ in range(8))
+ '</style>"##;\nfn h() {}\n',
0,
),
# //! module doc may run long -> EXEMPT (no cap)
("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0),
# /*! block module doc may run long -> EXEMPT
("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0),
]
ok = True
with tempfile.TemporaryDirectory() as d:
for idx, (src, want) in enumerate(cases):
p = Path(d) / f"case{idx}.rs"
p.write_text(src)
got = len(violations(p))
flag = "ok" if got == want else "FAIL"
if got != want:
ok = False
print(f" selftest {idx}: want {want} got {got} [{flag}]")
print("selftest PASSED" if ok else "selftest FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
-120
View File
@@ -1,120 +0,0 @@
#!/usr/bin/env bash
#
# leak-guard.sh — self-contained public-repo leak gate.
#
# This is the LAST line of defense in CI. It is intentionally self-contained:
# public CI cannot reach the private tooling, so this script encodes ONLY the
# generic net — internal infrastructure references, agent-context files, and
# AI-attribution in commit messages. It deliberately contains NO project-
# specific reverse-engineering vocabulary (those words would themselves be a
# leak). The richer private scanner stays private.
#
# Fails (exit 1) if any of the following appear in the repo:
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
# 2. tracked file content matching the internal-infra net,
# 3. a commit message (in the given range) with AI attribution.
#
# Usage:
# leak-guard.sh [<commit-range>]
# <commit-range> optional git rev-list range to scan commit messages
# (e.g. "abc..def"). If omitted, commit-message scan is
# skipped (path + content checks always run).
set -euo pipefail
# Absolute path to this script, resolved before any cd, so we can exclude it
# from the content scan (it necessarily contains the detection patterns).
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
REPO="$(git rev-parse --show-toplevel)"
cd "$REPO"
fail=0
note() { printf ' ✗ %s\n' "$1"; fail=1; }
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
# the patterns themselves must not name any org-specific identifier (doing so
# would itself leak the infra they guard). We catch the leak *class*:
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
# - docker.internal.
# The full org-specific net (literal hostnames, service names, repo paths,
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
# path). This names NO specific user — it matches the leak *class* (any home
# path), so the pattern itself reveals nothing org- or person-specific. A real
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
# this regardless of whose machine it came from. The username segment is a
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
echo "── leak-guard: tracked agent-context paths ──"
while IFS= read -r f; do
case "$f" in
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
esac
done < <(git ls-files)
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
# an argument (not interpolated into a //) so metacharacters like the "/" in a
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
# don't abort the scan.
pcre_matches() {
perl -e '
my ($file, $re) = @ARGV;
open(my $fh, "<:raw", $file) or exit 0;
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
' "$1" "$2" 2>/dev/null
}
# This script's own source necessarily contains the detection patterns (e.g.
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
echo "── leak-guard: internal-infra references in tracked files ──"
while IFS= read -r f; do
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
[ -f "$f" ] || continue
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "internal-infra reference: $f:$hit"
done < <(pcre_matches "$f" "$INFRA_RE")
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
done < <(pcre_matches "$f" "$HOMEPATH_RE")
done < <(git ls-files)
RANGE="${1:-}"
if [ -n "$RANGE" ]; then
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
while IFS= read -r sha; do
[ -z "$sha" ] && continue
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
# Pass the pattern as an argument (not interpolated into a //) so the
# lookbehind char class and "/" don't break the regex.
hit="$(printf '%s' "$msg" | perl -e '
my $re = $ARGV[0]; my $rx = qr/$re/i;
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
' "$ATTR_RE" | head -1 || true)"
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
done < <(git rev-list "$RANGE" 2>/dev/null || true)
fi
echo
if [ "$fail" -ne 0 ]; then
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
exit 1
fi
echo "✓ leak-guard: clean"
+30
View File
@@ -12,3 +12,33 @@ unlock phase, then the key-resolution phase).
carried verbatim so an app renderer never has to match an enum back to a
name it already has. Only the OUTCOME / path enums are structured states
the app maps to i18n English.
## `KeyStep` — a source's contribution to the key phase
Each source that ran contributes one `KeyStep`: `who` (its `label()`), `path`
(the `Vec<KeyNode>` it walked), and `outcome` (`KeyOutcome`). Two fields
de-conflate a keyless result — WHY a matched disc produced no usable key:
- `matched_entry: Option<MatchedEntry>` — the shape of the source's MATCHED
entry when it matched this disc; `None` for a true miss or a source kind
with no such shape. An app MAY log it verbatim and render a
matched-but-no-key verdict.
- `store_entries: Option<usize>` — per-disc entries loaded in the source's
store, when known, so a true-miss verdict can name the store size (`… not in
keydb (N entries loaded)`). `None` for a source that carries no such count.
## `MatchedEntry` — booleans-and-lengths, never material
A shape summary of a matched entry — safe to print at any log level, no key
MATERIAL: `has_vuk`, `has_unit_keys`, `unit_keys_len`, `has_media_key`,
`has_keydb_vid`, `enc_title_keys_len`, `vid_available`. It answers "the disc
WAS found — so why no key?" (e.g. a Media Key present but no VID on this path).
## `KeyNode::NoDerivableKey`
A derivation-path node distinct from `NoEntry`: the source MATCHED this disc
but could not derive any usable key from the matched entry (no derivation
material for the path taken). `NoEntry` = the disc was not found at all;
`NoDerivableKey` = found, but the key was not — the node that turns a flat
`no entry` verdict into `matched disc > … > NO KEY`. See docs/keysource.md
(`resolve_unit_keys`) for how a source populates it.
+8 -5
View File
@@ -114,13 +114,16 @@ reverted in 0.13.20 — see the note at the top of `linux.rs`.)
The macOS backend uses a C shim (`macos_shim.c`) for IOKit exclusive access.
The shim handles:
1. `shim_open_exclusive(bsd_name)` — unmounts the target device via `diskutil`,
then walks the IOKit registry to find the `IOBDServices` matching the
requested BSD name (IOBDServices → IOBDBlockStorageDriver → IOMedia → "BSD Name"),
then creates MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess.
1. `shim_open_exclusive(selector)` — for `/dev/diskN`, unmounts the target via
`diskutil` and resolves its IOBDServices via the media BSD name. For an
empty drive's `ioreg:<registry-id>` selector, resolves IOBDServices directly
and skips unmount/Disk Arbitration because there is no media node. Both paths
create MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess.
2. `shim_list_drives()` — registry-based enumeration with zero SCSI, zero exclusive
access, zero unmounts. Reads IOBDServices "Device Characteristics" for
vendor/model/firmware and child IOMedia "BSD Name" for the device path.
vendor/model/firmware. Uses child IOMedia "BSD Name" when media exists;
otherwise returns an opaque IOKit registry-ID selector so empty drives remain
discoverable and openable.
3. `shim_execute()` / `shim_close()` — raw CDB dispatch and cleanup.
On non-zero SCSI status, the transport parses sense key from the sense buffer
+18
View File
@@ -111,6 +111,24 @@ streaming decode self-healing. Re-sending an identical param set is benign
This strictly supersets the earlier change-only re-assert, so the
param-set-revert fix is unaffected.
## length-prefix self-check (issue #52)
`length_prefix_tiles` walks the assembled `frame_data` ([u32-BE len][body]
records) and confirms they EXACTLY tile it: every declared length fits, none is
zero, and the last body ends precisely at the buffer end with no trailing
bytes. `frame_data` is length-prefixed by construction (`push_length_prefixed`
/ `handle_param_set` write a 4-byte BE prefix — `LENGTH_PREFIX_SIZE`, which MUST
equal the hvcC `lengthSizeMinusOne + 1`), so a `false` means a framing desync a
downstream demuxer would report as "Invalid NAL unit size (N>M)".
`HevcParser::parse` runs this as defense in depth: if the self-check fails it
logs `src` at WARN and DROPS the access unit (`return Vec::new()`) rather than
emit a mis-framed frame. Because no real input can desync a
built-by-construction buffer, the drop branch is exercised end-to-end via a
test-only seam (`FORCE_FRAMING_DESYNC`) that appends a stray byte just before
the guard; see `parse_drops_desynced_access_unit`. Disconnecting the guard from
`parse` fails that test.
## `parse_mastering_display`
Parses a Mastering Display Colour Volume SEI payload (Rec. ITU-T H.265
+26
View File
@@ -99,6 +99,32 @@ on `disc_hash` and reads no samples; the online source submits the ctx's
content samples (a base batch for `get_unit_keys`, an index-1 anchor batch
for `get_fmts_indexes`) to the key service.
## `resolve_unit_keys` — de-conflating "no entry" from "matched, no key"
`get_unit_keys` returns a bare `Vec<UnitKey>`: an empty result cannot say
WHETHER the source had no entry for this disc at all, or matched the disc and
still derived nothing (the classic "matched but needs a VID this path can't
supply"). `resolve_unit_keys` carries that distinction. It returns a
`UnitKeyResolution`:
* `keys: Vec<UnitKey>` — the terminal Unit Keys produced (empty = none).
* `matched: bool` — the source matched this disc in its store (by hash / VID)
even if it then derived no key; `false` is a genuine miss.
* `miss_path: Vec<KeyNode>` — when `matched` and `keys` is empty, the nodes
walked AFTER the implicit `MatchedDisc` node (e.g. `[NoVid]` or
`[NoDerivableKey]`); empty means the caller supplies a bare
`NoDerivableKey`. Ignored unless `matched` and `keys` is empty.
* `matched_entry: Option<MatchedEntry>` — a booleans-and-lengths shape of the
matched entry (no key material) for the application to log.
* `store_entries: Option<usize>` — per-disc entries loaded in this source's
store, so a true-miss verdict can name the store size.
The trait's default is coarse: forward to `get_unit_keys` and report
`matched = false`. A source that keys on a per-disc identity (a keydb)
overrides it to set `matched` / `miss_path` / `matched_entry`, so the trace can
render `matched disc > no VID > NO KEY` instead of a flat `no entry`. See
docs/aacs-trace.md for the trace-node shapes.
## `resolve_and_apply_traced` — one-shot semantics and CPS-unit numbering
One-shot per source: each source's `KeySource::get_unit_keys` is called
+8 -5
View File
@@ -34,7 +34,11 @@ IOKit plugin chain failed" into the same.
## Drive enumeration and the media-presence probe
`list_drives` uses the IOKit registry directly via `shim_list_drives` —
no exclusive access, no SCSI commands, no unmounts.
no exclusive access, no SCSI commands, no unmounts. It enumerates
`IOBDServices` even when no `IOMedia` child exists. With media, the
returned path remains `/dev/diskN`; with an empty tray it uses the opaque
`ioreg:<registry-id>` selector so the service can still be opened directly.
That selector is an internal list-then-open handle, not a filesystem path.
`drive_has_disc` is documented (`crate::scsi::drive_has_disc`) as the
cheap, side-effect-free "is there a disc?" question, suitable for a
@@ -50,10 +54,9 @@ dropping) exclusive access each time.
Instead `drive_has_disc` answers via `shim_media_present`, the IOKit
registry only: steps 1-5 above are the *transport* open path, and this
probe must not run any of them. The registry answers the same question
with no side effect at all: the IOStorageFamily publishes an IOMedia
object for a removable device only while media is present and removes
it on eject, so a matching IOMedia is exactly "a disc is in the drive".
probe must not run any of them. For a BSD disk path, the registry looks
for its matching `IOMedia`; for an `ioreg:<id>` selector, it resolves the
drive service and checks whether that service currently has media.
No SCSI command is issued, which is why no timeout parameter is
involved.
+46
View File
@@ -280,6 +280,52 @@ pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
}
}
/// Remove bus encryption across a sector-aligned `buf`, gated to the disc's
/// encrypted-content ranges. `base_lba` is the LBA of `buf`'s first 2048-byte
/// sector (sector `i` = `base_lba + i`). A sector is de-bussed only when `ranges`
/// is `None` (content-only caller) or its LBA falls inside a sorted
/// `(start_lba, sector_count)` range — a clear UDF/nav sector outside content is
/// left untouched (de-bussing it would corrupt plaintext). Key schedule expanded
/// once. Single de-bus entry point (see the `sector::bus_removal` module doc).
pub(crate) fn decrypt_bus_in_content(
buf: &mut [u8],
read_data_key: &[u8; 16],
base_lba: u32,
ranges: Option<&[(u32, u32)]>,
) {
debug_assert!(
ranges.is_none_or(|rs| rs.windows(2).all(|w| w[0].0 <= w[1].0)),
"content ranges must be sorted ascending by start LBA for the binary search"
);
let in_content = |lba: u32| -> bool {
match ranges {
None => true,
// `rs` is sorted ascending by start and non-overlapping (merged
// extents), so binary-search the last range with start <= lba and
// test containment — O(log n) per sector, not O(ranges).
Some(rs) => {
let i = rs.partition_point(|&(start, _)| start <= lba);
i > 0 && {
let (start, cnt) = rs[i - 1];
(lba as u64) < start as u64 + cnt as u64
}
}
}
};
let cipher = crate::aacs::crypto::new_cipher_for(read_data_key);
for (i, sector) in buf.chunks_mut(SECTOR_BYTES).enumerate() {
if sector.len() < SECTOR_BYTES {
break; // trailing partial sector (never on a whole-sector read)
}
let lba = base_lba.saturating_add(i as u32);
if !in_content(lba) {
continue;
}
// First 16 bytes of each sector are plaintext on the wire.
crate::aacs::crypto::cbc_decrypt_blocks(&cipher, &mut sector[16..SECTOR_BYTES]);
}
}
/// 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
+41
View File
@@ -61,6 +61,41 @@ pub struct KeyStep {
pub who: String,
pub path: Vec<KeyNode>,
pub outcome: KeyOutcome,
/// Shape of the source's MATCHED entry, when it matched this disc — a
/// booleans-and-lengths summary (no key material) that de-conflates WHY a
/// matched disc produced no key. `None` when the source did not match (a
/// true miss) or the source kind carries no such shape. An application MAY
/// log it verbatim and render it into a matched-but-no-key verdict.
pub matched_entry: Option<MatchedEntry>,
/// Number of per-disc entries loaded in the source's store, when known — so
/// a true-miss verdict can name the store size (`… not in keydb (N entries
/// loaded)`) and a reporter can confirm a wrong-pressing at a glance.
/// `None` for a source that carries no such count. No secret.
pub store_entries: Option<usize>,
}
/// A shape summary of a key source's MATCHED entry — booleans and lengths only,
/// never key MATERIAL — so an application can log WHY a disc that WAS found in
/// the store still produced no usable key (the classic "matched but needs a VID
/// this path can't supply"). Every field is safe to print at any log level.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct MatchedEntry {
/// The entry carries a Volume Unique Key (title keys can boil directly).
pub has_vuk: bool,
/// The entry carries one or more pre-decrypted terminal Unit Keys.
pub has_unit_keys: bool,
/// Count of pre-decrypted terminal Unit Keys the entry carries.
pub unit_keys_len: usize,
/// The entry carries a Media Key (a VID is still needed to reach the VUK).
pub has_media_key: bool,
/// The entry carries its own Volume ID (the keydb `I` token).
pub has_keydb_vid: bool,
/// Count of the disc's encrypted title keys available to boil (from
/// `Unit_Key_RO.inf`); `0` when none were captured.
pub enc_title_keys_len: usize,
/// A Volume ID is available on this resolve path (from the drive handshake
/// or the entry) — the gate the `MK → VUK` step needs.
pub vid_available: bool,
}
/// A node on the derivation path a source walked. Ordered as encountered; not
@@ -71,6 +106,10 @@ pub enum KeyNode {
MatchedDisc,
/// The source had no entry for this disc.
NoEntry,
/// The source matched this disc but could not derive any usable key from the
/// matched entry (e.g. no derivation material for the path taken). Distinct
/// from [`NoEntry`](Self::NoEntry): the disc WAS found, the key was not.
NoDerivableKey,
/// Pre-decrypted unit keys were found.
FoundUnitKeys,
/// A VUK was found.
@@ -123,6 +162,8 @@ mod tests {
KeyNode::DerivedUnitKeys,
],
outcome: KeyOutcome::Resolved,
matched_entry: None,
store_entries: None,
}],
};
// Clone + PartialEq (derive contract the renderers rely on).
+431
View File
@@ -0,0 +1,431 @@
//! Minimal, read-only parser for `/BDMV/BDJO/*.bdjo` — the BD-J Object files.
//!
//! Only the Application Management Table (AMT) is decoded, and only the four
//! fields the issue #45 Tier-2 menu-walk needs from each application record:
//! its `application_control_code` (1 = AUTOSTART), `base_directory`,
//! `classpath_extension`, and `initial_class` (the Xlet's fully-qualified class
//! name). Everything else in the file is skipped by width.
//!
//! Layout follows the published BD-J Object (BDJO) file format: a fixed 48-byte
//! header (8-byte magic+version, then a 40-byte section-address table that real
//! players skip), followed by the terminal-info, app-cache-info,
//! accessible-playlists, and application-management-table sections in sequence.
//!
//! This is a DOCUMENTED BINARY FORMAT read as data, never executed. Every field
//! is bounds-checked and any malformed input yields `None` — panic-free like
//! [`crate::bdnav::index`]. Tier-2 falls back to scanning every jar when this
//! returns `None`.
/// One application record from a BDJO Application Management Table — only the
/// fields the menu-walk consumes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BdjoApp {
/// `application_control_code`: 1 = AUTOSTART, 2 = PRESENT.
pub control_code: u8,
/// `base_directory` — the primary jar id (e.g. "00000"), naming
/// `/BDMV/JAR/<base_directory>.jar`.
pub base_dir: String,
/// `classpath_extension` — additional `;`-separated jar ids on the
/// classpath, or empty.
pub classpath_extension: String,
/// `initial_class` — the autostart Xlet's fully-qualified class name
/// (e.g. "com.foxbd.StandardMenuXlet").
pub initial_class: String,
}
impl BdjoApp {
/// Whether this application autostarts (the one whose Xlet drives the disc).
pub fn is_autostart(&self) -> bool {
self.control_code == AUTOSTART
}
/// The jar ids that make up this app's classpath: `base_directory` first,
/// then each `;`-separated `classpath_extension` entry. Empty tokens are
/// dropped. These name `/BDMV/JAR/<id>.jar`.
pub fn jar_ids(&self) -> Vec<String> {
let mut out = Vec::new();
let base = self.base_dir.trim();
if !base.is_empty() {
out.push(base.to_string());
}
for part in self.classpath_extension.split(';') {
let part = part.trim();
if !part.is_empty() && !out.iter().any(|e| e == part) {
out.push(part.to_string());
}
}
out
}
}
const AUTOSTART: u8 = 1;
// "BDJO" magic. The 4-byte version that follows ("0100"/"0200"/"0240"/"0300")
// is read but not validated — a future revision must still let Tier-2 fall back
// gracefully rather than mis-reject a readable table.
const MAGIC: u32 = u32::from_be_bytes(*b"BDJO");
// Cap on applications parsed from one AMT. `number_of_applications` is a u8, so
// 255 is the hard ceiling already; the cap is a belt-and-suspenders bound.
const MAX_APPS: usize = 256;
/// Parse a `.bdjo` file's Application Management Table. Returns the list of
/// application records (in file order), or `None` on any structural problem.
pub(crate) fn parse(data: &[u8]) -> Option<Vec<BdjoApp>> {
let mut r = BitReader::new(data);
// ── Header ──────────────────────────────────────────────────────────────
if r.read(32)? as u32 != MAGIC {
return None;
}
r.skip(32)?; // version_number (not load-bearing for resolution)
r.skip(40 * 8)?; // section-address table (players read sections sequentially)
// ── TerminalInfo ────────────────────────────────────────────────────────
r.skip(32)?; // length
r.skip(5 * 8)?; // default_font (5 bytes)
r.skip(4 + 1 + 1)?; // initial_havi_config_id + menu_call_mask + title_search_mask
r.skip(34)?; // padding
// ── AppCacheInfo ──────────────────────────────────────────────────────────
r.skip(32)?; // length
let num_item = r.read(8)? as usize;
r.skip(8)?; // padding
for _ in 0..num_item {
r.skip(12 * 8)?; // each item: type(1) + ref_to_name(5) + lang_code(3) + pad(3)
}
// ── AccessiblePlaylists ───────────────────────────────────────────────────
r.skip(32)?; // length
let num_pl = r.read(11)? as usize;
r.skip(1 + 1)?; // access_to_all_flag + autostart_first_playlist_flag
r.skip(19)?; // padding
for _ in 0..num_pl {
r.skip(6 * 8)?; // each: name(5) + pad(1)
}
// ── ApplicationManagementTable ────────────────────────────────────────────
r.skip(32)?; // length
let num_app = r.read(8)? as usize;
r.skip(8)?; // padding
if num_app > MAX_APPS {
return None;
}
let mut apps = Vec::with_capacity(num_app);
for _ in 0..num_app {
apps.push(parse_app(&mut r)?);
}
Some(apps)
}
// Parse one Application() record from the Application Management Table.
fn parse_app(r: &mut BitReader<'_>) -> Option<BdjoApp> {
let control_code = r.read(8)? as u8;
r.skip(4)?; // application_type
r.skip(4)?; // reserved
r.skip(32)?; // organization_id
r.skip(16)?; // application_id
r.skip(80)?; // descriptor tag + length (10 bytes)
// application descriptor
let num_profile = r.read(4)? as usize;
r.skip(12)?; // padding
for _ in 0..num_profile {
r.skip(6 * 8)?; // profile_number(2) + major/minor/micro(3) + pad(1)
}
r.skip(8)?; // application_priority
r.skip(2 + 2 + 4)?; // binding + visibility + reserved
// application_name section: u16 data_length, then that many bytes, word-aligned.
let names_len = r.read(16)? as usize;
r.skip(names_len * 8)?;
if !names_len.is_multiple_of(2) {
r.skip(8)?;
}
// icon_locator (word-aligned string), then icon_flags(16).
read_app_string(r)?;
r.skip(16)?;
let base_dir = read_app_string(r)?;
let classpath_extension = read_app_string(r)?;
let initial_class = read_app_string(r)?;
// application_parameters: u8 data_length, that many bytes, word-aligned.
let params_len = r.read(8)? as usize;
r.skip(params_len * 8)?;
if params_len.is_multiple_of(2) {
r.skip(8)?;
}
Some(BdjoApp {
control_code,
base_dir,
classpath_extension,
initial_class,
})
}
// A word-aligned length-prefixed string (`_read_app_string`): u8 length, then
// `length` bytes, then one pad byte when `length` is EVEN. Bytes are Latin-1
// decoded (FQCNs/jar ids are ASCII); trailing NULs are trimmed.
fn read_app_string(r: &mut BitReader<'_>) -> Option<String> {
let len = r.read(8)? as usize;
let mut s = String::with_capacity(len);
for _ in 0..len {
s.push(r.read(8)? as u8 as char);
}
if len.is_multiple_of(2) {
r.skip(8)?;
}
Some(s.trim_end_matches('\0').to_string())
}
// ── MSB-first bit reader ─────────────────────────────────────────────────────
// BDJO integers are bit-packed and consumed most-significant-first.
// Every read/skip is bounds-checked and returns `None` past the end.
struct BitReader<'a> {
data: &'a [u8],
bit_pos: usize,
}
impl<'a> BitReader<'a> {
fn new(data: &'a [u8]) -> Self {
BitReader { data, bit_pos: 0 }
}
fn total_bits(&self) -> usize {
self.data.len() * 8
}
// Read up to 64 bits, MSB-first. `None` if fewer than `n` bits remain.
fn read(&mut self, n: u32) -> Option<u64> {
if n == 0 {
return Some(0);
}
if n > 64 {
return None;
}
let end = self.bit_pos.checked_add(n as usize)?;
if end > self.total_bits() {
return None;
}
let mut v: u64 = 0;
for _ in 0..n {
let byte = self.data[self.bit_pos >> 3];
let bit = (byte >> (7 - (self.bit_pos & 7))) & 1;
v = (v << 1) | bit as u64;
self.bit_pos += 1;
}
Some(v)
}
// Advance `n` bits. `None` if that would run past the end.
fn skip(&mut self, n: usize) -> Option<()> {
let end = self.bit_pos.checked_add(n)?;
if end > self.total_bits() {
return None;
}
self.bit_pos = end;
Some(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── Bit reader unit tests ──────────────────────────────────────────────
#[test]
fn bit_reader_reads_msb_first_and_bounds_checks() {
let data = [0b1011_0010u8, 0b0100_0001];
let mut r = BitReader::new(&data);
assert_eq!(r.read(1), Some(1));
assert_eq!(r.read(3), Some(0b011));
assert_eq!(r.read(4), Some(0b0010));
assert_eq!(r.read(8), Some(0b0100_0001));
// Nothing left.
assert_eq!(r.read(1), None);
assert_eq!(r.read(0), Some(0));
}
#[test]
fn bit_reader_skip_past_end_is_none() {
let data = [0u8; 2];
let mut r = BitReader::new(&data);
assert_eq!(r.skip(16), Some(()));
assert_eq!(r.skip(1), None);
}
// ── Fixture builder ─────────────────────────────────────────────────────
// Byte-aligned BDJO builder for a single-app AMT. All bit fields here happen
// to fall on byte boundaries, so the fixture can be assembled from bytes.
struct AppSpec {
control_code: u8,
base_dir: &'static str,
classpath_extension: &'static str,
initial_class: &'static str,
}
fn push_app_string(buf: &mut Vec<u8>, s: &str) {
buf.push(s.len() as u8);
buf.extend_from_slice(s.as_bytes());
if s.len().is_multiple_of(2) {
buf.push(0); // word-align pad on even length
}
}
fn build_bdjo(apps: &[AppSpec]) -> Vec<u8> {
let mut b = Vec::new();
// Header
b.extend_from_slice(b"BDJO");
b.extend_from_slice(b"0200");
b.extend_from_slice(&[0u8; 40]); // section-address table
// TerminalInfo: length(4) + default_font(5) + 1 byte (havi+masks) + pad(34 bits)
b.extend_from_slice(&[0u8; 4]);
b.extend_from_slice(&[0u8; 5]);
// 4+1+1 = 6 bits then 34 bits padding = 40 bits = 5 bytes total.
b.extend_from_slice(&[0u8; 5]);
// AppCacheInfo: length(4) + num_item(1)=0 + pad(1)
b.extend_from_slice(&[0u8; 4]);
b.push(0);
b.push(0);
// AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)] = 4 bytes, num_pl=0
b.extend_from_slice(&[0u8; 4]);
b.extend_from_slice(&[0u8; 4]);
// AppManagementTable: length(4) + num_app(1) + pad(1)
b.extend_from_slice(&[0u8; 4]);
b.push(apps.len() as u8);
b.push(0);
for a in apps {
b.push(a.control_code); // control_code(8)
b.push(0); // type(4)+reserved(4)
b.extend_from_slice(&[0u8; 4]); // org_id(32)
b.extend_from_slice(&[0u8; 2]); // app_id(16)
b.extend_from_slice(&[0u8; 10]); // descriptor tag+length(80)
b.push(0); // num_profile(4)+pad(4)... wait 4+12=16 bits
b.push(0); // (num_profile nibble high, then 12 bits pad) => 2 bytes, 0 profiles
b.push(0); // priority(8)
b.push(0); // binding(2)+visibility(2)+reserved(4)
// application_name: data_length(16)=0, no word-align (even 0)
b.extend_from_slice(&[0u8; 2]);
// icon_locator (empty word-aligned string): len=0 + pad
push_app_string(&mut b, "");
b.extend_from_slice(&[0u8; 2]); // icon_flags(16)
push_app_string(&mut b, a.base_dir);
push_app_string(&mut b, a.classpath_extension);
push_app_string(&mut b, a.initial_class);
// application_parameters: data_length(8)=0 + word-align (even) pad
b.push(0);
b.push(0);
}
b
}
#[test]
fn parses_autostart_app_fqcn_and_jar_ids() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "00001;00002",
initial_class: "com.foxbd.StandardMenuXlet",
}]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps.len(), 1);
let a = &apps[0];
assert!(a.is_autostart());
assert_eq!(a.initial_class, "com.foxbd.StandardMenuXlet");
assert_eq!(a.base_dir, "00000");
assert_eq!(a.jar_ids(), vec!["00000", "00001", "00002"]);
}
#[test]
fn parses_multiple_apps_and_finds_the_autostart_one() {
let bytes = build_bdjo(&[
AppSpec {
control_code: 2, // PRESENT, not autostart
base_dir: "00009",
classpath_extension: "",
initial_class: "com.studio.Helper",
},
AppSpec {
control_code: 1, // AUTOSTART
base_dir: "00000",
classpath_extension: "",
initial_class: "com.studio.MainXlet",
},
]);
let apps = parse(&bytes).expect("parses");
assert_eq!(apps.len(), 2);
let auto: Vec<&BdjoApp> = apps.iter().filter(|a| a.is_autostart()).collect();
assert_eq!(auto.len(), 1);
assert_eq!(auto[0].initial_class, "com.studio.MainXlet");
assert_eq!(auto[0].jar_ids(), vec!["00000"]);
}
#[test]
fn rejects_bad_magic() {
let mut bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "",
initial_class: "X",
}]);
bytes[0..4].copy_from_slice(b"NOPE");
assert!(parse(&bytes).is_none());
}
#[test]
fn truncation_yields_none_never_panics() {
let bytes = build_bdjo(&[AppSpec {
control_code: 1,
base_dir: "00000",
classpath_extension: "",
initial_class: "com.studio.MainXlet",
}]);
// Every truncation length must return None (or Some), never panic.
for cut in 0..bytes.len() {
let _ = parse(&bytes[..cut]);
}
}
#[test]
fn parse_never_panics_on_random_bytes() {
let mut state: u64 = 0xB0D0_DEAD_BEEF_1234u64;
for _ in 0..500 {
// xorshift64*
let mut x = state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
state = x;
let len = (x % 300) as usize;
let mut buf = vec![0u8; len];
for (i, b) in buf.iter_mut().enumerate() {
*b = ((x >> (i % 57)) & 0xFF) as u8;
}
// Give some of them a valid magic to exercise deeper paths.
if len >= 4 && x & 1 == 0 {
buf[0..4].copy_from_slice(b"BDJO");
}
let _ = parse(&buf);
}
}
#[test]
fn jar_ids_dedups_and_skips_empties() {
let a = BdjoApp {
control_code: 1,
base_dir: "00000".into(),
classpath_extension: "00000;;00003; ".into(),
initial_class: "X".into(),
};
assert_eq!(a.jar_ids(), vec!["00000", "00003"]);
}
}
+1
View File
@@ -7,6 +7,7 @@
//!
//! Contract: read-only, bounded, and never panics or hard-fails.
pub(crate) mod bdjo;
pub(crate) mod index;
pub(crate) mod mobj;
pub(crate) mod vm;
-1
View File
@@ -1308,7 +1308,6 @@ mod tests {
}];
let mut keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::MpegPs,
};
resolve_dvd_title_key(
+101 -59
View File
@@ -116,14 +116,15 @@ pub fn decrypt_threads() -> usize {
pub enum DecryptKeys {
/// No encryption on this disc.
None,
/// AACS (Blu-ray / UHD / HD-DVD). Unit keys + optional read data key. The
/// `format` is the disc's content container (BD/UHD/FMTS = Transport Stream,
/// HD-DVD `.evo` = Program Stream); it travels with the keys because both are
/// resolved once per disc, and the key SELECTOR (`is_clean`) needs it to prove
/// a key structurally against the right container.
/// AACS (Blu-ray / UHD / HD-DVD). Unit keys only — bus encryption is ALREADY
/// removed upstream by the drive's single de-bus point
/// ([`crate::sector::bus_removal::BusStage`]), so no read data key is threaded
/// here. The `format` is the disc's content container (BD/UHD/FMTS = Transport
/// Stream, HD-DVD `.evo` = Program Stream); it travels with the keys because
/// both are resolved once per disc, and the key SELECTOR (`is_clean`) needs it
/// to prove a key structurally against the right container.
Aacs {
unit_keys: Vec<(u32, [u8; 16])>,
read_data_key: Option<[u8; 16]>,
format: crate::disc::ContentFormat,
},
/// CSS (DVD). Title key for sector descrambling.
@@ -366,9 +367,17 @@ pub(crate) fn decrypt_sectors_mapped_in_content(
/// 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)
debug_assert!(
ranges.windows(2).all(|w| w[0].0 <= w[1].0),
"content ranges must be sorted ascending by start LBA for the binary search"
);
// Sorted, non-overlapping (merged extents): binary-search the last range
// whose start <= lba and test containment — O(log n), not O(ranges).
let i = ranges.partition_point(|&(start, _)| start <= lba);
i > 0 && {
let (start, count) = ranges[i - 1];
(lba - start) < count
}
}
// AACS scheme step: apply `map`'s per-unit keys to `buf`, in-place — no key
@@ -381,12 +390,8 @@ fn apply_aacs_map(
map: &AacsKeyMap,
content: Option<&[(u32, u32)]>,
) -> Result<(), crate::error::Error> {
let (unit_keys, rdk, format) = match keys {
DecryptKeys::Aacs {
unit_keys,
read_data_key,
format,
} => (unit_keys, *read_data_key, *format),
let (unit_keys, format) = match keys {
DecryptKeys::Aacs { unit_keys, format } => (unit_keys, *format),
// Clear / CSS: the mapped path is AACS-only. Leave the buffer untouched;
// CSS descrambles via `decrypt_sectors` and `None` is already clear.
_ => return Ok(()),
@@ -451,11 +456,10 @@ fn apply_aacs_map(
if !aacs::content::aacs_unit_encrypted(chunk, format) {
return;
}
// Bounds already proven above; index directly.
// Bounds already proven above; index directly. Bus encryption was already
// removed by the drive's single de-bus point before this buffer arrived —
// this path only applies the CPS unit key.
let key = &unit_keys[key_idx].1;
if let Some(ref rdk_key) = rdk {
aacs::content::decrypt_bus(chunk, rdk_key);
}
aacs::content::decrypt_unit(chunk, key);
// Correct-phase forensic verify (silent unless the map is wrong).
if matches!(phase, Phase::Even | Phase::Odd) && !aacs::content::is_clean(chunk, format) {
@@ -711,7 +715,6 @@ mod tests {
fn content_gate_aacs_keys_fail_loud_not_ok_zero() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(1, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -801,7 +804,6 @@ mod tests {
fn aacs_scrambled_trailing_partial_is_rejected() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One CLEAR leading unit (passes through) + a 4096-byte (two-sector) tail
@@ -828,7 +830,6 @@ mod tests {
fn aacs_clear_trailing_partial_passes_through() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -866,7 +867,6 @@ mod tests {
assert!(
DecryptKeys::Aacs {
unit_keys: vec![(0, [0; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}
.is_encrypted()
@@ -1042,7 +1042,6 @@ mod tests {
fn aacs_mapped_out_of_range_key_idx_errors() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -1062,7 +1061,6 @@ mod tests {
fn aacs_mapped_empty_unit_keys_errors() {
let keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -1079,7 +1077,6 @@ mod tests {
fn aacs_via_unmapped_decrypt_sectors_fails_loud() {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -1114,63 +1111,117 @@ 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.
// A `SectorSource` that returns a fixed wire buffer for any read (the bytes a
// bus-encrypted drive would put on the transport), reporting the full span —
// the input to the drive-owned bus-removal stream in the ordering tests below.
struct WireSource {
bytes: Vec<u8>,
}
impl crate::sector::SectorSource for WireSource {
fn capacity_sectors(&self) -> u32 {
(self.bytes.len() / 2048) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let n = (count as usize * 2048).min(self.bytes.len());
buf[..n].copy_from_slice(&self.bytes[..n]);
Ok(n)
}
}
// AACS 2.0 bus-then-unit ordering, proven at the SINGLE de-bus point: the
// drive-owned bus-removal stream strips the bus layer AT READ TIME, then the
// downstream mapped decrypt strips the unit-key layer (no bus key threaded).
#[test]
fn mapped_decrypt_removes_bus_then_unit_encryption_end_to_end() {
fn bus_removal_then_mapped_unit_decrypt_recovers_plaintext_end_to_end() {
use crate::sector::SectorSource;
use crate::sector::bus_removal::{BusRemovalSectorSource, BusStage};
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);
// Inner layer: unit-key encryption. Outer layer: drive bus-encryption —
// exactly the bytes a bus-encrypted drive puts on the wire.
let mut wire = clear.clone();
aacs_encrypt_unit_for_test(&mut wire, &unit_key);
aacs::content::encrypt_bus(&mut wire, &rdk);
// Read the wire through the drive-owned bus-removal stream (host-key
// stage): the ONE place bus decryption is applied.
let mut src =
BusRemovalSectorSource::new(WireSource { bytes: wire }, BusStage::AacsHostKey(rdk));
let mut buf = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
let n = src.read_sectors(0, 3, &mut buf, false).unwrap();
assert_eq!(n, aacs::content::ALIGNED_UNIT_LEN);
// Bus removed, but the CPS unit-key layer is still in place — not yet clear.
assert!(
!aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"the delivered unit must still be scrambled before decrypt"
"bus removed at read, but the CPS unit-key layer must still be scrambled"
);
// The downstream mapped decrypt applies ONLY the unit key over the
// already-de-bussed content.
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");
.expect("unit-key decrypt over de-bussed content must succeed");
assert_eq!(
buf, clear,
"read_data_key (bus) then unit-key must recover the plaintext exactly"
"bus removal (at read) then unit-key decrypt 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.
// Necessity: the SAME wire bytes read through a PASSTHROUGH stage keep the bus
// layer, so unit-key decrypt alone runs over bus-encrypted data and cannot
// recover clear — the bus key is genuinely required, and de-bus precedes it.
#[test]
fn mapped_decrypt_without_read_data_key_cannot_clear_bus_encrypted_content() {
fn without_bus_removal_mapped_unit_decrypt_cannot_clear_bus_encrypted_content() {
use crate::sector::SectorSource;
use crate::sector::bus_removal::{BusRemovalSectorSource, BusStage};
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 mut wire = clear.clone();
aacs_encrypt_unit_for_test(&mut wire, &unit_key);
aacs::content::encrypt_bus(&mut wire, &rdk);
let keys_no_rdk = DecryptKeys::Aacs {
// Passthrough: no bus key, so the wire is delivered still-bus-encrypted.
let mut src =
BusRemovalSectorSource::new(WireSource { bytes: wire }, BusStage::Passthrough);
let mut buf = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
src.read_sectors(0, 3, &mut buf, false).unwrap();
// Snapshot the still-bus-encrypted wire as delivered, so we can prove the
// unit-key decrypt actually TOUCHED the buffer (not a silent no-op) — a
// bare `!= clear` also passes on an untouched buffer.
let wire_delivered = buf.clone();
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)]);
// 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);
// way; the point is the BYTES are not the plaintext without bus removal.
let _ = decrypt_sectors_mapped(&mut buf, &keys, 0, &map);
assert_ne!(
buf, clear,
"without the read_data_key, unit-key decrypt alone cannot recover clear"
"without bus removal first, unit-key decrypt alone cannot recover clear"
);
assert_ne!(
buf, wire_delivered,
"unit-key decrypt must have actually run over the buffer (not a no-op); \
it just can't recover clear without bus removal first"
);
}
@@ -1390,7 +1441,6 @@ mod tests {
// AACS, encrypted, no map installed at all.
let mut aacs_keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAAu8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut buf = vec![0u8; ul];
@@ -1440,7 +1490,6 @@ mod tests {
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: ContentFormat::BdTs,
};
// The map covers unit 0 only. Unit 1 is the orphan.
@@ -1500,7 +1549,6 @@ mod tests {
}
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges_phased(vec![(0, 8 * usz, 0, Phase::Even)]);
@@ -1535,7 +1583,6 @@ mod tests {
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)]);
@@ -1579,7 +1626,6 @@ mod tests {
}
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)]);
@@ -1623,7 +1669,6 @@ mod tests {
let mut buf = build();
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b)],
read_data_key: None,
format: ContentFormat::BdTs,
};
decrypt_sectors_mapped(&mut buf, &keys, 0, &map)
@@ -1634,7 +1679,6 @@ mod tests {
let mut buf = build();
let swapped = DecryptKeys::Aacs {
unit_keys: vec![(1, key_b), (0, key_a)],
read_data_key: None,
format: ContentFormat::BdTs,
};
assert!(
@@ -1657,7 +1701,6 @@ mod tests {
buf[..ul].copy_from_slice(&u0);
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xCCu8; 16])], // map slot points at the WRONG key
read_data_key: None,
format: ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * usz, 0, Phase::Even)]);
@@ -1682,7 +1725,6 @@ mod tests {
}
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: ContentFormat::BdTs,
};
decrypt_sectors_mapped(
+1 -1
View File
@@ -646,7 +646,7 @@ mod tests {
);
assert_eq!(
reason,
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio)",
"main_feature_order(nav-feature, authoring-feature, standalone, has-video, fits-disc, largest-size, longest, richest-audio, more-video, more-subs)",
"the reason must name the selection keys (authoring, standalone-over-composite, and has-video gates, then the physical keys) in priority order"
);
}
+6 -19
View File
@@ -372,7 +372,6 @@ impl Disc {
key_source: KeyOrigin::ExternalUk,
vuk: None,
unit_keys: vec![],
read_data_key: handshake.and_then(|h| h.read_data_key),
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
uk_ro: uk_ro_data,
mkb: mkb_bytes,
@@ -772,7 +771,8 @@ mod tests {
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state");
assert_eq!(st.volume_id, vid);
assert_eq!(st.read_data_key, Some(rdk));
// The bus key no longer propagates onto AacsState — it feeds the drive's
// single de-bus point via the handshake, not a decrypt-time field.
}
// OEM bus-key gate: a bus-encrypted disc on a LIVE drive with no
@@ -831,7 +831,6 @@ mod tests {
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
assert!(st.bus_encryption);
assert_eq!(st.read_data_key, Some([0x22u8; 16]));
}
/// Finding: `is_drive_unlocker(matched)` on the cert route was provably always
@@ -864,10 +863,6 @@ mod tests {
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
@@ -878,7 +873,6 @@ mod tests {
let (mut disc, udf) = disc_with_cert(0x01, true);
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("ISO bus disc → ok");
assert!(st.bus_encryption);
assert_eq!(st.read_data_key, None);
}
/// AACS 1.0 BD (V10 cert, bus_encryption off) on a live drive with NO
@@ -895,7 +889,6 @@ mod tests {
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
assert!(!st.bus_encryption);
assert_eq!(st.read_data_key, None);
}
/// With NO handshake, volume_id defaults to all-zero (encrypt.rs
@@ -914,7 +907,6 @@ mod tests {
);
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("state");
assert_eq!(st.volume_id, [0u8; 16]);
assert_eq!(st.read_data_key, None);
}
/// `handshake_has_volume_id` treats an all-zero Volume ID as absent and
@@ -1291,11 +1283,10 @@ mod tests {
));
}
/// 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.
/// A handshake that carries a VID but `read_data_key: None` must still surface
/// the VID onto the state. On a non-bus-encrypted disc (so the bus-key gate
/// does not fire) the VID propagates; the bus key itself no longer lives on
/// AacsState — it feeds the drive's single de-bus point via the handshake.
#[test]
fn resolve_vid_only_surfaces_vid_with_absent_read_data_key() {
let (mut disc, udf) = disc_with_cert(0x00, false); // V10, bus off
@@ -1311,9 +1302,5 @@ mod tests {
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"
);
}
}
+3 -6
View File
@@ -1226,7 +1226,6 @@ mod tests {
key_source: crate::disc::KeyOrigin::ExternalUk,
vuk: None,
unit_keys: vec![(0u32, [0u8; 16])],
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
@@ -1593,11 +1592,9 @@ mod tests {
assert!(matches!(err, Error::DirNameCollision { .. }));
}
// 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.
// Two names differing only by CASE collide on a case-INSENSITIVE host (macOS
// APFS / Windows NTFS) but coexist on a case-SENSITIVE one, so the extract
// must abort on the former and not the latter — outcome tracks the real volume.
#[test]
fn names_differing_only_by_case_collide_only_on_a_case_insensitive_host() {
let root = DirSpec {
+574 -78
View File
@@ -492,6 +492,53 @@ pub(crate) fn aacs_dir_present(udf_fs: &crate::udf::UdfFs) -> bool {
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some()
}
// Title-ranking heuristic thresholds. Each gates a DISTINCT decision — several
// share a value (0.5) by coincidence, so they are named and tuned separately.
// See docs/disc-mod.md for the shape gate.
/// Nav candidate: a real-video title qualifies only if it runs at least this
/// fraction of the longest probable-video title's duration.
const NAV_CANDIDATE_MIN_DURATION_FRAC: f64 = 0.5;
/// Payload floor: a promoted title must hold at least this fraction of the
/// largest video-bearing title's byte size, else a tiny playback-valid branch
/// could win nav/authoring.
const FEATURE_PAYLOAD_MIN_SIZE_FRAC: f64 = 0.10;
/// Composite/wrapper gate: candidate wrapper Q must be at least this fraction of
/// the outer title P's byte size to count as a plausible wrapper.
const WRAPPER_MIN_SIZE_FRAC: f64 = 0.5;
/// Seamless-branch guard (issue #45): Q is a wrapper if it is a much SHORTER cut,
/// i.e. its duration is below this fraction of P's duration.
const SEAMLESS_SHORTER_CUT_DURATION_FRAC: f64 = 0.85;
/// Seamless-branch guard (issue #45): Q is a wrapper if it is nearly P's SIZE, i.e.
/// its byte size is at least this fraction of P's (a bumper, not a real branch).
const SEAMLESS_BUMPER_SIZE_FRAC: f64 = 0.90;
/// Authoring hint: honoured only when the title runs at least this fraction of the
/// longest probable-video title's duration.
const AUTHORING_MIN_DURATION_FRAC: f64 = 0.5;
/// Seamless-branch guard (issue #45): the minimum number of chapter marks a title
/// must carry to look like a COMPLETE feature presentation (a real feature has a
/// full chapter table; a bare body/branch subset carries one mark or none).
const MIN_FEATURE_CHAPTERS: usize = 2;
/// Seamless-branch guard (issue #45): a complete feature's chapter marks span at
/// least this fraction of its own runtime (start-to-last), distinguishing a real
/// chapter table from a couple of clustered marks.
const CHAPTER_SPAN_MIN_FRAC: f64 = 0.5;
/// Issue #45: does `q` look like a COMPLETE feature presentation — a real chapter
/// table (≥ [`MIN_FEATURE_CHAPTERS`] marks) spanning ≥ [`CHAPTER_SPAN_MIN_FRAC`]
/// of its runtime? A bare body subset (Alita's 00703 = one mark) fails; a genuine
/// feature (Alita's 00800 = 37 marks) passes. Gates the composite bumper limb so a
/// chaptered feature is not demoted below its own un-chaptered body subset. This is
/// the ABSOLUTE-threshold form; to switch to the RELATIVE form (only if a hoard
/// sweep finds an SM3-style decoy whose chaptered member is the SUPERSET) replace
/// the call site with `p.chapters.len() <= q.chapters.len()`. See docs/disc-mod.md.
fn is_complete_feature_presentation(q: &DiscTitle) -> bool {
q.chapters.len() >= MIN_FEATURE_CHAPTERS
&& q.duration_secs > 0.0
&& (q.chapters.last().map(|c| c.time_secs).unwrap_or(0.0)
- q.chapters.first().map(|c| c.time_secs).unwrap_or(0.0))
>= CHAPTER_SPAN_MIN_FRAC * q.duration_secs
}
/// Union a set of extents into sorted, merged, disjoint `(start_lba,
/// sector_count)` ranges — the pure, testable core of
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
@@ -1435,8 +1482,6 @@ pub struct AacsState {
pub vuk: Option<[u8; 16]>,
/// Decrypted unit keys (CPS unit number, key)
pub unit_keys: Vec<(u32, [u8; 16])>,
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
pub read_data_key: Option<[u8; 16]>,
/// Volume ID (16 bytes) -- from SCSI handshake
pub volume_id: [u8; 16],
/// Raw `Unit_Key_RO.inf` bytes (encrypted unit keys + CPS map). Stashed at
@@ -1461,7 +1506,6 @@ impl std::fmt::Debug for AacsState {
.field("key_source", &self.key_source)
.field("vuk", &self.vuk.map(|_| "<redacted>"))
.field("unit_keys_len", &self.unit_keys.len())
.field("read_data_key", &self.read_data_key.map(|_| "<redacted>"))
.field("volume_id", &"<redacted>")
.field("uk_ro_len", &self.uk_ro.len())
.field("mkb_len", &self.mkb.len())
@@ -1736,6 +1780,10 @@ impl Disc {
buffered.prefetch_ranges(&ranges);
}
// The AACS bus stage is decided ONCE from the handshake result, captured
// before `handshake` is moved into `scan_with` (see `BusStage`).
let bus_key = handshake.as_ref().and_then(|h| h.read_data_key);
tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams");
let disc = Self::scan_with(
&mut buffered,
@@ -1747,6 +1795,11 @@ impl Disc {
)?;
tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed");
// Wire the SINGLE de-bus point onto the drive from the handshake result,
// BEFORE the caller samples keys or muxes (the earlier UDF/metadata reads
// ran under default Passthrough).
Self::wire_bus_removal(session, bus_key, disc.encrypted_content_ranges());
// No CSS key recovery at scan time: DVD CSS keys are per-title and are
// re-cracked keylessly at read/decrypt time (`disc.css` stays unset),
// so log the format rather than a key state that is always `None`.
@@ -1754,6 +1807,25 @@ impl Disc {
Ok(disc)
}
/// Wire the SINGLE AACS bus-removal de-bus point onto `session` from a
/// completed handshake. The cert-route Read Data Key (`bus_key`, `None` on
/// the firmware/vendor route) picks the `BusStage`; the encrypted-content
/// ranges gate it. ALWAYS install the ranges even when empty: `None` means
/// "de-bus every sector", `Some([])` means "de-bus none" — so a host-key disc
/// that parsed zero content extents fails SAFE (untouched) instead of de-bussing
/// clear UDF/nav bytes to garbage. Extracted from [`Self::scan`] so it is
/// unit-testable without a live SCSI handshake.
pub(crate) fn wire_bus_removal(
session: &mut Drive,
bus_key: Option<[u8; 16]>,
content_ranges: Vec<(u32, u32)>,
) {
session.set_bus_stage(crate::sector::bus_removal::BusStage::from_read_data_key(
bus_key,
));
session.set_bus_content_ranges(std::sync::Arc::from(content_ranges.into_boxed_slice()));
}
// The extents an image-time CSS crack scans, in the crate's CANONICAL
// order (main feature's own extents, playback order) — do not re-derive
// this inline. See docs/disc-mod.md — image_crack_extents.
@@ -2045,7 +2117,9 @@ impl Disc {
.iter()
.filter(|t| {
t.has_video()
&& (longest_video_dur <= 0.0 || t.duration_secs >= 0.5 * longest_video_dur)
&& (longest_video_dur <= 0.0
|| t.duration_secs
>= NAV_CANDIDATE_MIN_DURATION_FRAC * longest_video_dur)
})
.map(|t| t.playlist_id)
.collect();
@@ -2134,8 +2208,14 @@ impl Disc {
/// duration sort: a branching UHD's play-all can report an inflated
/// duration/size exceeding the disc's physical capacity. These are the
/// [`Self::canonical_title_order`] sort keys; see docs/disc-mod.md.
pub const CANONICAL_TITLE_ORDER_KEYS: &'static [&'static str] =
&["fits-disc", "largest-size", "longest", "richest-audio"];
pub const CANONICAL_TITLE_ORDER_KEYS: &'static [&'static str] = &[
"fits-disc",
"largest-size",
"longest",
"richest-audio",
"more-video",
"more-subs",
];
// Content-based forced-subtitle detection, restricted to `BdTs` titles —
// gates on STREAM CONTAINER not disc-tree format (only BdTs carries
@@ -2177,6 +2257,19 @@ impl Disc {
// (a full-audio main vs a stereo-only twin) — prefer lossless-multichannel.
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
// Issue #45: before the lowest-id key, prefer the STREAM-RICHER sibling on
// the axes audio_richness misses (extra video = Dolby Vision EL; extra
// subtitle). True siblings match both and fall through to lowest-id.
.then_with(|| b.video_streams().count().cmp(&a.video_streams().count()))
.then_with(|| {
b.subtitle_streams()
.count()
.cmp(&a.subtitle_streams().count())
})
// Final determinism (issue #45): among equal-size seamless siblings
// (Alita 00800-00808, same body + different logo) pick the LOWEST
// playlist_id. Inert where an earlier key separates the pair.
.then_with(|| a.playlist_id.cmp(&b.playlist_id))
}
/// The keys [`Self::main_feature_order`] layers ON TOP of
@@ -2226,7 +2319,8 @@ impl Disc {
.max()
.unwrap_or(0);
let carries_feature_payload = |t: &DiscTitle| {
max_video_size == 0 || t.size_bytes as f64 >= 0.10 * max_video_size as f64
max_video_size == 0
|| t.size_bytes as f64 >= FEATURE_PAYLOAD_MIN_SIZE_FRAC * max_video_size as f64
};
// The composite scan below is O(n^2 * clip-set-size); title/clip counts are
// untrusted, so a crafted image could stall it. Bound the total pair work;
@@ -2254,12 +2348,15 @@ impl Disc {
!qj.is_empty()
&& qj.len() < pi.len()
&& q.has_probable_video()
&& q.size_bytes as f64 >= 0.5 * p.size_bytes as f64
// Seamless-branch guard (issue #45): wrapper only
// if Q is a much SHORTER cut (concat) or nearly P's
// SIZE-equal (bumper). See docs/disc-mod.md.
&& (q.duration_secs < 0.85 * p.duration_secs
|| q.size_bytes as f64 >= 0.90 * p.size_bytes as f64)
&& q.size_bytes as f64 >= WRAPPER_MIN_SIZE_FRAC * p.size_bytes as f64
// Seamless-branch guard (issue #45): wrapper only if Q is
// a much SHORTER cut (concat) OR nearly P's SIZE AND a
// complete feature (bumper) — see is_complete_feature_presentation.
&& (q.duration_secs
< SEAMLESS_SHORTER_CUT_DURATION_FRAC * p.duration_secs
|| (q.size_bytes as f64
>= SEAMLESS_BUMPER_SIZE_FRAC * p.size_bytes as f64
&& is_complete_feature_presentation(q)))
&& qj.is_subset(pi)
}
});
@@ -2269,7 +2366,8 @@ impl Disc {
let authoring = !composite
&& hint.is_some_and(|h| h.matches(p.playlist_id, &p.playlist))
&& p.has_video()
&& (longest_video_dur <= 0.0 || p.duration_secs >= 0.5 * longest_video_dur)
&& (longest_video_dur <= 0.0
|| p.duration_secs >= AUTHORING_MIN_DURATION_FRAC * longest_video_dur)
&& carries_feature_payload(p);
// Nav result is video- and payload-gated like the authoring hint,
// so a streamless or low-byte branch/DV presentation cannot win
@@ -2499,13 +2597,10 @@ impl std::fmt::Debug for Key {
// CPS units. See docs/disc-mod.md for why "first sample decrypts" is unsafe.
fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>],
format: ContentFormat,
) -> bool {
use crate::aacs::content::{
ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, is_clean,
};
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit, is_clean};
let scrambled: Vec<&[u8]> = samples
.iter()
.map(|s| s.as_slice())
@@ -2531,11 +2626,9 @@ fn aligned_unit_keys_validate(
hb.tick_cpu(tried, total);
tried += 1;
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
// bus layer (AACS 2.0) first, then the CPS unit key, then the structural
// proof — the composed form of the old `decrypt_unit_full`.
if let Some(rdk) = read_data_key {
decrypt_bus(&mut probe, rdk);
}
// Samples arrive ALREADY de-bussed (the drive's single de-bus point
// removed AACS 2.0 bus encryption at read time), so validation only
// applies the CPS unit key, then the structural proof.
decrypt_unit(&mut probe, k);
if is_clean(&probe, format) {
covered = true;
@@ -2577,7 +2670,6 @@ impl Disc {
}
crate::decrypt::DecryptKeys::Aacs {
unit_keys: aacs.unit_keys.clone(),
read_data_key: aacs.read_data_key,
format: self.content_format,
}
} else if let Some(ref css) = self.css {
@@ -2879,7 +2971,6 @@ impl Disc {
key_source: KeyOrigin::ExternalUk,
vuk: None,
unit_keys: keys,
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
@@ -2927,14 +3018,9 @@ impl Disc {
/// rejected key (`Err(AacsKeyRejected)`) falls through to the caller's
/// next candidate. Pass `&[]` to skip validation (resume / mapfile cache).
pub fn decrypt_with(&mut self, key: Key, samples: &[Vec<u8>]) -> Result<()> {
// The AACS 2.x bus key, needed to de-scramble a sample for validation;
// captured before any mutable borrow. None for AACS 1.0 and file-backed
// ISO units (bus encryption was already removed at read time).
let read_data_key = self.aacs.as_ref().and_then(|a| a.read_data_key);
// Resolve the supplied key DOWN to candidate unit keys WITHOUT
// committing them, so a wrong higher-level key can be rejected before it
// poisons disc state.
// Samples arrive already de-bussed (drive's single bus-removal point), so
// validation needs no bus key. Resolve the key DOWN to candidate unit keys
// WITHOUT committing — a wrong higher key is rejected before it poisons state.
let (candidate_unit_keys, candidate_vuk) = if let Key::Unit(keys) = key {
// Terminal — the source / mapfile already holds the final UKs.
(keys, None)
@@ -3003,12 +3089,7 @@ impl Disc {
// VALIDATE against real ciphertext. Conservative: reject only when a
// supplied sample is AACS-scrambled and NO candidate key can de-scramble
// it; with no samples the key is accepted as-is (sample-less paths unchanged).
if !aligned_unit_keys_validate(
&candidate_unit_keys,
read_data_key.as_ref(),
samples,
self.content_format,
) {
if !aligned_unit_keys_validate(&candidate_unit_keys, samples, self.content_format) {
return Err(crate::error::Error::AacsKeyRejected);
}
@@ -3796,7 +3877,6 @@ mod tests {
key_source: KeyOrigin::ExternalUk,
vuk: Some([0xD5; 16]),
unit_keys: vec![(1, [0xD5; 16])],
read_data_key: Some([0xD5; 16]),
volume_id: [0xD5; 16],
uk_ro: vec![1, 2, 3],
mkb: vec![4, 5, 6],
@@ -4458,6 +4538,65 @@ mod tests {
);
}
// Issue #45: equal size/duration/audio siblings differing only on video-count or
// subtitle-count must pick the stream-richer playlist (not lowest id); truly
// identical siblings still fall through to lowest-id determinism.
#[test]
fn canonical_title_order_prefers_stream_richer_sibling_issue_45() {
let cap = 100_000_000_000u64;
let base = |id: u16| DiscTitle {
playlist_id: id,
size_bytes: 57_000_000_000,
duration_secs: 8000.0,
..title_with_video(Codec::Hevc, Resolution::R2160p)
};
// Higher-id sibling carries an extra VIDEO stream (a Dolby Vision EL).
let mut richer_v = base(801);
richer_v.streams.push(Stream::Video(VideoStream {
pid: 0x1015,
codec: Codec::Hevc,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: true,
label: String::new(),
measured_cicp: None,
}));
let mut vv = [base(40), richer_v];
vv.sort_by(|a, b| Disc::canonical_title_order(a, b, cap));
assert_eq!(
vv[0].playlist_id, 801,
"the video-richer sibling (Dolby Vision EL) must win over the lowest id"
);
// Higher-id sibling carries an extra SUBTITLE track.
let mut richer_s = base(801);
richer_s.streams.push(Stream::Subtitle(SubtitleStream {
pid: 0x12a4,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: LabelQualifier::None,
codec_data: None,
}));
let mut ss = [base(40), richer_s];
ss.sort_by(|a, b| Disc::canonical_title_order(a, b, cap));
assert_eq!(
ss[0].playlist_id, 801,
"the subtitle-richer sibling must win over the lowest id"
);
// Truly identical siblings → lowest-id determinism preserved.
let mut ii = [base(808), base(800)];
ii.sort_by(|a, b| Disc::canonical_title_order(a, b, cap));
assert_eq!(
ii[0].playlist_id, 800,
"identical seamless siblings still pick the lowest id"
);
}
/// A BD title carrying video, `id`, `dur`/`size`, and the given clip ids.
fn bd_title(playlist: &str, id: u16, dur: f64, size: u64, clip_ids: &[&str]) -> DiscTitle {
DiscTitle {
@@ -4480,6 +4619,88 @@ mod tests {
}
}
/// A BD title as [`bd_title`], plus a chapter table of `n_chapters` marks
/// evenly spanning `dur` (first at 0, last at `dur`) — models a COMPLETE
/// feature presentation for the issue #45 seamless-branch guard. `n_chapters`
/// must be ≥ 2 for the marks to span any runtime.
fn bd_title_chaptered(
playlist: &str,
id: u16,
dur: f64,
size: u64,
clip_ids: &[&str],
n_chapters: usize,
) -> DiscTitle {
let chapters = (0..n_chapters)
.map(|i| Chapter {
time_secs: if n_chapters <= 1 {
0.0
} else {
i as f64 * dur / (n_chapters - 1) as f64
},
name: (i + 1).to_string(),
})
.collect();
DiscTitle {
chapters,
..bd_title(playlist, id, dur, size, clip_ids)
}
}
/// A BD title with `n_chapters` marks clustered in the first `span_secs` of a
/// `dur`-second runtime (first at 0, last at `span_secs`) — lets a test drive
/// the CHAPTER_SPAN_MIN_FRAC boundary independently of chapter count.
fn bd_title_chaptered_span(
playlist: &str,
id: u16,
dur: f64,
size: u64,
clip_ids: &[&str],
n_chapters: usize,
span_secs: f64,
) -> DiscTitle {
let chapters = (0..n_chapters)
.map(|i| Chapter {
time_secs: if n_chapters <= 1 {
0.0
} else {
i as f64 * span_secs / (n_chapters - 1) as f64
},
name: (i + 1).to_string(),
})
.collect();
DiscTitle {
chapters,
..bd_title(playlist, id, dur, size, clip_ids)
}
}
// Issue #45 boundary: marks that cluster in the first ~20% of runtime span
// < CHAPTER_SPAN_MIN_FRAC of duration, so they are NOT a complete feature.
#[test]
fn is_complete_feature_presentation_false_when_marks_cluster_early() {
// 5 marks over the first 20% (span 0.2 < 0.5 of duration).
let clustered =
bd_title_chaptered_span("00001.mpls", 1, 1000.0, 1_000_000, &["00001"], 5, 200.0);
assert!(
!is_complete_feature_presentation(&clustered),
"marks spanning < 0.5 of runtime are not a complete feature"
);
}
// Issue #45 boundary: marks spanning exactly CHAPTER_SPAN_MIN_FRAC of the
// runtime (with >= MIN_FEATURE_CHAPTERS marks) DO look like a real feature.
#[test]
fn is_complete_feature_presentation_true_at_span_threshold() {
// 2 marks (== MIN_FEATURE_CHAPTERS) spanning exactly 0.5 of duration.
let at_threshold =
bd_title_chaptered_span("00001.mpls", 1, 1000.0, 1_000_000, &["00001"], 2, 500.0);
assert!(
is_complete_feature_presentation(&at_threshold),
"marks spanning >= 0.5 of runtime with enough marks are a complete feature"
);
}
/// Same, but with an empty STN stream list — models a title whose FIRST
/// PlayItem is a non-video bumper (`has_video()` is false; `streams` reflect
/// PlayItem 0 only).
@@ -4496,9 +4717,9 @@ mod tests {
}
}
// Regression for a real UHD decoy hang: 00245.mpls is a WRAPPER
// COMPOSITE (bumper+feature+outro) that is LONGEST/LARGEST; must be
// demoted STRUCTURALLY, not by stream count. See docs/disc-mod.md.
// Regression for a real UHD decoy hang: 00245.mpls is a WRAPPER COMPOSITE
// (bumper+feature+outro), LONGEST/LARGEST; demoted STRUCTURALLY by the chapter
// table (issue #45) — wrapped feature 00001 is chaptered, the decoy is not.
#[test]
fn main_feature_order_demotes_wrapper_composite_decoy() {
let decoy = bd_title_no_stn_video(
@@ -4508,7 +4729,7 @@ mod tests {
78_427_502_592,
&["00339", "00001", "00336"],
);
let feature = bd_title("00001.mpls", 1, 8038.4, 76_525_627_392, &["00001"]);
let feature = bd_title_chaptered("00001.mpls", 1, 8038.4, 76_525_627_392, &["00001"], 12);
let extra = bd_title("00248.mpls", 248, 794.2, 60_000_000_000, &["00248"]);
let capacity = 88_000_000_000u64;
@@ -4547,7 +4768,9 @@ mod tests {
78_000_000_000,
&["dead", "00001"],
);
let feature = bd_title("00001.mpls", 1, 8000.0, 76_000_000_000, &["00001"]);
// The wrapped feature carries the real chapter table (issue #45), so the
// bumper limb correctly detects 00245 as its wrapper composite.
let feature = bd_title_chaptered("00001.mpls", 1, 8000.0, 76_000_000_000, &["00001"], 12);
let small = vec![decoy.clone(), feature.clone()];
assert!(
Disc::rank_titles(&small, None, None)[0].composite,
@@ -4578,7 +4801,9 @@ mod tests {
78_427_502_592,
&["00001", "00336"],
);
let feature = bd_title("00001.mpls", 1, 8038.4, 76_525_627_392, &["00001"]);
// The wrapped feature carries the real chapter table (issue #45); the
// video-bearing wrapper carries none, so it stays demoted.
let feature = bd_title_chaptered("00001.mpls", 1, 8038.4, 76_525_627_392, &["00001"], 12);
let capacity = 88_000_000_000u64;
let mut titles = vec![composite, feature];
@@ -4612,29 +4837,32 @@ mod tests {
);
}
/// Issue #45 (SAFETY): a genuine seamless-branch FEATURE (00800, 4 clips,
/// 2h03) is a SUPERSET of its single-clip body decoy (00700, 1h50, ≥50 %
/// size). The old size-only composite gate mis-demoted 00800 as a "play-all
/// wrapper" and ripped the shorter decoy. The duration-shape guard keeps
/// 00800: its body subset runs almost as long (1h50 vs 2h03, ~0.89) yet is
/// markedly smaller (0.55) — the hallmark of seamless branching, not
/// concatenation.
/// Issue #45 (SAFETY): the REAL Alita BD-J numbers. Feature 00800 (4 clips,
/// 7317 s, 58.61 GB, 37 chapter marks) is a SUPERSET of its single-clip body
/// 00703 ([00688], 6951 s, 56.88 GB, 1 mark). The body is 0.97 of the feature's
/// size, so the size-only bumper limb fires (0.97 ≥ 0.90) while the body runs
/// over 0.85 of the feature so the concat limb does NOT — the OLD gate demoted
/// 00800 and ripped the body. The chapter-table guard keeps 00800: its body
/// subset carries one mark (< MIN_FEATURE_CHAPTERS), not a complete feature.
#[test]
fn seamless_branch_feature_not_demoted_issue_45() {
// 00800: real feature = body + intro/credits/seamless branch segments.
let feature = bd_title(
// 00800: real feature = body + intro/credits/seamless branch segments,
// with a full 37-mark chapter table.
let feature = bd_title_chaptered(
"00800.mpls",
800,
7380.0, // 2h03
60_000_000_000,
&["00800", "00801", "00802", "00803"],
7317.0,
58_610_000_000,
&["00687", "00688", "00674", "00689"],
37,
);
// 00700: single body clip, a PROPER subset, 1h50 (~89 % of 00800's
// duration) and 55 % of its size — enough to trip the old size gate.
let decoy = bd_title("00700.mpls", 700, 6600.0, 33_000_000_000, &["00800"]);
// 00703: single body clip, a PROPER subset, 6951 s (~0.95 of 00800's
// duration → concat limb inert) and 56.88 GB (~0.97 of its size → old
// bumper limb would fire) — one chapter mark, not a feature.
let decoy = bd_title_chaptered("00703.mpls", 703, 6951.0, 56_880_000_000, &["00688"], 1);
let capacity = 88_000_000_000u64;
// The body decoy is a proper subset with ≥50 % size — the OLD gate
// The body subset is a proper subset with ≥90 % size — the OLD gate
// flagged 00800 composite on that alone. It must no longer be flagged.
let ranks = Disc::rank_titles(&[feature.clone(), decoy.clone()], None, None);
assert!(
@@ -4683,6 +4911,161 @@ mod tests {
);
}
/// Issue #45 (Alita BD-J): the disc has NO authoritative static playlist
/// chain (First-Play is a BD-J Xlet, index title's playlist has an empty
/// TableOfAccessiblePlayLists), so selection is a pure structural failsafe.
/// The chaptered feature 00800 is a SUPERSET of its un-chaptered body subset
/// 00703 that is 0.97 of its size — the size-only bumper limb would demote
/// it. The chapter-table guard keeps 00800 (its body carries one mark).
#[test]
fn alita_bdj_body_subset_does_not_demote_chaptered_feature_issue_45() {
let feature = bd_title_chaptered(
"00800.mpls",
800,
7317.0,
58_610_000_000,
&["00687", "00688", "00674", "00689"],
37,
);
let body = bd_title_chaptered("00703.mpls", 703, 6951.0, 56_880_000_000, &["00688"], 1);
let capacity = 88_000_000_000u64;
let ranks = Disc::rank_titles(&[feature.clone(), body.clone()], None, None);
assert!(
!ranks[0].composite,
"the chaptered feature must not be flagged a wrapper composite of its body"
);
// No hint, no nav — the BD-J disc gives neither; selection is structural.
let mut titles = vec![body, feature];
Disc::sort_titles_by_main_feature(&mut titles, capacity, None, None);
assert_eq!(
titles[0].playlist_id, 800,
"the chaptered feature is selected, not its un-chaptered body subset"
);
}
/// Issue #45 (secondary determinism): the nine feature variants 00800-00808
/// share the body clip 00688 and differ only by opening logo, so they are
/// equal in size/duration/audio — the lowest-playlist_id final tie-break
/// deterministically picks 00800. All nine rank above the un-chaptered body
/// subset 00703, and none is demoted as a composite of it.
#[test]
fn alita_full_family_ranks_above_body() {
let mut titles: Vec<DiscTitle> = (0..9)
.map(|k| {
let id = 800 + k as u16;
bd_title_chaptered(
&format!("008{k:02}.mpls"),
id,
7317.0,
58_610_000_000,
&[&format!("logo{k}"), "00688"],
37,
)
})
.collect();
// The bare body: a proper subset of every family member, one chapter mark,
// 0.97 of their size (would trip the old bumper limb).
titles.push(bd_title_chaptered(
"00703.mpls",
703,
6951.0,
56_880_000_000,
&["00688"],
1,
));
let capacity = 88_000_000_000u64;
let ranks = Disc::rank_titles(&titles, None, None);
assert!(
ranks.iter().all(|r| !r.composite),
"no equal-size seamless sibling nor its body subset is a composite"
);
Disc::sort_titles_by_main_feature(&mut titles, capacity, None, None);
assert_eq!(
titles[0].playlist_id, 800,
"the lowest-id feature variant is the deterministic pick among equals"
);
assert_eq!(
titles.last().unwrap().playlist_id,
703,
"the smaller body subset ranks below the whole feature family"
);
}
/// Issue #45 (SM3 topology, opposite of Alita): the real chaptered feature
/// 00001 is the SUBSET; the decoy 00245 = [bumper][feature][outro] is the
/// SUPERSET. Because the subset IS a complete feature presentation (real
/// chapter table), the bumper limb still fires and the wrapper stays demoted.
#[test]
fn bumper_wrapper_with_chaptered_subset_still_demoted() {
let wrapper = bd_title(
"00245.mpls",
245,
8350.0,
78_000_000_000,
&["00339", "00001", "00336"],
);
// 0.98 of the wrapper's size (bumper limb) and a full chapter table.
let feature = bd_title_chaptered("00001.mpls", 1, 8038.0, 76_500_000_000, &["00001"], 24);
let capacity = 88_000_000_000u64;
let ranks = Disc::rank_titles(&[wrapper.clone(), feature.clone()], None, None);
assert!(
ranks[0].composite,
"a bumper wrapper of a CHAPTERED feature subset is still a composite"
);
assert!(
!ranks[1].composite,
"the chaptered feature subset is standalone"
);
let mut titles = vec![wrapper, feature];
Disc::sort_titles_by_main_feature(&mut titles, capacity, None, None);
assert_eq!(
titles[0].playlist_id, 1,
"the chaptered feature wins; its bumper wrapper is demoted"
);
}
/// Issue #45: when the bumper-size subset carries NO chapter table it is not
/// a complete feature, so the bumper limb does not fire and the SUPERSET is
/// kept — the exact mechanism that saves Alita's 00800 over its body 00703.
#[test]
fn un_chaptered_bumper_subset_keeps_superset() {
// Superset feature, chaptered.
let superset = bd_title_chaptered(
"00800.mpls",
800,
7317.0,
58_610_000_000,
&["00687", "00688", "00674", "00689"],
30,
);
// Subset: 0.97 of the superset's size (bumper limb size), but NO chapters.
let subset = bd_title("00703.mpls", 703, 6951.0, 56_880_000_000, &["00688"]);
assert!(
subset.chapters.is_empty(),
"the body subset carries no marks"
);
let capacity = 88_000_000_000u64;
let ranks = Disc::rank_titles(&[superset.clone(), subset.clone()], None, None);
assert!(
!ranks[0].composite,
"an un-chaptered bumper-size subset must not demote its superset"
);
let mut titles = vec![subset, superset];
Disc::sort_titles_by_main_feature(&mut titles, capacity, None, None);
assert_eq!(
titles[0].playlist_id, 800,
"the superset feature is kept, not the un-chaptered body subset"
);
}
/// The disc's own authoring-designated feature wins even when it is NOT the
/// longest/largest title — a size-independent signal a decoy cannot forge.
#[test]
@@ -4748,7 +5131,9 @@ mod tests {
78_000_000_000,
&["00339", "00001", "00336"],
);
let feature = bd_title("00001.mpls", 1, 8000.0, 76_000_000_000, &["00001"]);
// The wrapped feature carries the real chapter table (issue #45), so the
// streamless wrapper 00245 is detected as a composite and demoted.
let feature = bd_title_chaptered("00001.mpls", 1, 8000.0, 76_000_000_000, &["00001"], 12);
let capacity = 88_000_000_000u64;
let mut titles = vec![streamless, feature];
@@ -5167,13 +5552,8 @@ mod tests {
disc.inject_unit_keys(uk.clone());
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs {
unit_keys,
read_data_key,
..
} => {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
}
_ => panic!("expected Aacs decrypt keys after injecting a UK"),
}
@@ -5216,7 +5596,6 @@ mod tests {
key_source: KeyOrigin::DeviceKey,
vuk: None,
unit_keys,
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
@@ -5469,6 +5848,131 @@ mod tests {
}
}
/// Issue #46: a source that MATCHED the disc but derived no key must render
/// distinctly from a true miss — `matched disc > no VID`, NOT `no entry` —
/// and carry the matched entry's shape for the app to log.
#[test]
fn matched_but_underivable_disc_is_not_reported_as_no_entry() {
use crate::aacs::trace::{KeyNode, MatchedEntry};
use crate::keysource::{
KeySource, ResolveCtx, UnitKeyResolution, resolve_and_apply_traced,
};
/// Matched the disc, had a Media Key, but no VID on this path — the
/// classic issue-#46 case (a keydb hit that cannot finish).
struct MatchedNoVid;
impl KeySource for MatchedNoVid {
fn get_unit_keys(
&self,
_ctx: &dyn ResolveCtx,
) -> std::result::Result<Vec<crate::aacs::types::UnitKey>, crate::error::Error>
{
Ok(Vec::new())
}
fn resolve_unit_keys(
&self,
_ctx: &dyn ResolveCtx,
) -> std::result::Result<UnitKeyResolution, crate::error::Error> {
Ok(UnitKeyResolution {
keys: Vec::new(),
matched: true,
miss_path: vec![KeyNode::NoVid],
matched_entry: Some(MatchedEntry {
has_media_key: true,
enc_title_keys_len: 1,
..Default::default()
}),
store_entries: Some(3),
})
}
fn label(&self) -> &'static str {
"keydb"
}
}
let inputs = crate::keysource::DiscInputs {
disc_hash: "0xMATCH".into(),
volume_id: [0u8; 16],
version: crate::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: None,
};
let mut disc = {
let mut d = make_test_disc(1000, "UHD");
d.encrypted = true;
d.aacs = Some(aacs_with(Vec::new()));
d
};
let sources: Vec<Box<dyn KeySource>> = vec![Box::new(MatchedNoVid)];
let (ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut disc);
assert!(!ok);
assert_eq!(
trace.keys[0].path,
vec![KeyNode::MatchedDisc, KeyNode::NoVid],
"a matched-but-underivable disc must NOT collapse to `no entry`"
);
assert_eq!(
trace.keys[0].matched_entry.map(|m| m.has_media_key),
Some(true),
"the matched entry's shape must survive to the trace for logging"
);
}
/// Companion to the above: a matched entry with NO derivation material at all
/// gets the library's default `no derivable key` node (empty `miss_path`),
/// still distinct from a true miss.
#[test]
fn matched_with_no_material_renders_no_derivable_key() {
use crate::aacs::trace::KeyNode;
use crate::keysource::{
KeySource, ResolveCtx, UnitKeyResolution, resolve_and_apply_traced,
};
struct MatchedNoMaterial;
impl KeySource for MatchedNoMaterial {
fn get_unit_keys(
&self,
_ctx: &dyn ResolveCtx,
) -> std::result::Result<Vec<crate::aacs::types::UnitKey>, crate::error::Error>
{
Ok(Vec::new())
}
fn resolve_unit_keys(
&self,
_ctx: &dyn ResolveCtx,
) -> std::result::Result<UnitKeyResolution, crate::error::Error> {
Ok(UnitKeyResolution {
matched: true,
..Default::default()
})
}
}
let inputs = crate::keysource::DiscInputs {
disc_hash: "0xMATCH".into(),
volume_id: [0u8; 16],
version: crate::aacs::mkb::AACS_MAJOR_UHD,
mkb: Vec::new(),
unit_key_ro: Vec::new(),
samples: Vec::new(),
volume_label: None,
};
let mut disc = {
let mut d = make_test_disc(1000, "UHD");
d.encrypted = true;
d.aacs = Some(aacs_with(Vec::new()));
d
};
let sources: Vec<Box<dyn KeySource>> = vec![Box::new(MatchedNoMaterial)];
let (_ok, trace) = resolve_and_apply_traced(&sources, &inputs, &mut disc);
assert_eq!(
trace.keys[0].path,
vec![KeyNode::MatchedDisc, KeyNode::NoDerivableKey],
);
}
/// Same AACS-no-key disc under `--raw` (raw=true) must PROCEED — the user
/// asked for the encrypted image and needs no key.
#[test]
@@ -5847,7 +6351,6 @@ mod tests {
// like resume / mapfile must be unaffected).
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[],
ContentFormat::BdTs
));
@@ -5866,7 +6369,6 @@ mod tests {
));
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[clear.clone()],
ContentFormat::BdTs
));
@@ -5882,21 +6384,18 @@ mod tests {
// Right key -> de-scrambles -> accept (NO false reject of a good key).
assert!(super::aligned_unit_keys_validate(
&[(7, uk)],
None,
std::slice::from_ref(&enc),
ContentFormat::BdTs
));
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(
&[(7, [0x00u8; 16])],
None,
std::slice::from_ref(&enc),
ContentFormat::BdTs
));
// Empty key set against a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(
&[],
None,
&[enc],
ContentFormat::BdTs
));
@@ -5935,7 +6434,6 @@ mod tests {
// reject. This is the bug fix: previously this returned true.
assert!(!super::aligned_unit_keys_validate(
&[(0, uk0)],
None,
&samples,
ContentFormat::BdTs
));
@@ -5943,7 +6441,6 @@ mod tests {
// Complete key set (both CPS units) -> accept.
assert!(super::aligned_unit_keys_validate(
&[(0, uk0), (1, uk1)],
None,
&samples,
ContentFormat::BdTs
));
@@ -5951,7 +6448,6 @@ mod tests {
// Order-independent: covering key present anywhere in the set is fine.
assert!(super::aligned_unit_keys_validate(
&[(1, uk1), (0, uk0)],
None,
&samples,
ContentFormat::BdTs
));
+1 -38
View File
@@ -1,44 +1,7 @@
//! macOS drive discovery and device resolution.
//!
//! `find_drives` uses IOKit registry enumeration (via `scsi::list_drives`)
//! to discover optical drives without exclusive access or unmounts. Only
//! the returned paths are then opened for INQUIRY to build full `DriveId`.
//! macOS device resolution.
use crate::drive::DeviceResolution;
use crate::error::{Error, Result};
use crate::identity::DriveId;
/// SCSI peripheral device type 5 = MMC / optical, in the low 5 bits of
/// INQUIRY byte 0.
const SCSI_PERIPHERAL_TYPE_OPTICAL: u8 = 0x05;
/// Discover optical drives via the IOKit registry (`scsi::list_drives`),
/// then open each candidate for INQUIRY to build a full `DriveId`.
///
/// Any drive where `scsi::open` or `DriveId::from_drive` fails, or whose
/// peripheral device type is not optical (MMC, type 0x05), is silently
/// skipped — the same MMC filter the Linux and Windows backends apply.
pub fn find_drives() -> Vec<(String, DriveId)> {
let mut drives = Vec::new();
let discovered = crate::scsi::list_drives();
for info in discovered {
let path = std::path::Path::new(&info.path);
match crate::scsi::open(path) {
Ok(mut transport) => {
if let Ok(id) = DriveId::from_drive(transport.as_mut())
&& !id.raw_inquiry.is_empty()
&& (id.raw_inquiry[0] & 0x1F) == SCSI_PERIPHERAL_TYPE_OPTICAL
{
drives.push((info.path.clone(), id));
}
}
Err(_) => {
continue;
}
}
}
drives
}
/// Resolve a device path on macOS. There is no `sr`→`sg` style
/// substitution here (that is a Linux concern), so any existing path is
+305 -11
View File
@@ -116,6 +116,20 @@ pub struct Drive {
halt: Arc<AtomicBool>,
/// Event handler — fires for read errors and library-level state changes.
event_fn: Option<Box<dyn Fn(Event) + Send>>,
/// The SINGLE AACS bus-encryption removal point. Decided ONCE from the
/// unlock/handshake result during [`crate::disc::Disc::scan`] and applied on
/// every read below this drive, so every reader above (sampler, mux, sweep,
/// validation) sees already-de-bussed content and never threads a bus key.
/// Defaults to [`BusStage::Passthrough`](crate::sector::bus_removal::BusStage)
/// (firmware/vendor unlock de-busses at the drive, or the disc carries no bus
/// encryption).
bus_stage: crate::sector::bus_removal::BusStage,
/// Encrypted-content extent map (sorted `(start_lba, sector_count)`) that
/// gates the host-key de-bus to content sectors — clear UDF/nav sectors read
/// during a whole-disc pass are left untouched. `None` = de-bus every read
/// sector (a content-only reader). Ignored entirely under
/// `BusStage::Passthrough`. See docs/drive-mod.md — bus-removal wiring.
bus_content_ranges: Option<Arc<[(u32, u32)]>>,
/// Linux only: raw fd for the corresponding block device (`/dev/sr*`)
/// used as a recovery fallback when SCSI READ via `/dev/sg*` returns
/// an error. The kernel `sr_mod` driver auto-retries failed reads
@@ -157,11 +171,46 @@ impl Drive {
device_path: device.to_string_lossy().to_string(),
halt: Arc::new(AtomicBool::new(false)),
event_fn: None,
bus_stage: crate::sector::bus_removal::BusStage::Passthrough,
bus_content_ranges: None,
#[cfg(target_os = "linux")]
block_dev_fd,
})
}
/// Set the SINGLE AACS bus-removal stage for this drive, decided once from
/// the unlock/handshake result. `AacsHostKey(rdk)` de-busses each content
/// read in software; `Passthrough` (a firmware/vendor unlock, or a non-bus
/// disc) is a no-op. Every reader above the drive inherits the result.
pub fn set_bus_stage(&mut self, stage: crate::sector::bus_removal::BusStage) {
self.bus_stage = stage;
}
/// Gate host-key de-bussing to the disc's encrypted-content extents (sorted
/// `(start_lba, sector_count)`), so clear UDF/nav sectors read during a
/// whole-disc pass are left untouched. Set after the disc structure is parsed
/// (see [`crate::disc::Disc::encrypted_content_ranges`]); unset means every
/// read sector is treated as content.
pub fn set_bus_content_ranges(&mut self, ranges: Arc<[(u32, u32)]>) {
self.bus_content_ranges = Some(ranges);
}
/// The SOLE application of AACS bus decryption in the read path: under a
/// host-key stage, de-bus the `data` that a raw read landed at `lba`, gated
/// to the encrypted-content ranges. `Passthrough` is a no-op, so the raw
/// `read`/`read_fua` used by internal callers stay bus-encrypted while
/// `SectorSource` readers above see plaintext content.
fn remove_bus_encryption(&self, lba: u32, data: &mut [u8]) {
if let crate::sector::bus_removal::BusStage::AacsHostKey(rdk) = &self.bus_stage {
crate::aacs::content::decrypt_bus_in_content(
data,
rdk,
lba,
self.bus_content_ranges.as_deref(),
);
}
}
// Test-only constructor: build a `Drive` over an arbitrary `ScsiTransport`
// (no profile, no platform driver, no block-device fallback) so
// command-builder/response-parser logic can be exercised against a mock.
@@ -186,6 +235,8 @@ impl Drive {
device_path: "test".to_string(),
halt: Arc::new(AtomicBool::new(false)),
event_fn: None,
bus_stage: crate::sector::bus_removal::BusStage::Passthrough,
bus_content_ranges: None,
#[cfg(target_os = "linux")]
block_dev_fd: None,
}
@@ -1169,7 +1220,9 @@ impl SectorSource for Drive {
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
self.read(lba, count, buf, recovery)
let n = self.read(lba, count, buf, recovery)?;
self.remove_bus_encryption(lba, &mut buf[..n]);
Ok(n)
}
fn read_sectors_fua(
@@ -1180,7 +1233,9 @@ impl SectorSource for Drive {
recovery: bool,
fua: bool,
) -> Result<usize> {
self.read_fua(lba, count, buf, recovery, fua)
let n = self.read_fua(lba, count, buf, recovery, fua)?;
self.remove_bus_encryption(lba, &mut buf[..n]);
Ok(n)
}
fn set_speed(&mut self, kbs: u16) {
@@ -1197,11 +1252,53 @@ impl SectorSource for Drive {
/// drives without opening, use `scsi::list_drives()` instead.
/// See docs/drive-mod.md — find_drive selection policy.
pub fn find_drive() -> Option<Drive> {
select_drive_with_media(
discover_drives()
#[cfg(target_os = "macos")]
let candidates = crate::scsi::list_drives()
.into_iter()
.map(|info| info.path)
.collect::<Vec<_>>();
#[cfg(not(target_os = "macos"))]
let candidates = discover_drives()
.into_iter()
.map(|(path, _)| path)
.collect::<Vec<_>>();
let opened =
candidates
.into_iter()
.filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok()),
)
.filter_map(|path| match Drive::open(std::path::Path::new(&path)) {
Ok(drive)
if !drive.drive_id.raw_inquiry.is_empty()
&& (drive.drive_id.raw_inquiry[0] & 0x1F) == 0x05 =>
{
Some(drive)
}
Ok(drive) => {
tracing::debug!(
target: "freemkv::drive",
path,
"skipping non-optical SCSI device during drive selection"
);
drop(drive);
None
}
Err(error) => {
tracing::warn!(
target: "freemkv::drive",
path,
error = %error,
"drive open failed during autodetection"
);
None
}
});
select_drive_with_media(opened)
}
#[cfg(not(target_os = "macos"))]
fn discover_drives() -> Vec<(String, DriveId)> {
platform::find_drives()
}
// Pick a drive preferring DiscPresent status, falling back to the first
@@ -1279,11 +1376,6 @@ fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<(
}
}
/// Internal: discover drive paths + IDs without opening full Drive objects.
fn discover_drives() -> Vec<(String, DriveId)> {
platform::find_drives()
}
/// Structured outcome of [`resolve_device`] — a machine-readable signal
/// (no English prose) the application layer can render however it likes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1519,6 +1611,208 @@ mod command_tests {
Drive::from_transport_for_test(Box::new(FixedTransport { payload }))
}
// AACS bus-removal tests (the drive's single de-bus point): a clear "content"
// buffer with plaintext first-16-of-each-sector (bus enc leaves those clear on
// the wire) and a known 16..2048 pattern; `sectors` exercises cross-sector gating.
fn clear_bus_content(sectors: usize) -> Vec<u8> {
let mut v = vec![0u8; sectors * 2048];
for (i, b) in v.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(31).wrapping_add(7);
}
v
}
// Passthrough (the default) is a no-op: every read byte comes back verbatim,
// even when the wire happens to be clear content.
#[test]
fn bus_passthrough_read_returns_bytes_unchanged() {
use crate::sector::SectorSource;
let clear = clear_bus_content(3);
let mut d = drive_with(clear.clone());
let mut got = vec![0u8; clear.len()];
let n = d.read_sectors(0, 3, &mut got, false).unwrap();
assert_eq!(n, clear.len());
assert_eq!(got, clear, "default Passthrough must not alter any byte");
}
// Host-key stage de-busses at read time: a bus-ENCRYPTED wire comes back as
// the known plaintext. MUTATION: leaving `bus_stage` Passthrough (dropping the
// de-bus call) returns ciphertext, so this goes red.
#[test]
fn bus_host_key_removes_bus_encryption_at_read() {
use crate::sector::SectorSource;
let rdk = [0x5Au8; 16];
let clear = clear_bus_content(3);
let mut wire = clear.clone();
crate::aacs::content::encrypt_bus(&mut wire, &rdk); // model the drive's forward bus transform
assert_ne!(wire, clear, "fixture must actually be bus-encrypted");
let mut d = drive_with(wire);
d.set_bus_stage(crate::sector::bus_removal::BusStage::AacsHostKey(rdk));
let mut got = vec![0u8; clear.len()];
let n = d.read_sectors(0, 3, &mut got, false).unwrap();
assert_eq!(n, clear.len());
assert_eq!(
got, clear,
"AacsHostKey must recover the plaintext byte-for-byte"
);
}
// The FUA read path applies the same single de-bus step (the per-sector
// recovery lever must not ship ciphertext).
#[test]
fn bus_host_key_removes_bus_encryption_on_fua_read() {
use crate::sector::SectorSource;
let rdk = [0x5Au8; 16];
let clear = clear_bus_content(3);
let mut wire = clear.clone();
crate::aacs::content::encrypt_bus(&mut wire, &rdk);
let mut d = drive_with(wire);
d.set_bus_stage(crate::sector::bus_removal::BusStage::AacsHostKey(rdk));
let mut got = vec![0u8; clear.len()];
let n = d.read_sectors_fua(0, 3, &mut got, false, true).unwrap();
assert_eq!(n, clear.len());
assert_eq!(got, clear, "the FUA path must de-bus too");
}
// Content-range gating: a sector OUTSIDE the encrypted-content extents is
// clear filesystem/nav and must pass through untouched — de-bussing it would
// corrupt plaintext. LBA 0 is outside content [300,303).
#[test]
fn bus_content_ranges_pass_through_sectors_outside_content() {
use crate::sector::SectorSource;
let rdk = [0x5Au8; 16];
let clear = clear_bus_content(1); // clear filesystem sector on the wire
let mut d = drive_with(clear.clone());
d.set_bus_stage(crate::sector::bus_removal::BusStage::AacsHostKey(rdk));
d.set_bus_content_ranges(std::sync::Arc::from(
vec![(300u32, 3u32)].into_boxed_slice(),
));
let mut got = vec![0u8; clear.len()];
// LBA 0 is outside content → no de-bus → bytes unchanged.
let n = d.read_sectors(0, 1, &mut got, false).unwrap();
assert_eq!(n, clear.len());
assert_eq!(
got, clear,
"a sector outside content must pass through untouched"
);
}
// The complement: a bus-encrypted sector INSIDE the content map is de-bussed.
#[test]
fn bus_content_ranges_debus_sectors_inside_content() {
use crate::sector::SectorSource;
let rdk = [0x33u8; 16];
let clear = clear_bus_content(3);
let mut wire = clear.clone();
crate::aacs::content::encrypt_bus(&mut wire, &rdk);
let mut d = drive_with(wire);
d.set_bus_stage(crate::sector::bus_removal::BusStage::AacsHostKey(rdk));
d.set_bus_content_ranges(std::sync::Arc::from(
vec![(300u32, 3u32)].into_boxed_slice(),
));
let mut got = vec![0u8; clear.len()];
// Read the 3 content sectors at LBA 300 → all inside → de-bussed to plaintext.
let n = d.read_sectors(300, 3, &mut got, false).unwrap();
assert_eq!(n, clear.len());
assert_eq!(got, clear, "content sectors must be de-bussed to plaintext");
}
// Partial gating within one read: content [301,302) covers only the middle
// sector of a 3-sector read at LBA 300. Only that sector is de-bussed; the
// clear neighbours (300, 302) pass through untouched.
#[test]
fn bus_content_ranges_gate_per_sector_within_a_read() {
use crate::sector::SectorSource;
let rdk = [0x44u8; 16];
// Sector 1 is bus-encrypted content; sectors 0 and 2 are clear on the wire.
let clear = clear_bus_content(3);
let mut wire = clear.clone();
let mut mid = clear[2048..4096].to_vec();
crate::aacs::content::encrypt_bus(&mut mid, &rdk); // encrypt only the middle sector's body
wire[2048..4096].copy_from_slice(&mid);
let mut d = drive_with(wire);
d.set_bus_stage(crate::sector::bus_removal::BusStage::AacsHostKey(rdk));
d.set_bus_content_ranges(std::sync::Arc::from(
vec![(301u32, 1u32)].into_boxed_slice(),
));
let mut got = vec![0u8; clear.len()];
d.read_sectors(300, 3, &mut got, false).unwrap();
// All three sectors come back as the known plaintext: 0 and 2 were clear
// and untouched; 1 was de-bussed back to clear.
assert_eq!(
got, clear,
"only the in-range sector is de-bussed; clear neighbours untouched"
);
}
// F1 fail-safe: an AacsHostKey stage with an EMPTY content map must de-bus
// NOTHING (empty `Some([])`), not everything — Disc::scan always installs the
// map now, so a host-key disc that parsed no titles leaves clear sectors alone.
#[test]
fn bus_empty_content_ranges_debus_nothing() {
use crate::sector::SectorSource;
let rdk = [0x7Eu8; 16];
let clear = clear_bus_content(2);
let mut wire = clear.clone();
crate::aacs::content::encrypt_bus(&mut wire, &rdk);
let mut d = drive_with(wire.clone());
d.set_bus_stage(crate::sector::bus_removal::BusStage::AacsHostKey(rdk));
// Empty map = "no content anywhere" → de-bus nothing (fail-safe).
d.set_bus_content_ranges(std::sync::Arc::from(
Vec::<(u32, u32)>::new().into_boxed_slice(),
));
let mut got = vec![0u8; clear.len()];
d.read_sectors(300, 2, &mut got, false).unwrap();
assert_eq!(
got, wire,
"empty content map must leave every sector untouched (still bus-encrypted), \
never de-bus clear bytes into garbage"
);
}
// scan()'s handshake→bus_stage wiring, via the `wire_bus_removal` seam. CERT
// route: a Some(rdk) handshake arms AacsHostKey + installs the content map, so
// an in-range bus-encrypted sector is de-bussed to plaintext.
#[test]
fn wire_bus_removal_cert_route_arms_host_key_and_ranges() {
use crate::sector::SectorSource;
let rdk = [0x6Bu8; 16];
let clear = clear_bus_content(1);
let mut wire = clear.clone();
crate::aacs::content::encrypt_bus(&mut wire, &rdk);
let mut d = drive_with(wire);
crate::disc::Disc::wire_bus_removal(&mut d, Some(rdk), vec![(300u32, 3u32)]);
let mut got = vec![0u8; clear.len()];
d.read_sectors(300, 1, &mut got, false).unwrap();
assert_eq!(
got, clear,
"Some(read_data_key) must arm AacsHostKey + ranges so content is de-bussed"
);
}
// FIRMWARE/vendor route: a None handshake maps to Passthrough, so even an
// in-range sector is NOT de-bussed. MUTATION: mapping None to AacsHostKey
// would corrupt these bytes here.
#[test]
fn wire_bus_removal_firmware_route_is_passthrough() {
use crate::sector::SectorSource;
let rdk = [0x6Bu8; 16];
let clear = clear_bus_content(1);
let mut wire = clear.clone();
crate::aacs::content::encrypt_bus(&mut wire, &rdk); // still bus-encrypted on the wire
let mut d = drive_with(wire.clone());
crate::disc::Disc::wire_bus_removal(&mut d, None, vec![(300u32, 3u32)]);
let mut got = vec![0u8; clear.len()];
d.read_sectors(300, 1, &mut got, false).unwrap();
assert_eq!(
got, wire,
"None must map to Passthrough — no host-side de-bus, bytes returned verbatim"
);
}
#[test]
fn read_capacity_normal_adds_one() {
// last_lba = 0x0000_0063 (99) → capacity 100 sectors.
+70 -7
View File
@@ -226,6 +226,33 @@ impl ResolveCtx for DiscInputsCtx<'_> {
}
}
/// The de-conflated result of a source's unit-key resolution: the keys it
/// produced (empty when none) PLUS why it produced them — so a caller can tell
/// a genuine "no entry for this disc" from a MATCHED disc that yielded no
/// derivable key. The bare `Vec` of [`KeySource::get_unit_keys`] cannot make
/// that distinction; [`KeySource::resolve_unit_keys`] carries it.
#[derive(Debug, Clone, Default)]
pub struct UnitKeyResolution {
/// Terminal Unit Keys produced (empty = this source yielded none).
pub keys: Vec<UnitKey>,
/// The source matched this disc in its store (by hash / VID), even if it
/// then derived no key. `false` = a genuine miss ("no entry").
pub matched: bool,
/// When `matched` and `keys` is empty: the derivation nodes walked AFTER the
/// implicit `MatchedDisc` node — e.g. `[NoVid]` (had a Media Key, no VID) or
/// `[NoDerivableKey]` (no material at all). Empty ⇒ the caller supplies a
/// bare `NoDerivableKey`. Ignored unless `matched` and `keys` is empty.
pub miss_path: Vec<crate::aacs::trace::KeyNode>,
/// When `matched`: a booleans-and-lengths shape of the matched entry (no key
/// material), for the application to log. `None` for a miss or a source kind
/// with no such shape.
pub matched_entry: Option<crate::aacs::trace::MatchedEntry>,
/// Number of per-disc entries loaded in this source's store, when known — so
/// a true-miss verdict can name the store size. `None` for a source that
/// carries no such count.
pub store_entries: Option<usize>,
}
/// A key source: an adapter over a backing store that resolves a disc's terminal
/// Unit Keys.
///
@@ -240,6 +267,21 @@ pub trait KeySource {
/// `Vec` is a genuine "no key here"; `Err` is a source failure.
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error>;
/// De-conflated counterpart to [`get_unit_keys`](Self::get_unit_keys):
/// besides the keys, report whether this disc MATCHED the source's store and
/// (on a keyless match) why nothing was derivable — so the trace can render
/// `matched disc > no VID > NO KEY` instead of a flat `no entry`.
///
/// The default is coarse: forward to [`get_unit_keys`](Self::get_unit_keys)
/// and report `matched = false`. A source that keys on a per-disc identity
/// (a keydb) overrides this to set `matched`/`miss_path`/`matched_entry`.
fn resolve_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<UnitKeyResolution, Error> {
Ok(UnitKeyResolution {
keys: self.get_unit_keys(ctx)?,
..Default::default()
})
}
/// Resolve this disc's AACS 2.1 forensic index keys — the per-index keys the
/// base Unit Key cannot open (see [`crate::aacs::segment`]) — ordered by
/// forensic index (element `i` carries `UnitKey.idx == i`, forensic index
@@ -312,10 +354,11 @@ pub fn resolve_and_apply_traced(
for source in sources {
// `who` is the source's own stable identifier — no enum to map back to.
let who = source.label().to_string();
match source.get_unit_keys(&ctx) {
Ok(uks) if !uks.is_empty() => {
match source.resolve_unit_keys(&ctx) {
Ok(r) if !r.keys.is_empty() => {
// Positional index → canonical CPS-unit number (position + 1).
let unit_keys: Vec<(u32, [u8; 16])> = uks
let unit_keys: Vec<(u32, [u8; 16])> = r
.keys
.iter()
.map(|uk| (uk.idx.saturating_add(1), uk.key))
.collect();
@@ -327,6 +370,8 @@ pub fn resolve_and_apply_traced(
who,
path: vec![KeyNode::FoundUnitKeys, KeyNode::DerivedUnitKeys],
outcome: KeyOutcome::Resolved,
matched_entry: r.matched_entry,
store_entries: r.store_entries,
});
return (true, trace);
}
@@ -335,15 +380,31 @@ pub fn resolve_and_apply_traced(
who,
path: vec![KeyNode::FoundUnitKeys],
outcome: KeyOutcome::NoKey,
matched_entry: r.matched_entry,
store_entries: r.store_entries,
});
}
// The source ANSWERED and holds nothing for this disc. This — and
// only this — is `NoEntry`: the claim "I looked, it is not there".
Ok(_) => {
// The source ANSWERED with no keys. De-conflate a MATCHED-but-
// underivable disc from a true miss (`NoEntry`); a source that can't
// tell them apart (the trait default) reports `matched = false`.
Ok(r) => {
let path = if r.matched {
let mut p = vec![KeyNode::MatchedDisc];
if r.miss_path.is_empty() {
p.push(KeyNode::NoDerivableKey);
} else {
p.extend(r.miss_path);
}
p
} else {
vec![KeyNode::NoEntry]
};
trace.keys.push(KeyStep {
who,
path: vec![KeyNode::NoEntry],
path,
outcome: KeyOutcome::NoKey,
matched_entry: r.matched_entry,
store_entries: r.store_entries,
});
}
// The source could NOT answer (unreachable/errored/refused), so path is
@@ -357,6 +418,8 @@ pub fn resolve_and_apply_traced(
who,
path: Vec::new(),
outcome: KeyOutcome::NoKey,
matched_entry: None,
store_entries: None,
});
}
}
+719
View File
@@ -0,0 +1,719 @@
//! Generic BD-J feature-playlist resolver — the issue #45 "menu-walk"
//! authoritative layer, run when no framework parser surfaced a manifest hint.
//!
//! Two tiers, most reliable first:
//!
//! * **Tier 1 — jar-space resource sweep (High confidence).** Newer discs embed
//! the same `dcx.xml` / `playlists.xml` / `*.properties` manifests that Fox
//! and Paramount ship loose, but INSIDE a `/BDMV/JAR/*.jar` instead. The
//! feature id is still statically recoverable: reuse
//! [`fox::feature_hint`](super::fox::feature_hint) /
//! [`paramount::feature_hint_from_xml`](super::paramount::feature_hint_from_xml).
//!
//! * **Tier 2 — autostart-Xlet locator scan (best-effort, Medium).** Parse the
//! `/BDMV/BDJO/*.bdjo` Application Management Table for the AUTOSTART app's
//! jar(s) ([`crate::bdnav::bdjo`]), harvest `NNNNN.mpls` / `bd://…PLAYLIST:N`
//! String constants from that jar's `.class` constant pools, intersect them
//! with the real `/BDMV/PLAYLIST/*.mpls` set, and disambiguate by parsed
//! duration — the feature is a single candidate that dominates the runner-up
//! and carries multiple audio streams. A hint is emitted ONLY when a single
//! dominant candidate survives; integer-only evidence never emits.
//!
//! Everything failing here (modern-Fox pure-bytecode `StandardMenuXlet` with no
//! usable `.mpls` string, no usable integer constant) is left to the chapter
//! failsafe in `disc::mod`. This module is read-only, bounded, and never panics
//! (the caller also wraps it in `catch_unwind`).
use super::class_reader::CpInfo;
use super::{FeaturePlaylistHint, fox, jar, paramount};
use crate::bdnav::bdjo;
use crate::sector::SectorSource;
use crate::udf::UdfFs;
use std::collections::HashSet;
/// Resolve the disc's feature playlist by walking the BD-J jar space. Returns a
/// hint only when one is unambiguously recoverable; `None` otherwise (defer to
/// the failsafe).
pub(crate) fn resolve(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<FeaturePlaylistHint> {
if let Some(hint) = tier1_manifest_sweep(reader, udf).filter(|h| !h.is_empty()) {
tracing::info!(?hint, "bdj menu-walk: Tier-1 embedded manifest hint");
return Some(hint);
}
if let Some(hint) = tier2_locator_scan(reader, udf).filter(|h| !h.is_empty()) {
tracing::info!(?hint, "bdj menu-walk: Tier-2 autostart-Xlet locator hint");
return Some(hint);
}
None
}
// ── Tier 1 ───────────────────────────────────────────────────────────────────
// Cap on bytes decoded from a single embedded manifest as UTF-8. Manifests are
// small; a hostile jar entry can't force an unbounded scan.
const MAX_MANIFEST_BYTES: usize = 4 * 1024 * 1024;
fn tier1_manifest_sweep(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<FeaturePlaylistHint> {
jar::for_each_jar(reader, udf, |_entry, archive| {
jar::try_each_resource(archive, |name, bytes| {
if bytes.len() > MAX_MANIFEST_BYTES {
return None;
}
let text = std::str::from_utf8(bytes).ok()?;
hint_from_manifest(name, text)
})
})
}
// Derive a feature hint from one embedded manifest, dispatched by filename.
fn hint_from_manifest(name: &str, text: &str) -> Option<FeaturePlaylistHint> {
let lower = name.to_ascii_lowercase();
if lower.ends_with("dcx.xml") {
fox::feature_hint(text)
} else if lower.ends_with("playlists.xml") {
paramount::feature_hint_from_xml(text)
} else if lower.ends_with(".properties") || lower.ends_with(".version") {
props_hint(text)
} else {
None
}
}
// Scan a `key=value` properties/version manifest for a feature-playlist id. A
// key naming both a feature/playlist AND an id (or playlist) whose value carries
// a u16 playlist number yields the canonical `NNNNN.mpls` hint.
fn props_hint(text: &str) -> Option<FeaturePlaylistHint> {
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') || line.starts_with('!') {
continue;
}
let Some((key, val)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_ascii_lowercase();
let names_playlist = key.contains("feature") || key.contains("playlist");
let names_id = key.contains("id") || key.contains("playlist");
if !(names_playlist && names_id) {
continue;
}
if let Some(id) = playlist_id_from_str(val.trim()) {
return Some(hint_for(id));
}
}
None
}
// ── Tier 2 ───────────────────────────────────────────────────────────────────
// Cap on distinct harvested playlist-id candidates — bounds work on a hostile
// jar. Real menus reference a handful.
const MAX_CANDIDATES: usize = 1024;
// The feature must run at least this many times longer than the runner-up to be
// unambiguous; a closer field is not resolvable here and defers to the failsafe.
const DOMINANCE_RATIO: f64 = 1.5;
fn tier2_locator_scan(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<FeaturePlaylistHint> {
// 1. AUTOSTART app's jar(s) from the BDJO AMT. Empty → scan every jar.
let targets = autostart_jar_ids(reader, udf);
// 2. Harvest playlist-id candidates from those jars' `.class` constant pools.
let candidates = harvest_candidates(reader, udf, &targets);
if candidates.is_empty() {
return None;
}
// 3. Intersect with the playlists that actually exist on the disc.
let real = real_playlist_ids(udf);
let hits: Vec<u16> = candidates
.into_iter()
.filter(|id| real.contains(id))
.collect();
if hits.is_empty() {
return None;
}
// 4. Score each survivor by (duration secs, primary-audio count).
let mut scored: Vec<(u16, u64, usize)> = hits
.into_iter()
.filter_map(|id| mpls_stats(reader, udf, id).map(|(secs, aud)| (id, secs, aud)))
.collect();
if scored.is_empty() {
return None;
}
// Longest first; lowest id breaks a duration tie (deterministic).
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
// 5. Emit only a single dominant, multi-audio candidate.
choose_dominant(&scored).map(hint_for)
}
// The dominant feature id, or None when the field is ambiguous. Requires the top
// candidate to carry multiple audio streams and (when a runner-up exists) to run
// at least DOMINANCE_RATIO times longer than it.
fn choose_dominant(scored: &[(u16, u64, usize)]) -> Option<u16> {
let (top_id, top_secs, top_aud) = *scored.first()?;
if top_aud < 2 {
return None;
}
match scored.get(1) {
None => Some(top_id), // single candidate: unambiguous
Some(&(_, runner_secs, _)) => {
(top_secs as f64 >= DOMINANCE_RATIO * runner_secs as f64).then_some(top_id)
}
}
}
// The jar ids of every AUTOSTART application across all `.bdjo` files. Empty when
// there is no readable BDJO — Tier-2 then scans every jar.
fn autostart_jar_ids(reader: &mut dyn SectorSource, udf: &UdfFs) -> HashSet<String> {
let mut out = HashSet::new();
let Some(dir) = udf.find_dir("/BDMV/BDJO") else {
return out;
};
let names: Vec<String> = dir
.entries
.iter()
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".bdjo"))
.map(|e| e.name.clone())
.collect();
for name in names {
let path = format!("/BDMV/BDJO/{name}");
let Ok(data) = udf.read_file(reader, &path) else {
continue;
};
let Some(apps) = bdjo::parse(&data) else {
continue;
};
for app in apps.iter().filter(|a| a.is_autostart()) {
for id in app.jar_ids() {
out.insert(id);
}
}
}
out
}
// Harvest playlist-id candidates from `.class` constant pools. When `targets` is
// non-empty, only jars whose id is in it are scanned (the autostart classpath);
// otherwise every jar is scanned.
fn harvest_candidates(
reader: &mut dyn SectorSource,
udf: &UdfFs,
targets: &HashSet<String>,
) -> HashSet<u16> {
let mut ids = HashSet::new();
let _: Option<()> = jar::for_each_jar(reader, udf, |entry_name, archive| {
if !targets.is_empty() && !targets.contains(jar_stem(entry_name)) {
return None;
}
jar::for_each_class(archive, |_class_name, class| {
for (_, cp) in class.constant_pool.iter() {
if let CpInfo::Utf8(s) = cp {
scan_locator_ids(s, &mut ids);
}
}
});
None
});
ids
}
// The jar id of a `/BDMV/JAR/*.jar` entry name ("00000.jar" -> "00000").
fn jar_stem(entry_name: &str) -> &str {
let lower = entry_name.to_ascii_lowercase();
if lower.ends_with(".jar") {
&entry_name[..entry_name.len() - 4]
} else {
entry_name
}
}
// Harvest playlist ids from one string: `NNNNN.mpls` filenames and
// `PLAYLIST:NNNNN` locator constants. Integer-only evidence is intentionally NOT
// harvested here — the design leaves bare integers to the failsafe.
fn scan_locator_ids(s: &str, out: &mut HashSet<u16>) {
if out.len() >= MAX_CANDIDATES {
return;
}
let lower = s.to_ascii_lowercase();
let bytes = s.as_bytes(); // same byte positions as `lower` (ASCII case only)
// `NNNNN.mpls`: digits immediately preceding a ".mpls".
let mut from = 0;
while let Some(rel) = lower[from..].find(".mpls") {
let at = from + rel;
let mut start = at;
while start > 0 && bytes[start - 1].is_ascii_digit() {
start -= 1;
}
if start < at
&& let Ok(id) = s[start..at].parse::<u16>()
{
out.insert(id);
if out.len() >= MAX_CANDIDATES {
return;
}
}
from = at + ".mpls".len();
}
// `PLAYLIST:NNNNN`: digits immediately following a "playlist:" token.
let mut from = 0;
while let Some(rel) = lower[from..].find("playlist:") {
let at = from + rel + "playlist:".len();
let mut end = at;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
if end > at
&& let Ok(id) = s[at..end].parse::<u16>()
{
out.insert(id);
if out.len() >= MAX_CANDIDATES {
return;
}
}
from = at.max(from + 1);
}
}
// The playlist ids present as `/BDMV/PLAYLIST/NNNNN.mpls`.
fn real_playlist_ids(udf: &UdfFs) -> HashSet<u16> {
let mut out = HashSet::new();
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
return out;
};
for e in &dir.entries {
if e.is_dir || !e.name.to_ascii_lowercase().ends_with(".mpls") {
continue;
}
let stem = &e.name[..e.name.len() - ".mpls".len()];
if let Ok(id) = stem.parse::<u16>() {
out.insert(id);
}
}
out
}
// (duration_secs, primary-audio-stream count) for one playlist id, or None if it
// cannot be read/parsed.
fn mpls_stats(reader: &mut dyn SectorSource, udf: &UdfFs, id: u16) -> Option<(u64, usize)> {
let path = format!("/BDMV/PLAYLIST/{id:05}.mpls");
let data = udf.read_file(reader, &path).ok()?;
let pl = crate::mpls::parse(&data).ok()?;
let ticks: u64 = pl
.play_items
.iter()
.map(|pi| pi.out_time.saturating_sub(pi.in_time) as u64)
.sum();
let secs = ticks / 45_000;
// stream_type 2 == primary audio (see mpls::StreamEntry).
let audio = pl.streams.iter().filter(|s| s.stream_type == 2).count();
Some((secs, audio))
}
// ── Shared helpers ───────────────────────────────────────────────────────────
fn hint_for(id: u16) -> FeaturePlaylistHint {
FeaturePlaylistHint {
playlist_id: Some(id),
filename: Some(format!("{id:05}.mpls")),
}
}
// A playlist id from a free-form string value: keep leading digits, parse u16.
fn playlist_id_from_str(v: &str) -> Option<u16> {
let digits: String = v.chars().take_while(|c| c.is_ascii_digit()).collect();
let digits = if digits.is_empty() {
v.chars().filter(|c| c.is_ascii_digit()).collect()
} else {
digits
};
digits.parse::<u16>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::udf::fixture::{DirSpec, FileSpec, MemDisc, build_udf_skeleton, file_with, lay_dir};
use std::io::{Cursor, Write as _};
// ── scan_locator_ids ────────────────────────────────────────────────────
#[test]
fn scan_finds_mpls_filenames() {
let mut ids = HashSet::new();
scan_locator_ids("bd://JAR:00000/00800.mpls", &mut ids);
assert!(ids.contains(&800));
let mut ids = HashSet::new();
scan_locator_ids("00243.MPLS", &mut ids);
assert!(ids.contains(&243));
}
#[test]
fn scan_finds_playlist_locator_constants() {
let mut ids = HashSet::new();
scan_locator_ids("bd://0.PLAYLIST:00800", &mut ids);
assert!(ids.contains(&800));
}
#[test]
fn scan_ignores_bare_integers() {
// An integer-only string carries no ".mpls"/"PLAYLIST:" locator, so it
// must NOT become a candidate — bare integers defer to the failsafe.
let mut ids = HashSet::new();
scan_locator_ids("800", &mut ids);
scan_locator_ids("theNumberIs 00800 done", &mut ids);
assert!(ids.is_empty());
}
// ── choose_dominant ─────────────────────────────────────────────────────
#[test]
fn dominant_single_multi_audio_candidate_wins() {
assert_eq!(choose_dominant(&[(800, 7000, 6)]), Some(800));
}
#[test]
fn dominant_requires_multiple_audio() {
// A lone candidate with a single audio stream is not a feature.
assert_eq!(choose_dominant(&[(800, 7000, 1)]), None);
}
#[test]
fn dominant_needs_1_5x_over_runner_up() {
// 7000 vs 5000 = 1.4x → ambiguous → None.
assert_eq!(choose_dominant(&[(800, 7000, 6), (801, 5000, 6)]), None);
// 7000 vs 4000 = 1.75x → dominant.
assert_eq!(
choose_dominant(&[(800, 7000, 6), (801, 4000, 6)]),
Some(800)
);
}
#[test]
fn dominant_two_equal_durations_is_none() {
assert_eq!(choose_dominant(&[(800, 7000, 6), (801, 7000, 6)]), None);
}
// ── props_hint ──────────────────────────────────────────────────────────
#[test]
fn props_hint_reads_feature_playlist_key() {
let text = "menu.jar=00001\nfeature.playlist.id=00800\nfoo=bar\n";
let h = props_hint(text).expect("hint");
assert_eq!(h.playlist_id, Some(800));
assert_eq!(h.filename.as_deref(), Some("00800.mpls"));
}
#[test]
fn props_hint_ignores_unrelated_keys() {
assert!(props_hint("version=1\nbuild=42\n").is_none());
}
// ── mpls fixture builder (compact single-play-item playlist) ─────────────
fn audio_stream_entry(pid: u16) -> Vec<u8> {
let mut out = vec![3u8, 0x01]; // se_len, type = PlayItem clip
out.extend_from_slice(&pid.to_be_bytes());
// attrs: coding_type(TrueHD) + (5.1<<4|48k) + language
let attrs = [0x83u8, (6 << 4) | 1, b'e', b'n', b'g'];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
// A playlist: one play item of `secs` seconds with `n_audio` audio streams.
fn build_mpls(secs: u32, n_audio: u8) -> Vec<u8> {
let playlist_start: u32 = 40;
let mut buf = Vec::new();
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&playlist_start.to_be_bytes());
buf.extend_from_slice(&[0u8; 28]); // mark_start + pad to 40
let pl_start = buf.len();
buf.extend_from_slice(&[0u8; 4]); // length placeholder
buf.extend_from_slice(&[0u8; 2]); // reserved
buf.extend_from_slice(&1u16.to_be_bytes()); // num_play_items
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
let mut item = Vec::new();
item.extend_from_slice(b"00000"); // clip_id
item.extend_from_slice(b"M2TS");
item.push(0); // reserved
item.push(0); // is_multi_angle + connection_condition
item.push(0); // stc_id
item.extend_from_slice(&0u32.to_be_bytes()); // in_time
item.extend_from_slice(&(secs * 45000).to_be_bytes()); // out_time
item.extend_from_slice(&[0u8; 8]); // UO mask
item.push(0); // misc
item.push(0); // still_mode
item.extend_from_slice(&[0u8; 2]); // still_time
// STN table
let stn_start = item.len();
item.extend_from_slice(&[0u8; 2]); // length placeholder
item.extend_from_slice(&[0u8; 2]); // reserved
item.push(0); // n_video
item.push(n_audio); // n_audio
item.extend_from_slice(&[0u8; 6]); // pg/ig/sec.../dv counts
item.extend_from_slice(&[0u8; 4]); // reserved
for i in 0..n_audio {
item.extend_from_slice(&audio_stream_entry(0x1100 + i as u16));
}
let stn_len = (item.len() - stn_start - 2) as u16;
item[stn_start..stn_start + 2].copy_from_slice(&stn_len.to_be_bytes());
buf.extend_from_slice(&(item.len() as u16).to_be_bytes());
buf.extend_from_slice(&item);
let pl_len = (buf.len() - pl_start - 4) as u32;
buf[pl_start..pl_start + 4].copy_from_slice(&pl_len.to_be_bytes());
buf
}
// Minimal `.class` carrying the given Utf8 constants (no fields/methods).
fn build_class(utf8: &[&str]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&0xCAFEBABEu32.to_be_bytes());
out.extend_from_slice(&0u16.to_be_bytes()); // minor
out.extend_from_slice(&52u16.to_be_bytes()); // major
out.extend_from_slice(&((utf8.len() + 1) as u16).to_be_bytes());
for s in utf8 {
out.push(1);
out.extend_from_slice(&(s.len() as u16).to_be_bytes());
out.extend_from_slice(s.as_bytes());
}
out.extend_from_slice(&[0u8; 14]); // access/this/super/interfaces/fields/methods/attrs (7 u16)
out
}
// Zip entries into an in-memory jar (Stored).
fn build_jar(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, data) in entries {
w.start_file(*name, opts).unwrap();
w.write_all(data).unwrap();
}
w.finish().unwrap();
}
buf
}
// Lay a BDMV tree with the given PLAYLIST + JAR (+ optional BDJO) files onto
// an in-memory disc and return (disc, udf). LBAs are hand-assigned and never
// overlap.
fn build_disc(
playlists: Vec<(&str, Vec<u8>)>,
jars: Vec<(&str, Vec<u8>)>,
bdjos: Vec<(&str, Vec<u8>)>,
) -> (MemDisc, crate::udf::UdfFs) {
let mut icb = 100u32;
let mut data = 2000u32;
let mut next = |sz: u64| {
let (i, d) = (icb, data);
icb += 1;
data += (sz / 2048) as u32 + 2;
(i, d)
};
let mk = |files: Vec<(&str, Vec<u8>)>, next: &mut dyn FnMut(u64) -> (u32, u32)| {
files
.into_iter()
.map(|(name, bytes)| {
let (i, d) = next(bytes.len() as u64);
file_with(name, i, d, bytes, true)
})
.collect::<Vec<FileSpec>>()
};
let playlist_dir = DirSpec {
name: "PLAYLIST".to_string(),
icb_lba: 50,
dir_data_lba: 51,
files: mk(playlists, &mut next),
subdirs: vec![],
};
let jar_dir = DirSpec {
name: "JAR".to_string(),
icb_lba: 52,
dir_data_lba: 53,
files: mk(jars, &mut next),
subdirs: vec![],
};
let mut subdirs = vec![playlist_dir, jar_dir];
if !bdjos.is_empty() {
subdirs.push(DirSpec {
name: "BDJO".to_string(),
icb_lba: 54,
dir_data_lba: 55,
files: mk(bdjos, &mut next),
subdirs: vec![],
});
}
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 12,
dir_data_lba: 13,
files: vec![],
subdirs,
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: vec![],
subdirs: vec![bdmv],
};
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
(disc, udf)
}
// ── Tier 1 integration ──────────────────────────────────────────────────
#[test]
fn tier1_resolves_from_embedded_playlists_xml() {
let xml = br#"<playlists>
<playlist name="Feature" id="00800" aud="eng,fra,spa" duration="7000" />
<playlist name="Preview" id="00050" aud="eng" duration="120" />
</playlists>"#;
let jar = build_jar(&[
("com/studio/Menu.class", build_class(&["unrelated"])),
("00000/playlists.xml", xml.to_vec()),
]);
let (mut disc, udf) = build_disc(vec![], vec![("00000.jar", jar)], vec![]);
let hint = resolve(&mut disc, &udf).expect("Tier-1 hint");
assert_eq!(hint.playlist_id, Some(800));
}
// ── Tier 2 integration ──────────────────────────────────────────────────
#[test]
fn tier2_resolves_dominant_mpls_locator() {
// Autostart jar's class references "00800.mpls"; the disc has a long
// 00800 (multi-audio) and a short 00801. No manifest → Tier-2 resolves.
let class = build_class(&["bd://JAR:00000/00800.mpls", "some/other/String"]);
let jar = build_jar(&[("com/studio/MainXlet.class", class)]);
let (mut disc, udf) = build_disc(
vec![
("00800.mpls", build_mpls(7000, 6)),
("00801.mpls", build_mpls(120, 2)),
],
vec![("00000.jar", jar)],
vec![], // no BDJO → scan all jars
);
let hint = resolve(&mut disc, &udf).expect("Tier-2 hint");
assert_eq!(hint.playlist_id, Some(800));
assert_eq!(hint.filename.as_deref(), Some("00800.mpls"));
}
#[test]
fn tier2_abstains_when_candidate_not_a_real_playlist() {
// The class points at 09999.mpls, which is not on the disc → no hit.
let class = build_class(&["09999.mpls"]);
let jar = build_jar(&[("com/studio/MainXlet.class", class)]);
let (mut disc, udf) = build_disc(
vec![("00800.mpls", build_mpls(7000, 6))],
vec![("00000.jar", jar)],
vec![],
);
assert!(resolve(&mut disc, &udf).is_none());
}
#[test]
fn tier2_abstains_on_integer_only_bytecode() {
// The autostart class carries only a bare integer constant (800) and no
// .mpls / PLAYLIST: locator string — modern-Fox StandardMenuXlet shape.
// No candidate is harvested → defer to the chapter failsafe.
let mut class = Vec::new();
class.extend_from_slice(&0xCAFEBABEu32.to_be_bytes());
class.extend_from_slice(&0u16.to_be_bytes());
class.extend_from_slice(&52u16.to_be_bytes());
class.extend_from_slice(&2u16.to_be_bytes()); // cp_count = 2 (one entry)
class.push(3); // CONSTANT_Integer
class.extend_from_slice(&800u32.to_be_bytes());
class.extend_from_slice(&[0u8; 14]);
let jar = build_jar(&[("com/foxbd/StandardMenuXlet.class", class)]);
let (mut disc, udf) = build_disc(
vec![("00800.mpls", build_mpls(7000, 6))],
vec![("00000.jar", jar)],
vec![],
);
assert!(resolve(&mut disc, &udf).is_none());
}
#[test]
fn tier2_uses_bdjo_autostart_jar_targeting() {
// Two jars; only the autostart one (00000) names the feature. A decoy jar
// (00009) points at a different real playlist. The BDJO AMT pins 00000 as
// autostart, so only its candidate (00800) is harvested.
let auto_class = build_class(&["00800.mpls"]);
let decoy_class = build_class(&["00801.mpls"]);
let auto_jar = build_jar(&[("com/studio/MainXlet.class", auto_class)]);
let decoy_jar = build_jar(&[("com/studio/Decoy.class", decoy_class)]);
let bdjo = bdjo_with_autostart("00000");
let (mut disc, udf) = build_disc(
vec![
("00800.mpls", build_mpls(7000, 6)),
// 00801 is LONGER, so if the decoy jar were scanned it would win.
("00801.mpls", build_mpls(9000, 6)),
],
vec![("00000.jar", auto_jar), ("00009.jar", decoy_jar)],
vec![("00000.bdjo", bdjo)],
);
let hint = resolve(&mut disc, &udf).expect("Tier-2 hint via BDJO targeting");
assert_eq!(
hint.playlist_id,
Some(800),
"only the autostart jar's candidate should be considered"
);
}
// Build a minimal single-app BDJO whose AUTOSTART app has the given base_dir.
fn bdjo_with_autostart(base_dir: &str) -> Vec<u8> {
fn app_string(buf: &mut Vec<u8>, s: &str) {
buf.push(s.len() as u8);
buf.extend_from_slice(s.as_bytes());
if s.len().is_multiple_of(2) {
buf.push(0);
}
}
let mut b = Vec::new();
b.extend_from_slice(b"BDJO");
b.extend_from_slice(b"0200");
b.extend_from_slice(&[0u8; 40]); // section-address table
b.extend_from_slice(&[0u8; 4]); // TerminalInfo length
b.extend_from_slice(&[0u8; 5]); // default_font
b.extend_from_slice(&[0u8; 5]); // havi/masks + padding (40 bits)
b.extend_from_slice(&[0u8; 4]); // AppCacheInfo length
b.push(0); // num_item
b.push(0); // padding
b.extend_from_slice(&[0u8; 4]); // AccessiblePlaylists length
b.extend_from_slice(&[0u8; 4]); // num_pl(11)+flags(2)+pad(19) = 0
b.extend_from_slice(&[0u8; 4]); // AMT length
b.push(1); // num_app
b.push(0); // padding
// App record
b.push(1); // control_code = AUTOSTART
b.push(0); // type(4)+reserved(4)
b.extend_from_slice(&[0u8; 4]); // org_id
b.extend_from_slice(&[0u8; 2]); // app_id
b.extend_from_slice(&[0u8; 10]); // descriptor tag+length
b.push(0); // num_profile(4)+pad
b.push(0);
b.push(0); // priority
b.push(0); // binding/visibility/reserved
b.extend_from_slice(&[0u8; 2]); // app_name data_length = 0
app_string(&mut b, ""); // icon_locator
b.extend_from_slice(&[0u8; 2]); // icon_flags
app_string(&mut b, base_dir); // base_dir
app_string(&mut b, ""); // classpath_extension
app_string(&mut b, "com.studio.MainXlet"); // initial_class
b.push(0); // params data_length = 0
b.push(0); // word-align pad
b
}
}
+65 -12
View File
@@ -155,12 +155,25 @@ pub(crate) fn labels_from_dcx(text: &str) -> Vec<StreamLabel> {
labels
}
// Pick the richest `<playlist name="feature">` element (ties to first) —
// Fox presses one per regional variant. Returning ONE avoids merging two
// STN tables. See docs/fox.md for full rationale.
// A feature runs at least this long (`durs` is seconds — durs=7628 is a 2h7m
// feature). A stated sub-minute `name="feature"` is a decoy and is never
// selected; the guard is inert when no `durs` is present. Shared with paramount.
use super::MIN_FEATURE_SECS;
// The `durs` (seconds) of a `<playlist>` element, digits only, when stated.
fn playlist_duration_secs(element: &str) -> Option<u64> {
let v = xml::attr(element, "durs")?;
let digits: String = v.chars().filter(|c| c.is_ascii_digit()).collect();
digits.parse::<u64>().ok().filter(|&d| d > 0)
}
// Pick the richest `<playlist name="feature">` (most nested streams; a `durs`
// tiebreak breaks a count tie by longest time; first feature wins a full tie; a
// stated sub-minute feature is skipped). See docs/fox.md for full rationale.
fn select_feature_playlist(text: &str) -> Option<&str> {
let mut best: Option<&str> = None;
let mut best_streams = 0usize;
// rank = (nested stream count, durs seconds). None durs sorts as 0 so it
// never displaces a stream-count-equal sibling that states a longer time.
let mut best: Option<(&str, (usize, u64))> = None;
let mut from = 0;
while let Some((s, e)) = xml::find_element(text, "playlist", from) {
let element = &text[s..e];
@@ -173,16 +186,21 @@ fn select_feature_playlist(text: &str) -> Option<&str> {
if !is_feature {
continue;
}
let dur = playlist_duration_secs(element);
// A stated sub-minute feature is a decoy — never the real feature.
if dur.is_some_and(|d| d < MIN_FEATURE_SECS) {
continue;
}
let streams = count_elements(element, "audio") + count_elements(element, "subtitle");
// First feature wins the tie (matches the registry's first-wins rule);
// a strictly richer table displaces it. `best.is_none()` so a feature
// with zero nested streams is still selected — its id feeds the hint.
if best.is_none() || streams > best_streams {
best_streams = streams;
best = Some(element);
let rank = (streams, dur.unwrap_or(0));
// Strictly-better rank displaces; a full tie keeps the first feature.
// `best.is_none()` so a feature with zero nested streams is still
// selected — its id feeds the hint.
if best.is_none_or(|(_, br)| rank > br) {
best = Some((element, rank));
}
}
best
best.map(|(el, _)| el)
}
/// The authoring id (digits only, e.g. "00800") of the selected feature
@@ -485,6 +503,41 @@ mod tests {
assert_eq!(feature_playlist_id(menus_only), None);
}
/// On an equal nested-stream count, the longer `durs` (seconds) wins the
/// tiebreak rather than document order.
#[test]
fn feature_duration_breaks_a_stream_count_tie() {
let doc = r#"<dcx><disc>
<playlist id="00800" lang="eng" name="feature" durs="6000">
<audio id="01" lang="eng" type="feature"/>
<subtitle id="01" lang="eng" type="feature"/>
</playlist>
<playlist id="00900" lang="eng" name="feature" durs="8000">
<audio id="01" lang="eng" type="feature"/>
<subtitle id="01" lang="eng" type="feature"/>
</playlist>
</disc></dcx>"#;
assert_eq!(feature_playlist_id(doc), Some("00900".to_string()));
}
/// A stated sub-minute `name="feature"` is a decoy and is skipped in favour
/// of the real (longer) feature.
#[test]
fn sub_minute_feature_is_skipped() {
let doc = r#"<dcx><disc>
<playlist id="00050" lang="eng" name="feature" durs="4">
<audio id="01" lang="eng" type="feature"/>
<audio id="02" lang="fra" type="feature"/>
<subtitle id="01" lang="eng" type="feature"/>
</playlist>
<playlist id="00800" lang="eng" name="feature" durs="7628">
<audio id="01" lang="eng" type="feature"/>
</playlist>
</disc></dcx>"#;
// 00050 has more nested streams but is sub-minute → rejected; 00800 wins.
assert_eq!(feature_playlist_id(doc), Some("00800".to_string()));
}
/// A feature playlist with no nested streams (an authoring edge) produces
/// no labels rather than a spurious empty-slot entry.
#[test]
+75
View File
@@ -18,6 +18,11 @@ use zip::ZipArchive;
// pre-sizing. See docs/jar.md — MAX_CLASS_BYTES rationale.
const MAX_CLASS_BYTES: u64 = 64 * 1024 * 1024;
// Cap on bytes read from one non-`.class` resource entry (the dcx.xml /
// playlists.xml / *.properties manifests the Tier-1 sweep reads). Same
// attacker-controlled-size defence as MAX_CLASS_BYTES — see docs/jar.md.
const MAX_RESOURCE_BYTES: u64 = 16 * 1024 * 1024;
/// In-memory zip archive: backed by a `Vec<u8>` read from UDF. Owns
/// the buffer; callers pass it to [`has_path_prefix`], [`for_each_class`],
/// etc.
@@ -119,6 +124,44 @@ where
None
}
/// Iterate every NON-`.class` entry in the jar, read its bytes (bounded by
/// [`MAX_RESOURCE_BYTES`]), and call `f` with `(entry_name, &bytes)`. Returns
/// the first `Some(R)` the callback produces, or `None` when every resource was
/// visited without a hit.
///
/// This is the Tier-1 menu-walk sweep: newer discs embed the same
/// `dcx.xml`/`playlists.xml` manifests INSIDE a jar rather than as loose
/// `/BDMV/JAR/<id>/` files. Directory entries and `.class` files are skipped;
/// unreadable entries are silently ignored (robustness over completeness).
pub fn try_each_resource<R, F>(archive: &mut Jar, mut f: F) -> Option<R>
where
F: FnMut(&str, &[u8]) -> Option<R>,
{
for i in 0..archive.len() {
let Ok(entry) = archive.by_index(i) else {
continue;
};
let name = entry.name().to_string();
if name.ends_with('/') || name.to_ascii_lowercase().ends_with(".class") {
continue;
}
// Declared uncompressed size is attacker-controlled — grow the buffer
// incrementally and cap the read rather than pre-sizing from it.
let mut bytes = Vec::new();
if entry
.take(MAX_RESOURCE_BYTES)
.read_to_end(&mut bytes)
.is_err()
{
continue;
}
if let Some(r) = f(&name, &bytes) {
return Some(r);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -292,4 +335,36 @@ mod tests {
for_each_class(&mut jar, |_, _| visited += 1);
assert_eq!(visited, 1);
}
#[test]
fn try_each_resource_reads_non_class_entries_and_skips_classes() {
let xml = b"<dcx><disc/></dcx>";
// A .class entry must be skipped; the .xml resource must be surfaced.
let mut jar = {
use std::io::{Cursor, Write as _};
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
w.start_file("com/x/Menu.class", opts).unwrap();
w.write_all(MINIMAL_CLASS).unwrap();
w.start_file("00000/dcx.xml", opts).unwrap();
w.write_all(xml).unwrap();
w.finish().unwrap();
}
ZipArchive::new(Cursor::new(buf)).expect("valid zip")
};
let mut seen: Vec<String> = Vec::new();
let found: Option<Vec<u8>> = try_each_resource(&mut jar, |name, bytes| {
seen.push(name.to_string());
name.ends_with("dcx.xml").then(|| bytes.to_vec())
});
assert_eq!(found.as_deref(), Some(&xml[..]));
assert!(
!seen.iter().any(|n| n.ends_with(".class")),
"a .class entry must never be offered as a resource"
);
}
}
+157 -2
View File
@@ -8,6 +8,7 @@
//! (set [`ParseResult::confidence`] to drive tie-breaking)
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
mod bdj_feature;
mod bdmt;
pub(crate) mod class_reader;
pub mod clpi_audit;
@@ -252,13 +253,35 @@ pub fn apply(
udf: &UdfFs,
titles: &mut [DiscTitle],
) -> Option<FeaturePlaylistHint> {
let (labels, feature_playlist) =
let (labels, winner_hint) =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf)))
.unwrap_or_default();
if !labels.is_empty() {
apply_labels(&labels, titles);
}
feature_playlist.filter(|h| !h.is_empty())
// The feature hint rides its own pass, independent of whether any parser
// produced labels (like bdmt): a jar-only disc still yields a hint. Wrapped
// separately so a menu-walk fault can't lose an already-extracted hint.
let hint = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
resolve_feature_hint(reader, udf, winner_hint)
}))
.ok()
.flatten();
hint.filter(|h| !h.is_empty())
}
// The disc's feature-playlist hint: the winning parser's manifest hint when it
// carries one, else the generic BD-J menu-walk (`bdj_feature::resolve`). Kept
// separate from extraction so a hint survives an empty/absent label set.
fn resolve_feature_hint(
reader: &mut dyn SectorSource,
udf: &UdfFs,
winner_hint: Option<FeaturePlaylistHint>,
) -> Option<FeaturePlaylistHint> {
if let Some(hint) = winner_hint.filter(|h| !h.is_empty()) {
return Some(hint);
}
bdj_feature::resolve(reader, udf)
}
// Min streams of one type before a language sequence anchors the label
@@ -270,6 +293,11 @@ const MIN_ANCHOR_STREAMS: usize = 2;
// so 0 is unused; only stream_id-bearing labels may carry it.
const NO_STN_SLOT: u16 = 0;
// A stated feature playlist runs at least this long (seconds); a sub-minute
// stated duration is a decoy (Fox name=feature, Paramount _Start_Angle), never
// the feature. Inert when no duration is stated. Shared by fox and paramount.
pub(crate) const MIN_FEATURE_SECS: u64 = 60;
// Two ISO 639-2 codes that do NOT contradict: equal (case/padding
// insensitive), or either side is empty ("unknown", never a contradiction).
fn languages_compatible(a: &str, b: &str) -> bool {
@@ -3376,3 +3404,130 @@ mod clpi_orphan_tests {
assert_eq!(labels.len(), 1);
}
}
// ── apply(): independent feature-hint pass (issue #45 menu-walk) ─────────────
#[cfg(test)]
mod feature_hint_pass_tests {
use super::*;
use crate::udf::fixture::*;
use std::io::{Cursor, Write as _};
// Zip entries into an in-memory jar (Stored, no compression).
fn build_jar(entries: &[(&str, Vec<u8>)]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, data) in entries {
w.start_file(*name, opts).unwrap();
w.write_all(data).unwrap();
}
w.finish().unwrap();
}
buf
}
/// A jar-only disc — no loose manifest, no vendor labels, no parser matches —
/// still yields a feature hint, because the menu-walk hint pass runs
/// independently of `labels.is_empty()`. This is the whole point of routing
/// the hint through `resolve_feature_hint` rather than dropping it with the
/// (empty) label set. The embedded `playlists.xml` names the feature 00800.
#[test]
fn apply_returns_hint_for_a_jar_only_disc_with_no_labels() {
let xml = br#"<playlists>
<playlist name="Feature" id="00800" aud="eng,fra,spa" duration="7000" />
<playlist name="Preview" id="00050" aud="eng" duration="90" />
</playlists>"#;
let jar = build_jar(&[
("com/studio/Menu.class", Vec::new()), // an entry, not a parseable class
("00000/playlists.xml", xml.to_vec()),
]);
let jar_dir = DirSpec {
name: "JAR".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: vec![file_with("00000.jar", 32, 4000, jar, true)],
subdirs: vec![],
};
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![jar_dir],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
// No titles to label; the hint pass must still fire.
let mut titles: Vec<DiscTitle> = Vec::new();
let hint =
apply(&mut disc, &udf, &mut titles).expect("a jar-only disc still yields a hint");
assert_eq!(hint.playlist_id, Some(800));
assert_eq!(hint.filename.as_deref(), Some("00800.mpls"));
}
/// The winner_hint early-return: when the winning parser already carried a
/// non-empty hint, resolve_feature_hint returns it verbatim and never runs
/// the Tier-2 menu-walk. The disc's bdj_feature::resolve would yield 00800,
/// but the supplied winner_hint (00042) must win — proving the short-circuit.
#[test]
fn resolve_feature_hint_returns_winner_hint_over_menu_walk() {
let xml = br#"<playlists>
<playlist name="Feature" id="00800" aud="eng,fra,spa" duration="7000" />
</playlists>"#;
let jar = build_jar(&[("00000/playlists.xml", xml.to_vec())]);
let jar_dir = DirSpec {
name: "JAR".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: vec![file_with("00000.jar", 32, 4000, jar, true)],
subdirs: vec![],
};
let bdmv = DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![jar_dir],
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![bdmv],
};
let mut disc = MemDisc::new();
build_udf_skeleton(&mut disc, 10);
lay_dir(&mut disc, &root);
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
// Sanity: the Tier-2 menu-walk on this disc resolves to 00800.
let tier2 = bdj_feature::resolve(&mut disc, &udf);
assert_eq!(tier2.and_then(|h| h.playlist_id), Some(800));
// A DIFFERENT non-empty winner_hint must short-circuit before Tier-2.
let winner = FeaturePlaylistHint {
playlist_id: Some(42),
filename: Some("00042.mpls".to_string()),
};
let got = resolve_feature_hint(&mut disc, &udf, Some(winner.clone()));
assert_eq!(
got,
Some(winner),
"a non-empty winner_hint wins over the Tier-2 menu-walk result"
);
}
}
+189 -24
View File
@@ -212,37 +212,137 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
labels
}
/// Find the feature playlist element (the one with the most non-empty
/// audio slots).
/// The feature playlist hint for a whole `playlists.xml` document — selects the
/// feature element and derives its id/filename. Exposed for the generic BD-J
/// menu-walk (`bdj_feature`) Tier-1 sweep, which finds this manifest embedded
/// inside a jar rather than as a loose file.
pub(crate) fn feature_hint_from_xml(text: &str) -> Option<super::FeaturePlaylistHint> {
let feature = find_feature_playlist(text)?;
feature_hint(&feature)
}
// A playlist must run at least this long (seconds) to be the feature — kills the
// `_Start_Angle` 2-second decoy (Sony SM3 UHD id 00243). Applied only when a
// duration is actually stated; absent, it stays inert. Shared with fox.
use super::MIN_FEATURE_SECS;
// A `<playlist>` element's stated running time in seconds, if any. Read from the
// first present of a set of duration-like attributes (the corpus is not a spec;
// `durs` matches the sibling Fox manifest's seconds convention). Digits only.
fn playlist_duration_secs(element: &str) -> Option<u64> {
for key in ["duration", "durs", "dur", "runtime", "length", "len"] {
if let Some(v) = xml::attr(element, key) {
let digits: String = v.chars().filter(|c| c.is_ascii_digit()).collect();
if let Ok(secs) = digits.parse::<u64>()
&& secs > 0
{
return Some(secs);
}
}
}
None
}
// A feature-selection candidate: the element text, its non-empty audio-slot
// count, and its stated duration (seconds) when known.
struct Candidate {
element: String,
aud: usize,
dur: Option<u64>,
}
impl Candidate {
// A candidate whose duration is stated AND sub-minute can never be the
// feature — the _Start_Angle decoy guard.
fn is_sub_minute(&self) -> bool {
self.dur.is_some_and(|d| d < MIN_FEATURE_SECS)
}
}
/// Find the feature playlist element. Order, refined for Sony SM3 UHD:
/// 1. exact `name="Feature"` (case-insensitive) — longest-duration among any,
/// else first;
/// 2. else any name matching `/feature/i` (`_Feature`, `Feature_A`, …) —
/// longest-duration among them, else the most-audio one;
/// 3. else the most-audio playlist overall, breaking ties by longest duration.
///
/// A playlist whose stated duration is sub-minute is never returned (the
/// `_Start_Angle` decoy).
fn find_feature_playlist(text: &str) -> Option<String> {
let mut best: Option<String> = None;
let mut best_aud_count = 0;
let mut exact: Vec<Candidate> = Vec::new();
let mut feature_like: Vec<Candidate> = Vec::new();
let mut all: Vec<Candidate> = Vec::new();
let mut from = 0;
while let Some((start, end)) = xml::find_element(text, "playlist", from) {
let element = &text[start..end];
// Prefer name="Feature" explicitly.
if let Some(name) = xml::attr(element, "name")
&& name.eq_ignore_ascii_case("Feature")
{
return Some(element.to_string());
}
// Otherwise pick the one with the most audio streams. Count only
// non-empty slots so a malformed `aud=",,,,,"` can't outscore a
// legitimate feature.
if let Some(aud) = xml::attr(element, "aud") {
let count = aud.split(',').filter(|s| !s.trim().is_empty()).count();
if count > best_aud_count {
best_aud_count = count;
best = Some(element.to_string());
}
}
from = end;
// Count only non-empty audio slots so a malformed `aud=",,,,,"` can't
// outscore a legitimate feature.
let aud = xml::attr(element, "aud")
.map(|a| a.split(',').filter(|s| !s.trim().is_empty()).count())
.unwrap_or(0);
let dur = playlist_duration_secs(element);
let make = || Candidate {
element: element.to_string(),
aud,
dur,
};
match xml::attr(element, "name") {
Some(name) if name.eq_ignore_ascii_case("Feature") => exact.push(make()),
Some(name) if name.to_ascii_lowercase().contains("feature") => {
feature_like.push(make())
}
_ => {}
}
all.push(make());
}
best
// Tier 1: exact name="Feature" — longest duration, else first. A
// zero-audio exact feature is still valid (its id feeds the hint).
if let Some(el) = pick(&exact, Key::Duration, false) {
return Some(el);
}
// Tier 2: /feature/i names (Sony `_Feature`, `Feature_A`, `Feature_B`) —
// longest duration among them, else most audio.
if let Some(el) = pick(&feature_like, Key::Duration, true) {
return Some(el);
}
// Tier 3: most audio across all playlists, longest duration as the tiebreak.
pick(&all, Key::Audio, true)
}
// Which signal dominates candidate ranking: duration (tiers 1-2) or audio-slot
// count (tier 3). The other is the tiebreak.
#[derive(Clone, Copy)]
enum Key {
Duration,
Audio,
}
// Choose the best candidate under `key`: skip sub-minute decoys; first-wins on a
// full tie (strict `>`). When `require_audio`, a candidate needs at least one
// audio slot to be eligible. `None` duration sorts below any stated duration.
fn pick(cands: &[Candidate], key: Key, require_audio: bool) -> Option<String> {
let rank = |c: &Candidate| match key {
Key::Duration => (c.dur, c.aud as u64),
Key::Audio => (Some(c.aud as u64), c.dur.unwrap_or(0)),
};
let mut best: Option<&Candidate> = None;
for c in cands {
if c.is_sub_minute() {
continue;
}
if require_audio && c.aud == 0 {
continue;
}
if best.is_none_or(|b| rank(c) > rank(b)) {
best = Some(c);
}
}
best.map(|c| c.element.clone())
}
#[cfg(test)]
@@ -750,4 +850,69 @@ mod tests {
"first playlist must win a tie, got: {feature}"
);
}
// Sony SM3 UHD regression: `_Start_Angle` (id 00243, 2s) shares the
// feature's audio slots and comes first — `/feature/i` matching plus the
// sub-minute guard now reject the decoy the old rule picked.
#[test]
fn feature_selection_rejects_start_angle_decoy() {
let xml = r#"
<playlist name="_Start_Angle" id="00243" aud="eng,fra,spa" duration="2" />
<playlist name="_Feature" id="00800" aud="eng,fra,spa" duration="7000" />
<playlist name="Feature_A" id="00801" aud="eng,fra,spa" duration="7000" />
"#;
let feature = find_feature_playlist(xml).expect("a feature is found");
assert!(
!feature.contains("_Start_Angle"),
"the 2-second angle decoy must never be the feature: {feature}"
);
// Tie between the two /feature/i playlists → first wins (_Feature, 00800).
let h = feature_hint_from_xml(xml).expect("hint");
assert_eq!(h.playlist_id, Some(800));
assert_eq!(h.filename.as_deref(), Some("00800.mpls"));
}
// Among /feature/i playlists, the longest duration wins — not document order.
#[test]
fn feature_like_longest_duration_wins() {
let xml = r#"
<playlist name="_Feature" id="00800" aud="eng,fra" duration="6000" />
<playlist name="Feature_B" id="00802" aud="eng,fra" duration="8000" />
"#;
let h = feature_hint_from_xml(xml).expect("hint");
assert_eq!(
h.playlist_id,
Some(802),
"the longer /feature/i playlist wins"
);
}
// The sub-minute guard also applies when the ONLY /feature/i playlist is a
// decoy: it is rejected and selection falls through to the real feature.
#[test]
fn sub_minute_feature_name_is_rejected() {
let xml = r#"
<playlist name="Feature_Trailer" id="00050" aud="eng,fra,spa" duration="30" />
<playlist name="MainMovie" id="00800" aud="eng,fra,spa" duration="7000" />
"#;
let feature = find_feature_playlist(xml).expect("a feature is found");
// Feature_Trailer is /feature/i but sub-minute → rejected; tier 3 picks
// MainMovie (equal audio, far longer duration).
assert!(feature.contains(r#"id="00800""#), "got {feature}");
}
// Duration attribute reading: several key spellings, digits only, zero → None.
#[test]
fn playlist_duration_reads_known_attrs() {
assert_eq!(
playlist_duration_secs(r#"<playlist duration="7000" />"#),
Some(7000)
);
assert_eq!(
playlist_duration_secs(r#"<playlist durs="7628" />"#),
Some(7628)
);
assert_eq!(playlist_duration_secs(r#"<playlist dur="0" />"#), None);
assert_eq!(playlist_duration_secs(r#"<playlist name="x" />"#), None);
}
}
+194
View File
@@ -186,6 +186,14 @@ thread_local! {
static PARAM_REASSERT_REALLOCS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
// Test-only: forces a framing desync just before the parse() self-check guard
// so its drop branch is exercised end-to-end (frame_data is length-prefixed by
// construction, so no real input desyncs it). See parse_drops_desynced_access_unit.
#[cfg(test)]
thread_local! {
static FORCE_FRAMING_DESYNC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
// 33-bit 90kHz PTS wraps at 2^33 ticks (~26.5h). A backward step of ~2^33 is a
// wraparound (unwrap, add 2^33) not a clip reset (arbitrary sub-2^33 backward
// step); accepting steps within `PTS_WRAP_PERIOD`/2 of a full period separates the two cases.
@@ -362,6 +370,11 @@ fn reassert_active(prefix: &mut Vec<u8>, cur: &Option<Vec<u8>>, emitted: bool) {
push_length_prefixed(prefix, active);
}
// Width of the NAL length prefix this parser writes (4-byte BE, `u32::to_be_bytes`).
// MUST equal hvcC `lengthSizeMinusOne + 1` (byte 21, low bits = 3 => 4); a mismatch
// is the framing desync a demuxer reports as "Invalid NAL unit size".
const LENGTH_PREFIX_SIZE: usize = 4;
// Appends `nal` as a 4-byte BE length prefix + body. Skipped (not
// mis-framed) if `nal.len()` overflows u32 — unreachable in practice.
fn push_length_prefixed(out: &mut Vec<u8>, nal: &[u8]) {
@@ -372,6 +385,37 @@ fn push_length_prefixed(out: &mut Vec<u8>, nal: &[u8]) {
out.extend_from_slice(nal);
}
/// Walk a length-prefixed NAL buffer (`[u32-BE len][body]` records) and confirm the
/// records EXACTLY tile it: every declared length fits, none is zero, and the last
/// body ends precisely at the buffer end with no trailing bytes. Our `frame_data`
/// is length-prefixed by construction, so this is a self-consistency guard — a
/// `false` means a framing desync that a downstream demuxer would report as
/// "Invalid NAL unit size (N>M)", and such an access unit must be dropped rather
/// than emitted. See docs/hevc.md — length-prefix self-check (issue #52).
fn length_prefix_tiles(data: &[u8]) -> bool {
let mut pos = 0usize;
while pos < data.len() {
// The full length field must be present.
if pos + LENGTH_PREFIX_SIZE > data.len() {
return false;
}
let len =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
// A zero-length record is a structurally invalid (empty) NALU.
if len == 0 {
return false;
}
pos += LENGTH_PREFIX_SIZE;
// The declared body must fit within the remaining buffer.
match pos.checked_add(len) {
Some(end) if end <= data.len() => pos = end,
_ => return false,
}
}
// Exact tiling: the walk must land precisely on the buffer end.
pos == data.len()
}
impl CodecParser for HevcParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
if pes.data.is_empty() {
@@ -563,6 +607,28 @@ impl CodecParser for HevcParser {
}
}
// Test-only seam: append a stray byte so the guard below sees a desync,
// exercising its drop branch end-to-end (no real input can desync here).
#[cfg(test)]
if FORCE_FRAMING_DESYNC.with(|c| c.get()) {
frame_data.push(0xFF);
}
// Defense in depth (issue #52): frame_data is length-prefixed by
// construction, but a desynced buffer would surface as "Invalid NAL unit
// size". Drop it (log src) rather than emit a mis-framed access unit.
if !length_prefix_tiles(&frame_data) {
tracing::warn!(
target: "freemkv::mux::hevc",
src = ?pes.source,
frame_len = frame_data.len(),
keyframe,
"HEVC length-prefix self-check failed; dropping desynced access unit \
(would surface downstream as \"Invalid NAL unit size\")"
);
return Vec::new();
}
// HDR10 static metadata is stamped onto every frame's PictureInfo once
// both SEI messages are seen, riding the deferred-muxer path (reads it
// from the first coded picture before the track header). `None` for SDR tracks.
@@ -2689,6 +2755,134 @@ mod tests {
);
}
// ── issue #52 (Fix A): length-prefix self-consistency ───────────────────
#[test]
fn hvcc_length_size_matches_push_length_prefixed_width() {
// Regression: hvcC byte-21 lengthSizeMinusOne+1 (low 2 bits) MUST equal the
// prefix width push_length_prefixed writes (LENGTH_PREFIX_SIZE); a divergence
// is the framing desync reported as "Invalid NAL unit size".
let sps = make_sps_with_chroma(1, 2, 2);
let cp = codec_private_from_sps(&sps);
let length_size_minus_one = (cp[21] & 0x03) as usize;
assert_eq!(
length_size_minus_one + 1,
LENGTH_PREFIX_SIZE,
"hvcC lengthSizeMinusOne+1 must equal the NAL length-prefix width"
);
assert_eq!(LENGTH_PREFIX_SIZE, 4, "HEVC length prefix is 4-byte BE");
}
#[test]
fn length_prefix_tiles_accepts_well_formed_records() {
// Two records: len 3 + len 2, back to back, tiling the buffer exactly.
let mut buf = Vec::new();
push_length_prefixed(&mut buf, &[0xAA, 0xBB, 0xCC]);
push_length_prefixed(&mut buf, &[0x11, 0x22]);
assert!(length_prefix_tiles(&buf));
assert!(length_prefix_tiles(&[]), "an empty buffer trivially tiles");
}
#[test]
fn length_prefix_tiles_rejects_a_desynced_frame() {
// A declared length that OVERRUNS the buffer — the shape that surfaces as
// "Invalid NAL unit size (N>M)". Declared 0x1000_0001 bytes, only a few
// present.
let overrun = [0x10, 0x00, 0x00, 0x01, 0xAA, 0xBB, 0xCC];
assert!(
!length_prefix_tiles(&overrun),
"a length that overruns the buffer must be rejected"
);
// Trailing garbage past the last complete record.
let mut trailing = Vec::new();
push_length_prefixed(&mut trailing, &[0xAA, 0xBB]);
trailing.push(0x99); // one stray byte, not a full length field
assert!(
!length_prefix_tiles(&trailing),
"trailing bytes that don't form a record must be rejected"
);
// A zero-length record (empty NALU) is structurally invalid.
let zero_len = [0x00, 0x00, 0x00, 0x00];
assert!(
!length_prefix_tiles(&zero_len),
"a zero-length record must be rejected"
);
// A truncated length prefix (fewer than 4 bytes at the tail).
assert!(
!length_prefix_tiles(&[0x00, 0x00]),
"a truncated length prefix must be rejected"
);
}
#[test]
fn parsed_annex_b_access_unit_tiles_exactly_and_is_emitted() {
// Positive path: a normal Annex-B AU (VPS + SPS + PPS + IDR slice) parses
// to a frame whose length-prefixed data tiles EXACTLY, so the guard emits
// it (never dropped).
let mut parser = HevcParser::new();
let sps = make_sps_with_chroma(1, 2, 2);
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32)); // VPS
data.extend_from_slice(&[0xAA, 0xBB]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&sps); // SPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34)); // PPS
data.extend_from_slice(&[0xDD, 0xEE]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(19)); // IDR_W_RADL slice → keyframe
data.extend_from_slice(&[0x10, 0x20, 0x30]);
let frames = parser.parse(&make_pes(data, Some(0)));
assert_eq!(frames.len(), 1, "the access unit is emitted, not dropped");
assert!(
length_prefix_tiles(&frames[0].data),
"an emitted HEVC frame's records must tile exactly"
);
}
// The build a normal Annex-B AU (VPS + SPS + PPS + IDR) as a helper for the
// end-to-end guard test below.
#[cfg(test)]
fn annex_b_access_unit() -> Vec<u8> {
let sps = make_sps_with_chroma(1, 2, 2);
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32)); // VPS
data.extend_from_slice(&[0xAA, 0xBB]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&sps); // SPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34)); // PPS
data.extend_from_slice(&[0xDD, 0xEE]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(19)); // IDR_W_RADL slice → keyframe
data.extend_from_slice(&[0x10, 0x20, 0x30]);
data
}
// Issue #52: the guard's WIRING in parse() — a desynced access unit is
// dropped (empty emit), a well-formed one is emitted. Disconnecting the guard
// from parse() would let the desynced case through and fail this test.
#[test]
fn parse_drops_desynced_access_unit() {
// Well-formed: emitted.
let mut parser = HevcParser::new();
let frames = parser.parse(&make_pes(annex_b_access_unit(), Some(0)));
assert_eq!(frames.len(), 1, "a well-formed access unit is emitted");
// Same AU, framing forced out of sync: the guard drops it.
FORCE_FRAMING_DESYNC.with(|c| c.set(true));
let mut parser2 = HevcParser::new();
let dropped = parser2.parse(&make_pes(annex_b_access_unit(), Some(0)));
FORCE_FRAMING_DESYNC.with(|c| c.set(false));
assert!(
dropped.is_empty(),
"a desynced access unit is dropped by parse(), not emitted"
);
}
#[test]
fn hvcc_handles_emulation_prevention_in_sps() {
// Insert emulation-prevention bytes (00 00 03) after the parsed fields
+77 -18
View File
@@ -16,6 +16,14 @@ const SEGMENT_PCS: u8 = 0x16;
// Caps a malformed stream that appends non-PCS segments forever without a PCS,
// dropping further appends until the next PCS resyncs — mirrors DTS/AC-3 caps.
const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
// Fallback on-screen dwell (5 s) for a display set whose real end is unknown (EOS
// trailing set, no-PTS/truncated arms): a None duration on a DefaultDuration-less
// subtitle track leaves ffmpeg "Timestamps are unset in a packet" (issue #52).
const DEFAULT_PGS_DURATION_NS: u64 = 5_000_000_000;
// Cap on a COMPUTED span (clear_pts - display_pts): a missing intermediate PCS can
// inflate one set to minutes. Past this the value is untrusted and the fallback
// dwell is used instead; set well above any real dwell so long cues aren't clipped.
const MAX_PGS_DURATION_NS: u64 = 30_000_000_000;
// Offset of number_of_composition_objects in a PCS: 3-byte segment header +
// 10 bytes of PCS fields (video_w/h, frame_rate, comp_num, comp_state,
// palette_update, palette_id_ref) = 13.
@@ -209,7 +217,15 @@ impl PgsParser {
fn emit_pending(&mut self, end_pts_ns: i64) -> Option<Frame> {
let (facts, data) = self.pending.take()?;
let start_pts = facts.presentation_ns().unwrap_or(0);
let duration = end_pts_ns.saturating_sub(start_pts).max(0) as u64;
let computed = end_pts_ns.saturating_sub(start_pts).max(0) as u64;
// Trust the real computed span, but clamp a pathologically large one (a
// missing intermediate PCS makes one set look like it lingered for
// minutes) back to the fallback dwell rather than emit an absurd cue.
let duration = if computed > MAX_PGS_DURATION_NS {
DEFAULT_PGS_DURATION_NS
} else {
computed
};
Some(Frame {
discontinuity: false,
coding: None,
@@ -249,7 +265,7 @@ impl CodecParser for PgsParser {
pts_ns: facts.presentation_ns().unwrap_or(0),
keyframe: true,
data,
duration_ns: None,
duration_ns: Some(DEFAULT_PGS_DURATION_NS),
}]
})
.unwrap_or_default();
@@ -276,7 +292,7 @@ impl CodecParser for PgsParser {
pts_ns: facts.presentation_ns().unwrap_or(0),
keyframe: true,
data,
duration_ns: None,
duration_ns: Some(DEFAULT_PGS_DURATION_NS),
}),
};
out.extend(frame);
@@ -307,7 +323,7 @@ impl CodecParser for PgsParser {
pts_ns: facts.presentation_ns().unwrap_or(0),
keyframe: true,
data,
duration_ns: None,
duration_ns: Some(DEFAULT_PGS_DURATION_NS),
}));
}
},
@@ -336,7 +352,7 @@ impl CodecParser for PgsParser {
pts_ns: pts.unwrap_or(0),
keyframe: true,
data: pes.data.clone(),
duration_ns: None,
duration_ns: Some(DEFAULT_PGS_DURATION_NS),
});
}
// No pending set AND no PTS: drop it. Emitting at pts_ns=0 would
@@ -360,7 +376,7 @@ impl CodecParser for PgsParser {
pts_ns: facts.presentation_ns().unwrap_or(0),
keyframe: true,
data,
duration_ns: None,
duration_ns: Some(DEFAULT_PGS_DURATION_NS),
}],
None => Vec::new(),
}
@@ -548,8 +564,10 @@ mod tests {
assert_eq!(frames.len(), 1, "final pending subtitle must flush");
assert_eq!(frames[0].pts_ns, 1_000_000_000);
assert_eq!(frames[0].data, display);
// Trailing block lingers to EOF — no duration per module doc.
assert_eq!(frames[0].duration_ns, None);
// Trailing block has no follower PCS, so its real end is unknown; it now
// carries the synthesized fallback dwell (was `None`) so every subtitle
// block gets a BlockDuration (issue #52).
assert_eq!(frames[0].duration_ns, Some(DEFAULT_PGS_DURATION_NS));
}
#[test]
@@ -575,16 +593,20 @@ mod tests {
}
#[test]
fn clear_pcs_without_pts_emits_pending_undurated() {
// A clear PCS that lacks a PTS can't compute a duration; the pending
// display is still emitted, but with no duration (lingers to EOF)
// instead of a bogus absurd one.
fn clear_pcs_without_pts_emits_fallback_duration() {
// A clear PCS that lacks a PTS can't compute a real duration; the pending
// display is still emitted, now with the synthesized fallback dwell (was
// `None`) so the subtitle block carries a BlockDuration (issue #52).
let mut parser = PgsParser::new();
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
let f = parser.parse(&make_pes(pcs_bytes(0), None));
assert_eq!(f.len(), 1);
assert_eq!(f[0].pts_ns, 1_000_000_000, "pending keeps its real start");
assert_eq!(f[0].duration_ns, None, "no duration without a clear PTS");
assert_eq!(
f[0].duration_ns,
Some(DEFAULT_PGS_DURATION_NS),
"fallback duration when the clear PCS carries no PTS"
);
}
#[test]
@@ -605,7 +627,11 @@ mod tests {
let frames = parser.parse(&make_pes(truncated, Some(180000)));
assert_eq!(frames.len(), 1, "pending display flushed on truncated PCS");
assert_eq!(frames[0].data, display, "pending bitmap not polluted");
assert_eq!(frames[0].duration_ns, None, "flushed undurated");
assert_eq!(
frames[0].duration_ns,
Some(DEFAULT_PGS_DURATION_NS),
"flushed with fallback duration (issue #52)"
);
assert!(parser.pending.is_none(), "parser resynced");
}
@@ -695,6 +721,37 @@ mod tests {
assert_eq!(f[0].duration_ns, Some(0));
}
#[test]
fn pathologically_large_computed_duration_clamps_to_fallback() {
// A missing intermediate clear PCS makes a set appear to linger until a much
// later one; a computed span past MAX_PGS_DURATION_NS is untrusted and falls
// back to the dwell instead of an absurd multi-minute cue (90000 ticks = 1s).
let display_pts = 90_000_i64; // 1 s
// 40 s later in 90 kHz ticks (> 30 s cap).
let clear_pts = display_pts + 40 * 90_000;
let mut parser = PgsParser::new();
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(display_pts)));
let f = parser.parse(&make_pes(pcs_bytes(0), Some(clear_pts)));
assert_eq!(f.len(), 1);
assert_eq!(
f[0].duration_ns,
Some(DEFAULT_PGS_DURATION_NS),
"a >30s computed span is clamped to the fallback dwell"
);
}
#[test]
fn legitimate_long_duration_is_not_clamped() {
// A real, in-range dwell (10 s, well under the 30 s cap) is preserved
// exactly — the clamp must never clip a legitimately long subtitle.
let display_pts = 90_000_i64; // 1 s
let clear_pts = display_pts + 10 * 90_000; // +10 s
let mut parser = PgsParser::new();
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(display_pts)));
let f = parser.parse(&make_pes(pcs_bytes(0), Some(clear_pts)));
assert_eq!(f[0].duration_ns, Some(10_000_000_000));
}
// --- clear / replace edge cases ---
#[test]
@@ -722,11 +779,11 @@ mod tests {
assert_eq!(f2.len(), 1);
assert_eq!(f2[0].pts_ns, 2_000_000_000);
assert_eq!(f2[0].duration_ns, Some(1_000_000_000));
// Third held; flush emits it undurated.
// Third held; flush emits it with the fallback dwell (no follower PCS).
let tail = parser.flush();
assert_eq!(tail.len(), 1);
assert_eq!(tail[0].pts_ns, 3_000_000_000);
assert_eq!(tail[0].duration_ns, None);
assert_eq!(tail[0].duration_ns, Some(DEFAULT_PGS_DURATION_NS));
}
#[test]
@@ -740,7 +797,7 @@ mod tests {
let exactly_13 = vec![SEGMENT_PCS; PCS_NUM_OBJECTS_OFFSET]; // 13 bytes
let f = parser.parse(&make_pes(exactly_13, Some(180000)));
assert_eq!(f.len(), 1, "13-byte PCS is truncated → flush pending");
assert_eq!(f[0].duration_ns, None);
assert_eq!(f[0].duration_ns, Some(DEFAULT_PGS_DURATION_NS));
assert!(parser.pending.is_none());
}
@@ -768,7 +825,9 @@ mod tests {
assert_eq!(f.len(), 1);
assert!(f[0].keyframe);
assert_eq!(f[0].pts_ns, 1_000_000_000);
assert_eq!(f[0].duration_ns, None);
// A pass-through segment has no known end; it now carries the fallback
// dwell (was `None`) so it too gets a BlockDuration (issue #52).
assert_eq!(f[0].duration_ns, Some(DEFAULT_PGS_DURATION_NS));
}
#[test]
-3
View File
@@ -1383,7 +1383,6 @@ mod tests {
// engages. Key contents are irrelevant to the read plan.
let aacs = DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16]), (1, [1u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
// 100 units (300 sectors). A 10-unit Even forensic segment at LBA [30,60):
@@ -1859,7 +1858,6 @@ mod tests {
let title = synthetic_title(COUNT);
let keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut stream = DiscStream::new(
@@ -2385,7 +2383,6 @@ mod tests {
synthetic_title(8),
crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
},
3,
-4
View File
@@ -1584,7 +1584,6 @@ mod tests {
}];
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
@@ -1644,7 +1643,6 @@ mod tests {
key_source: crate::disc::KeyOrigin::KeyDb,
vuk: None,
unit_keys: vec![(0, unit_key)],
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
@@ -1852,7 +1850,6 @@ mod tests {
let mut r = mk_reader();
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let map = resolve_inline_base_map(
@@ -1871,7 +1868,6 @@ mod tests {
let mut r = mk_reader();
let mut keys_raw = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let map_raw = resolve_inline_base_map(
+159
View File
@@ -546,6 +546,14 @@ pub struct MkvMuxer<W: Write + Seek> {
/// non-monotonic. Keying the exemption on track type (not index) keeps that
/// EL's true PTS instead of clobbering it to prev+1ms.
track_is_video: Vec<bool>,
/// Per-track-index flag: true if the track is a subtitle track
/// (`track_type == 17`). A subtitle block must NEVER be written as a bare
/// `SimpleBlock`: subtitle tracks carry no `DefaultDuration`, so without a
/// `BlockDuration` a demuxer has nothing to bound the cue and ffmpeg reports
/// "Timestamps are unset in a packet for stream N" (issue #52). Keyed on
/// track type (mirroring `track_is_video`) so the invariant holds for every
/// subtitle track — including a sparse forced-narrative second track.
track_is_subtitle: Vec<bool>,
/// Index of the PRIMARY video track — the first track whose type is video.
/// This (not the literal index 0) is the clip-boundary epoch driver: the
/// M2TS/PMT path orders streams by PMT declaration order and may list an
@@ -698,6 +706,11 @@ struct InterlacedRewrite {
// video and TrueHD's 0.833 ms AUs. See docs/mkv-mux.md — TimestampScale choice.
const TIMESTAMP_SCALE_NS: i64 = 100_000;
// Last-resort BlockDuration (1 s) for a SUBTITLE frame that reaches the muxer with
// no duration. Parsers (PGS) already synthesize one; this only fires if another
// subtitle path forgets, keeping every subtitle block durated (issue #52).
const MIN_SUBTITLE_BLOCK_DURATION_NS: i64 = 1_000_000_000;
// Nominal new-cluster interval (2 s) in TimestampScale ticks; a keyframe
// only opens a new cluster once this much has elapsed. See docs/mkv-mux.md
// — cluster duration vs. i16 block-relative limit.
@@ -1128,6 +1141,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
.iter()
.map(|t| t.track_type == ebml::TRACK_TYPE_VIDEO)
.collect(),
track_is_subtitle: tracks
.iter()
.map(|t| t.track_type == ebml::TRACK_TYPE_SUBTITLE)
.collect(),
primary_video_track: tracks
.iter()
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
@@ -1350,6 +1367,21 @@ impl<W: Write + Seek> MkvMuxer<W> {
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
let duration_ticks =
duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
// Defense in depth (issue #52): a SUBTITLE block must never be a bare
// SimpleBlock (no DefaultDuration => unbounded cue, ffmpeg "Timestamps are
// unset"). Substitute a minimum fallback so it takes the BlockGroup arm.
let is_subtitle = self
.track_is_subtitle
.get(track_idx)
.copied()
.unwrap_or(false);
let duration_ticks = match duration_ticks {
Some(dt) => Some(dt),
None if is_subtitle => {
Some((MIN_SUBTITLE_BLOCK_DURATION_NS / TIMESTAMP_SCALE_NS).max(1) as u64)
}
None => None,
};
// A BlockAdditional with BlockAddID=2 conforms only when the track declared
// the matching mvcC mapping; if not (dependent-view params never captured
// before the header was written), drop it and emit a plain block instead.
@@ -4790,6 +4822,133 @@ mod tests {
);
}
// ── issue #52: every subtitle block carries a BlockDuration ──────────────
fn make_subtitle_track() -> MkvTrack {
MkvTrack {
track_type: ebml::TRACK_TYPE_SUBTITLE,
codec_id: ebml::CODEC_PGS,
language: "eng".into(),
name: String::new(),
codec_private: None,
is_default: false,
is_forced: false,
pixel_width: 0,
pixel_height: 0,
default_duration_ns: 0,
display_width: 0,
display_height: 0,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
/// Matroska TrackNumbers (1-based) of every SimpleBlock in the output, in
/// emission order. The subtitle invariant is that NONE of these is a
/// subtitle track.
fn all_simple_block_tracks(data: &[u8]) -> Vec<u64> {
let mut out = Vec::new();
for (body_start, body_size, _ts) in find_clusters(data) {
let body = &data[body_start..body_start + body_size as usize];
let mut cursor = Cursor::new(body);
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
while (cursor.position() as usize) < body.len() {
let Ok((id, size, _)) = ebml::read_element_header(&mut cursor) else {
break;
};
let start = cursor.position() as usize;
if start + size as usize > body.len() {
break;
}
if id == ebml::SIMPLE_BLOCK {
let blk = &body[start..start + size as usize];
let track = if blk[0] & 0x80 != 0 {
(blk[0] & 0x7F) as u64
} else {
(((blk[0] & 0x3F) as u64) << 8) | blk[1] as u64
};
out.push(track);
}
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
}
}
out
}
#[test]
fn subtitle_frame_with_no_duration_becomes_block_group_with_block_duration() {
// issue #52: a SUBTITLE frame with duration=None must never be a bare
// SimpleBlock (no DefaultDuration => unbounded cue). The muxer substitutes a
// minimum fallback and emits a BlockGroup WITH a BlockDuration.
let tracks = [make_subtitle_track()];
let data = mux_with_durations(&tracks, &[(0, 0, true, vec![0x16, 0xAA], None)]);
let groups = all_block_groups(&data);
assert_eq!(
groups.len(),
1,
"the subtitle frame must be a BlockGroup, not a SimpleBlock"
);
assert!(
groups[0].duration > 0,
"the BlockGroup must carry a BlockDuration (got {})",
groups[0].duration
);
assert!(
all_simple_block_tracks(&data).is_empty(),
"no subtitle frame may be emitted as a bare SimpleBlock"
);
}
#[test]
fn every_block_on_both_subtitle_tracks_has_a_block_duration() {
// Models a Blu-ray's two PGS tracks: a DENSE dialogue track and a SPARSE
// forced-narrative one. EVERY block on BOTH must be a BlockGroup carrying a
// BlockDuration, even those handed in with duration=None.
let tracks = [make_subtitle_track(), make_subtitle_track()];
let frames: Vec<DurFrame> = vec![
// Track 0 (dense): opens the cluster, then two more, mixing a real
// duration with two None durations.
(0, 0, true, vec![0x16, 0x01], Some(2_000_000_000)),
(0, 1_000_000_000, true, vec![0x16, 0x02], None),
(0, 2_000_000_000, true, vec![0x16, 0x03], None),
// Track 1 (sparse): a single forced cue, handed in with no duration.
(1, 500_000_000, true, vec![0x16, 0xFF], None),
];
let data = mux_with_durations(&tracks, &frames);
assert!(
all_simple_block_tracks(&data).is_empty(),
"no subtitle frame on EITHER track may be a bare SimpleBlock"
);
let groups = all_block_groups(&data);
assert_eq!(groups.len(), 4, "all four subtitle frames are BlockGroups");
for g in &groups {
assert!(
g.duration > 0,
"every subtitle block must carry a BlockDuration (track {}, dur {})",
g.track,
g.duration
);
}
// Both subtitle tracks (Matroska TrackNumbers 1 and 2) are represented.
assert!(groups.iter().any(|g| g.track == 1), "dense track present");
assert!(groups.iter().any(|g| g.track == 2), "sparse track present");
}
// Cluster boundary (CLUSTER_DURATION_TICKS): a new cluster opens on a video
// keyframe once >= the cluster duration has elapsed since the open cluster's
// timestamp; one just under the boundary stays in the current cluster.
-15
View File
@@ -2359,7 +2359,6 @@ mod tests {
// (pool_len == 1 would short-circuit to content_map before any read).
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut reader = HaltCountSource {
@@ -2397,7 +2396,6 @@ mod tests {
}];
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut reader = HaltCountSource {
@@ -3170,7 +3168,6 @@ mod tests {
};
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let title = multi_cps_title(start, sectors);
@@ -3205,7 +3202,6 @@ mod tests {
};
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let title = multi_cps_title(start, sectors);
@@ -3237,7 +3233,6 @@ mod tests {
};
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let title = multi_cps_title(start, sectors);
@@ -3337,7 +3332,6 @@ mod tests {
]);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut cache = super::DiscKeyCache::new();
@@ -3430,7 +3424,6 @@ mod tests {
];
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
@@ -3499,7 +3492,6 @@ mod tests {
]);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut cache = super::DiscKeyCache::new();
@@ -3575,7 +3567,6 @@ mod tests {
// to index 0 without sampling); neither opens the extent → fail loud.
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, [0x02u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
super::resolve_mux_key_map_cached(
@@ -3643,7 +3634,6 @@ mod tests {
let title = multi_cps_title(1000, 30);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x01u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let got = super::resolve_fmts_key_map(
@@ -3680,7 +3670,6 @@ mod tests {
let title = multi_cps_title(1000, 30);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x01u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let got = super::resolve_fmts_key_map(
@@ -4016,7 +4005,6 @@ mod tests {
fn fmts_keys() -> DecryptKeys {
DecryptKeys::Aacs {
unit_keys: vec![(0, FMTS_BASE_KEY)],
read_data_key: None,
format: ContentFormat::BdTs,
}
}
@@ -4042,7 +4030,6 @@ mod tests {
fn fmts_two_cps_keys() -> DecryptKeys {
DecryptKeys::Aacs {
unit_keys: vec![(1, FMTS_BASE_KEY), (2, FMTS_CPS2_KEY)],
read_data_key: None,
format: ContentFormat::BdTs,
}
}
@@ -4407,7 +4394,6 @@ mod tests {
]);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut cache = super::DiscKeyCache::new();
@@ -5012,7 +4998,6 @@ mod tests {
)]);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut cache = super::DiscKeyCache::new();
+3 -5
View File
@@ -2089,11 +2089,9 @@ 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.
// A provisional must retire even PAST the epoch-history cap: the old identity
// `epoch_offsets.len()` pinned at MAX_EPOCHS once the ring filled, so a
// provisional compared equal forever; monotonic `epoch_seq` fixes that.
#[test]
fn a_provisional_retires_even_past_the_epoch_history_cap() {
let mut tc = TimelineContinuity::new();
+10 -11
View File
@@ -141,18 +141,17 @@ impl PesFrame {
if len > MAX_FRAME_SIZE {
return Err(crate::error::Error::PesFrameTooLarge { size: len }.into());
}
// 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
// Grow INCREMENTALLY (not `vec![0u8; len]`): `len` is attacker-controlled to 256 MiB, so
// a truncated stream claiming a huge frame must not pre-allocate the max. Read each chunk
// straight into `data`'s own tail — no scratch buffer, no second copy; short reads EOF.
const GROW_CHUNK: usize = 1024 * 1024; // 1 MiB growth step
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;
let mut filled = 0usize;
while filled < len {
let want = (len - filled).min(GROW_CHUNK);
data.resize(filled + want, 0);
r.read_exact(&mut data[filled..filled + want])?;
filled += want;
}
Ok(Some(Self {
track,
+24 -20
View File
@@ -338,24 +338,25 @@ impl ScsiTransport for SgIoTransport {
let recovery = self.fd_recovery.clone();
let dead = self.dead.clone();
// Cap outstanding recovery threads: past MAX_RECOVERY_THREADS a
// sustained wedge would spawn unbounded threads, so fall back to
// doing the close/reopen inline on this thread instead.
// Cap outstanding recovery threads (past MAX a sustained wedge spawns
// unbounded threads): reserve the slot ATOMICALLY via fetch_add, act on
// the pre-increment value, and give the slot back if it overshoots.
use std::sync::atomic::Ordering;
if RECOVERY_THREADS.load(Ordering::Acquire) < MAX_RECOVERY_THREADS {
RECOVERY_THREADS.fetch_add(1, Ordering::AcqRel);
if RECOVERY_THREADS.fetch_add(1, Ordering::AcqRel) < MAX_RECOVERY_THREADS {
std::thread::spawn(move || {
if old_fd >= 0 {
unsafe { libc::close(old_fd) };
}
RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel);
});
} else if old_fd >= 0 {
unsafe { libc::close(old_fd) };
} else {
RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel);
if old_fd >= 0 {
unsafe { libc::close(old_fd) };
}
}
if RECOVERY_THREADS.load(Ordering::Acquire) < MAX_RECOVERY_THREADS {
RECOVERY_THREADS.fetch_add(1, Ordering::AcqRel);
if RECOVERY_THREADS.fetch_add(1, Ordering::AcqRel) < MAX_RECOVERY_THREADS {
std::thread::spawn(move || {
// Don't unwrap: a device path with an interior NUL would
// panic this detached thread (silently swallowed). Bail
@@ -383,17 +384,20 @@ impl ScsiTransport for SgIoTransport {
}
RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel);
});
} else if let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) {
// At the recovery-thread cap: reopen inline (blocking this
// call briefly) instead of spawning an unbounded 9th thread.
let new_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if new_fd >= 0 {
self.fd = new_fd;
} else {
// Over the cap: give the reservation back, then reopen inline
// (blocking this call briefly) instead of spawning a 9th thread.
RECOVERY_THREADS.fetch_sub(1, Ordering::AcqRel);
if let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) {
let new_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if new_fd >= 0 {
self.fd = new_fd;
}
}
}
+93 -16
View File
@@ -29,7 +29,7 @@ static OPEN: AtomicBool = AtomicBool::new(false);
#[repr(C)]
#[derive(Copy, Clone)]
struct ShimDriveInfo {
bsd_name: [u8; 32],
device_selector: [u8; 32],
vendor: [u8; 32],
model: [u8; 48],
firmware: [u8; 16],
@@ -66,6 +66,14 @@ fn bsd_name_of(device: &Path) -> Result<&str> {
.unwrap_or(dev_str))
}
fn device_path_for_selector(selector: &str) -> String {
if selector.starts_with("ioreg:") {
selector.to_string()
} else {
format!("/dev/{selector}")
}
}
// Maps a shim_open_exclusive failure sentinel (negative rc, not an
// IOReturn) to its typed Error variant, pulled out standalone so the
// mapping can be unit-tested. See docs/scsi-macos.md.
@@ -217,7 +225,7 @@ impl ScsiTransport for MacScsiTransport {
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let mut buf = [ShimDriveInfo {
bsd_name: [0; 32],
device_selector: [0; 32],
vendor: [0; 32],
model: [0; 48],
firmware: [0; 16],
@@ -225,20 +233,23 @@ pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let count = unsafe { shim_list_drives(buf.as_mut_ptr(), buf.len() as i32) };
let mut out = Vec::new();
for info in buf.iter().take((count as usize).min(buf.len())) {
let bsd_name = cstr_to_str(&info.bsd_name);
if bsd_name.is_empty() {
continue;
}
out.push(super::DriveInfo {
path: format!("/dev/{bsd_name}"),
vendor: cstr_to_str(&info.vendor).to_string(),
model: cstr_to_str(&info.model).to_string(),
firmware: cstr_to_str(&info.firmware).to_string(),
});
buf.iter()
.take((count as usize).min(buf.len()))
.filter_map(drive_info_from_shim)
.collect()
}
fn drive_info_from_shim(info: &ShimDriveInfo) -> Option<super::DriveInfo> {
let selector = cstr_to_str(&info.device_selector);
if selector.is_empty() {
return None;
}
out
Some(super::DriveInfo {
path: device_path_for_selector(selector),
vendor: cstr_to_str(&info.vendor).to_string(),
model: cstr_to_str(&info.model).to_string(),
firmware: cstr_to_str(&info.firmware).to_string(),
})
}
fn cstr_to_str(bytes: &[u8]) -> &str {
@@ -268,7 +279,8 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
#[cfg(test)]
mod tests {
use super::{
K_MAX_CDB_SIZE, OPEN, bsd_name_of, cstr_to_str, drive_has_disc, map_shim_open_error,
K_MAX_CDB_SIZE, OPEN, ShimDriveInfo, bsd_name_of, cstr_to_str, device_path_for_selector,
drive_has_disc, drive_info_from_shim, map_shim_open_error,
};
use crate::error::Error;
use std::path::Path;
@@ -279,6 +291,71 @@ mod tests {
assert_eq!(bsd_name_of(Path::new("/dev/disk4")).unwrap(), "disk4");
assert_eq!(bsd_name_of(Path::new("/dev/rdisk4")).unwrap(), "disk4");
assert_eq!(bsd_name_of(Path::new("disk4")).unwrap(), "disk4");
assert_eq!(
bsd_name_of(Path::new("ioreg:4294967295")).unwrap(),
"ioreg:4294967295"
);
}
#[test]
fn list_path_preserves_registry_selector_and_uses_dev_for_bsd_name() {
assert_eq!(device_path_for_selector("disk4"), "/dev/disk4");
assert_eq!(device_path_for_selector("disk0"), "/dev/disk0");
assert_eq!(
device_path_for_selector("ioreg:4294967295"),
"ioreg:4294967295"
);
// Registry IDs are uint64 values and the shim emits the complete
// decimal value into its fixed-width selector field.
assert_eq!(
device_path_for_selector("ioreg:18446744073709551615"),
"ioreg:18446744073709551615"
);
}
fn shim_drive(selector: &[u8], vendor: &[u8], model: &[u8], firmware: &[u8]) -> ShimDriveInfo {
let mut info = ShimDriveInfo {
device_selector: [0; 32],
vendor: [0; 32],
model: [0; 48],
firmware: [0; 16],
};
info.device_selector[..selector.len()].copy_from_slice(selector);
info.vendor[..vendor.len()].copy_from_slice(vendor);
info.model[..model.len()].copy_from_slice(model);
info.firmware[..firmware.len()].copy_from_slice(firmware);
info
}
#[test]
fn shim_drive_record_maps_bsd_selector_and_identity_fields() {
let info = shim_drive(b"disk4", b"HL-DT-ST", b"BD-RE BU40N", b"1.03");
let mapped = drive_info_from_shim(&info).expect("non-empty selector");
assert_eq!(mapped.path, "/dev/disk4");
assert_eq!(mapped.vendor, "HL-DT-ST");
assert_eq!(mapped.model, "BD-RE BU40N");
assert_eq!(mapped.firmware, "1.03");
}
#[test]
fn shim_drive_record_keeps_empty_tray_registry_selector() {
let info = shim_drive(
b"ioreg:18446744073709551615",
b"HL-DT-ST",
b"BD-RE BU40N",
b"1.03",
);
let mapped = drive_info_from_shim(&info).expect("registry selector");
assert_eq!(mapped.path, "ioreg:18446744073709551615");
assert_eq!(mapped.vendor, "HL-DT-ST");
assert_eq!(mapped.model, "BD-RE BU40N");
assert_eq!(mapped.firmware, "1.03");
}
#[test]
fn shim_drive_record_with_empty_selector_is_skipped() {
let info = shim_drive(b"", b"HL-DT-ST", b"BD-RE BU40N", b"1.03");
assert!(drive_info_from_shim(&info).is_none());
}
// Shim fixed-width fields are NUL-terminated C strings with garbage
+99 -14
View File
@@ -12,6 +12,10 @@
#include <sys/wait.h>
#include <fcntl.h>
#include <signal.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
#include <errno.h>
extern char **environ;
@@ -32,7 +36,7 @@ typedef struct {
} ShimHandle;
typedef struct {
char bsd_name[32];
char device_selector[32];
char vendor[32];
char model[48];
char firmware[16];
@@ -77,6 +81,54 @@ static int registry_entry_bsd_name(io_registry_entry_t entry, char *buf, size_t
return ok;
}
// Empty optical drives have an IOBDServices node but no IOMedia node, hence
// no BSD diskN name. Use the service's IOKit registry ID as an opaque selector
// until media appears. It is process/boot-local, which is sufficient for the
// list-then-open GUI flow.
static int registry_id_selector(io_registry_entry_t entry, char *buf, size_t buflen) {
uint64_t registry_id = 0;
if (IORegistryEntryGetRegistryEntryID(entry, &registry_id) != KERN_SUCCESS) return 0;
int n = snprintf(buf, buflen, "ioreg:%" PRIu64, registry_id);
return n > 0 && (size_t)n < buflen;
}
static int parse_registry_id_selector(const char *selector, uint64_t *registry_id) {
static const char prefix[] = "ioreg:";
if (strncmp(selector, prefix, sizeof(prefix) - 1) != 0) return 0;
const char *digits = selector + sizeof(prefix) - 1;
if (!*digits) return 0;
for (const char *p = digits; *p; p++) {
if (*p < '0' || *p > '9') return 0;
}
errno = 0;
char *end = NULL;
unsigned long long parsed = strtoull(digits, &end, 10);
if (errno == ERANGE || !end || *end || end == digits) return 0;
*registry_id = (uint64_t)parsed;
return 1;
}
static io_service_t find_bdsvc_by_registry_id(mach_port_t mp, uint64_t registry_id) {
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
if (!matching) return 0;
io_iterator_t iter;
if (IOServiceGetMatchingServices(mp, matching, &iter) != KERN_SUCCESS) return 0;
io_service_t result = 0;
io_service_t svc;
while ((svc = IOIteratorNext(iter)) != 0) {
uint64_t candidate = 0;
if (IORegistryEntryGetRegistryEntryID(svc, &candidate) == KERN_SUCCESS
&& candidate == registry_id) {
result = svc;
break;
}
IOObjectRelease(svc);
}
while ((svc = IOIteratorNext(iter)) != 0) IOObjectRelease(svc);
IOObjectRelease(iter);
return result;
}
static io_registry_entry_t find_iomedia_child(io_registry_entry_t parent) {
io_iterator_t iter;
kern_return_t kr = IORegistryEntryGetChildIterator(parent, kIOServicePlane, &iter);
@@ -372,7 +424,7 @@ static void da_release(void) {
// ── Public API ────────────────────────────────────────────────────────────
int shim_open_exclusive(const char *bsd_name) {
int shim_open_exclusive(const char *selector) {
kern_return_t kr;
HRESULT hr;
SInt32 score = 0;
@@ -387,6 +439,28 @@ int shim_open_exclusive(const char *bsd_name) {
return 0;
}
uint64_t registry_id = 0;
int is_registry_selector = parse_registry_id_selector(selector, &registry_id);
char bsd_name[32] = {0};
io_service_t svc = 0;
mach_port_t mp = MACH_PORT_NULL;
if (is_registry_selector) {
if (IOMainPort(0, &mp) != kIOReturnSuccess) {
pthread_mutex_unlock(&g_handle_lock);
return -1;
}
svc = find_bdsvc_by_registry_id(mp, registry_id);
if (!svc) {
pthread_mutex_unlock(&g_handle_lock);
return -1;
}
// If media is present, retain the old unmount/claim behavior. With an
// empty tray there is no BSD disk to unmount or hand to DiskArbitration.
bdsvc_to_bsd_name(svc, bsd_name, sizeof(bsd_name));
} else {
strlcpy(bsd_name, selector, sizeof(bsd_name));
}
// Unmount via diskutil, invoked directly with posix_spawn (no shell) so
// the BSD device name can never be interpreted as shell syntax. A shell
// wrapper here (system()/sh -c) was a command-injection vector for an
@@ -394,13 +468,13 @@ int shim_open_exclusive(const char *bsd_name) {
// argv element also sidesteps the old buffer-truncation concern entirely.
// stdout/stderr go to /dev/null to keep diskutil chatter out of the
// caller's streams.
{
if (bsd_name[0]) {
posix_spawn_file_actions_t fa;
posix_spawn_file_actions_init(&fa);
posix_spawn_file_actions_addopen(&fa, STDOUT_FILENO, "/dev/null", O_WRONLY, 0);
posix_spawn_file_actions_addopen(&fa, STDERR_FILENO, "/dev/null", O_WRONLY, 0);
char *const argv[] = {
"diskutil", "unmountDisk", "force", (char *)bsd_name, NULL
"diskutil", "unmountDisk", "force", bsd_name, NULL
};
pid_t pid;
if (posix_spawn(&pid, "/usr/sbin/diskutil", &fa, NULL, argv, environ) == 0) {
@@ -442,19 +516,18 @@ int shim_open_exclusive(const char *bsd_name) {
}
posix_spawn_file_actions_destroy(&fa);
}
usleep(500000);
if (bsd_name[0]) usleep(500000);
mach_port_t mp;
// Check the return before using the port. On failure IOMainPort leaves `mp`
// untouched, so every IOKit call below would run against an uninitialised
// mach port. shim_list_drives does check it; this path did not.
if (IOMainPort(0, &mp) != kIOReturnSuccess) {
if (mp == MACH_PORT_NULL && IOMainPort(0, &mp) != kIOReturnSuccess) {
pthread_mutex_unlock(&g_handle_lock);
return -1;
}
io_service_t svc = find_bdsvc_by_bsd_name(mp, bsd_name);
if (!svc) {
if (!svc) svc = find_bdsvc_by_bsd_name(mp, bsd_name);
if (!svc && !is_registry_selector) {
svc = find_bdsvc_from_iomedia(mp, bsd_name);
}
if (!svc) {
@@ -552,7 +625,7 @@ int shim_open_exclusive(const char *bsd_name) {
// 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);
if (bsd_name[0]) da_hold(bsd_name);
return 0;
}
@@ -639,10 +712,20 @@ int shim_execute(const unsigned char *cdb, unsigned char cdb_len,
// IOMedia whose "BSD Name" is the requested device IS the presence of a disc.
//
// Returns 1 (media present), 0 (no media), or -1 (IOKit unavailable).
int shim_media_present(const char *bsd_name) {
int shim_media_present(const char *selector) {
mach_port_t mp;
if (IOMainPort(0, &mp) != kIOReturnSuccess) return -1;
uint64_t registry_id = 0;
if (parse_registry_id_selector(selector, &registry_id)) {
io_service_t svc = find_bdsvc_by_registry_id(mp, registry_id);
if (!svc) return 0;
char bsd_name[32] = {0};
int has_media = bdsvc_to_bsd_name(svc, bsd_name, sizeof(bsd_name));
IOObjectRelease(svc);
return has_media && bsd_name[0] ? 1 : 0;
}
CFMutableDictionaryRef matching = IOServiceMatching("IOMedia");
if (!matching) return -1;
@@ -655,7 +738,7 @@ int shim_media_present(const char *bsd_name) {
while ((media = IOIteratorNext(iter)) != 0) {
char name[64];
if (registry_entry_bsd_name(media, name, sizeof(name))
&& strcmp(name, bsd_name) == 0)
&& strcmp(name, selector) == 0)
{
found = 1;
}
@@ -701,9 +784,11 @@ int shim_list_drives(ShimDriveInfo *out, int max_entries) {
memset(info, 0, sizeof(*info));
bdsvc_device_info(svc, info);
bdsvc_to_bsd_name(svc, info->bsd_name, sizeof(info->bsd_name));
if (!bdsvc_to_bsd_name(svc, info->device_selector, sizeof(info->device_selector))) {
registry_id_selector(svc, info->device_selector, sizeof(info->device_selector));
}
if (info->bsd_name[0]) {
if (info->device_selector[0]) {
count++;
}
+3 -2
View File
@@ -462,8 +462,9 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
/// no firmware reset, no init.
#[derive(Debug, Clone)]
pub struct DriveInfo {
/// Platform device path: `/dev/sgN` (Linux), `/dev/diskN` (macOS),
/// `\\.\CdRomN` (Windows).
/// Platform device path: `/dev/sgN` (Linux), `/dev/diskN` or an opaque
/// `ioreg:<id>` selector for an empty optical drive (macOS), `\\.\CdRomN`
/// (Windows).
pub path: String,
/// SCSI INQUIRY vendor identifier (e.g. `"HL-DT-ST"`).
pub vendor: String,
+398
View File
@@ -0,0 +1,398 @@
//! `BusRemovalSectorSource` — a stream form of AACS bus-encryption removal.
//!
//! # Where bus removal actually happens
//! AACS 2.0 "bus encryption" (BEE) wraps content on the drive↔host transport,
//! and removing it is the UNLOCKER's job. The single de-bus TRANSFORM is
//! [`decrypt_bus_in_content`]; in the PRODUCTION path it is applied once, at the
//! very bottom of the read chain, inside the live drive's own `SectorSource`
//! impl (`Drive::remove_bus_encryption`, gated by the drive's `BusStage` +
//! content ranges). Every reader above it (key sampler, mux, unit-key
//! validation, whole-disc sweep) reads the `Drive` as `&mut dyn SectorSource`
//! and so transparently sees bus-removed sectors and never reasons about bus
//! encryption.
//!
//! This decorator is an equivalent, independently-tested STREAM form of that
//! same transform (it also calls [`decrypt_bus_in_content`]), kept for tests and
//! for any future non-`Drive` source that needs de-bussing. It is NOT wired into
//! the production chain today. DANGER: never wrap the live `Drive` in this
//! decorator — the `Drive` already de-busses, so a second pass would DOUBLE-
//! de-bus and corrupt content. There must only ever be ONE de-bus in a chain.
//!
//! # How the stage is chosen
//! [`BusStage`] is computed ONCE from the unlock result:
//! * a firmware/vendor unlocker (freemkv / LibreDrive / Renesas) removes bus
//! encryption AT THE DRIVE (`Bus=off`), and a non-bus disc (DVD / clear BD)
//! has none → [`BusStage::Passthrough`] (reads pass through untouched);
//! * the AACS cert route yields the Read Data Key from the AKE, so the host
//! must de-bus each content sector → [`BusStage::AacsHostKey`].
//!
//! # Content gating
//! De-bussing a clear UDF/nav sector would corrupt plaintext, so the host-key
//! path is gated to the disc's encrypted-content extents via
//! [`with_content_ranges`](BusRemovalSectorSource::with_content_ranges) — the
//! same `(start_lba, sector_count)` map [`DecryptingSectorSource`] uses. Absent
//! a map, the caller reads content-only (title extents) and every sector is
//! treated as content — matching the pre-overhaul behaviour where `decrypt_bus`
//! ran on content units only.
//!
//! [`decrypt_bus_in_content`]: crate::aacs::content::decrypt_bus_in_content
//! [`DecryptingSectorSource`]: crate::sector::decrypting::DecryptingSectorSource
use std::sync::Arc;
use crate::error::Result;
use super::SectorSource;
/// How this drive's AACS bus encryption is removed — decided once from the
/// unlock result, never re-examined downstream.
#[derive(Clone)]
pub enum BusStage {
/// Bus encryption was removed at the drive by a firmware/vendor unlocker
/// (`Bus=off`), or the disc carries none (DVD / clear BD). Reads pass
/// through untouched.
Passthrough,
/// AACS cert route: the host holds the Read Data Key from the AKE and
/// de-busses each content sector in software.
AacsHostKey([u8; 16]),
}
impl BusStage {
/// Map an unlock/handshake result to the bus stage: a cert-route Read Data
/// Key (`Some`) means the host must de-bus content in software
/// ([`BusStage::AacsHostKey`]); its absence (`None`) means bus encryption was
/// removed at the drive by a firmware/vendor unlock, or the disc never had it
/// ([`BusStage::Passthrough`]). This is the SINGLE decision point that wires
/// [`crate::disc::Disc::scan`]'s handshake to the drive's de-bus.
pub fn from_read_data_key(read_data_key: Option<[u8; 16]>) -> Self {
match read_data_key {
Some(rdk) => BusStage::AacsHostKey(rdk),
None => BusStage::Passthrough,
}
}
}
// `BusStage` wraps a raw key; keep it out of logs.
impl std::fmt::Debug for BusStage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BusStage::Passthrough => write!(f, "BusStage::Passthrough"),
BusStage::AacsHostKey(_) => write!(f, "BusStage::AacsHostKey([redacted])"),
}
}
}
/// Decorator: read from `inner`, then remove AACS bus encryption from the bytes
/// that landed in `buf` when the [`BusStage`] carries a host key. A
/// [`BusStage::Passthrough`] stream is a zero-cost pass-through.
pub struct BusRemovalSectorSource<S: SectorSource> {
inner: S,
stage: BusStage,
/// Encrypted-content extent map (sorted/merged `(start_lba, sector_count)`).
/// `None` = caller reads content-only, so every sector is de-bussed;
/// `Some` = a whole-disc reader, so clear sectors outside these ranges pass
/// through untouched. Ignored entirely by [`BusStage::Passthrough`].
content_ranges: Option<Arc<[(u32, u32)]>>,
}
impl<S: SectorSource> BusRemovalSectorSource<S> {
/// Wrap `inner` with the bus stage decided by the unlock result.
pub fn new(inner: S, stage: BusStage) -> Self {
Self {
inner,
stage,
content_ranges: None,
}
}
/// Restrict host-key de-bussing to the disc's encrypted-content extents;
/// clear sectors outside them pass through untouched. Whole-disc readers
/// (sweep / patch) set this; content-only readers (mux title extents,
/// sampler) leave it unset.
pub fn with_content_ranges(mut self, ranges: Arc<[(u32, u32)]>) -> Self {
self.content_ranges = Some(ranges);
self
}
/// `&mut` counterpart of [`with_content_ranges`](Self::with_content_ranges),
/// for readers that build the stream before the content map is known.
pub fn set_content_ranges(&mut self, ranges: Arc<[(u32, u32)]>) {
self.content_ranges = Some(ranges);
}
/// Borrow the inner source (tests / introspection).
pub fn inner(&self) -> &S {
&self.inner
}
/// Mutable borrow of the inner source.
pub fn inner_mut(&mut self) -> &mut S {
&mut self.inner
}
/// Consume the decorator and return the underlying source.
pub fn into_inner(self) -> S {
self.inner
}
}
impl<S: SectorSource> SectorSource for BusRemovalSectorSource<S> {
fn capacity_sectors(&self) -> u32 {
self.inner.capacity_sectors()
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
self.read_sectors_fua(lba, count, buf, recovery, false)
}
fn read_sectors_fua(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
fua: bool,
) -> Result<usize> {
let n = self
.inner
.read_sectors_fua(lba, count, buf, recovery, fua)?;
if let BusStage::AacsHostKey(rdk) = &self.stage {
crate::aacs::content::decrypt_bus_in_content(
&mut buf[..n],
rdk,
lba,
self.content_ranges.as_deref(),
);
}
Ok(n)
}
fn set_speed(&mut self, kbs: u16) {
self.inner.set_speed(kbs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::content::encrypt_bus;
use crate::consts::SECTOR_BYTES;
/// A source returning a fixed buffer for any read (starting at whatever the
/// fixture built), reporting the full span.
struct FixedSource {
bytes: Vec<u8>,
}
impl SectorSource for FixedSource {
fn capacity_sectors(&self) -> u32 {
(self.bytes.len() / SECTOR_BYTES) as u32
}
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let n = (count as usize * SECTOR_BYTES).min(self.bytes.len());
buf[..n].copy_from_slice(&self.bytes[..n]);
Ok(n)
}
}
// A recognisable clear "content" unit: plaintext first 16 bytes of each
// sector (bus enc leaves those clear on the wire), a known pattern in
// 16..2048. Two sectors so cross-sector gating is exercised.
fn clear_content(sectors: usize) -> Vec<u8> {
let mut v = vec![0u8; sectors * SECTOR_BYTES];
for (i, b) in v.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(31).wrapping_add(7);
}
v
}
// The single wiring decision: a cert-route Read Data Key becomes a host-key
// stage; its absence (firmware/vendor unlock, or a non-bus disc) is Passthrough.
#[test]
fn from_read_data_key_maps_handshake_to_stage() {
let rdk = [0xABu8; 16];
match BusStage::from_read_data_key(Some(rdk)) {
BusStage::AacsHostKey(k) => {
assert_eq!(k, rdk, "cert RDK carries into the host-key stage")
}
BusStage::Passthrough => panic!("a Some(read_data_key) must map to AacsHostKey"),
}
assert!(
matches!(BusStage::from_read_data_key(None), BusStage::Passthrough),
"no read_data_key (firmware/vendor unlock or non-bus disc) must map to Passthrough"
);
}
// Passthrough must hand bytes back byte-identical — even a stream fed
// already-clear content never touches it.
#[test]
fn passthrough_returns_bytes_unchanged() {
let clear = clear_content(2);
let src = FixedSource {
bytes: clear.clone(),
};
let mut s = BusRemovalSectorSource::new(src, BusStage::Passthrough);
let mut got = vec![0u8; 2 * SECTOR_BYTES];
let n = s.read_sectors(100, 2, &mut got, false).unwrap();
assert_eq!(n, 2 * SECTOR_BYTES);
assert_eq!(got, clear, "Passthrough must not alter any byte");
}
// The core contract: a bus-ENCRYPTED sector read through an AacsHostKey
// stream comes back as the known plaintext. MUTATION: dropping the de-bus
// call (or using Passthrough) leaves the ciphertext, so this goes red.
#[test]
fn host_key_removes_bus_encryption() {
let rdk = [0x5Au8; 16];
let clear = clear_content(2);
let mut wire = clear.clone();
encrypt_bus(&mut wire, &rdk); // model the drive's forward bus transform
assert_ne!(wire, clear, "fixture must actually be bus-encrypted");
let src = FixedSource { bytes: wire };
let mut s = BusRemovalSectorSource::new(src, BusStage::AacsHostKey(rdk));
let mut got = vec![0u8; 2 * SECTOR_BYTES];
let n = s.read_sectors(0, 2, &mut got, false).unwrap();
assert_eq!(n, 2 * SECTOR_BYTES);
assert_eq!(
got, clear,
"AacsHostKey must recover the plaintext byte-for-byte"
);
}
// A sector OUTSIDE the content map is clear filesystem and must pass through
// untouched (de-bussing it would corrupt plaintext). LBA 0 is outside content
// [300,303), so the unencrypted bytes come back verbatim.
#[test]
fn host_key_passes_through_sectors_outside_content() {
let rdk = [0x5Au8; 16];
let clear = clear_content(1); // clear filesystem sector on the wire
let src = FixedSource {
bytes: clear.clone(),
};
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(300u32, 3u32)].into_boxed_slice());
let mut s = BusRemovalSectorSource::new(src, BusStage::AacsHostKey(rdk))
.with_content_ranges(ranges);
let mut got = vec![0u8; SECTOR_BYTES];
// LBA 0 is outside content → no de-bus → bytes unchanged.
let n = s.read_sectors(0, 1, &mut got, false).unwrap();
assert_eq!(n, SECTOR_BYTES);
assert_eq!(
got, clear,
"a sector outside content must pass through untouched"
);
}
// The complement: a bus-encrypted sector INSIDE the content map is de-bussed.
#[test]
fn host_key_debusses_sectors_inside_content() {
let rdk = [0x33u8; 16];
let clear = clear_content(1);
let mut wire = clear.clone();
encrypt_bus(&mut wire, &rdk);
let src = FixedSource { bytes: wire };
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(300u32, 3u32)].into_boxed_slice());
let mut s = BusRemovalSectorSource::new(src, BusStage::AacsHostKey(rdk))
.with_content_ranges(ranges);
let mut got = vec![0u8; SECTOR_BYTES];
// LBA 300 is inside content → de-bus → plaintext recovered.
let n = s.read_sectors(300, 1, &mut got, false).unwrap();
assert_eq!(n, SECTOR_BYTES);
assert_eq!(
got, clear,
"a content sector must be de-bussed to plaintext"
);
}
// Per-sector gating WITHIN one multi-sector read at the decorator level:
// content [301,302) covers only the middle sector of a 3-sector read at 300.
// MUTATION: gating the whole buffer on the first sector's LBA goes red here.
#[test]
fn host_key_gates_per_sector_within_a_multi_sector_read() {
let rdk = [0x44u8; 16];
let clear = clear_content(3);
let mut wire = clear.clone();
// Encrypt ONLY the middle sector's body; 0 and 2 stay clear on the wire.
let mut mid = clear[SECTOR_BYTES..2 * SECTOR_BYTES].to_vec();
encrypt_bus(&mut mid, &rdk);
wire[SECTOR_BYTES..2 * SECTOR_BYTES].copy_from_slice(&mid);
let src = FixedSource { bytes: wire };
let ranges: Arc<[(u32, u32)]> = Arc::from(vec![(301u32, 1u32)].into_boxed_slice());
let mut s = BusRemovalSectorSource::new(src, BusStage::AacsHostKey(rdk))
.with_content_ranges(ranges);
let mut got = vec![0u8; 3 * SECTOR_BYTES];
s.read_sectors(300, 3, &mut got, false).unwrap();
assert_eq!(
got, clear,
"only the in-range middle sector is de-bussed; clear neighbours untouched"
);
}
// Only the reported `n` bytes are de-bussed; a short read must not touch
// bytes beyond `n`. Also proves capacity delegates.
#[test]
fn debus_bounded_by_reported_n_and_capacity_delegates() {
let rdk = [0x77u8; 16];
// Two IDENTICAL bus-encrypted sectors on the wire, but the source reports
// only the FIRST was read. Sector 0 must be de-bussed to plaintext; sector
// 1 (beyond the reported n) must be left as ciphertext, untouched.
let clear = clear_content(1);
let mut enc = clear.clone();
encrypt_bus(&mut enc, &rdk);
let mut bytes = enc.clone();
bytes.extend_from_slice(&enc); // two encrypted sectors
struct ShortSource {
bytes: Vec<u8>,
report: usize,
}
impl SectorSource for ShortSource {
fn capacity_sectors(&self) -> u32 {
42
}
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
buf[..self.bytes.len()].copy_from_slice(&self.bytes);
Ok(self.report)
}
}
let src = ShortSource {
bytes,
report: SECTOR_BYTES, // only sector 0 "read"
};
let mut s = BusRemovalSectorSource::new(src, BusStage::AacsHostKey(rdk));
assert_eq!(s.capacity_sectors(), 42, "capacity must delegate to inner");
let mut got = vec![0u8; 2 * SECTOR_BYTES];
let n = s.read_sectors(0, 2, &mut got, false).unwrap();
assert_eq!(n, SECTOR_BYTES);
// Sector 0 was de-bussed to plaintext...
assert_eq!(
&got[..SECTOR_BYTES],
&clear[..],
"reported sector de-bussed"
);
// ...and sector 1 (beyond n) was NOT touched — still ciphertext.
assert_eq!(
&got[SECTOR_BYTES..],
&enc[..],
"bytes beyond the reported n must stay ciphertext"
);
}
}
-5
View File
@@ -566,7 +566,6 @@ mod tests {
src,
DecryptKeys::Aacs {
unit_keys: Vec::new(),
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
},
);
@@ -757,7 +756,6 @@ mod tests {
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![(
@@ -789,7 +787,6 @@ mod tests {
let src = FixedUnit { unit };
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut dec = DecryptingSectorSource::new(src, keys); // no with_key_map
@@ -811,7 +808,6 @@ mod tests {
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![(
@@ -841,7 +837,6 @@ mod tests {
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)]));
+1
View File
@@ -8,6 +8,7 @@
//! `SectorSource` and applies AACS / CSS in-place decrypt to
//! yield plaintext sectors.
pub mod bus_removal;
pub mod decrypting;
pub mod prefetched;
-1
View File
@@ -692,7 +692,6 @@ mod tests {
key_source: crate::disc::KeyOrigin::KeyDb,
vuk: None,
unit_keys: Vec::new(),
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
+118
View File
@@ -4470,6 +4470,124 @@ mod tests {
);
}
// read_directory follows a type-3 CONTINUATION pointer (ECMA-167 4/14.14.1.1):
// extent 0 (sector 60) has the first FID; a type-3 AD → block 50 → tail AD →
// extent 1 (sector 61) with the second FID. MUTATION: not following loses it.
#[test]
fn read_directory_follows_type3_continuation() {
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0); // parent (..)
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);
// ICB ADs: (recorded, 2048, lba=60) then a type-3 continuation (→ lba 50).
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(&((3u32 << 30) | 2048).to_le_bytes());
body.extend_from_slice(&50u32.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
reader.put(50, build_cont_block(&[(0, e1.len() as u32, 61)]));
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 directory with a continuation AD must be readable");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string(), "SECOND.CLPI".to_string()],
"the FID in the continuation block must be read, not dropped"
);
assert_eq!(parsed.size, 2048 + e1.len() as u64);
}
// A continuation block opens with a 24-byte AED (tag 258); read_directory reads
// the tail ADs from offset 24, not 0. MUTATION: reading from 0 mis-parses the
// AED tag as a bogus AD and loses the continuation extent (Blu-ray 3D bug).
#[test]
fn read_directory_continuation_skips_aed_header() {
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0);
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 1959);
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);
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(&((3u32 << 30) | 2048).to_le_bytes());
body.extend_from_slice(&50u32.to_le_bytes());
let mut cont = build_cont_block(&[(0, e1.len() as u32, 61)]);
// Stamp a realistic AED tag (258) so a from-offset-0 regression mis-parses it.
cont[0..2].copy_from_slice(&258u16.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
reader.put(50, cont);
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("continuation with an AED header must be readable");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string(), "SECOND.CLPI".to_string()],
"continuation ADs must be read past the 24-byte AED header, not from offset 0"
);
}
// Hostile input: a continuation pointer that cycles to its own block. The
// MAX_AD_BLOCKS budget must bound the walk so it TERMINATES (returns the FIDs
// gathered before the cycle) instead of looping forever or panicking.
#[test]
fn read_directory_self_cycling_continuation_terminates() {
let mut e0 = Vec::new();
push_fid_iu(&mut e0, "", 5, true, true, 0);
push_fid_iu(&mut e0, "FIRST.CLPI", 7, false, false, 1959);
let mut sec0 = [0u8; 2048];
sec0.copy_from_slice(&e0);
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(&((3u32 << 30) | 2048).to_le_bytes());
body.extend_from_slice(&50u32.to_le_bytes());
let mut reader = MemReader::new();
reader.put(5, build_dir_icb_flagged(0, &body));
reader.put(60, sec0);
// Continuation block 50 points at ITSELF (type-3 → lba 50).
reader.put(50, build_cont_block(&[(3, 2048, 50)]));
reader.put(7, build_efe_icb(11, 2048, 0));
let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0, &mut 0, &mut HashSet::new())
.expect("a self-cycling continuation must terminate, not hang or error");
assert_eq!(
child_names(&parsed),
vec!["FIRST.CLPI".to_string()],
"FIDs before the cycle are returned; the MAX_AD_BLOCKS budget bounds the walk"
);
}
#[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
+327
View File
@@ -0,0 +1,327 @@
//! Opt-in checks against an attached macOS optical drive.
//!
//! Run with:
//! `cargo test --test macos_drive_tests -- --ignored --nocapture`
#![cfg(target_os = "macos")]
use libfreemkv::{Drive, DriveStatus, list_drives, scsi::drive_has_disc};
use std::path::Path;
use std::path::PathBuf;
fn drive_name(drive: &libfreemkv::DriveInfo) -> String {
format!("{} {}", drive.vendor.trim(), drive.model.trim())
.trim()
.to_string()
}
/// Exercises the empty-tray path end to end: IOKit enumeration must retain an
/// IOBDServices drive without IOMedia, and the opaque selector must still open
/// that exact service and issue real SCSI commands.
#[test]
#[ignore = "requires an attached optical drive with an empty tray"]
fn empty_tray_drive_is_enumerated_opened_and_reports_no_disc() {
let drives = list_drives();
assert!(!drives.is_empty(), "no optical drive enumerated");
let empty = drives
.iter()
.find(|drive| matches!(drive_has_disc(Path::new(&drive.path)), Ok(false)))
.unwrap_or_else(|| {
panic!(
"no empty optical drive found among {} drive(s)",
drives.len()
)
});
assert!(
empty.path.starts_with("ioreg:"),
"empty-tray path should use its IOKit service selector, got {:?}",
empty.path
);
let mut drive = Drive::open(Path::new(&empty.path)).unwrap_or_else(|e| {
panic!(
"failed to open {} ({}) with empty tray: {e}",
drive_name(empty),
empty.path
)
});
assert_eq!(
drive.drive_id.raw_inquiry.first().map(|byte| byte & 0x1f),
Some(0x05),
"opened service must identify as an MMC optical drive"
);
assert_eq!(
drive.drive_status(),
DriveStatus::NoDisc,
"an empty tray should report NoDisc after a real SCSI status query"
);
// Opening the selected drive through the same session path as the GUI must
// reach the drive and fail because there is no medium, not because the
// optical drive was undiscoverable. Drop the first handle before reopening.
drop(drive);
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let mut session = DiscSession::open(
DeviceTarget::Path(PathBuf::from(&empty.path)),
KeySpec::default(),
)
.unwrap_or_else(|e| panic!("failed to create empty-tray session: {e}"));
assert!(
session.scan(ScanOptions::default()).is_err(),
"scanning an empty tray must fail as no media, not report an empty drive as a disc"
);
}
/// Reads real filesystem/title metadata from a disc, without muxing or saving
/// its content. Run only after inserting a retail Blu-ray or DVD.
#[test]
#[ignore = "requires an inserted Blu-ray/DVD; scan reads disc metadata"]
fn inserted_disc_scans_filesystem_and_finds_titles() {
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let drives = list_drives();
let media_checks: Vec<_> = drives
.iter()
.map(|drive| {
(
drive.path.as_str(),
drive.vendor.as_str(),
drive.model.as_str(),
drive_has_disc(Path::new(&drive.path)),
)
})
.collect();
let drive = drives
.iter()
.find(|drive| matches!(drive_has_disc(Path::new(&drive.path)), Ok(true)))
.unwrap_or_else(|| {
panic!("no optical drive with media detected; probes: {media_checks:?}")
});
println!(
"scanning {} ({}) {}",
drive.path,
drive_name(drive),
if drive.path.starts_with("ioreg:") {
"[IOKit service selector]"
} else {
""
}
);
let mut session = DiscSession::open(
DeviceTarget::Path(PathBuf::from(&drive.path)),
KeySpec::default(),
)
.unwrap_or_else(|e| panic!("failed to open {} ({}): {e}", drive_name(drive), drive.path));
let disc = session.scan(ScanOptions::default()).unwrap_or_else(|e| {
panic!(
"scan failed for {} ({}): {e}",
drive_name(drive),
drive.path
)
});
assert!(
!disc.titles.is_empty(),
"disc scan completed but found no titles on {} ({})",
drive_name(drive),
drive.path
);
}
/// Exercises the autodetect route used by bare `disc://` sources. Keeping this
/// separate from the explicit-path test catches discovery/open mismatches.
#[test]
#[ignore = "requires an inserted Blu-ray/DVD; opens the first detected drive"]
fn inserted_disc_autodetects_and_scans_titles() {
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let mut session = DiscSession::open(DeviceTarget::Autodetect, KeySpec::default())
.unwrap_or_else(|e| panic!("autodetect failed to open the inserted disc: {e}"));
let disc = session
.scan(ScanOptions::default())
.unwrap_or_else(|e| panic!("autodetected drive failed to scan the disc: {e}"));
assert!(
!disc.titles.is_empty(),
"autodetected drive scanned the disc but found no titles"
);
}
/// INQUIRY populates vendor / model / firmware for every enumerated drive.
/// Blank fields would indicate the IOKit walk dropped an INQUIRY-carrying
/// child, which would silently break drive-fingerprint keydb lookups.
#[test]
#[ignore = "requires an attached optical drive"]
fn every_enumerated_drive_reports_vendor_model_firmware() {
let drives = list_drives();
assert!(!drives.is_empty(), "no optical drive enumerated");
for d in &drives {
assert!(!d.vendor.trim().is_empty(), "empty vendor for {}", d.path);
assert!(!d.model.trim().is_empty(), "empty model for {}", d.path);
assert!(
!d.firmware.trim().is_empty(),
"empty firmware for {} ({} {})",
d.path,
d.vendor,
d.model
);
assert!(
!d.path.is_empty(),
"empty selector path for {} {}",
d.vendor,
d.model
);
}
}
/// Opening the same drive twice back-to-back must succeed. Catches leaked
/// exclusive-open state in the IOKit session path — a regression would
/// surface as "device busy" on the second open.
#[test]
#[ignore = "requires an attached optical drive"]
fn drive_can_be_reopened_after_close() {
let drives = list_drives();
let target = drives.first().expect("no optical drive enumerated");
for i in 0..3 {
let drive = Drive::open(Path::new(&target.path)).unwrap_or_else(|e| {
panic!(
"iter {i}: failed to reopen {} ({}): {e}",
drive_name(target),
target.path
)
});
drop(drive);
}
}
/// UHD disc scan populates the AACS 2.0 shape we advertise on the UI:
/// bus_encryption true, MKB version present, disc_hash populated, VID set,
/// at least one CPS unit key. Verifies the scan pipeline end-to-end for
/// the exact disc type most likely to regress.
#[test]
#[ignore = "requires an inserted UHD Blu-ray; scans AACS 2.0 disc metadata"]
fn inserted_uhd_disc_reports_full_aacs2_state() {
use libfreemkv::disc::DiscFormat;
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let mut session = DiscSession::open(DeviceTarget::Autodetect, KeySpec::default())
.unwrap_or_else(|e| panic!("autodetect failed to open the inserted disc: {e}"));
let disc = session
.scan(ScanOptions::default())
.unwrap_or_else(|e| panic!("scan failed: {e}"));
assert!(
matches!(disc.format, DiscFormat::Uhd),
"expected UHD DiscFormat, got {:?}",
disc.format
);
assert!(
disc.capacity_bytes >= 40_000_000_000,
"UHD capacity should be ≥ 40 GB, got {} bytes",
disc.capacity_bytes
);
assert!(
disc.layers >= 2,
"UHD disc should report ≥ 2 layers, got {}",
disc.layers
);
let aacs = disc.aacs.as_ref().expect("UHD disc must have AacsState");
assert_eq!(aacs.version, 2, "UHD disc must be AACS 2.0");
assert!(aacs.bus_encryption, "UHD disc must set bus_encryption");
assert!(
aacs.mkb_version.is_some_and(|v| v > 0),
"UHD disc must expose MKB version, got {:?}",
aacs.mkb_version
);
assert!(
aacs.disc_hash.starts_with("0x") && aacs.disc_hash.len() == 42,
"disc_hash should be 0x-prefixed 40 hex chars, got {:?}",
aacs.disc_hash
);
assert_ne!(
aacs.volume_id, [0u8; 16],
"VID must be populated after a successful AACS 2.0 handshake"
);
assert!(
!aacs.unit_keys.is_empty(),
"at least one CPS unit key expected for a scannable UHD disc"
);
}
/// Scan is idempotent: rescanning the same session yields the same disc
/// hash and MKB version. Guards against stateful drift in the scan path.
#[test]
#[ignore = "requires an inserted Blu-ray/DVD; rescans twice"]
fn rescan_returns_stable_disc_identity() {
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let mut session = DiscSession::open(DeviceTarget::Autodetect, KeySpec::default())
.unwrap_or_else(|e| panic!("open failed: {e}"));
let first_snapshot = {
let first = session
.scan(ScanOptions::default())
.unwrap_or_else(|e| panic!("first scan failed: {e}"));
first
.aacs
.as_ref()
.map(|a| (a.disc_hash.clone(), a.mkb_version, a.volume_id))
};
let second_snapshot = {
let second = session
.scan(ScanOptions::default())
.unwrap_or_else(|e| panic!("second scan failed: {e}"));
second
.aacs
.as_ref()
.map(|a| (a.disc_hash.clone(), a.mkb_version, a.volume_id))
};
let (Some(a), Some(b)) = (first_snapshot, second_snapshot) else {
return;
};
assert_eq!(a.0, b.0, "disc_hash drifted across rescans");
assert_eq!(a.1, b.1, "mkb_version drifted across rescans");
assert_eq!(a.2, b.2, "VID drifted across rescans");
}
/// `drive_has_disc` matches what `DiscSession::open + scan` actually finds.
/// Every drive returning `Ok(true)` must be scannable; every `Ok(false)`
/// must fail to scan. Catches enumeration/probe divergence.
#[test]
#[ignore = "requires an attached optical drive; probes every enumerated drive"]
fn drive_has_disc_matches_scan_outcome() {
use libfreemkv::{DeviceTarget, DiscSession, KeySpec, ScanOptions};
let drives = list_drives();
assert!(!drives.is_empty(), "no optical drive enumerated");
for d in &drives {
match drive_has_disc(Path::new(&d.path)) {
Ok(true) => {
let mut session = DiscSession::open(
DeviceTarget::Path(PathBuf::from(&d.path)),
KeySpec::default(),
)
.unwrap_or_else(|e| panic!("open failed for {}: {e}", d.path));
let _ = session
.scan(ScanOptions::default())
.unwrap_or_else(|e| panic!("scan failed on drive_has_disc=true: {e}"));
}
Ok(false) => {
let mut session = DiscSession::open(
DeviceTarget::Path(PathBuf::from(&d.path)),
KeySpec::default(),
)
.unwrap_or_else(|e| panic!("open failed for empty tray {}: {e}", d.path));
assert!(
session.scan(ScanOptions::default()).is_err(),
"scan unexpectedly succeeded on drive_has_disc=false: {}",
d.path
);
}
Err(e) => panic!("drive_has_disc errored on {}: {e}", d.path),
}
}
}
+85
View File
@@ -0,0 +1,85 @@
//! macOS platform-integration tests that do NOT require an optical drive.
//!
//! These exercise the Mac-only code paths under `platform/fs_type/macos.rs`,
//! `io/platform_macos.rs`, and `io/writeback_file` end-to-end against the real
//! kernel. They run under `cargo test` without `--ignored`, so CI on macOS
//! (and the local `cargo test` a developer would run before pushing) covers
//! them automatically.
#![cfg(target_os = "macos")]
use libfreemkv::WritebackFile;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::PathBuf;
fn tmp_path(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
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);
p.push(format!("freemkv-macos-test-{pid}-{nanos}-{name}"));
p
}
#[test]
fn writeback_file_roundtrips_write_read() {
let path = tmp_path("roundtrip");
let payload = b"freemkv writeback roundtrip on APFS";
{
let mut wf = WritebackFile::create(&path).expect("create WritebackFile");
wf.write_all(payload).expect("write");
wf.sync_all().expect("sync_all");
}
let mut f = std::fs::File::open(&path).expect("reopen for read");
let mut buf = Vec::new();
f.read_to_end(&mut buf).expect("read");
let _ = std::fs::remove_file(&path);
assert_eq!(buf, payload);
}
#[test]
fn writeback_file_seek_and_patch() {
let path = tmp_path("seek");
let mut wf = WritebackFile::create(&path).expect("create WritebackFile");
wf.write_all(b"AAAAAAAA").expect("initial write");
wf.seek(SeekFrom::Start(3)).expect("seek back");
wf.write_all(b"BB").expect("patch write");
wf.sync_all().expect("sync_all");
drop(wf);
let contents = std::fs::read(&path).expect("read back");
let _ = std::fs::remove_file(&path);
assert_eq!(&contents, b"AAABBAAA");
}
#[test]
fn writeback_file_create_with_size_hint_preallocates() {
let path = tmp_path("prealloc");
let hint: u64 = 16 * 1024 * 1024;
let mut wf = WritebackFile::create_with_size_hint(&path, hint).expect("create_with_size_hint");
wf.write_all(&[0xAB; 4096]).expect("write header");
wf.sync_all().expect("sync_all");
drop(wf);
// Reported file length reflects bytes written, not the preallocation.
let meta = std::fs::metadata(&path).expect("stat");
let _ = std::fs::remove_file(&path);
assert_eq!(
meta.len(),
4096,
"reported length should equal bytes written"
);
}
#[test]
fn list_drives_does_not_panic_without_hardware() {
// Even on a Mac with no optical drive, enumeration must return cleanly.
let _ = libfreemkv::list_drives();
}
-1
View File
@@ -88,7 +88,6 @@ fn decrypt_keys_is_encrypted_variants() {
let aacs = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: libfreemkv::disc::ContentFormat::BdTs,
};
assert!(aacs.is_encrypted());