mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebd726c0f2 |
@@ -1 +0,0 @@
|
||||
github: MattJackson
|
||||
+206
-74
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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,12 +1,6 @@
|
||||
[](https://github.com/sponsors/MattJackson)
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/freemkv/libfreemkv/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/freemkv/libfreemkv)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/freemkv/libfreemkv)
|
||||
[](https://www.bestpractices.dev/projects/14745)
|
||||
[](https://api.reuse.software/info/github.com/freemkv/libfreemkv)
|
||||
[](#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
@@ -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"
|
||||
@@ -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))
|
||||
Executable
+120
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+38
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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), &[]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, ®istry_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, ®istry_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, ®istry_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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user