Compare commits

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