mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
769249832d |
Submodule
+1
Submodule .github added at 73870259ea
@@ -1 +0,0 @@
|
||||
github: MattJackson
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**What happened?**
|
||||
|
||||
|
||||
**What did you expect?**
|
||||
|
||||
|
||||
**Steps to reproduce**
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Drive info** (run `freemkv info`)
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
**Environment**
|
||||
- OS:
|
||||
- freemkv version:
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
name: Drive Profile
|
||||
about: Submit drive data (auto-created by freemkv info --share)
|
||||
labels: drive-profile
|
||||
---
|
||||
|
||||
Paste your `freemkv info` output here.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a feature
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**What would you like?**
|
||||
|
||||
|
||||
**Why?**
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Version updates are monthly and grouped into one PR per ecosystem.
|
||||
# Security updates are separate and run whenever an advisory is published.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
- dependency-name: freemkv/.github*
|
||||
@@ -1,307 +0,0 @@
|
||||
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.
|
||||
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`.
|
||||
resolve-msrv:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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.
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if grep -qE "^## \[${version}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md has an entry for ${version}."
|
||||
exit 0
|
||||
fi
|
||||
msg="CHANGELOG.md has no '## [${version}]' entry"
|
||||
if [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_BASE_REF:-}" = "main" ]; then
|
||||
echo "::error::${msg} — main requires a changelog entry for the released version."
|
||||
exit 1
|
||||
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
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo fmt --check
|
||||
working-directory: libfreemkv
|
||||
- run: cargo +stable clippy --all-targets -- -D warnings
|
||||
working-directory: libfreemkv
|
||||
|
||||
test:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo test --tests
|
||||
working-directory: libfreemkv
|
||||
# `--tests` skips doctests; the keys compile_fail doctests enforce spec LK19.
|
||||
- run: cargo test --doc
|
||||
working-directory: libfreemkv
|
||||
|
||||
# The memory orderings in `scsi::fd_handoff` (and `halt`) compile to the same instructions
|
||||
# on x86_64, so the WRONG ones pass every run; loom enumerates the executions
|
||||
# the C++20 model permits and is the only regression coverage that code has.
|
||||
# Its own job because RUSTFLAGS is part of the rust-cache key. Takes the sibling
|
||||
# checkout even with --no-default-features: features don't exempt an optional
|
||||
# path dep from manifest RESOLUTION.
|
||||
loom:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
RUSTFLAGS: --cfg loom
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo test --lib --no-default-features --features scsi fd_handoff
|
||||
working-directory: libfreemkv
|
||||
# LT3: `Halt`'s cancel Release / observe Acquire pair (stop design §2.1, SS-23).
|
||||
- run: cargo test --lib --no-default-features --features scsi loom_halt
|
||||
working-directory: libfreemkv
|
||||
# `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
|
||||
|
||||
deny:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
# Run from the repo dir so the generated .cargo/config.toml path patches apply.
|
||||
- run: cargo deny --all-features check advisories bans licenses sources
|
||||
working-directory: libfreemkv
|
||||
|
||||
# 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:
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo check
|
||||
working-directory: libfreemkv
|
||||
|
||||
check-windows:
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # 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.
|
||||
- run: cargo build --tests
|
||||
working-directory: libfreemkv
|
||||
|
||||
# Did this change break anything downstream? libfreemkv sits below all four
|
||||
# dependents; a break here is worth strictly more than anywhere else. Runs
|
||||
# their suites (not just check) — a behavioural change keeps signatures but
|
||||
# fails a dependent's tests, which is the shape worth catching. This caught an
|
||||
# engine fixture break the moment it landed, so it stays on the dev fast lane.
|
||||
consumers:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with: { path: libfreemkv }
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock freemkv-keysources freemkv-engine freemkv-i18n freemkv bdemu
|
||||
- name: Point every dependent at THIS libfreemkv commit
|
||||
shell: bash
|
||||
run: |
|
||||
for c in freemkv-keysources freemkv-engine freemkv bdemu; do
|
||||
mkdir -p "$c/.cargo"
|
||||
cat > "$c/.cargo/config.toml" <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
freemkv-keysources = { path = "../freemkv-keysources" }
|
||||
freemkv-engine = { path = "../freemkv-engine" }
|
||||
freemkv-i18n = { path = "../freemkv-i18n" }
|
||||
freemkv-unlock = { path = "../freemkv-unlock" }
|
||||
EOF
|
||||
done
|
||||
# freemkv's app build (`--features gui`) compiles a GTK4 + libadwaita
|
||||
# shell, so testing it needs these pkg-config dev headers.
|
||||
- name: Install GTK4 + libadwaita development headers (for freemkv)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-4-dev libadwaita-1-dev build-essential
|
||||
# Dependents track the highest supported Rust — build on stable so this job
|
||||
# satisfies the highest dependent floor (the library's own lower MSRV, which
|
||||
# the `test` job verifies, cannot compile them).
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: |
|
||||
freemkv-keysources
|
||||
freemkv-engine
|
||||
freemkv
|
||||
bdemu
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-keysources
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-engine
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv
|
||||
- run: cargo test --tests --features gui
|
||||
working-directory: freemkv
|
||||
- run: cargo test --tests
|
||||
working-directory: bdemu
|
||||
|
||||
# Coverage: non-required, informational only (codecov.yml), no job needs: it.
|
||||
coverage:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- uses: taiki-e/install-action@72f835688cb6c15903b796f704b01e652a2b8835 # cargo-llvm-cov
|
||||
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
|
||||
working-directory: libfreemkv
|
||||
- uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: libfreemkv/lcov.info
|
||||
slug: freemkv/libfreemkv
|
||||
fail_ci_if_error: false
|
||||
@@ -1,12 +0,0 @@
|
||||
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.
|
||||
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@v2
|
||||
@@ -1,18 +0,0 @@
|
||||
name: doc-links
|
||||
|
||||
# Thin caller → the canonical reusable doc-links gate in freemkv/.github.
|
||||
# libfreemkv deps freemkv-unlock by version (git-tag override), so the reusable
|
||||
# checks it out as a sibling and path-patches it, and resolves the toolchain
|
||||
# from rust-version.
|
||||
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
|
||||
patch: freemkv-unlock
|
||||
@@ -1,58 +0,0 @@
|
||||
name: FFmpeg validation
|
||||
|
||||
# Called by qa.yml on pushes to qa, or manually for a development branch.
|
||||
# Pure Rust tests remain in dev CI; these tests require external codecs.
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
FREEMKV_FFMPEG_ARTIFACT_DIR: ${{ github.workspace }}/ffmpeg-artifacts
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- name: Read the crate's MSRV
|
||||
id: msrv
|
||||
working-directory: libfreemkv
|
||||
run: echo "v=$(sed -n 's/^rust-version = "\(.*\)"/\1/p' Cargo.toml)" >> "$GITHUB_OUTPUT"
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ steps.msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- name: Install software encoders and decoders
|
||||
run: sudo apt-get update && sudo apt-get install -y ffmpeg
|
||||
- name: Record tool versions
|
||||
run: |
|
||||
mkdir -p "$FREEMKV_FFMPEG_ARTIFACT_DIR"
|
||||
ffmpeg -version > "$FREEMKV_FFMPEG_ARTIFACT_DIR/ffmpeg-version.txt"
|
||||
ffprobe -version > "$FREEMKV_FFMPEG_ARTIFACT_DIR/ffprobe-version.txt"
|
||||
rustc --version > "$FREEMKV_FFMPEG_ARTIFACT_DIR/rustc-version.txt"
|
||||
- name: Generate fixtures and validate libfreemkv output
|
||||
working-directory: libfreemkv
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
cargo test --release --lib ffmpeg_ -- --ignored --nocapture 2>&1 | tee "$FREEMKV_FFMPEG_ARTIFACT_DIR/tests.log"
|
||||
- name: Upload fixtures, probes, hashes and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: ffmpeg-validation-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.FREEMKV_FFMPEG_ARTIFACT_DIR }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
@@ -1,14 +0,0 @@
|
||||
name: identity-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on:
|
||||
push:
|
||||
branches: [dev, qa, main]
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
identity:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
|
||||
@@ -1,11 +0,0 @@
|
||||
name: leak-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on: [push, pull_request]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
leak-guard:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
|
||||
@@ -1,180 +0,0 @@
|
||||
name: qa
|
||||
|
||||
# ── The qa gate: "is this production worth?" ────────────────────────────────
|
||||
#
|
||||
# dev -> qa -> main.
|
||||
#
|
||||
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
|
||||
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
|
||||
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
|
||||
# that a commit is production worth: everything expensive that can run without
|
||||
# physical media. `main` only ever receives a qa that went green here.
|
||||
#
|
||||
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
|
||||
# dependencies from dev tips would be validating a combination that is not the
|
||||
# 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.
|
||||
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;
|
||||
# clippy (cross-lint) runs on stable instead.
|
||||
resolve-msrv:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
|
||||
# qa right now, and is it the one I tested?" — a question that otherwise gets
|
||||
# answered from memory.
|
||||
#
|
||||
# This runs FIRST and does not depend on the gates, deliberately. A red
|
||||
# candidate needs a name more than a green one does: "rc3 failed
|
||||
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
|
||||
# Red on qa is a working gate, not an incident — it is the branch saying this
|
||||
# is not production worth yet. Fix on dev, get dev green, push qa again.
|
||||
#
|
||||
# release.yml excludes v*-rc* so a candidate never publishes a release.
|
||||
rc-tag:
|
||||
# A manual dispatch from another branch must not stamp a candidate.
|
||||
if: github.ref_name == 'qa'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
|
||||
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
|
||||
# lexical sort gets that backwards from the tenth candidate on.
|
||||
# Retry on a lost race for the same ordinal: refetch tags, recompute.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch --tags --force origin
|
||||
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
|
||||
tag="v$v-rc$(( ${n:-0} + 1 ))"
|
||||
git tag "$tag"
|
||||
git push origin "$tag" && break
|
||||
git tag -d "$tag"
|
||||
[ "$attempt" -lt 5 ] || { echo "could not push an rc tag" >&2; exit 1; }
|
||||
done
|
||||
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The debug suite runs on every dev push. Release is a DIFFERENT build:
|
||||
# overflow checks are off, debug_assert! is compiled out, and inlining
|
||||
# changes what the optimiser can prove. A test that only passes in debug is
|
||||
# a test that never guarded the binary anyone actually ships.
|
||||
release-tests:
|
||||
needs: resolve-msrv
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo test --release --tests
|
||||
working-directory: libfreemkv
|
||||
- run: cargo test --release --doc
|
||||
working-directory: libfreemkv
|
||||
|
||||
# Independent decoder/remux verification uses generated media and belongs
|
||||
# on qa, alongside the release suite. Also runnable manually on a branch.
|
||||
ffmpeg-validation:
|
||||
uses: ./.github/workflows/ffmpeg-validation.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# clippy's output is target-dependent: cfg-gated code only gets linted on
|
||||
# the target it compiles for. Linting solely on the dev machine's host
|
||||
# target is how a lint that CI rejects reaches a push.
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
# 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
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
working-directory: libfreemkv
|
||||
|
||||
# Windows compiles the tests but does not run them, matching the policy
|
||||
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
|
||||
# of the SCSI transport and platform layers compile on no other runner, so
|
||||
# without this they are first built at release time.
|
||||
windows-build:
|
||||
needs: resolve-msrv
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: freemkv-unlock
|
||||
patch: freemkv-unlock
|
||||
self: libfreemkv
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
workspaces: libfreemkv
|
||||
- run: cargo build --release --tests
|
||||
working-directory: libfreemkv
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# NOT the release-candidate tags. Every push to `qa` stamps a
|
||||
# v<version>-rc<N> so a run can be named, and 'v*' matches those too —
|
||||
# which would have this workflow build and PUBLISH a GitHub release for
|
||||
# every candidate, including the red ones.
|
||||
- '!v*-rc*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
if [ "$CARGO_VER" != "${{ github.ref_name }}" ]; then
|
||||
echo "::error::Cargo.toml says $CARGO_VER but tag is ${{ github.ref_name }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version match: $CARGO_VER"
|
||||
|
||||
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
|
||||
# release job does 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
|
||||
# (freemkv/autorip/bdemu) git-tag-pin libfreemkv and therefore start building
|
||||
# the instant this tag exists — so this test job must NOT sit on their
|
||||
# critical path.
|
||||
test:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ steps.msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
# Cargo.lock is now committed (issue #15), but --locked is still omitted:
|
||||
# the sibling path-patch layout resolves the freemkv crates as path
|
||||
# packages, which --locked can't satisfy.
|
||||
- run: cargo test
|
||||
|
||||
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
|
||||
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
|
||||
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
|
||||
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
|
||||
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
|
||||
|
||||
release:
|
||||
# Only needs `verify`; the GitHub Release can be cut as soon as the version
|
||||
# check passes, in parallel with test.
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
- uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
+1
-24
@@ -1,24 +1 @@
|
||||
/target
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.cargo/
|
||||
|
||||
# session scratch — never track (may contain RE breadcrumbs)
|
||||
scratch/
|
||||
|
||||
# stray local build artifact
|
||||
/rust_out
|
||||
|
||||
# internal agent context — never publish (path AND dir; leak-guard blocks both)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# Nightly harness output. Written into the repo it audits, and it embeds
|
||||
# absolute paths from the machine that ran it — which must never reach a public
|
||||
# repo. Ignored rather than relocated so a run from any working copy is safe.
|
||||
.nightly/
|
||||
|
||||
# cargo-mutants working output: large, machine-specific, never committed
|
||||
mutants.out/
|
||||
mutants.out.old/
|
||||
.aider*
|
||||
|
||||
-1289
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the project maintainer via GitHub at https://github.com/MattJackson. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -1,38 +0,0 @@
|
||||
# Contributing to libfreemkv
|
||||
|
||||
Thanks for your interest in contributing!
|
||||
|
||||
## Ways to help
|
||||
|
||||
- **Report a bug** — open an issue with steps to reproduce
|
||||
- **Submit your drive profile** — run `freemkv info --share` to help expand hardware support
|
||||
- **Fix a bug** — fork, branch, PR
|
||||
- **Add a feature** — open an issue first to discuss
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Parity goldens
|
||||
|
||||
`parity_*` tests pin what every input-to-output path produces today (output hashes, loss
|
||||
counters, refusal codes) in `tests/goldens/*.golden`. A change that moves one must say why.
|
||||
Re-bless on purpose, then review the diff:
|
||||
|
||||
```bash
|
||||
FREEMKV_BLESS_GOLDENS=1 cargo test --lib parity_
|
||||
git diff tests/goldens
|
||||
```
|
||||
|
||||
## Code style
|
||||
|
||||
- Follow Rust conventions (`cargo fmt`, `cargo clippy`)
|
||||
- Field names follow SPC-4 and MMC-6 SCSI standards
|
||||
- Error codes are structured — no user-facing text in the library
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree your code will be licensed under MIT.
|
||||
Generated
-863
@@ -1,863 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"pioneer-optical",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libfreemkv"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"crossbeam-channel",
|
||||
"flate2",
|
||||
"freemkv-unlock",
|
||||
"libc",
|
||||
"libfreemkv",
|
||||
"loom",
|
||||
"memchr",
|
||||
"rayon",
|
||||
"roxmltree",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"socket2",
|
||||
"tempfile",
|
||||
"tracing",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "loom"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"generator",
|
||||
"scoped-tls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pioneer-optical"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf5074a27cc39fb664d0b994814ffe8530e3e8590aff0d104723332dbcb1abc4"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.8.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
categories = ["hardware-support", "multimedia"]
|
||||
# Keep internal AI-instruction / private notes out of the published crate.
|
||||
exclude = ["CLAUDE.md"]
|
||||
# OFF crates.io: libfreemkv git-deps freemkv-unlock (firmware, never published),
|
||||
# so libfreemkv itself can only be consumed by git tag. Clients git-tag-pin it.
|
||||
publish = false
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# ── Feature layout ───────────────────────────────────────────────────────────
|
||||
# `scsi` — the SCSI passthrough transport layer ONLY (`src/scsi/` + `error`):
|
||||
# ScsiTransport/execute, ScsiResult/ScsiSense/parse_sense, DriveInfo,
|
||||
# list_drives, drive_has_disc, CDB builders, the per-OS backends and
|
||||
# the raw-`cc` macOS shim (built by build.rs). Pulls only `tracing`
|
||||
# (transport instrumentation) plus the always-on `libc` target dep —
|
||||
# none of the ripping dependency tree.
|
||||
# `rip` — the full ripping tree (drive lifecycle, disc/UDF/MPLS/CLPI parsing,
|
||||
# AACS/CSS decrypt, PES/mux pipeline, keydb, freemkv-unlock seam). All
|
||||
# heavy deps (serde, zip, rayon, roxmltree, crossbeam, memchr,
|
||||
# sha1, sha2, aes, base64, socket2, rustix, freemkv-unlock, serde_json) live here.
|
||||
# `default = ["rip", "scsi"]` preserves today's full build byte-for-byte;
|
||||
# consumers wanting transport only use `default-features = false, features =
|
||||
# ["scsi"]`. `rip` implies `scsi` because the ripping tree reads through it.
|
||||
[features]
|
||||
default = ["rip", "scsi"]
|
||||
scsi = ["dep:tracing"]
|
||||
rip = [
|
||||
"scsi",
|
||||
"dep:serde",
|
||||
"dep:serde_json",
|
||||
"dep:sha1",
|
||||
"dep:sha2",
|
||||
"dep:aes",
|
||||
"dep:freemkv-unlock",
|
||||
"dep:zip",
|
||||
"dep:flate2",
|
||||
"dep:base64",
|
||||
"dep:roxmltree",
|
||||
"dep:crossbeam-channel",
|
||||
"dep:rayon",
|
||||
"dep:memchr",
|
||||
"dep:socket2",
|
||||
"dep:rustix",
|
||||
]
|
||||
# `test-util`: `libfreemkv::test_util` fixtures for consumers' tests (dev-dependency
|
||||
# only; never in a release build). Implies the rip tree the fixtures build on.
|
||||
test-util = ["rip"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1", optional = true }
|
||||
sha1 = { version = "0.11", optional = true }
|
||||
# SHA-256 for the key-set fingerprints (VID and legacy key identity, keys-upfront §4.1, J18).
|
||||
sha2 = { version = "0.11", optional = true }
|
||||
aes = { version = "0.9", optional = true }
|
||||
# Resolved by the git-tag [patch.crates-io] below (re-pinned by
|
||||
# release-orchestrate.yml); local dev patches it to ../freemkv-unlock.
|
||||
freemkv-unlock = { version = "1.8", optional = true }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"], optional = true }
|
||||
# zlib for Matroska ContentCompression (mkvmerge compresses S_VOBSUB by default); the
|
||||
# same backend `zip` already pulls in.
|
||||
flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"], optional = true }
|
||||
base64 = { version = "0.23", optional = true }
|
||||
# Safe TCP keepalive setup for `network://` receive (dead-peer detection);
|
||||
# `all` provides TcpKeepalive::with_retries.
|
||||
socket2 = { version = "0.6", features = ["all"], optional = true }
|
||||
# Safe readiness poll (poll/WSAPoll) so a halt-aware `network://` receive never
|
||||
# arms SO_RCVTIMEO, which on Windows can drop data when it cancels a recv.
|
||||
rustix = { version = "1", default-features = false, features = ["std", "event"], optional = true }
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
|
||||
# attribute order, self-closing tags).
|
||||
roxmltree = { version = "0.21", optional = true }
|
||||
# Trace-level instrumentation for the read/transport path (SgIoTransport::execute
|
||||
# and friends). Part of the `scsi` feature — the transport layer traces here.
|
||||
# Debug/trace logging is the permitted string output. Consumers (autorip) wire a
|
||||
# tracing subscriber and pipe events into the JSONL debug log.
|
||||
tracing = { version = "0.1", optional = true }
|
||||
# Bounded MPSC channel with kernel-wakeup send_timeout. Used by `io::pipeline`
|
||||
# so the halt-aware send/finish loops can BLOCK on consumer drain instead of
|
||||
# polling — the 50 ms poll cadence of the previous mpsc-based impl capped mux
|
||||
# throughput at ~1 MB/s (0.21.7).
|
||||
crossbeam-channel = { version = "0.5", optional = true }
|
||||
# Persistent work-stealing thread pool for parallel AACS unit
|
||||
# decryption. Per-call std::thread::scope spawned fresh OS threads
|
||||
# and that overhead dominated for typical batch sizes (60 units).
|
||||
# rayon's global pool initialises once on first use.
|
||||
rayon = { version = "1", optional = true }
|
||||
# SIMD-accelerated bytestring search. Drives the HEVC/H.264 start-code
|
||||
# scan in `mux::codec::h264::find_start_code` — naive byte-by-byte
|
||||
# walk is ~500 MB/s single-thread on x86_64; memchr's vectorised
|
||||
# `memmem::find` for the 3-byte `00 00 01` needle hits ~5 GB/s on
|
||||
# AVX2-capable hosts.
|
||||
memchr = { version = "2", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
# The crate's own `test_util` for its integration tests (`tests/crypto_tests.rs` decrypts
|
||||
# through `test_util::decrypt_unit`; the unit decrypt is crate-private, KU §2.2).
|
||||
libfreemkv = { path = ".", features = ["test-util"] }
|
||||
|
||||
# Permutation model checker for the `scsi::fd_handoff` atomics — the fd
|
||||
# hand-off between a Linux recovery thread and `SgIoTransport::drop`. A normal
|
||||
# test cannot see a missing release edge there: the orderings compile to the
|
||||
# same instructions on x86_64, so the wrong ones pass every time. loom
|
||||
# enumerates the executions the C++20 model permits and reports the leaked
|
||||
# descriptor directly.
|
||||
#
|
||||
# Behind `cfg(loom)`, which nothing sets unless you ask for it, so this is out
|
||||
# of the graph for every ordinary build and for every CI job but the dedicated
|
||||
# `loom` one. Run it with:
|
||||
#
|
||||
# RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features \
|
||||
# --features scsi fd_handoff
|
||||
#
|
||||
# `cargo test --lib` specifically: as a DEV-dependency loom is only in scope for
|
||||
# test targets, which is why `fd_handoff` gates its import on `all(loom, test)`
|
||||
# and falls back to std elsewhere. Without that, `--cfg loom` would break plain
|
||||
# `cargo build`/`clippy` with an unresolved-crate error.
|
||||
[target.'cfg(loom)'.dev-dependencies]
|
||||
loom = "0.7"
|
||||
|
||||
[[bench]]
|
||||
name = "sgio_read"
|
||||
harness = false
|
||||
|
||||
# freemkv-unlock is off crates.io: resolve it from its GIT TAG. Consumers carry
|
||||
# the same patch; a gitignored .cargo/config.toml overrides it to a local path.
|
||||
[patch.crates-io]
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.1" }
|
||||
|
||||
[lints.rust]
|
||||
# `loom` is set by RUSTFLAGS for the model checker above, never by a feature.
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(loom)'] }
|
||||
|
||||
[lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,11 +0,0 @@
|
||||
ISO/IEC 11172-5 reference software (dist10) -- table data
|
||||
|
||||
src/mux/codec/testdata/dist10/alloc_0..3 are the Table 3-B.2a-d bit-allocation
|
||||
tables of ISO/IEC 11172-3 Annex B, as shipped in the ISO/MPEG Audio Subgroup
|
||||
Software Simulation Group's dist10 reference software (commit ea3c5ee of
|
||||
joncampbell123/iso-dist10). They are used here only as test data to check the
|
||||
Layer II parser.
|
||||
|
||||
Copyright remains with ISO/IEC and the original authors of that software. No
|
||||
separate licence text accompanies these files; they are not covered by this
|
||||
project's MIT licence, and their terms are those of the dist10 distribution.
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,25 +0,0 @@
|
||||
# libfreemkv — local dev helper.
|
||||
# Mirrors the workspace-wide CI checks but scoped to this single crate.
|
||||
|
||||
.PHONY: test build check loom ci clean
|
||||
|
||||
test:
|
||||
cargo test --tests
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
|
||||
check:
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Model-check the scsi::fd_handoff atomics. `cargo test` cannot fail on those
|
||||
# orderings — they compile to the same instructions on x86_64 — so this is the
|
||||
# only check on them. Mirrors the `loom` CI job.
|
||||
loom:
|
||||
RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features --features scsi fd_handoff
|
||||
|
||||
ci: check build test loom
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
@@ -1,216 +0,0 @@
|
||||
[](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
|
||||
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, and content reading in one crate. Drive-level unlocking is handled internally; consumers work with disc access and decryption only.
|
||||
|
||||
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require disc-specific volume unique keys, supplied by the consumer (e.g. via [freemkv-keysources](https://github.com/freemkv/freemkv-keysources), which owns `keydb.cfg` lookup/download); libfreemkv itself never reads `keydb.cfg` or downloads keys, and no AACS key material is compiled in.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
|
||||
|
||||
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)** · **[Changelog](CHANGELOG.md)**
|
||||
|
||||
Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
## Install
|
||||
|
||||
Consumed by git tag (not published to crates.io):
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use libfreemkv::{Drive, Disc, ScanOptions};
|
||||
use std::path::Path;
|
||||
|
||||
// Open drive — identified via INQUIRY
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?; // wait for disc
|
||||
drive.init()?; // unlock + prep (handled internally)
|
||||
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||
|
||||
for title in &disc.titles {
|
||||
println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
}
|
||||
|
||||
// Stream pipeline — read PES frames from any source, write to any output
|
||||
let opts = libfreemkv::InputOptions::default();
|
||||
let ctx = libfreemkv::Ctx::default(); // halt, events, loss counters for the run
|
||||
let mut input = libfreemkv::input("iso://Disc.iso", &opts, &ctx)?;
|
||||
let title = input.info().clone();
|
||||
let mut output = libfreemkv::output("mkv://Movie.mkv", &title)?;
|
||||
while let Ok(Some(frame)) = input.read() {
|
||||
output.write(&frame)?;
|
||||
}
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
### Multi-pass recovery rip
|
||||
|
||||
The sweep/patch strategy, ddrescue mapfile, damage classification and
|
||||
multipass loop live in the
|
||||
`freemkv-engine` crate as `freemkv_engine::recovery::{copy, sweep, patch}`.
|
||||
|
||||
libfreemkv keeps the layers underneath: the raw single-shot read
|
||||
(`Drive::read`) and the SCSI-fact translation (`SenseFamily`) that the engine's
|
||||
strategy is built on. The dependency runs engine → libfreemkv, so this crate
|
||||
cannot call into it; front-ends get recovery from the engine directly.
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
|
||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 7 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, DBP, Deluxe, Fox)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
|
||||
- **Content reading** — adaptive batch reads with automatic decryption
|
||||
- **Stream I/O** — unified stream pipeline for reading and writing any format
|
||||
|
||||
### Streams
|
||||
|
||||
| Stream | Input | Output | Transport |
|
||||
|--------|-------|--------|-----------|
|
||||
| `disc://` | Yes | -- | Optical drive via SCSI (live Read policy: adaptive batches, recovery read, skip or fail) |
|
||||
| `iso://` / `dir://` | Yes | -- | Disc image file or folder (image Read policy; written by `freemkv_engine::recovery`) |
|
||||
| MkvStream | Yes | Yes | Matroska container |
|
||||
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
||||
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
|
||||
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
|
||||
|
||||
Inputs implement `PesSource` (`read()`) and outputs `PesSink` (`write()`); a format that is both implements both. `open_source()` / `input()` open every input URL (`disc://` included) and `output()` every output URL. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
|
||||
### Keys
|
||||
|
||||
DVDs (CSS) decrypt out of the box, with no external key file needed.
|
||||
|
||||
Blu-rays and UHD (AACS) require Unit Keys passed in via `ScanOptions`/`KeySpec`. libfreemkv does not read `keydb.cfg` or fetch keys itself — that's the consumer's job (see [freemkv-keysources](https://github.com/freemkv/freemkv-keysources)). No AACS key material is compiled into the binary.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Drive — open, identify, init, single-shot read
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||
└── unlock_bridge — private seam to the freemkv-unlock crate
|
||||
(firmware / AACS cert / CSS bus-auth unlockers)
|
||||
|
||||
Disc — scan titles, streams, AACS/CSS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
├── MPLS parser — playlists → titles + clips + streams
|
||||
├── CLPI parser — clip info → EP map → sector extents
|
||||
├── IFO parser — DVD title sets, PGC chains, cell addresses
|
||||
├── Labels — 7 BD-J format parsers (detect + parse)
|
||||
├── AACS — key resolution + content decryption
|
||||
└── CSS — DVD CSS (bus auth → player-key disc crack → known-plaintext title-key attack)
|
||||
|
||||
Streams — one PES pipeline
|
||||
├── Source — open_source(url): drive, image, folder, file, socket, pipe
|
||||
├── Read stage — extents → chunks under the source's ReadPolicy (live | image)
|
||||
├── Decrypt stage — AACS / CSS, content-detected, damage blanked and counted
|
||||
├── Demux — TS / PS → PES (one demux for every sector source)
|
||||
├── PesSource/PesSink — read()/write() PES frames
|
||||
├── MkvStream — MKV mux/demux
|
||||
├── M2tsStream — BD transport stream
|
||||
├── NetworkStream — TCP with FMKV metadata header
|
||||
├── StdioStream — stdin/stdout pipe
|
||||
└── NullStream — discard sink
|
||||
```
|
||||
|
||||
Build API documentation with `cargo doc --no-deps --open`. The public
|
||||
[FVI format specification](https://freemkv.org/docs/fvi-format/) describes exported video indexes.
|
||||
|
||||
## Error Codes
|
||||
|
||||
All errors are structured with numeric codes. No user-facing English text — applications format their own messages.
|
||||
|
||||
| Range | Category |
|
||||
|-------|----------|
|
||||
| E1xxx | Device errors (not found, permission) |
|
||||
| E2xxx | Profile errors (unsupported drive) |
|
||||
| E3xxx | Unlock errors (failed, signature) |
|
||||
| E4xxx | SCSI errors (command failed, timeout) |
|
||||
| E5xxx | I/O errors |
|
||||
| E6xxx | Disc format errors |
|
||||
| E7xxx | AACS errors |
|
||||
| E8xxx | KEYDB update errors |
|
||||
| E9xxx | Stream / mux errors (URL, PES, ISO, pipeline, demux) |
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Status | Backend |
|
||||
|----------|--------|---------|
|
||||
| Linux | Supported | SG_IO ioctl |
|
||||
| macOS | Supported | IOKit SCSITask |
|
||||
| Windows | Supported | SPTI |
|
||||
|
||||
## Contributing
|
||||
|
||||
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
|
||||
|
||||
## Validation
|
||||
|
||||
Run `cargo test --tests`, `cargo fmt --check`, and
|
||||
`cargo clippy --all-targets -- -D warnings` before pushing.
|
||||
|
||||
The FFmpeg interoperability workflow runs on `qa` pushes or manual dispatch.
|
||||
With FFmpeg/ffprobe installed, run:
|
||||
|
||||
```sh
|
||||
cargo test --release --lib ffmpeg_ -- --ignored --nocapture
|
||||
```
|
||||
|
||||
It generates synthetic PGS/video/audio fixtures and checks decoded content,
|
||||
timing and decoder warnings. Set `FREEMKV_FFMPEG_ARTIFACT_DIR` to retain local
|
||||
fixtures; CI retains fixtures and logs for 14 days. Real-disc QA is separate.
|
||||
|
||||
FFmpeg runs as an installed executable; we do not link or distribute its
|
||||
binaries. This use is permitted by its software licenses; codec patent rights
|
||||
are separate. See [FFmpeg legal guidance](https://ffmpeg.org/legal.html).
|
||||
|
||||
MKV remuxing preserves decoder delay and frame padding locally. Network/stdio
|
||||
PES serialization does not carry that metadata. Direct `PesFrame` constructors
|
||||
must set `discard_padding_ns` to zero unless preserving trimming.
|
||||
-56
@@ -1,56 +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 repository is the project's own work under the MIT licence, except the
|
||||
# MPEG audio ISO reference tables under src/mux/codec/testdata/dist10, which are
|
||||
# third-party test data and carry their own annotation at the end of this file.
|
||||
|
||||
[[annotations]]
|
||||
path = [
|
||||
"**/*.rs",
|
||||
"**/*.md",
|
||||
"**/*.toml",
|
||||
"**/*.lock",
|
||||
"**/*.yml",
|
||||
"**/*.yaml",
|
||||
"**/*.json",
|
||||
"**/*.sh",
|
||||
"**/*.py",
|
||||
"**/*.ps1",
|
||||
"**/*.golden",
|
||||
"**/*.svg",
|
||||
"**/*.png",
|
||||
"**/*.ico",
|
||||
"**/*.plist",
|
||||
"**/*.manifest",
|
||||
"**/.gitignore",
|
||||
".gitignore",
|
||||
"Makefile",
|
||||
"LICENSE",
|
||||
"build.rs",
|
||||
"**/*.c",
|
||||
"**/*.mp2",
|
||||
"**/*.class",
|
||||
"**/PROVENANCE",
|
||||
"tests/*.txt",
|
||||
]
|
||||
precedence = "aggregate"
|
||||
SPDX-FileCopyrightText = "2026 Matthew Jackson & Contributors"
|
||||
SPDX-License-Identifier = "MIT"
|
||||
|
||||
# Table 3-B.2a-d allocation data copied from the ISO/IEC 11172-5 reference
|
||||
# software (dist10, ISO/MPEG Audio Subgroup Software Simulation Group); this is
|
||||
# not the project's work and is not MIT-licensed.
|
||||
[[annotations]]
|
||||
path = "src/mux/codec/testdata/dist10/alloc_*"
|
||||
precedence = "aggregate"
|
||||
SPDX-FileCopyrightText = "ISO/IEC JTC1/SC29/WG11 (MPEG) and the ISO/MPEG Audio Subgroup Software Simulation Group"
|
||||
SPDX-License-Identifier = "LicenseRef-ISO-dist10"
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.6.x | Yes |
|
||||
| < 1.6 | No |
|
||||
|
||||
Only the current 1.6.x line receives security fixes.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report vulnerabilities privately through GitHub Security Advisories:
|
||||
https://github.com/freemkv/libfreemkv/security/advisories/new
|
||||
|
||||
Do not open a public issue for a security report. Include the affected
|
||||
version, steps to reproduce, and the impact you believe the issue has.
|
||||
|
||||
## Response time
|
||||
|
||||
You will get an initial response within 7 days.
|
||||
@@ -1,312 +0,0 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
Common problems and solutions for optical drive ripping with freemkv.
|
||||
|
||||
---
|
||||
|
||||
## 1. USB-SATA Bridge Issues
|
||||
|
||||
This is the single most common source of problems when ripping discs over USB.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The drive disappears mid-rip. The ripping tool reports the device is gone, and `ls /dev/sg*` no longer shows it.
|
||||
- The device re-enumerates under a different name: `sg4` becomes `sg5`, then `sg7`, then `sg11` after each USB port reset.
|
||||
- `dmesg` shows USB port resets: `usb X-Y: reset high-speed USB device`, `xhci_hcd 0000:00:14.0: Cannot enable. Maybe the USB cable is bad?`, or `usb-storage: device reset failed`.
|
||||
- The SCSI layer reports `host_status=7` (Linux USB transport error) in sense data.
|
||||
- The drive works fine for reading data discs or burning, but crashes when hitting damaged sectors during a rip.
|
||||
- After the crash, the drive is completely invisible until physically unplugged and reconnected.
|
||||
|
||||
### Root Cause
|
||||
|
||||
USB-SATA bridges translate between the USB Mass Storage protocol (BOT or UAS) and the drive's native SATA interface. When the optical drive encounters an unreadable sector, it returns a SCSI CHECK CONDITION with sense key 0x03 (MEDIUM ERROR). Some bridge chipsets -- particularly the Initio INIC-36xx family -- have buggy firmware that mishandles this error response.
|
||||
|
||||
Specific failure modes:
|
||||
|
||||
- **Incorrect residue reporting.** The bridge claims a different number of bytes transferred than what actually occurred. The Linux USB storage driver sees this discrepancy as a protocol violation and resets the port to recover. The `US_FL_IGNORE_RESIDUE` quirk exists specifically for this class of bug (see `drivers/usb/storage/transport.c` in the Linux kernel).
|
||||
- **Bridge firmware crash.** On some Initio bridges, a malformed SCSI error response from the drive causes the bridge MCU to hang entirely. The USB controller sees the device stop responding and initiates a port reset. The bridge recovers (it re-enumerates), but the rip is dead -- all state is lost.
|
||||
- **Sense data corruption.** The bridge forwards garbled or truncated sense data to the host, which the SCSI midlayer cannot parse, leading to a transport reset.
|
||||
|
||||
This is a hardware + firmware problem, not a software bug. The same drive connected via direct SATA does not exhibit these symptoms.
|
||||
|
||||
### Known Affected Bridges
|
||||
|
||||
| Chipset | USB IDs | Notes |
|
||||
|---------|---------|-------|
|
||||
| Initio INIC-3609 | `13fd:3609` | Very common in cheap SATA-to-USB enclosures. Highly problematic. |
|
||||
| Initio INIC-3619 | `13fd:3940` | Same firmware family as INIC-3609. |
|
||||
| Initio INIC-3069 | `13fd:0840` | Older variant, same residue bug. |
|
||||
| ASMedia ASM1051 | `174c:5106` | Early ASM SATA bridge. Residue issues on error paths. |
|
||||
| JMicron JMB36x | `152d:0561` | Some firmware versions. Not all JMicroon chips are affected. |
|
||||
|
||||
If your drive came in a pre-built external enclosure (Vantec, Sabrent, OWC, etc.), it almost certainly uses one of these bridge chips internally.
|
||||
|
||||
### The Fix: USB Storage Quirk
|
||||
|
||||
Apply the `US_FL_IGNORE_RESIDUE` kernel quirk for your bridge. This tells the Linux USB storage driver to ignore the residue field in SCSI response frames, preventing the port reset on mismatched byte counts.
|
||||
|
||||
**Step 1: Identify your bridge's vendor:product ID.**
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Look for your drive's entry. Example output:
|
||||
|
||||
```
|
||||
Bus 002 Device 005: ID 13fd:0840 Initio Corporation INIC-3609
|
||||
```
|
||||
|
||||
Here the vendor ID is `13fd` and the product ID is `0840`.
|
||||
|
||||
**Step 2: Apply the quirk at runtime.**
|
||||
|
||||
```bash
|
||||
echo "13fd:0840:i" > /sys/module/usb_storage/parameters/quirks
|
||||
```
|
||||
|
||||
Replace `13fd:0840` with your device's actual IDs. The `:i` flag means `US_FL_IGNORE_RESIDUE`.
|
||||
|
||||
You can combine multiple flags. Common additions:
|
||||
|
||||
- `:i` -- ignore residue (`US_FL_IGNORE_RESIDUE`)
|
||||
- `:u` -- force BOT mode instead of UAS, for bridges with UAS bugs
|
||||
|
||||
**Step 3: Reconnect the drive.** Unplug and replug the USB cable, or bind/unbind the device. The quirk is applied per-module-load, so existing sessions may need the drive reconnected.
|
||||
|
||||
### Making It Persistent
|
||||
|
||||
Add the quirk to your kernel boot parameters so it survives reboots.
|
||||
|
||||
Edit `/etc/default/grub` (GRUB) and add to `GRUB_CMDLINE_LINUX_DEFAULT`:
|
||||
|
||||
```
|
||||
GRUB_CMDLINE_LINUX_DEFAULT="quiet usb_storage.quirks=13fd:0840:i"
|
||||
```
|
||||
|
||||
Then rebuild the GRUB config:
|
||||
|
||||
```bash
|
||||
sudo update-grub
|
||||
```
|
||||
|
||||
For systemd-boot, add to your loader entry or `/etc/kernel/cmdline`:
|
||||
|
||||
```
|
||||
usb_storage.quirks=13fd:0840:i
|
||||
```
|
||||
|
||||
Multiple devices can be separated by commas:
|
||||
|
||||
```
|
||||
usb_storage.quirks=13fd:0840:i,174c:5106:u
|
||||
```
|
||||
|
||||
### Recommended Bridges
|
||||
|
||||
If you are buying a USB-SATA adapter or enclosure for optical drive use:
|
||||
|
||||
| Bridge | USB IDs | Notes |
|
||||
|--------|---------|-------|
|
||||
| ASMedia ASM1153 | `174c:1153` | Reliable. Widely available in SATA-USB 3.0 cables. |
|
||||
| JMicron JMS578 | `152d:0578` | Good firmware. Supports UASP. |
|
||||
| Icy Box IB-AC640-C3 | N/A | Uses a known-good bridge internally. Plug-and-play. |
|
||||
|
||||
Avoid any enclosure or adapter listing an Initio chipset.
|
||||
|
||||
### Best Solution: Direct SATA
|
||||
|
||||
Connect your optical drive directly to a motherboard SATA port. This eliminates the USB-SATA bridge entirely and is the most reliable configuration:
|
||||
|
||||
- No USB protocol overhead or translation errors.
|
||||
- No bridge firmware bugs.
|
||||
- No port resets or re-enumeration.
|
||||
- Full SATA error recovery handled natively by the kernel's libata driver.
|
||||
- Sustained read speeds are limited only by the drive, not the USB bus.
|
||||
|
||||
If your machine has a free SATA port, use it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Damaged Disc Handling
|
||||
|
||||
### Symptoms
|
||||
|
||||
- SCSI MEDIUM ERROR (sense key 0x03) at specific LBAs. `dmesg` shows `sr X:0:0:0: [srY] Unrecoverable read error` or similar.
|
||||
- Read speed drops to near zero when approaching a damaged area.
|
||||
- The drive makes audible retrying noises (laser repositioning, spindle speed changes).
|
||||
- On USB-connected drives: the bridge crashes (see section 1 above) when the drive returns the error.
|
||||
|
||||
### How freemkv Handles This
|
||||
|
||||
freemkv uses a three-layer recovery model.
|
||||
|
||||
- **Pass 1 (`freemkv_engine::recovery::sweep`):** Fast sweep with 64 KB reads. On failure, zero-fills the block and skips forward. Writes a ddrescue-format mapfile for later retry.
|
||||
- **Pass 2+ (`freemkv_engine::recovery::patch`):** Targeted re-reads of bad ranges with a long 60-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window.
|
||||
- **In-stream (DiscStream):** Adaptive batch halving -- reduces request size on failure to isolate bad sectors within a larger block.
|
||||
|
||||
This means a disc with some bad sectors will still produce a usable ISO. The damaged areas are zero-filled in pass 1 and retried in subsequent passes. Structure-protected sectors (deliberate unreadable regions from copy protection) will never yield, which is expected.
|
||||
|
||||
### The Drive Taint Issue (LG BU40N)
|
||||
|
||||
Some drives, notably the LG BU40N, exhibit a "taint" behavior after encountering MEDIUM ERRORs:
|
||||
|
||||
1. The drive hits a damaged sector and returns a MEDIUM ERROR.
|
||||
2. From that point forward, **all subsequent reads fail** -- even reads to sectors that were previously successful.
|
||||
3. The only recovery is to physically unplug and reconnect the drive (or power-cycle it).
|
||||
|
||||
This is not a freemkv bug. It is a drive firmware behavior triggered by the interaction between the drive's internal error recovery and the USB-SATA bridge's handling of the error response. The drive firmware enters a degraded state that it does not recover from without a power cycle.
|
||||
|
||||
Workarounds:
|
||||
|
||||
- **Use a direct SATA connection.** This eliminates the bridge interaction that triggers the taint.
|
||||
- **Use a different bridge.** The ASM1153 and JMS578 are less likely to trigger this behavior.
|
||||
- **Accept the partial ISO.** freemkv's skip-forward recovery will zero-fill the unreadable blocks and continue. The resulting ISO may be playable with minor glitches in the affected areas.
|
||||
- **Physical replug between retry passes.** If running multi-pass patch, replug the drive between passes to clear the taint state.
|
||||
|
||||
freemkv deliberately does not attempt inline SCSI resets or eject cycles to recover from this state, because those operations were found to make the problem worse on affected hardware.
|
||||
|
||||
---
|
||||
|
||||
## 3. Drive Not Detected
|
||||
|
||||
### Check Hardware Visibility
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Verify the drive appears in the USB device list. If it does not show up, the drive is not visible to the host at all -- check cables, power, and USB port.
|
||||
|
||||
```bash
|
||||
ls /dev/sg*
|
||||
```
|
||||
|
||||
On Linux, optical drives appear as `/dev/sg*` devices (the SCSI Generic interface). freemkv uses `/dev/sg*`, not `/dev/sr*`. If `lsusb` shows the device but no `/dev/sg*` entry exists, the `sg` kernel module may not be loaded:
|
||||
|
||||
```bash
|
||||
sudo modprobe sg
|
||||
```
|
||||
|
||||
### Check Kernel Messages
|
||||
|
||||
```bash
|
||||
dmesg | grep -i usb | tail -30
|
||||
dmesg | grep -i sg | tail -10
|
||||
```
|
||||
|
||||
Look for:
|
||||
- USB enumeration errors or failed port resets.
|
||||
- `sg_add` messages confirming the sg device was registered.
|
||||
- Permission denied or access errors.
|
||||
|
||||
### Permission Issues
|
||||
|
||||
On most Linux distributions, `/dev/sg*` devices are owned by `root:disk` or `root:cdrom` with restricted permissions. Running freemkv as an unprivileged user will fail with permission errors.
|
||||
|
||||
Options:
|
||||
|
||||
- Add your user to the appropriate group:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG disk $USER
|
||||
```
|
||||
|
||||
Then log out and back in for the change to take effect. On some distributions the group is `cdrom` or `optical` instead of `disk`.
|
||||
|
||||
- Run with elevated privileges:
|
||||
|
||||
```bash
|
||||
sudo freemkv ...
|
||||
```
|
||||
|
||||
- Install a udev rule for persistent per-device permissions. Create `/etc/udev/rules.d/99-sg-optical.rules`:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="scsi_generic", ATTRS{type}=="5", MODE="0666"
|
||||
```
|
||||
|
||||
Then reload udev rules:
|
||||
|
||||
```bash
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
```
|
||||
|
||||
### Spin-Up Delay
|
||||
|
||||
Optical drives take 30-60 seconds to spin up and become ready after hot-plug or disc insertion. During this window, SCSI commands may return NOT READY or timeout.
|
||||
|
||||
freemkv's `Drive::wait_ready()` handles this automatically by polling with TEST UNIT READY until the drive responds. If you are writing your own code using the library, always call `wait_ready()` before `init()`:
|
||||
|
||||
```rust
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?; // blocks until disc is ready, up to 30s
|
||||
drive.init()?;
|
||||
```
|
||||
|
||||
If the drive was just plugged in, wait a full minute before concluding it is not detected.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to Identify Your USB-SATA Bridge
|
||||
|
||||
If you are experiencing the issues described in section 1, you need to know which bridge chipset your adapter or enclosure uses.
|
||||
|
||||
### Step 1: Find the Device
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Look for entries matching your drive or enclosure. Bridges may appear under their own manufacturer name or as a generic SATA device. Common examples:
|
||||
|
||||
```
|
||||
Bus 002 Device 005: ID 13fd:0840 Initio Corporation
|
||||
Bus 002 Device 006: ID 174c:1153 ASMedia Technology Inc. ASM1153
|
||||
Bus 002 Device 007: ID 152d:0578 JMicron Technology Corp. JMS578
|
||||
```
|
||||
|
||||
### Step 2: Match the IDs
|
||||
|
||||
| Vendor | Product ID | Chipset | Status |
|
||||
|--------|-----------|---------|--------|
|
||||
| `13fd` | `3609` | Initio INIC-3609 | Affected. Apply quirk. |
|
||||
| `13fd` | `3940` | Initio INIC-3619 | Affected. Apply quirk. |
|
||||
| `13fd` | `0840` | Initio INIC-3069 | Affected. Apply quirk. |
|
||||
| `174c` | `5106` | ASMedia ASM1051 | Affected (early firmware). Apply quirk. |
|
||||
| `174c` | `1153` | ASMedia ASM1153 | Good. No quirk needed. |
|
||||
| `152d` | `0561` | JMicron JMB36x | Affected (some firmware). Apply quirk if issues occur. |
|
||||
| `152d` | `0578` | JMicron JMS578 | Good. No quirk needed. |
|
||||
|
||||
### Step 3: Check dmesg for the Bridge Name
|
||||
|
||||
```bash
|
||||
dmesg | grep -i "usb-storage\|uas\|initio\|asmedia\|jmicron"
|
||||
```
|
||||
|
||||
This often reveals the bridge chipset even when `lsusb` shows a generic name.
|
||||
|
||||
### Step 4: If the Enclosure Is Sealed
|
||||
|
||||
Many external drive enclosures (Vantec NexStar, Sabrent, OWC, etc.) do not advertise the bridge chipset on the packaging. In this case:
|
||||
|
||||
1. Check `lsusb` while the enclosure is connected.
|
||||
2. Search the vendor:product ID online -- there are community-maintained lists of which chipsets popular enclosures use.
|
||||
3. If you cannot determine the chipset and are experiencing bridge crashes, assume it is an Initio and apply the quirk with its IDs.
|
||||
4. The definitive test: connect the bare drive to a motherboard SATA port. If the problems disappear, the bridge was the cause.
|
||||
|
||||
---
|
||||
|
||||
## 5. General Debugging Checklist
|
||||
|
||||
When something goes wrong during a rip, gather this information before filing an issue:
|
||||
|
||||
1. **freemkv version:** `freemkv --version` or the crate version in `Cargo.toml`.
|
||||
2. **Drive model:** from the drive label, or from `freemkv info disc://`.
|
||||
3. **Connection type:** USB (with bridge chipset if known) or direct SATA.
|
||||
4. **Operating system and kernel:** `uname -a`.
|
||||
5. **Kernel messages during the failure:** `dmesg | tail -50` immediately after the crash.
|
||||
6. **SCSI device:** which `/dev/sg*` the drive was on, and whether it changed after the failure.
|
||||
7. **The disc:** title, format (BD/DVD/UHD), condition.
|
||||
|
||||
Include all of the above in bug reports. SCSI transport errors that resolve with the `US_FL_IGNORE_RESIDUE` quirk or by switching to direct SATA are bridge firmware bugs, not freemkv bugs.
|
||||
Submodule
+1
Submodule autorip added at 3e14aabd33
Submodule
+1
Submodule bdemu added at 7ff5c04b9a
@@ -1,87 +0,0 @@
|
||||
// Mimics ISO dump exactly — read + write + progress
|
||||
use libfreemkv::Drive;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let device = std::env::args()
|
||||
.skip(1)
|
||||
.find(|a| !a.starts_with('-'))
|
||||
.unwrap_or_else(|| match libfreemkv::find_drive() {
|
||||
Some(d) => d.device_path().to_string(),
|
||||
None => {
|
||||
eprintln!("No drives found");
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| {
|
||||
eprintln!("Cannot open {}: {}", device, e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
eprintln!("wait_ready...");
|
||||
let _ = drive.wait_ready();
|
||||
eprintln!("read_capacity...");
|
||||
let cap = drive.read_capacity().unwrap();
|
||||
eprintln!("capacity: {} sectors", cap);
|
||||
|
||||
let batch = libfreemkv::disc::detect_max_batch_sectors(drive.device_path());
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
|
||||
// Open /dev/null writer like ISO dump does
|
||||
let file = std::fs::File::create("/dev/null").unwrap();
|
||||
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
||||
|
||||
eprintln!(
|
||||
"Reading 1000 batches ({:.1} MB) with write + progress...",
|
||||
1000.0 * batch as f64 * 2048.0 / 1_048_576.0
|
||||
);
|
||||
|
||||
let start = Instant::now();
|
||||
let mut ok = 0u32;
|
||||
let mut fail = 0u32;
|
||||
let mut bytes: u64 = 0;
|
||||
|
||||
// Recovery flag: true matches pre-0.11.13 bench behavior — full SCSI
|
||||
// ECC retry loop on errors (slower, what the rip path used before the
|
||||
// adaptive batch sizer landed). Flip to `false` for the fast-fail path
|
||||
// that current rips use; benches are configurable via this constant.
|
||||
const READ_WITH_RECOVERY: bool = true;
|
||||
|
||||
for i in 0..1000u32 {
|
||||
let lba = i * batch as u32;
|
||||
match drive.read(lba, batch, &mut buf, READ_WITH_RECOVERY) {
|
||||
Ok(_) => {
|
||||
writer.write_all(&buf).unwrap();
|
||||
ok += 1;
|
||||
bytes += buf.len() as u64;
|
||||
}
|
||||
Err(e) => {
|
||||
fail += 1;
|
||||
if fail <= 5 {
|
||||
eprintln!(" FAIL LBA {}: {}", lba, e);
|
||||
}
|
||||
buf.fill(0);
|
||||
writer.write_all(&buf).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
if i % 50 == 0 && i > 0 {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let mb = bytes as f64 / 1_048_576.0;
|
||||
eprint!("\r {:.1} MB | {:.1} MB/s ", mb, mb / elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
let mb = ok as f64 * batch as f64 * 2048.0 / 1_048_576.0;
|
||||
eprintln!(
|
||||
"\n{} ok, {} fail, {:.1} MB in {:.1}s = {:.1} MB/s",
|
||||
ok,
|
||||
fail,
|
||||
mb,
|
||||
elapsed,
|
||||
mb / elapsed
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
fn main() {
|
||||
emit_git_suffix();
|
||||
|
||||
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target == "macos" {
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
println!("cargo:rustc-link-lib=framework=DiskArbitration");
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let obj = format!("{out_dir}/macos_shim.o");
|
||||
let lib = format!("{out_dir}/libmacos_scsi.a");
|
||||
|
||||
// Build the shim for the TARGET arch, not the host's. A bare `cc` on an
|
||||
// Apple-Silicon CI runner defaults to arm64, so cross-building to
|
||||
// x86_64-apple-darwin would link a host-arch object against x86_64 Rust
|
||||
// code → "Undefined symbols for architecture x86_64". (Still raw `cc`,
|
||||
// not the `cc` crate, which breaks IOKit exclusive access.)
|
||||
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
|
||||
let clang_arch: &str = if target_arch == "aarch64" {
|
||||
"arm64"
|
||||
} else {
|
||||
&target_arch // x86_64 → x86_64
|
||||
};
|
||||
|
||||
// Match rustc's deployment target so the shim does not strongly link
|
||||
// symbols newer than the app promises to run on (IOMainPort is 12+).
|
||||
println!("cargo:rerun-if-env-changed=MACOSX_DEPLOYMENT_TARGET");
|
||||
let min_ver = std::env::var("MACOSX_DEPLOYMENT_TARGET").unwrap_or_else(|_| {
|
||||
if target_arch == "aarch64" {
|
||||
"11.0"
|
||||
} else {
|
||||
"10.12"
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
let min_flag = format!("-mmacosx-version-min={min_ver}");
|
||||
|
||||
let cc_status = std::process::Command::new("cc")
|
||||
.args([
|
||||
"-arch",
|
||||
clang_arch,
|
||||
&min_flag,
|
||||
"-c",
|
||||
"src/scsi/macos_shim.c",
|
||||
"-o",
|
||||
&obj,
|
||||
"-framework",
|
||||
"IOKit",
|
||||
"-framework",
|
||||
"CoreFoundation",
|
||||
"-Wall",
|
||||
"-O2",
|
||||
])
|
||||
.status()
|
||||
.expect("failed to spawn cc for macos_shim.c");
|
||||
// `.status()` succeeding only means the process RAN. A real compile error
|
||||
// exits non-zero, and ignoring that left no object file, which surfaced
|
||||
// much later as an unexplained link failure against a missing symbol. The
|
||||
// shim is macOS-only and is neither linted nor compiled on the other two
|
||||
// platforms, so a mistake in it has exactly one chance to be noticed.
|
||||
assert!(cc_status.success(), "cc failed to compile macos_shim.c");
|
||||
|
||||
let ar_status = std::process::Command::new("ar")
|
||||
.args(["rcs", &lib, &obj])
|
||||
.status()
|
||||
.expect("failed to spawn ar");
|
||||
assert!(ar_status.success(), "ar failed to create the static lib");
|
||||
|
||||
println!("cargo:rustc-link-search=native={out_dir}");
|
||||
println!("cargo:rustc-link-lib=static=macos_scsi");
|
||||
println!("cargo:rerun-if-changed=src/scsi/macos_shim.c");
|
||||
}
|
||||
}
|
||||
|
||||
/// Bake the git short hash into the build as `GIT_SUFFIX` so any muxed MKV or
|
||||
/// FVI index is traceable to the exact source revision (e.g. ` (g835cc99)`).
|
||||
/// Empty when git or the repo is unavailable (e.g. a source-archive build),
|
||||
/// leaving just the package version. Always emitted so `env!("GIT_SUFFIX")`
|
||||
/// resolves on every target.
|
||||
fn emit_git_suffix() {
|
||||
// Version label for the muxing-app / FVI generator tag. `FREEMKV_BUILD_LABEL`
|
||||
// overrides the Cargo package version when set (non-empty) — used to stamp a
|
||||
// pre-release/test build without bumping Cargo.toml and disturbing the
|
||||
// tag-pinned [patch] version matching. Unset → the package version.
|
||||
let version = std::env::var("FREEMKV_BUILD_LABEL")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.or_else(|| std::env::var("CARGO_PKG_VERSION").ok())
|
||||
.unwrap_or_default();
|
||||
println!("cargo:rustc-env=FREEMKV_VERSION={version}");
|
||||
println!("cargo:rerun-if-env-changed=FREEMKV_BUILD_LABEL");
|
||||
|
||||
let suffix = git_short_hash()
|
||||
.map(|h| format!(" (g{h})"))
|
||||
.unwrap_or_default();
|
||||
println!("cargo:rustc-env=GIT_SUFFIX={suffix}");
|
||||
|
||||
// Re-run when HEAD (or the branch it points at) moves so the stamp stays
|
||||
// current without a clean rebuild.
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
if let Ok(head) = std::fs::read_to_string(".git/HEAD")
|
||||
&& let Some(ref_path) = head.strip_prefix("ref: ")
|
||||
{
|
||||
println!("cargo:rerun-if-changed=.git/{}", ref_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
fn git_short_hash() -> Option<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--short=7", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let h = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if h.is_empty() { None } else { Some(h) }
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
# Coverage is informational only: it never fails CI or blocks a merge/release.
|
||||
# The `coverage` job in .github/workflows/ci.yml is non-required and no gate
|
||||
# depends on it.
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
|
||||
comment: false
|
||||
|
||||
ignore:
|
||||
- "tests"
|
||||
- "examples"
|
||||
- "benches"
|
||||
@@ -1,42 +0,0 @@
|
||||
# cargo-deny — gates the RESOLVED dependency graph (issue #15).
|
||||
# Runs in CI against the committed Cargo.lock: RUSTSEC advisories, a permissive
|
||||
# licence allow-list, and source/ban hygiene. Update the lock (cargo update)
|
||||
# to clear an advisory rather than adding an `ignore` entry without cause.
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
# ignore = ["RUSTSEC-0000-0000"] # only with a written justification
|
||||
|
||||
[licenses]
|
||||
# The whole tree is permissive. r-efi's LGPL arm is satisfied by Apache-2.0
|
||||
# (its expression is "Apache-2.0 OR LGPL-2.1-or-later"), so no copyleft is
|
||||
# allowed here. Add a licence only when a real dependency introduces it.
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"0BSD",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"Zlib",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
confidence-threshold = 0.9
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn" # transitive dupes (digest 0.10/0.11 etc.) are noise, not a gate
|
||||
wildcards = "deny"
|
||||
# The freemkv siblings resolve as path deps under CI's path-patch layout, which
|
||||
# reads as a wildcard version req. That is internal, not a supply-chain wildcard.
|
||||
allow-wildcard-paths = true
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
# The freemkv siblings are first-party git-tag deps; allow git sources from
|
||||
# our own org only (CI resolves them as git, not the local path override).
|
||||
[sources.allow-org]
|
||||
github = ["freemkv"]
|
||||
@@ -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\":{}}}",
|
||||
jstr(name),
|
||||
jstr(&format!("open: {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\":{}}}",
|
||||
jstr(name),
|
||||
jstr(&format!("scan: {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));
|
||||
}
|
||||
}
|
||||
Submodule
+1
Submodule freemkv added at d6e69f2c84
Submodule
+1
Submodule freemkv-tools added at 29ab4d8aef
Submodule
+1
Submodule freemkv.github.io added at 3deb297608
Submodule
+1
Submodule libfreemkv added at 291e537351
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
|
||||
def list_directory_contents():
|
||||
for item in os.listdir('.'):
|
||||
print(item)
|
||||
|
||||
if __name__ == '__main__':
|
||||
list_directory_contents()
|
||||
@@ -0,0 +1,36 @@
|
||||
import os
|
||||
|
||||
def read_md_and_code_files(directory):
|
||||
md_contents = []
|
||||
code_contents = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if file.endswith('.md'):
|
||||
file_path = os.path.join(root, file)
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
md_contents.append((file_path, content))
|
||||
elif file.endswith(('.py', '.js', '.rs')):
|
||||
file_path = os.path.join(root, file)
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
code_contents.append((file_path, content))
|
||||
|
||||
return md_contents, code_contents
|
||||
|
||||
if __name__ == '__main__':
|
||||
directory_to_read = input("Enter the path to the directory: ")
|
||||
md_files, code_files = read_md_and_code_files(directory_to_read)
|
||||
|
||||
print("MD Files:")
|
||||
for file_path, content in md_files:
|
||||
print(f"Contents of {file_path}:")
|
||||
print(content)
|
||||
print("-" * 40)
|
||||
|
||||
print("\nCode Files:")
|
||||
for file_path, content in code_files:
|
||||
print(f"Contents of {file_path}:")
|
||||
print(content)
|
||||
print("-" * 40)
|
||||
@@ -1 +0,0 @@
|
||||
max_width = 100
|
||||
@@ -1,333 +0,0 @@
|
||||
//! AACS content decryption — aligned-unit / bus decryption and TS verification.
|
||||
//! The low-level AES primitives it uses live in [`super::crypto`].
|
||||
|
||||
#[cfg(test)]
|
||||
use aes::Aes128;
|
||||
#[cfg(test)]
|
||||
use aes::cipher::{Array, KeyInit};
|
||||
|
||||
use super::crypto::{aes_cbc_decrypt, aes_cbc_encrypt, aes_ecb_encrypt};
|
||||
// Only this module's test fixtures build CBC ciphertext by hand now — the
|
||||
// production paths get the IV from `aes_cbc_encrypt` / `aes_cbc_decrypt`.
|
||||
#[cfg(test)]
|
||||
use super::crypto::AACS_IV;
|
||||
|
||||
// ── AACS constants ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Size of an AACS aligned unit (3 × 2048-byte sectors). `[BD]` §3.10.1.
|
||||
pub const ALIGNED_UNIT_LEN: usize = 6144;
|
||||
|
||||
/// An AACS aligned unit spans this many 2048-byte sectors (3).
|
||||
pub const ALIGNED_UNIT_SECTORS: u32 = (ALIGNED_UNIT_LEN / SECTOR_BYTES) as u32;
|
||||
|
||||
/// Whether `lba` sits on an AACS aligned-unit boundary, measured **relative to
|
||||
/// the encrypted region's base LBA** (`unit_base` = the clip/extent `start_lba`,
|
||||
/// NOT absolute disc LBA 0). This is the SINGLE source of truth used by the
|
||||
/// decrypt-on-read gate, the mux read paths, and key validation — never
|
||||
/// absolute `lba % 3`.
|
||||
///
|
||||
/// `saturating_sub` keeps `lba < unit_base` well-defined (clamped to 0, a unit boundary)
|
||||
/// instead of a `wrapping_sub` mod-3 underflow trap.
|
||||
pub fn is_unit_aligned(lba: u32, unit_base: u32) -> bool {
|
||||
lba.saturating_sub(unit_base)
|
||||
.is_multiple_of(ALIGNED_UNIT_SECTORS)
|
||||
}
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
|
||||
use crate::consts::BD_SOURCE_PACKET_BYTES;
|
||||
|
||||
/// TS sync byte.
|
||||
const TS_SYNC: u8 = 0x47;
|
||||
|
||||
// ── Content decryption ──────────────────────────────────────────────────────
|
||||
|
||||
/// The AUTHORITATIVE AACS "is this aligned unit encrypted?" signal, per
|
||||
/// container: for `BdTs` the Copy Permission Indicator in the top 2 bits of
|
||||
/// byte 0 (`[BD]` §3.10.2, always clear in the unencrypted 16-byte seed —
|
||||
/// `(buf[0] & 0xC0) == 0` → clear); for `MpegPs` (HD-DVD `.evo`) whether any
|
||||
/// pack of the slice carries `PES_scrambling_control` `01` (`[HD]` §4.3.2: HD DVD
|
||||
/// encrypts per pack, so every pack is judged, not the first).
|
||||
///
|
||||
/// Readable WITHOUT a key. Only meaningful when `unit` is read at the correct
|
||||
/// clip-FILE-anchored boundary.
|
||||
pub fn aacs_unit_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
use crate::disc::ContentFormat;
|
||||
match format {
|
||||
ContentFormat::BdTs => unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0,
|
||||
ContentFormat::MpegPs | ContentFormat::DvdPs => any_pack_scrambled(unit),
|
||||
}
|
||||
}
|
||||
|
||||
// Whether any pack of `buf` (a trailing partial pack included) is flagged scrambled.
|
||||
fn any_pack_scrambled(buf: &[u8]) -> bool {
|
||||
use super::hddvd::{PackKind, classify};
|
||||
buf.chunks(SECTOR_BYTES)
|
||||
.any(|p| classify(p) == PackKind::Scrambled)
|
||||
}
|
||||
|
||||
/// The AACS encrypted flag from an aligned unit's CLEAR seed, readable even on a
|
||||
/// trailing PARTIAL unit (unlike [`aacs_unit_encrypted`], which requires a whole
|
||||
/// 6144-byte unit for `BdTs`). The flag lives at a fixed low offset in the clear header, so a
|
||||
/// fragment that still contains that byte can be classified. Used to catch an
|
||||
/// encrypted unit truncated across a buffer/extent boundary — a fragment we cannot
|
||||
/// CBC-decrypt and must not emit as clear. `false` for a slice too short to hold
|
||||
/// the flag byte. Same clip-anchored-read caveat as [`aacs_unit_encrypted`].
|
||||
pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
use crate::disc::ContentFormat;
|
||||
match format {
|
||||
ContentFormat::BdTs => unit.first().is_some_and(|b| b & 0xC0 != 0),
|
||||
ContentFormat::MpegPs | ContentFormat::DvdPs => any_pack_scrambled(unit),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the Copy_permission_indicator of every source packet of a unit freemkv has
|
||||
/// DECRYPTED, so the flag is true of the bytes written (KU design §5.4, K-13). `BdTs`
|
||||
/// only; HD DVD (`MpegPs`) is excluded (§2.6). Never called on a unit left as ciphertext.
|
||||
pub(crate) fn clear_copy_permission_indicator(unit: &mut [u8], format: crate::disc::ContentFormat) {
|
||||
if format != crate::disc::ContentFormat::BdTs || unit.len() < ALIGNED_UNIT_LEN {
|
||||
return;
|
||||
}
|
||||
// KS-5 [BD] §3.10.2: "shall be set to 11₂ if the data is encrypted, or … 00₂ if the data
|
||||
// is not encrypted"; KS-6: CPI is the top 2 of TP_extra_header's 32 bits (ATS kept).
|
||||
// KS-22 libaacs `_verify_ts` (corroboration): "buf[i] &= ~0xc0;" per 192-byte packet.
|
||||
for packet in unit[..ALIGNED_UNIT_LEN].chunks_mut(BD_SOURCE_PACKET_BYTES) {
|
||||
packet[0] &= 0x3F;
|
||||
}
|
||||
}
|
||||
|
||||
/// Test helper: `plain` as a decrypt writes it, CPI cleared in every source packet (KS-5;
|
||||
/// KU design §5.4). For a buffer of whole decrypted units only.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cpi_cleared(mut plain: Vec<u8>) -> Vec<u8> {
|
||||
for packet in plain.chunks_mut(BD_SOURCE_PACKET_BYTES) {
|
||||
packet[0] &= 0x3F;
|
||||
}
|
||||
plain
|
||||
}
|
||||
|
||||
/// Was `unit` cut on its file's unit grid? An encrypted BD-TS unit's clear seed
|
||||
/// always starts a source packet, so its TS sync (0x47) sits at byte 4; a chunk
|
||||
/// read off the grid starts mid-unit on ciphertext. Clear units, and `MpegPs`
|
||||
/// (seed layout unverified), always pass. A wrong-grid chunk with CPI=0 also
|
||||
/// passes (~1 in 4 per unit), so single-unit reads can slip; multi-unit reads are
|
||||
/// caught almost surely. A damaged seed fails it too: either way the unit opens under
|
||||
/// no key and readers blank it (`decrypt::blank_damaged_units`); it is never E7013.
|
||||
pub(crate) fn aacs_unit_on_grid(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
format != crate::disc::ContentFormat::BdTs
|
||||
|| !aacs_unit_seed_encrypted(unit, format)
|
||||
|| unit.get(4) == Some(&TS_SYNC)
|
||||
}
|
||||
|
||||
/// True when an aligned unit is flagged encrypted AND still looks scrambled
|
||||
/// (structure not yet restored) — genuine encrypted content NOT yet decrypted.
|
||||
///
|
||||
/// Composes [`aacs_unit_encrypted`] (the authoritative flag) with an IDEMPOTENT "structure
|
||||
/// restored?" check, so callers that may run twice over the same buffer (re-decrypt, sampling,
|
||||
/// diagnosis) get a stable answer. A unit freemkv decrypted has its flag CLEARED (KS-5 `[BD]`
|
||||
/// §3.10.2: "00₂ if the data is not encrypted"; KU design §5.4), so it reads clear here; a
|
||||
/// unit left as ciphertext keeps it. Only meaningful at the clip-FILE-anchored boundary.
|
||||
pub fn aacs_unit_needs_decrypt(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
// "Still needs key" = flagged encrypted AND not structurally clean per the
|
||||
// ONE definition, [`is_clean`]'s min(E,4) proof floor. Never a second threshold:
|
||||
// the old >50% majority false-flagged bad-but-opened units, causing re-sample storms.
|
||||
aacs_unit_encrypted(unit, format) && !is_clean(unit, format)
|
||||
}
|
||||
|
||||
// Minimum synced 0x47 packets (of the ~31/unit) that PROVE a key opened a unit: an ABSOLUTE
|
||||
// proof floor, NOT a proportion (false-pass ≈ C(31,4)*256^-4 ≈ 1e-5/unit).
|
||||
const KEY_PROOF_PACKETS: usize = 4;
|
||||
|
||||
/// Structural "did a key open this content unit?" — the pure, NO-CRYPTO signal
|
||||
/// for key SELECTION (pick the right key among multiple on a multi-CPS disc) and
|
||||
/// read VERIFY. AACS has no cryptographic "did the key work" answer (no MAC), so
|
||||
/// a key is proven STRUCTURALLY: its plaintext must look like valid content for
|
||||
/// the disc's container. This dispatches to the right container check by
|
||||
/// `format` — BD/UHD/FMTS are Transport Stream ([`is_clean_ts`]); HD-DVD `.evo`
|
||||
/// is Program Stream ([`is_clean_ps`]). NOT a decryption verdict: a correct key
|
||||
/// can decrypt structurally-broken content, which is the muxer's concern.
|
||||
pub fn is_clean(unit: &[u8], format: crate::disc::ContentFormat) -> bool {
|
||||
match format {
|
||||
crate::disc::ContentFormat::BdTs => is_clean_ts(unit),
|
||||
crate::disc::ContentFormat::MpegPs | crate::disc::ContentFormat::DvdPs => is_clean_ps(unit),
|
||||
}
|
||||
}
|
||||
|
||||
// Structural "does this unit carry enough valid MPEG-TS to prove a key opened it?" (the mux's
|
||||
// key-selection/verify signal): synced >= min(E, KEY_PROOF_PACKETS) over non-padding packets.
|
||||
fn is_clean_ts(unit: &[u8]) -> bool {
|
||||
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
|
||||
let limit = ALIGNED_UNIT_LEN.min(unit.len());
|
||||
let mut content = 0usize;
|
||||
let mut synced = 0usize;
|
||||
// Skip packet 0: its sync byte lives in the clear 16-byte seed (unencrypted),
|
||||
// so it is `0x47` regardless of the key and proves nothing about decryption.
|
||||
let mut off = PKT;
|
||||
while off + PKT <= limit {
|
||||
let payload = &unit[off + 4..off + PKT];
|
||||
if !payload.iter().all(|&b| b == 0) {
|
||||
content += 1;
|
||||
if unit[off + 4] == TS_SYNC {
|
||||
synced += 1;
|
||||
}
|
||||
}
|
||||
off += PKT;
|
||||
}
|
||||
content == 0 || synced >= content.min(KEY_PROOF_PACKETS)
|
||||
}
|
||||
|
||||
/// Count the MPEG-TS sync bytes (`0x47`) present at the BD-TS packet stride
|
||||
/// (offset 4 and every 192 bytes after — 4-byte TP_extra_header + 188-byte
|
||||
/// TS packet). A clear or correctly-decrypted m2ts unit shows ~one per
|
||||
/// packet; an encrypted unit, or a non-content unit decrypted under a key
|
||||
/// that doesn't apply, shows ~none.
|
||||
pub fn ts_sync_count(unit: &[u8]) -> usize {
|
||||
let mut count = 0;
|
||||
let mut offset = 4;
|
||||
while offset < unit.len() {
|
||||
if unit[offset] == TS_SYNC {
|
||||
count += 1;
|
||||
}
|
||||
offset += BD_SOURCE_PACKET_BYTES;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Number of BD-TS packets in the unit — the maximum possible sync count.
|
||||
pub fn ts_packet_total(unit: &[u8]) -> usize {
|
||||
// One sync byte per 192-byte BD-TS packet (at offset 4 of each). The old
|
||||
// `(len - 4) / BD_SOURCE_PACKET_BYTES + 1` over-counted by one for lengths of the
|
||||
// form `4 + k·192`.
|
||||
unit.len() / BD_SOURCE_PACKET_BYTES
|
||||
}
|
||||
|
||||
// The Program-Stream arm of [`is_clean`] (HD-DVD `.evo`): no pack still flagged scrambled and
|
||||
// none failing its payload check past the clear head (`hddvd::payload_check`). The pack start
|
||||
// code proves nothing: an encrypted pack keeps it in its clear 128-byte head (`[HD]` §4.3.2).
|
||||
fn is_clean_ps(unit: &[u8]) -> bool {
|
||||
!any_pack_scrambled(unit)
|
||||
&& unit.as_chunks::<SECTOR_BYTES>().0.iter().all(|p| {
|
||||
p[..4] == [0x00, 0x00, 0x01, 0xBA] && super::hddvd::payload_check(p) != Some(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt one AACS aligned unit (6144 bytes) IN PLACE — PURE crypto that
|
||||
/// applies `unit_key`; makes no verdict about whether the plaintext is clean
|
||||
/// TS (that's the separate [`is_clean_ts`] question, for key SELECTION or a
|
||||
/// read VERIFY).
|
||||
///
|
||||
/// Applies the key UNCONDITIONALLY: does NOT check the encrypted-flag, so the CALLER must gate
|
||||
/// on [`aacs_unit_encrypted`] first. Block Key = AES-128E(Kcu, seed) ⊕ seed, then AES-128-CBC
|
||||
/// decrypt bytes 16..6144 under the AACS IV.
|
||||
pub(crate) fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return;
|
||||
}
|
||||
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
|
||||
let npkt = ALIGNED_UNIT_LEN / PKT;
|
||||
let mut pad = [false; ALIGNED_UNIT_LEN / PKT];
|
||||
for (p, slot) in pad.iter_mut().enumerate().take(npkt) {
|
||||
let off = p * PKT;
|
||||
*slot = unit[off..off + PKT].iter().all(|&b| b == 0);
|
||||
}
|
||||
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut decrypt_key = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
decrypt_key[i] = derived[i] ^ header[i];
|
||||
}
|
||||
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
|
||||
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
|
||||
if is_pad {
|
||||
let off = p * PKT;
|
||||
for b in unit[off..off + PKT].iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt one AACS aligned unit (6144 bytes) IN PLACE — the exact inverse of `decrypt_unit`.
|
||||
/// Caller must set the encrypted flag BEFORE calling and check the returned bool.
|
||||
#[must_use = "returns false when the slice is too short to encrypt, leaving \
|
||||
plaintext behind a flag that already says 'encrypted'"]
|
||||
pub fn encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||
let mut k = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
k[i] = derived[i] ^ header[i];
|
||||
}
|
||||
// CBC-encrypt bytes 16.. under the fixed AACS IV — the exact forward of the
|
||||
// `aes_cbc_decrypt` call in `decrypt_unit`, and one key expansion for the whole
|
||||
// unit rather than one per 16-byte block.
|
||||
aes_cbc_encrypt(&k, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
true
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Test-only unit-granular form; production de-busses via [`decrypt_bus_in_content`].
|
||||
#[cfg(test)]
|
||||
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.
|
||||
let cipher = crate::aacs::crypto::new_cipher_for(read_data_key);
|
||||
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
if sector_start + SECTOR_BYTES > unit.len() {
|
||||
break;
|
||||
}
|
||||
// First 16 bytes of each sector are plaintext
|
||||
crate::aacs::crypto::cbc_decrypt_blocks(
|
||||
&cipher,
|
||||
&mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove bus encryption from every whole 2048-byte sector of `buf` (bytes
|
||||
/// 16..2048 of each; the first 16 are plaintext on the wire). For content-only
|
||||
/// callers; whole-disc readers gate through `sector::bus_removal::BusMap`.
|
||||
pub(crate) fn decrypt_bus_sectors(buf: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
let cipher = crate::aacs::crypto::new_cipher_for(read_data_key);
|
||||
for sector in buf.as_chunks_mut::<SECTOR_BYTES>().0 {
|
||||
crate::aacs::crypto::cbc_decrypt_blocks(&cipher, &mut sector[16..]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only exact inverse of [`decrypt_bus`]: apply AACS 2.0 bus ENCRYPTION to
|
||||
/// an aligned unit in place — per-sector AES-CBC-encrypt bytes 16..2048 of each
|
||||
/// 2048-byte sector under `read_data_key`, leaving the first 16 bytes of every
|
||||
/// sector clear (they are plaintext on the wire, as `decrypt_bus` relies on).
|
||||
/// Used to build a bus-encrypted fixture that `decrypt_bus` must recover, and by
|
||||
/// the `decrypt.rs` end-to-end tests to model the drive's forward transform.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn encrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
if sector_start + SECTOR_BYTES > unit.len() {
|
||||
break;
|
||||
}
|
||||
crate::aacs::crypto::aes_cbc_encrypt(
|
||||
read_data_key,
|
||||
&mut unit[sector_start + 16..sector_start + SECTOR_BYTES],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "content_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
// Spec guards (keys-upfront-design §7.8) on the unit decrypt; here because
|
||||
// `decrypt_unit` is crate-private (KU §2.2). Vectors come from the `aes` crate.
|
||||
#[cfg(test)]
|
||||
#[path = "content_spec_guards_tests.rs"]
|
||||
mod spec_guards;
|
||||
@@ -1,157 +0,0 @@
|
||||
use super::*;
|
||||
use crate::spec::keys::*;
|
||||
use aes::cipher::{BlockCipherDecrypt, BlockCipherEncrypt};
|
||||
|
||||
const KCU: [u8; 16] = [
|
||||
0x6B, 0x1D, 0xE2, 0x04, 0x93, 0x5A, 0xC7, 0x38, 0x0F, 0xB4, 0x71, 0x2E, 0xD9, 0x86, 0x45, 0x10,
|
||||
];
|
||||
|
||||
// Ciphertext-looking unit with no all-zero packet and a flagged, varied seed.
|
||||
fn unit(salt: u8) -> Vec<u8> {
|
||||
let mut u: Vec<u8> = (0..ALIGNED_UNIT_LEN)
|
||||
.map(|i| (i as u8).wrapping_mul(31).wrapping_add(salt) | 1)
|
||||
.collect();
|
||||
u[0] |= 0xC0;
|
||||
u
|
||||
}
|
||||
|
||||
fn aes_e(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
let mut b: Array<u8, _> = (*block).into();
|
||||
Aes128::new(&(*key).into()).encrypt_block(&mut b);
|
||||
b.into()
|
||||
}
|
||||
|
||||
// KS-3 / KS-19 by hand: AES-128-CBC decrypt of 16..6144 under iv0, with the
|
||||
// KS-4 / KS-23 Block Key = AES-128E(Kcu, seed) ⊕ seed.
|
||||
fn reference_decrypt(unit: &[u8], kcu: &[u8; 16]) -> Vec<u8> {
|
||||
let seed: [u8; 16] = unit[..16].try_into().unwrap();
|
||||
let mut bk = aes_e(kcu, &seed);
|
||||
bk.iter_mut().zip(seed).for_each(|(b, s)| *b ^= s);
|
||||
let cipher = Aes128::new(&bk.into());
|
||||
let mut prev = AACS_IV;
|
||||
let mut out = unit.to_vec();
|
||||
for (o, c) in out[16..].chunks_mut(16).zip(unit[16..].chunks(16)) {
|
||||
let mut b: Array<u8, _> = <[u8; 16]>::try_from(c).unwrap().into();
|
||||
cipher.decrypt_block(&mut b);
|
||||
o.iter_mut()
|
||||
.zip(b.iter().zip(prev))
|
||||
.for_each(|(o, (p, v))| *o = p ^ v);
|
||||
prev = c.try_into().unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-3, KS-4 [BD] §3.10.1: "The
|
||||
/// first 16 bytes of each Aligned Unit is used as the seed"; "The final 6128 bytes … encrypted".
|
||||
#[test]
|
||||
fn seed_is_first_16_bytes_and_stays_clear() {
|
||||
assert!(
|
||||
KS_4_SEED
|
||||
.text
|
||||
.starts_with("The first 16 bytes of each Aligned Unit")
|
||||
);
|
||||
assert!(
|
||||
KS_3_CBC_PER_UNIT
|
||||
.text
|
||||
.starts_with("The final 6128 bytes of each Aligned")
|
||||
);
|
||||
let enc = unit(7);
|
||||
let mut dec = enc.clone();
|
||||
decrypt_unit(&mut dec, &KCU);
|
||||
assert_eq!(dec[..16], enc[..16], "the seed is never decrypted");
|
||||
let changed = dec[16..]
|
||||
.chunks(16)
|
||||
.zip(enc[16..].chunks(16))
|
||||
.all(|(d, e)| d != e);
|
||||
assert!(changed, "every block of the final 6128 bytes is decrypted");
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation proving otherwise — KS-3 [BD]
|
||||
/// §3.10.1: "A new CBC cipher chain is started for each Aligned Unit": the mapped decrypt
|
||||
/// of a multi-unit buffer, in either order, never chains one unit into the next.
|
||||
#[test]
|
||||
fn cbc_chain_restarts_every_unit() {
|
||||
use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase, decrypt_sectors_mapped};
|
||||
assert!(
|
||||
KS_3_CBC_PER_UNIT
|
||||
.text
|
||||
.contains("new CBC cipher chain is started for each")
|
||||
);
|
||||
// Encrypted TS units (sync at +4, CPI 11₂ per packet) so the mapped path decrypts.
|
||||
let plain: Vec<Vec<u8>> = (0..2)
|
||||
.map(|i| {
|
||||
let mut u = unit(40 + i);
|
||||
for p in u.chunks_mut(crate::consts::BD_SOURCE_PACKET_BYTES) {
|
||||
p[0] |= 0xC0;
|
||||
p[4] = TS_SYNC;
|
||||
}
|
||||
u
|
||||
})
|
||||
.collect();
|
||||
let enc: Vec<Vec<u8>> = plain
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut u = p.clone();
|
||||
assert!(encrypt_unit(&mut u, &KCU));
|
||||
u
|
||||
})
|
||||
.collect();
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, KCU)],
|
||||
format: crate::disc::ContentFormat::BdTs,
|
||||
};
|
||||
let map = AacsKeyMap::from_ranges_phased(vec![(0, 2 * ALIGNED_UNIT_SECTORS, 0, Phase::All)]);
|
||||
for order in [[0, 1], [1, 0]] {
|
||||
let mut buf: Vec<u8> = order.iter().flat_map(|&i| enc[i].clone()).collect();
|
||||
decrypt_sectors_mapped(&mut buf, &keys, 0, &map).expect("both units keyed");
|
||||
// KS-5: a decrypted unit's CPI reads 00₂ (KU design §5.4); the chain rule is KS-3.
|
||||
let want: Vec<u8> = order
|
||||
.iter()
|
||||
.flat_map(|&i| super::cpi_cleared(plain[i].clone()))
|
||||
.collect();
|
||||
assert!(
|
||||
buf == want,
|
||||
"order {order:?}: each unit decrypts on its own chain"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-4 [BD] §3.10.1 (Figure 3-8),
|
||||
/// corroborated by KS-23 libaacs: Block Key = AES-128E(Kcu, seed) ⊕ seed.
|
||||
#[test]
|
||||
fn block_key_is_aes128e_of_seed_xor_seed() {
|
||||
assert!(
|
||||
KS_23_LIBAACS_BLOCK_KEY
|
||||
.text
|
||||
.contains("key[a] ^= out_buf[a];")
|
||||
);
|
||||
let plain = unit(3);
|
||||
let seed: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
let mut bk = aes_e(&KCU, &seed);
|
||||
bk.iter_mut().zip(seed).for_each(|(b, s)| *b ^= s);
|
||||
// First CBC block under the independent Block Key and iv0.
|
||||
let mut x: [u8; 16] = plain[16..32].try_into().unwrap();
|
||||
x.iter_mut().zip(AACS_IV).for_each(|(b, v)| *b ^= v);
|
||||
let mut u = plain.clone();
|
||||
assert!(encrypt_unit(&mut u, &KCU));
|
||||
assert_eq!(
|
||||
u[16..32],
|
||||
aes_e(&bk, &x),
|
||||
"encrypt uses AES-128E(Kcu, seed) ⊕ seed"
|
||||
);
|
||||
decrypt_unit(&mut u, &KCU);
|
||||
assert_eq!(u, plain);
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-3 [BD] §3.10.1 "AES-128CBCE";
|
||||
/// KS-19 [CM] §2.1.2 iv0: the unit decrypt equals an independent AES-128-CBC.
|
||||
#[test]
|
||||
fn bd_aes_primitives_match_independent_impl() {
|
||||
assert!(KS_3_CBC_PER_UNIT.text.contains("Block Key and AES-128CBCE"));
|
||||
for salt in [0u8, 99, 200] {
|
||||
let enc = unit(salt);
|
||||
let mut got = enc.clone();
|
||||
decrypt_unit(&mut got, &KCU);
|
||||
assert_eq!(got, reference_decrypt(&enc, &KCU), "salt {salt}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,184 +0,0 @@
|
||||
//! AACS common cryptographic primitives — `[C]` Chapter 2 / §3.2.2.
|
||||
//!
|
||||
//! Source: `[C]` = AACS Introduction and Common Cryptographic Elements Book,
|
||||
//! Rev 0.953. The shared low-level building blocks — AES-128 ECB E/D, AES-G,
|
||||
//! the AES-G3 Triple Generator, AES-CBC decrypt — and their fixed constants
|
||||
//! (`iv0`, `s0`). Used by every AACS generation; relocated here so the
|
||||
//! primitives live in one place instead of being scattered across the
|
||||
//! content / keys / variant modules.
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
/// Fixed IV used by AACS for all AES-CBC operations. `[C]` §2.1.2 (default CBC IV, `iv0`).
|
||||
pub(crate) const AACS_IV: [u8; 16] = [
|
||||
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||
];
|
||||
|
||||
// Per-thread count of AES-128 key schedules built through `new_cipher`.
|
||||
// Test-only: lets tests assert the CBC decrypt hot path builds one schedule
|
||||
// per loop-invariant key. Thread-local, not atomic, since `cargo test` runs concurrently.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static KEY_EXPANSIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Build an AES-128 key schedule for a caller that will drive
|
||||
/// [`cbc_decrypt_blocks`] over several regions under one key. Also used by the
|
||||
/// `aacs::derive` cvalue-candidate ECB scan (L104), which shares one schedule
|
||||
/// per Processing Key across every candidate instead of building one per call.
|
||||
pub(crate) fn new_cipher_for(key: &[u8; 16]) -> Aes128 {
|
||||
new_cipher(key)
|
||||
}
|
||||
|
||||
/// Build an AES-128 key schedule. The single construction site for the CBC
|
||||
/// helpers, so `KEY_EXPANSIONS` can count them under test; the single-block ECB
|
||||
/// helpers build their own and are not counted.
|
||||
fn new_cipher(key: &[u8; 16]) -> Aes128 {
|
||||
#[cfg(test)]
|
||||
KEY_EXPANSIONS.with(|c| c.set(c.get() + 1));
|
||||
Aes128::new(&(*key).into())
|
||||
}
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. `[C]` §2.1.1 (`AES-128E`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block. `[C]` §2.1.1 (`AES-128D`).
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
aes_ecb_decrypt_with_cipher(&cipher, data)
|
||||
}
|
||||
|
||||
/// AES-128-ECB decrypt a single block under an already-expanded key schedule. Split out of
|
||||
/// [`aes_ecb_decrypt`] for a candidate-scan loop that reuses one Processing Key's schedule
|
||||
/// across many `(uv, cvalue)` pairs instead of rebuilding it per candidate (L104).
|
||||
pub(crate) fn aes_ecb_decrypt_with_cipher(cipher: &Aes128, data: &[u8; 16]) -> [u8; 16] {
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
// AES-128-CBC encrypt in place under AACS_IV. Forward direction / exact inverse of
|
||||
// aes_cbc_decrypt (`[C]` §2.1.2, AES-128CBCE).
|
||||
pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len().is_multiple_of(16),
|
||||
"aes_cbc_encrypt requires a block-aligned slice"
|
||||
);
|
||||
let cipher = new_cipher(key);
|
||||
let num_blocks = data.len() / 16;
|
||||
let mut prev = AACS_IV;
|
||||
// Forward order: each block is XORed with the PRECEDING ciphertext block.
|
||||
for i in 0..num_blocks {
|
||||
let offset = i * 16;
|
||||
let mut block = [0u8; 16];
|
||||
for j in 0..16 {
|
||||
block[j] = data[offset + j] ^ prev[j];
|
||||
}
|
||||
let mut ga: Array<u8, _> = block.into();
|
||||
cipher.encrypt_block(&mut ga);
|
||||
data[offset..offset + 16].copy_from_slice(&ga);
|
||||
prev.copy_from_slice(&ga);
|
||||
}
|
||||
}
|
||||
|
||||
// AES-128-CBC decrypt in-place under AACS_IV (`[C]` §2.1.2, AES-128CBCD).
|
||||
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
debug_assert!(
|
||||
data.len().is_multiple_of(16),
|
||||
"aes_cbc_decrypt requires a block-aligned slice"
|
||||
);
|
||||
cbc_decrypt_blocks(&new_cipher(key), data);
|
||||
}
|
||||
|
||||
// AES-128-CBC decrypt in place under AACS_IV with an already-expanded key schedule. Split out
|
||||
// of aes_cbc_decrypt for callers that share one key across several regions.
|
||||
pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) {
|
||||
// CBC decrypt parallelises: AES-decrypt a batch of blocks in one call (the
|
||||
// backend pipelines them), then XOR each with its preceding ciphertext.
|
||||
const BATCH: usize = 32;
|
||||
let len = data.len() / 16 * 16;
|
||||
let mut prev = AACS_IV;
|
||||
let mut blocks = [aes::Block::default(); BATCH];
|
||||
for chunk in data[..len].chunks_mut(16 * BATCH) {
|
||||
let n = chunk.len() / 16;
|
||||
for (b, c) in blocks.iter_mut().zip(chunk.as_chunks::<16>().0) {
|
||||
b.copy_from_slice(c);
|
||||
}
|
||||
cipher.decrypt_blocks(&mut blocks[..n]);
|
||||
// The next batch chains off this batch's last CIPHERTEXT block.
|
||||
let mut last = [0u8; 16];
|
||||
last.copy_from_slice(&chunk[(n - 1) * 16..]);
|
||||
// Walk backwards so each block's preceding ciphertext is still intact.
|
||||
for i in (0..n).rev() {
|
||||
let iv = if i == 0 {
|
||||
prev
|
||||
} else {
|
||||
let mut p = [0u8; 16];
|
||||
p.copy_from_slice(&chunk[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
for (d, (x, v)) in chunk[i * 16..(i + 1) * 16]
|
||||
.iter_mut()
|
||||
.zip(blocks[i].iter().zip(iv.iter()))
|
||||
{
|
||||
*d = x ^ v;
|
||||
}
|
||||
}
|
||||
prev = last;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-thread count of `aes_g` calls. Test-only: L103 hoists the slot-independent
|
||||
// `aes_g(kp, nonce)` out of the AACS 2.1 variant per-slot loop (~46k slots/disc), so a test
|
||||
// can assert it now runs once per variant MKB, not once per slot.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static AES_G_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
// AES-G(x1, x2) = AES-128D(x1, x2) XOR x2 (`[C]` §2.1.3, uses AES-128D). Used by the Media Key
|
||||
// Variant chain to derive Kvn and Kvu.
|
||||
pub(crate) fn aes_g(x1: &[u8; 16], x2: &[u8; 16]) -> [u8; 16] {
|
||||
#[cfg(test)]
|
||||
AES_G_CALLS.with(|c| c.set(c.get() + 1));
|
||||
let mut out = aes_ecb_decrypt(x1, x2);
|
||||
for i in 0..16 {
|
||||
out[i] ^= x2[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// AACS-G3 seed constant (`s0`). `[C]` §3.2.2.
|
||||
pub(crate) const AESG3_SEED: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
// AACS-G3: derive a subkey from a parent key (`[C]` §3.2.2, Triple AES Generator). Shared with
|
||||
// super::variant so both SD-tree walks stay byte-identical.
|
||||
pub(crate) fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||
let mut seed = AESG3_SEED;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
let mut out = aes_ecb_decrypt(key, &seed);
|
||||
for i in 0..16 {
|
||||
out[i] ^= seed[i];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "crypto_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "crypto_spec_guards_tests.rs"]
|
||||
mod spec_guards;
|
||||
@@ -1,20 +0,0 @@
|
||||
use super::AACS_IV;
|
||||
use crate::spec::keys::KS_19_IV0;
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-19 [CM] §2.1.2: "iv0, which
|
||||
/// is: 0BA0F8DDFEA61FB3D8DF9F566A050F78₁₆" (the value is read from the quote).
|
||||
#[test]
|
||||
fn cbc_iv_is_iv0() {
|
||||
let hex = KS_19_IV0
|
||||
.text
|
||||
.rsplit(' ')
|
||||
.next()
|
||||
.and_then(|h| h.strip_suffix("₁₆"))
|
||||
.expect("KS-19 ends with the iv0 value");
|
||||
let iv0: Vec<u8> = (0..32)
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||
.collect();
|
||||
assert_eq!(hex.len(), 32);
|
||||
assert_eq!(AACS_IV.to_vec(), iv0);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// s0 transcribed independently from `[C]` §3.2.2, not read from AESG3_SEED, so this can't
|
||||
// assert the production constant against itself.
|
||||
const S0: [u8; 16] = [
|
||||
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||
];
|
||||
|
||||
/// An arbitrary non-degenerate key. Nothing about it is secret or special;
|
||||
/// the AES-G3 relation holds for every key, and a constant-returning body
|
||||
/// cannot satisfy it for any.
|
||||
const K: [u8; 16] = [
|
||||
0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87, 0x96, 0xA5, 0xB4, 0xC3, 0xD2, 0xE1, 0xF0,
|
||||
];
|
||||
|
||||
// aesg3 is the SD-tree node function; a wrong `^` or a constant body would derive a
|
||||
// plausible-but-wrong Processing Key. Pinned via the spec relation.
|
||||
#[test]
|
||||
fn aesg3_inverts_to_the_spec_seed_under_aes_encrypt() {
|
||||
for inc in 0u8..=2 {
|
||||
let mut seed = S0;
|
||||
seed[15] = seed[15].wrapping_add(inc);
|
||||
|
||||
let out = aesg3(&K, inc);
|
||||
|
||||
// out == AES-128D(K, seed) XOR seed, so out XOR seed is the raw
|
||||
// decryption and re-encrypting it must land back on the seed.
|
||||
let mut pre = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
pre[i] = out[i] ^ seed[i];
|
||||
}
|
||||
assert_eq!(
|
||||
aes_ecb_encrypt(&K, &pre),
|
||||
seed,
|
||||
"AES-G3 inc={inc} must satisfy out = AES-128D(k, s0+inc) XOR (s0+inc)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The Triple Generator's three outputs are one node's two children plus its Processing Key;
|
||||
// if `inc` were ignored a descent would revisit its own parent.
|
||||
#[test]
|
||||
fn aesg3_yields_three_distinct_subkeys_for_the_three_increments() {
|
||||
let left = aesg3(&K, 0);
|
||||
let pk = aesg3(&K, 1);
|
||||
let right = aesg3(&K, 2);
|
||||
assert_ne!(left, pk, "left child and Processing Key must differ");
|
||||
assert_ne!(pk, right, "Processing Key and right child must differ");
|
||||
assert_ne!(left, right, "left and right children must differ");
|
||||
}
|
||||
|
||||
/// Distinct parent keys must yield distinct subkeys — the tree would
|
||||
/// collapse otherwise.
|
||||
#[test]
|
||||
fn aesg3_separates_distinct_parent_keys() {
|
||||
let mut other = K;
|
||||
other[0] ^= 0x01;
|
||||
assert_ne!(aesg3(&K, 1), aesg3(&other, 1));
|
||||
}
|
||||
|
||||
// The batched CBC decrypt inverts the (block-at-a-time) CBC encrypt at every
|
||||
// batch-boundary shape: one block, exactly one batch, a batch plus one, and a
|
||||
// whole de-bussed sector body.
|
||||
#[test]
|
||||
fn batched_cbc_decrypt_inverts_cbc_encrypt_across_batch_boundaries() {
|
||||
for blocks in [1usize, 31, 32, 33, 64, 383] {
|
||||
let plain: Vec<u8> = (0..blocks * 16).map(|i| (i * 7 + 3) as u8).collect();
|
||||
let mut data = plain.clone();
|
||||
aes_cbc_encrypt(&K, &mut data);
|
||||
assert_ne!(data, plain);
|
||||
aes_cbc_decrypt(&K, &mut data);
|
||||
assert_eq!(data, plain, "{blocks} blocks must round-trip");
|
||||
}
|
||||
}
|
||||
@@ -1,642 +0,0 @@
|
||||
//! Media-key derivation: DK/PK → Media Key via the subset-difference tree.
|
||||
//! `[C]` §3.2.2–§3.2.5.
|
||||
|
||||
use super::crypto::*;
|
||||
use super::inf::*;
|
||||
use super::mkb::*;
|
||||
use super::types::*;
|
||||
|
||||
/// `[C]` §3.2.5.1.4 Verify-Media-Key plaintext prefix.
|
||||
pub(super) const VERIFY_MAGIC: [u8; 8] = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
|
||||
|
||||
/// Derive Media Key from MKB data using processing keys.
|
||||
///
|
||||
/// A Processing Key is **terminal**: tried *directly* against the MKB
|
||||
/// cvalue tables (no tree descent) — the fast path.
|
||||
///
|
||||
/// If you hold a **device-node label** at unknown tree depth (not a
|
||||
/// terminal PK), use [`derive_media_key_from_dk`] instead — only that path
|
||||
/// walks the Subset-Difference tree.
|
||||
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
|
||||
let t = MkbTables::parse(mkb)?;
|
||||
try_pk_against_tables(processing_keys, t.uvs, t.cvalues, &t.mk_dv)
|
||||
}
|
||||
|
||||
// The three MKB tables every DK/PK derivation reads, located once and BORROWED
|
||||
// from the MKB (the cvalue table alone is MBs on a UHD MKB).
|
||||
pub(crate) struct MkbTables<'a> {
|
||||
mk_dv: [u8; 16],
|
||||
uvs: &'a [u8],
|
||||
cvalues: &'a [u8],
|
||||
}
|
||||
|
||||
/// Why an MKB cannot be derived through at all, as opposed to no key matching it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MkbClassError {
|
||||
/// Class II (`0x000A1003`): its Media Key Data is record `0x0c`, which the classical
|
||||
/// PK/DK derivation (record `0x05`) does not read.
|
||||
Unsupported(MkbType),
|
||||
}
|
||||
|
||||
impl MkbClassError {
|
||||
/// The stable numeric error code ([`E_MKB_CLASS_UNSUPPORTED`](crate::error::E_MKB_CLASS_UNSUPPORTED)).
|
||||
pub fn code(&self) -> u16 {
|
||||
crate::error::E_MKB_CLASS_UNSUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
/// `Err` when this MKB's class has no Media Key Data the PK/DK derivation can read, so a
|
||||
/// caller can report it instead of "no key matched". `Ok` for every other (or no) MKB.
|
||||
pub fn check_mkb_class(mkb: &[u8]) -> Result<(), MkbClassError> {
|
||||
match mkb_type(mkb) {
|
||||
Some(t @ MkbType::ClassII) => Err(MkbClassError::Unsupported(t)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MkbTables<'a> {
|
||||
pub(crate) fn parse(mkb: &'a [u8]) -> Option<Self> {
|
||||
if let Err(e) = check_mkb_class(mkb) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_class",
|
||||
error_code = e.code(),
|
||||
"Class II MKB (0x000A1003) keeps its media key data in record 0x0c; PK/DK \
|
||||
derivation reads only 0x05 and cannot derive a media key from it"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
mk_dv: mkb_find_mk_dv(mkb)?,
|
||||
uvs: find_record_slice(mkb, REC_SUBSET_DIFFERENCE)?,
|
||||
cvalues: find_record_slice(mkb, REC_MEDIA_KEY_DATA)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Core terminal-PK table scan: each PK tried directly against every (uv, cvalue) pair, no tree
|
||||
// descent. Factored out so reproduction harnesses can drive it with explicit tables.
|
||||
pub(crate) fn try_pk_against_tables(
|
||||
processing_keys: &[[u8; 16]],
|
||||
uvs: &[u8],
|
||||
cvalues: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for pk in processing_keys {
|
||||
// `pk` is loop-invariant across every (uv, cvalue) candidate below (up to ~46k on a
|
||||
// UHD MKB): build its AES-128 schedule once (L104) and reuse it via
|
||||
// `validate_processing_key_with_cipher`, byte-identical to `validate_processing_key`.
|
||||
let cipher = new_cipher_for(pk);
|
||||
for i in 0..num_uvs {
|
||||
if (i + 1) * 16 > cvalues.len() {
|
||||
continue;
|
||||
}
|
||||
let record_start = i * 5;
|
||||
if record_start + 5 > uvs.len() {
|
||||
continue;
|
||||
}
|
||||
let uv = &uvs[record_start + 1..record_start + 5];
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
if let Some(mk) = validate_processing_key_with_cipher(&cipher, cv, uv, mk_dv) {
|
||||
return Some(mk);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Same relation as [`validate_processing_key`], but driven by a caller-supplied AES-128 key
|
||||
// schedule for `pk` instead of building one internally — the cvalue-loop fast path
|
||||
// ([`try_pk_against_tables`]) shares one schedule across every candidate for a given pk (L104).
|
||||
fn validate_processing_key_with_cipher(
|
||||
cipher: &aes::Aes128,
|
||||
cvalue: &[u8],
|
||||
uv: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
if cvalue.len() < 16 || uv.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Step 1: mk = AES-128D(pk, cvalue), via the shared schedule.
|
||||
let mut cv = [0u8; 16];
|
||||
cv.copy_from_slice(&cvalue[..16]);
|
||||
let mut mk = aes_ecb_decrypt_with_cipher(cipher, &cv);
|
||||
|
||||
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
|
||||
for a in 0..4 {
|
||||
mk[12 + a] ^= uv[a];
|
||||
}
|
||||
|
||||
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
|
||||
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
|
||||
if dec_vd[..8] == VERIFY_MAGIC {
|
||||
return Some(mk);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Validate a processing key against a cvalue/UV pair; returns the Media Key
|
||||
// if valid. `[C]` §3.2.4 (mk = AES-128D(pk,cvalue); mk[12..16] ^= uv) then
|
||||
// §3.2.5.1.4 (dec_vd = AES-128D(mk,mk_dv); valid iff dec_vd[0..8] == magic).
|
||||
pub(crate) fn validate_processing_key(
|
||||
pk: &[u8; 16],
|
||||
cvalue: &[u8],
|
||||
uv: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Option<[u8; 16]> {
|
||||
if cvalue.len() < 16 || uv.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Step 1: mk = AES-128D(pk, cvalue)
|
||||
let mut cv = [0u8; 16];
|
||||
cv.copy_from_slice(&cvalue[..16]);
|
||||
let mut mk = aes_ecb_decrypt(pk, &cv);
|
||||
|
||||
// Step 2: XOR uv into the last 4 bytes of mk (mk[12..16]).
|
||||
for a in 0..4 {
|
||||
mk[12 + a] ^= uv[a];
|
||||
}
|
||||
|
||||
// Step 3 + 4: dec_vd = AES-128D(mk, mk_dv); verify magic.
|
||||
let dec_vd = aes_ecb_decrypt(&mk, mk_dv);
|
||||
if dec_vd[..8] == VERIFY_MAGIC {
|
||||
return Some(mk);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Compute v_mask from a UV value. `[C]` §3.2.3. Shared with [`super::variant`].
|
||||
pub(super) fn calc_v_mask(uv: u32) -> u32 {
|
||||
let mut v_mask: u32 = 0xFFFF_FFFF;
|
||||
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||
v_mask <<= 1;
|
||||
}
|
||||
v_mask
|
||||
}
|
||||
|
||||
/// Derive processing key from device key using subset-difference tree traversal.
|
||||
/// `[C]` §3.2.4 (device-tree descent, MSB-branch, terminal PK). Shared with [`super::variant`].
|
||||
pub(super) fn calc_pk_from_dk(
|
||||
dk: &[u8; 16],
|
||||
uv: u32,
|
||||
v_mask: u32,
|
||||
dev_key_v_mask: u32,
|
||||
) -> [u8; 16] {
|
||||
// Descend device node -> record node by the record's `uv` bits. Only the child
|
||||
// descended into matters, so derive ONE child per level (left=`aesg3(node,0)`,
|
||||
// right=`,2`) and the PK=`aesg3(.,1)` once at the end — ~3x fewer block ops.
|
||||
let mut node = *dk;
|
||||
let mut current_v_mask = dev_key_v_mask;
|
||||
|
||||
// Tree is <=32 levels deep (u32 mask). The arithmetic `>> 1` sign-extends, so a
|
||||
// v_mask coarser than dev_key_v_mask (crafted/corrupt MKB) would saturate to
|
||||
// 0xFFFF_FFFF and spin forever; bound to 32 steps so a bad disc can't hang the rip.
|
||||
let mut steps = 0u32;
|
||||
while current_v_mask != v_mask {
|
||||
if steps >= 32 {
|
||||
break;
|
||||
}
|
||||
steps += 1;
|
||||
// Find the highest unset bit in current_v_mask
|
||||
let mut bit_pos: i32 = -1;
|
||||
for i in (0..32).rev() {
|
||||
if (current_v_mask & (1u32 << i)) == 0 {
|
||||
bit_pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let inc = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
|
||||
0 // left child
|
||||
} else {
|
||||
2 // right child
|
||||
};
|
||||
node = aesg3(&node, inc);
|
||||
|
||||
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
|
||||
}
|
||||
|
||||
aesg3(&node, 1)
|
||||
}
|
||||
|
||||
/// Derive Media Key from MKB using device keys (subset-difference tree).
|
||||
///
|
||||
/// Thin wrapper over [`derive_media_key_and_pk_from_dk`] that drops the
|
||||
/// intermediate Processing Key. Callers that need the PK lineage (e.g.
|
||||
/// the key service banking DK·PK·MK) should call the `_and_pk_` form.
|
||||
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
|
||||
derive_media_key_and_pk_from_dk(mkb, device_keys).map(|(mk, _pk)| mk)
|
||||
}
|
||||
|
||||
/// Derive both the Media Key and the intermediate Processing Key from an
|
||||
/// MKB using device keys (subset-difference tree).
|
||||
///
|
||||
/// Identical walk to [`derive_media_key_from_dk`]; this form additionally
|
||||
/// returns the Processing Key `Kp` derived at the matching subset-difference
|
||||
/// node — the value `calc_pk_from_dk` produces immediately before it
|
||||
/// validates into the Media Key. Returns `Some((mk, pk))` for the first DK
|
||||
/// that walks a uv slot whose Processing Key validates against the MKB.
|
||||
pub fn derive_media_key_and_pk_from_dk(
|
||||
mkb: &[u8],
|
||||
device_keys: &[DeviceKey],
|
||||
) -> Option<([u8; 16], [u8; 16])> {
|
||||
dk_walk(&MkbTables::parse(mkb)?, device_keys)
|
||||
}
|
||||
|
||||
// The subset-difference DK walk over already-located tables.
|
||||
fn dk_walk(t: &MkbTables<'_>, device_keys: &[DeviceKey]) -> Option<([u8; 16], [u8; 16])> {
|
||||
let (mk_dv, uvs, cvalues) = (&t.mk_dv, t.uvs, t.cvalues);
|
||||
|
||||
// Count UV entries
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for dk in device_keys {
|
||||
let device_number = dk.node as u32;
|
||||
|
||||
// Find applying subset-difference for this device
|
||||
for uvs_idx in 0..num_uvs {
|
||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
|
||||
|
||||
// `num_uvs` used `take_while(.. c[0] & 0xC0 == 0)`, so revoked-marker bits
|
||||
// are already clear (no re-check). But shifts 32..=63 panic/wrap on `<<` and
|
||||
// the byte is disc-controlled, so skip an out-of-range slot rather than shift.
|
||||
if u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// u-mask = shift count of low-order 0 bits ([C] §3.2.5.1.5); v-mask [C] §3.2.3.
|
||||
let u_mask: u32 = 0xFFFF_FFFF << u_mask_shift;
|
||||
let v_mask = calc_v_mask(uv);
|
||||
|
||||
// Subset-difference applies iff (d&mu)==(uv&mu) && (d&mv)!=(uv&mv). [C] §3.2.4.
|
||||
if ((device_number & u_mask) == (uv & u_mask))
|
||||
&& ((device_number & v_mask) != (uv & v_mask))
|
||||
{
|
||||
// Found matching subset-difference — find the right device key.
|
||||
// dk.u_mask_shift is a u8 from keydb with no range check;
|
||||
// guard the shift the same way as the MKB byte above.
|
||||
if dk.u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||
let dev_key_u_mask: u32 = 0xFFFF_FFFF << dk.u_mask_shift;
|
||||
|
||||
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||
// Derive processing key via tree traversal
|
||||
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
|
||||
|
||||
// Validate and derive media key
|
||||
if uvs_idx < cvalues.len() / 16 {
|
||||
let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16];
|
||||
if let Some(mk) =
|
||||
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], mk_dv)
|
||||
{
|
||||
return Some((mk, pk));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Recover the subset-difference position (`node`, `uv`, `u_mask_shift`) of an
|
||||
/// UNPOSITIONED device key by scanning a disc MKB. A device key alone (just the
|
||||
/// 16 bytes) cannot be walked — the walk needs its tree node.
|
||||
///
|
||||
/// On the first verifying candidate it pins `(uv, u_mask_shift)` — invariant for
|
||||
/// the key across all discs — and resolves a gate-passing `node`. Returns a
|
||||
/// [`DeviceKey`] ready to bank and reuse on every future disc via
|
||||
/// [`derive_media_key_from_dk`]. `None` if the key does not apply to this MKB.
|
||||
pub fn recover_dk_position(mkb: &[u8], key: &[u8; 16]) -> Option<DeviceKey> {
|
||||
let t = MkbTables::parse(mkb)?;
|
||||
let (mk_dv, uvs, cvalues) = (t.mk_dv, t.uvs, t.cvalues);
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
let n_cv = cvalues.len() / 16;
|
||||
|
||||
// Hoisted once: the zero-descent Processing Key (device sits exactly at a record)
|
||||
// is `AES-G3(key, 1)`, independent of the record, so every slot's zero-descent
|
||||
// probe reuses this instead of re-deriving it per slot.
|
||||
let pk_zero_descent = aesg3(key, 1);
|
||||
|
||||
// Slots are independent so the scan parallelises (~181k slots, ~26s serial on a
|
||||
// UHD MKB). `find_map_any` returns the first match and cancels the rest; a valid
|
||||
// MKB has exactly one matching subset-difference, so which thread finds it is moot.
|
||||
use rayon::prelude::*;
|
||||
let found = (0..num_uvs.min(n_cv)).into_par_iter().find_map_any(|i| {
|
||||
let u_mask_shift = uvs[5 * i];
|
||||
if u_mask_shift >= 32 {
|
||||
return None;
|
||||
}
|
||||
let p_uv = &uvs[1 + 5 * i..];
|
||||
let uv_r = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv_r == 0 {
|
||||
return None;
|
||||
}
|
||||
let v_mask = calc_v_mask(uv_r);
|
||||
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||
let uv_bytes = &uvs[1 + i * 5..];
|
||||
|
||||
// Zero descent (device sits at this slot's node): cheapest, most common.
|
||||
if validate_processing_key(&pk_zero_descent, cv, uv_bytes, &mk_dv).is_some() {
|
||||
return Some((uv_r, u_mask_shift));
|
||||
}
|
||||
// Descent: device is an ANCESTOR of the slot. Walk the depth bit up from
|
||||
// the slot's lowest set bit; each level descends to the slot's node.
|
||||
let p = uv_r.trailing_zeros();
|
||||
for k in (p + 1)..32 {
|
||||
let uv_d = if k + 1 >= 32 {
|
||||
1u32 << k
|
||||
} else {
|
||||
(uv_r & (0xFFFF_FFFFu32 << (k + 1))) | (1u32 << k)
|
||||
};
|
||||
let pk = calc_pk_from_dk(key, uv_r, v_mask, calc_v_mask(uv_d));
|
||||
if validate_processing_key(&pk, cv, uv_bytes, &mk_dv).is_some() {
|
||||
return Some((uv_d, u_mask_shift));
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
found.and_then(|(uv, mask)| resolve_dk_node(mkb, key, uv, mask))
|
||||
}
|
||||
|
||||
// Resolve a positioned DeviceKey for an orphan `key` at `(uv, u_mask_shift)`:
|
||||
// find a `device_number` (node) that passes the walk's subset-difference
|
||||
// gate. Any gating node yields the same Media Key — a one-time ≤32-try search.
|
||||
pub(crate) fn resolve_dk_node(
|
||||
mkb: &[u8],
|
||||
key: &[u8; 16],
|
||||
uv: u32,
|
||||
u_mask_shift: u8,
|
||||
) -> Option<DeviceKey> {
|
||||
// `u_mask_shift` is disc/keydb-controlled: cap at the 32 real u32 bit positions
|
||||
// so `1u32 << b` can't overflow. Tables are located once, not per candidate node.
|
||||
let tables = MkbTables::parse(mkb);
|
||||
for b in 0..u_mask_shift.min(32) {
|
||||
let dk = DeviceKey {
|
||||
key: *key,
|
||||
node: ((uv ^ (1u32 << b)) & 0xFFFF) as u16,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
};
|
||||
if tables
|
||||
.as_ref()
|
||||
.is_some_and(|t| dk_walk(t, std::slice::from_ref(&dk)).is_some())
|
||||
{
|
||||
return Some(dk);
|
||||
}
|
||||
}
|
||||
// Parsed tables but no node gates: the position is unwalkable, so don't bank it.
|
||||
if tables.is_some() {
|
||||
return None;
|
||||
}
|
||||
// No parseable tables: fall back to the node itself.
|
||||
Some(DeviceKey {
|
||||
key: *key,
|
||||
node: (uv & 0xFFFF) as u16,
|
||||
uv,
|
||||
u_mask_shift,
|
||||
})
|
||||
}
|
||||
|
||||
/// Public, side-effect-free accessors over the MKB record helpers, exposed so
|
||||
/// independent reproduction harnesses can exercise the exact same parser + verify
|
||||
/// primitives the production walk uses.
|
||||
/// These are thin wrappers — no new logic.
|
||||
#[doc(hidden)]
|
||||
pub mod probe {
|
||||
use super::super::crypto::aes_ecb_decrypt;
|
||||
|
||||
/// `mk_dv` from the MKB's Verify-Media-Key record (type 0x81 / 0x86).
|
||||
pub fn mkb_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
super::mkb_find_mk_dv(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Explicit Subset-Difference record (type 0x04).
|
||||
pub fn mkb_subdiff(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_subdiff_records(mkb)
|
||||
}
|
||||
|
||||
/// Body of the MKB's Media-Key-Data (cvalues) record `0x05` (1:1 with the
|
||||
/// `0x04` Explicit Subset-Difference list). `0x07` is an index, never read.
|
||||
pub fn mkb_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
super::mkb_find_cvalues(mkb)
|
||||
}
|
||||
|
||||
/// Body (header stripped) of the first MKB record of `rec_type`. Lets a
|
||||
/// harness pin an exact record type for cross-checking the production
|
||||
/// cvalue selection (e.g. compare record `0x05` vs `0x07` sizes).
|
||||
pub fn mkb_record_body(mkb: &[u8], rec_type: u8) -> Option<Vec<u8>> {
|
||||
super::find_record_body(mkb, rec_type)
|
||||
}
|
||||
|
||||
/// AES-128-ECB single-block decrypt (the AACS verify primitive).
|
||||
pub fn aes_dec(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(key, block)
|
||||
}
|
||||
|
||||
/// Does `km` satisfy the MKB's Verify-Media-Key relation?
|
||||
/// `AES-D(km, mk_dv)[0..8] == 01 23 45 67 89 AB CD EF`.
|
||||
pub fn km_verifies(mkb: &[u8], km: &[u8; 16]) -> bool {
|
||||
match super::mkb_find_mk_dv(mkb) {
|
||||
Some(mk_dv) => aes_ecb_decrypt(km, &mk_dv)[..8] == super::VERIFY_MAGIC,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Volume key: Media Key + Volume ID → VUK → unit keys ──────────────────────
|
||||
|
||||
/// Derive VUK from Media Key and Volume ID. `[PR]` §3.3 / `[BD]` §3.3
|
||||
/// (`Kvu = AES-G(Km, IDv)`; AES-G uses AES-128D):
|
||||
/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
||||
pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] {
|
||||
let mut vuk = aes_ecb_decrypt(media_key, volume_id);
|
||||
for i in 0..16 {
|
||||
vuk[i] ^= volume_id[i];
|
||||
}
|
||||
vuk
|
||||
}
|
||||
|
||||
/// Decrypt an encrypted unit key using the VUK (AES-128-ECB). `[PR]` §3.5
|
||||
/// (Title Key unwrap `Kt = AES-128D(Ku, Kte)`); the BD "CPS Unit Key" synonym is `[BD]` §3.9.3.
|
||||
pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(vuk, encrypted_uk)
|
||||
}
|
||||
|
||||
// Decrypt every encrypted unit key in a parsed Unit_Key_RO.inf with a VUK,
|
||||
// paired with its declared CPS-unit number. The single VUK->unit-keys step
|
||||
// `resolve_candidate` calls.
|
||||
pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
|
||||
///
|
||||
/// Each variant carries the [`super::types`] newtype for that rung (a `Dk` is a
|
||||
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
|
||||
/// [`recover_dk_position`] first).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KeyCandidate {
|
||||
Uk(UnitKey),
|
||||
Vuk(Vuk),
|
||||
Mk(MediaKey),
|
||||
Pk(ProcessingKey),
|
||||
Dk(DeviceKey),
|
||||
}
|
||||
|
||||
/// The AACS key chain derived from a candidate, from [`resolve_candidate`].
|
||||
///
|
||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
|
||||
/// its declared CPS-unit number); the caller runs
|
||||
/// `decrypt_unit` + `is_clean_ts` to find which one actually opens the
|
||||
/// disc. Rungs above the candidate are `None`.
|
||||
#[derive(Clone)]
|
||||
pub struct ResolvedChain {
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub vuk: Option<Vuk>,
|
||||
pub mk: Option<MediaKey>,
|
||||
pub pk: Option<ProcessingKey>,
|
||||
/// The positioned device key (for a `Dk` candidate).
|
||||
pub dk: Option<DeviceKey>,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `unit_keys` holds raw title-key bytes, never printed. The
|
||||
// other rungs are `types` newtypes that self-redact. Guarded by
|
||||
// `resolved_chain_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ResolvedChain {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ResolvedChain")
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.field("vuk", &self.vuk)
|
||||
.field("mk", &self.mk)
|
||||
.field("pk", &self.pk)
|
||||
.field("dk", &self.dk)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
|
||||
///
|
||||
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
|
||||
/// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`,
|
||||
/// `VUK → UKs`, or `UK → itself`, parsing `Unit_Key_RO.inf` at `version` (from
|
||||
/// [`resolve_aacs_version`](crate::aacs::mkb::resolve_aacs_version), the resolver the scan
|
||||
/// uses) so a multi-CPS disc yields all its unit keys at the right stride.
|
||||
///
|
||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Returns `None` only when
|
||||
/// derivation itself cannot proceed.
|
||||
pub fn resolve_candidate(
|
||||
candidate: &KeyCandidate,
|
||||
mkb: &[u8],
|
||||
unit_key_ro: &[u8],
|
||||
vid: Option<Vid>,
|
||||
version: AacsVersion,
|
||||
) -> Option<ResolvedChain> {
|
||||
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number, through
|
||||
// the shared `derive_unit_keys` (the one place a VUK unwraps the unit keys).
|
||||
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
|
||||
// BD/UHD Unit_Key_RO.inf or HD DVD VTKF000.AACS — dispatched by magic.
|
||||
let ukf = parse_title_keys(unit_key_ro, version)?;
|
||||
if ukf.encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(derive_unit_keys(&ukf, &vuk.0))
|
||||
};
|
||||
|
||||
match candidate {
|
||||
KeyCandidate::Uk(uk) => {
|
||||
// `uk.idx` is positional; surface the declared CPS-unit number like the other arms,
|
||||
// falling back to the position if the file does not parse or lacks that slot.
|
||||
let declared = parse_title_keys(unit_key_ro, version)
|
||||
.and_then(|f| f.encrypted_keys.get(uk.idx as usize).map(|k| k.0))
|
||||
.unwrap_or(uk.idx);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: vec![(declared, uk.key)],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Vuk(v) => Some(ResolvedChain {
|
||||
unit_keys: boil(*v)?,
|
||||
vuk: Some(*v),
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Mk(mk) => {
|
||||
let vuk = Vuk(derive_vuk(&mk.0, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(*mk),
|
||||
pk: None,
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Pk(pk) => {
|
||||
let km = derive_media_key_from_pk(mkb, std::slice::from_ref(&pk.0))?;
|
||||
let vuk = Vuk(derive_vuk(&km, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(MediaKey(km)),
|
||||
pk: Some(*pk),
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Dk(dk) => {
|
||||
let (km, pk) = derive_media_key_and_pk_from_dk(mkb, std::slice::from_ref(dk))?;
|
||||
let vuk = Vuk(derive_vuk(&km, &vid?.0));
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(MediaKey(km)),
|
||||
pk: Some(ProcessingKey(pk)),
|
||||
dk: Some(dk.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "derive_resolve_candidate_tests.rs"]
|
||||
mod resolve_candidate_tests;
|
||||
|
||||
// Device-key POSITION recovery and the MKB probe accessors. No published AACS test vectors
|
||||
// exist; the relations (`[C]` §3.2.3-§3.2.5) are invertible, so `plant_mkb` below builds a
|
||||
// valid MKB for a CHOSEN key.
|
||||
#[cfg(test)]
|
||||
#[path = "derive_position_recovery_tests.rs"]
|
||||
mod position_recovery_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "derive_spec_guards_tests.rs"]
|
||||
mod spec_guards;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,531 +0,0 @@
|
||||
use super::*;
|
||||
use crate::aacs::crypto::aes_ecb_encrypt;
|
||||
|
||||
// A Class II MKB is reported as an unsupported class (typed, E7109), not as "no key
|
||||
// matched": derivation stops before reading its tables. A pre-recorded MKB passes.
|
||||
#[test]
|
||||
fn a_class_ii_mkb_is_an_explicit_unsupported_class() {
|
||||
let mkb = |t: u32| {
|
||||
let mut m = vec![0x10, 0x00, 0x00, 0x0C];
|
||||
m.extend_from_slice(&t.to_be_bytes());
|
||||
m.extend_from_slice(&[0, 0, 0, 1]);
|
||||
m
|
||||
};
|
||||
let class2 = mkb(MKB_TYPE_10_CLASS_II);
|
||||
let err = check_mkb_class(&class2).expect_err("class II is unsupported");
|
||||
assert_eq!(err, MkbClassError::Unsupported(MkbType::ClassII));
|
||||
assert_eq!(err.code(), crate::error::E_MKB_CLASS_UNSUPPORTED);
|
||||
assert!(MkbTables::parse(&class2).is_none());
|
||||
assert_eq!(derive_media_key_from_pk(&class2, &[[0u8; 16]]), None);
|
||||
assert_eq!(check_mkb_class(&mkb(MKB_TYPE_4_PRERECORDED)), Ok(()));
|
||||
assert_eq!(check_mkb_class(&mkb(MKB_20_CATEGORY_C)), Ok(()));
|
||||
assert_eq!(check_mkb_class(&[]), Ok(()));
|
||||
}
|
||||
|
||||
// km_verifies gates every candidate Media Key; mutation testing found `-> true` surviving
|
||||
// all 2,556 tests, i.e. unverified verification.
|
||||
#[test]
|
||||
fn km_verifies_accepts_only_the_key_its_record_was_built_for() {
|
||||
use crate::aacs::mkb::mkb_find_mk_dv;
|
||||
|
||||
let km: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF,
|
||||
];
|
||||
|
||||
let mut plain = [0u8; 16];
|
||||
plain[..8].copy_from_slice(&VERIFY_MAGIC);
|
||||
plain[8..].copy_from_slice(&[0xA5; 8]);
|
||||
let mk_dv = aes_ecb_encrypt(&km, &plain);
|
||||
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x81, 0x00, 0x00, 0x18]);
|
||||
mkb.extend_from_slice(&mk_dv);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(
|
||||
mkb_find_mk_dv(&mkb),
|
||||
Some(mk_dv),
|
||||
"fixture malformed — the verify record is not being found at all"
|
||||
);
|
||||
|
||||
assert!(
|
||||
probe::km_verifies(&mkb, &km),
|
||||
"the key the record was built for must verify"
|
||||
);
|
||||
|
||||
// The half that kills the `-> true` mutant. One flipped bit is the
|
||||
// strongest form of wrong key: a near-miss, not a random one.
|
||||
let mut wrong = km;
|
||||
wrong[15] ^= 0x01;
|
||||
assert!(
|
||||
!probe::km_verifies(&mkb, &wrong),
|
||||
"a key differing by ONE BIT must not verify; if it did, the MK-pool \
|
||||
brute force would accept whichever candidate it happened to try first"
|
||||
);
|
||||
|
||||
// No verify record means UNVERIFIABLE, which is not the same as verified.
|
||||
let bare = vec![0x10, 0x00, 0x00, 0x0C, 0, 0, 0, 0, 0, 0, 0, 1];
|
||||
assert!(
|
||||
!probe::km_verifies(&bare, &km),
|
||||
"an MKB with no verify record must not default to yes"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ResolvedChain.unit_keys` holds raw title-key bytes (the other rungs are
|
||||
/// self-redacting `types` newtypes). `Debug` must not leak the title keys.
|
||||
#[test]
|
||||
fn resolved_chain_debug_is_redacted() {
|
||||
let c = ResolvedChain {
|
||||
unit_keys: vec![(1, [0xD5; 16])],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
};
|
||||
let dbg = format!("{c:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"ResolvedChain leaked unit keys: {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("unit_keys_len"),
|
||||
"ResolvedChain missing redaction: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||
let uk_pos = 32usize;
|
||||
let stride = 48usize;
|
||||
let n = encs.len();
|
||||
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
|
||||
let mut inf = vec![0u8; total.max(20)];
|
||||
inf[..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());
|
||||
for (i, k) in encs.iter().enumerate() {
|
||||
let o = uk_pos + 48 + i * stride;
|
||||
inf[o..o + 16].copy_from_slice(k);
|
||||
}
|
||||
inf
|
||||
}
|
||||
|
||||
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
|
||||
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
|
||||
#[test]
|
||||
fn resolve_candidate_vuk_returns_all_cps_units() {
|
||||
let vuk = Vuk([0x33u8; 16]);
|
||||
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
|
||||
let inf = synth_inf(&encs);
|
||||
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None, AacsVersion::V10)
|
||||
.expect("vuk derives");
|
||||
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(
|
||||
cps,
|
||||
vec![1, 2, 3],
|
||||
"every CPS unit surfaced, numbered from the inf"
|
||||
);
|
||||
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
|
||||
assert_eq!(
|
||||
*key,
|
||||
decrypt_unit_key(&vuk.0, enc),
|
||||
"key = VUK-decrypt of its slot"
|
||||
);
|
||||
}
|
||||
assert_eq!(r.vuk, Some(vuk));
|
||||
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
|
||||
}
|
||||
|
||||
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_is_itself() {
|
||||
let uk = UnitKey::new(2, [0x9u8; 16]);
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None, AacsVersion::V10)
|
||||
.expect("uk is terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
assert!(r.vuk.is_none() && r.mk.is_none());
|
||||
}
|
||||
|
||||
/// A UK candidate's positional idx is reported as the declared CPS-unit number.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_reports_the_declared_cps_unit_number() {
|
||||
let inf = synth_inf(&[[0x11u8; 16], [0x22u8; 16]]);
|
||||
let uk = UnitKey::new(1, [0x9u8; 16]);
|
||||
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &inf, None, AacsVersion::V10)
|
||||
.expect("terminal");
|
||||
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
|
||||
}
|
||||
|
||||
/// The declared number comes from the file's own numbering (an HD DVD VTKF with a gap), and
|
||||
/// falls back to the position when the file does not parse or lacks that slot.
|
||||
#[test]
|
||||
fn resolve_candidate_uk_declared_number_follows_the_file_with_positional_fallback() {
|
||||
let mut vtkf = vec![0u8; 0x80 + 64 * 0x24];
|
||||
vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF");
|
||||
// Slots 2 and 5 (1-based) are present; slot 1 is a gap.
|
||||
for slot in [1usize, 4] {
|
||||
vtkf[0x80 + slot * 0x24] = 0x80;
|
||||
}
|
||||
let at = |idx: u32| {
|
||||
let uk = UnitKey::new(idx, [0x9u8; 16]);
|
||||
resolve_candidate(&KeyCandidate::Uk(uk), &[], &vtkf, None, AacsVersion::V10)
|
||||
.expect("terminal")
|
||||
.unit_keys[0]
|
||||
.0
|
||||
};
|
||||
assert_eq!(at(0), 2);
|
||||
assert_eq!(at(1), 5, "the file's number, not idx + 1");
|
||||
assert_eq!(at(7), 7, "no such slot: the position");
|
||||
let uk = UnitKey::new(3, [0x9u8; 16]);
|
||||
let r = resolve_candidate(
|
||||
&KeyCandidate::Uk(uk),
|
||||
&[],
|
||||
&[0u8; 4],
|
||||
None,
|
||||
AacsVersion::V10,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.unit_keys[0].0, 3, "unparseable file: the position");
|
||||
}
|
||||
|
||||
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
|
||||
#[test]
|
||||
fn resolve_candidate_mk_requires_vid() {
|
||||
let r = resolve_candidate(
|
||||
&KeyCandidate::Mk(MediaKey([1u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
AacsVersion::V10,
|
||||
);
|
||||
assert!(r.is_none(), "MK path returns None without a VID");
|
||||
}
|
||||
|
||||
/// A planted Processing Key resolves against a synthetic MKB and drives the
|
||||
/// FULL chain PK → MK → VUK → UK — proving a PK candidate yields real keys.
|
||||
#[test]
|
||||
fn resolve_candidate_pk_drives_full_chain() {
|
||||
let pk: [u8; 16] = [
|
||||
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
||||
0x00,
|
||||
];
|
||||
let mk: [u8; 16] = [
|
||||
0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE,
|
||||
0xAF,
|
||||
];
|
||||
let uv: [u8; 4] = [0x00, 0x00, 0x04, 0x00];
|
||||
|
||||
let mut mk_raw = mk;
|
||||
for a in 0..4 {
|
||||
mk_raw[12 + a] ^= uv[a];
|
||||
}
|
||||
let cv = aes_ecb_encrypt(&pk, &mk_raw);
|
||||
|
||||
let mut vd = [0x11u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = aes_ecb_encrypt(&mk, &vd);
|
||||
|
||||
// 4-byte record header (type + BE24 total length) + body.
|
||||
let rec = |t: u8, body: &[u8]| -> Vec<u8> {
|
||||
let total = 4 + body.len();
|
||||
let mut r = vec![
|
||||
t,
|
||||
((total >> 16) & 0xFF) as u8,
|
||||
((total >> 8) & 0xFF) as u8,
|
||||
(total & 0xFF) as u8,
|
||||
];
|
||||
r.extend_from_slice(body);
|
||||
r
|
||||
};
|
||||
let mut sd = vec![0u8];
|
||||
sd.extend_from_slice(&uv);
|
||||
let mut mkb = Vec::new();
|
||||
mkb.extend_from_slice(&rec(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x52]));
|
||||
mkb.extend_from_slice(&rec(0x86, &mk_dv));
|
||||
mkb.extend_from_slice(&rec(0x04, &sd));
|
||||
mkb.extend_from_slice(&rec(0x05, &cv));
|
||||
|
||||
let vid = Vid([0x42u8; 16]);
|
||||
let plain_uk = [0x7Eu8; 16];
|
||||
let vuk = derive_vuk(&mk, &vid.0);
|
||||
let enc = aes_ecb_encrypt(&vuk, &plain_uk);
|
||||
let inf = synth_inf(std::slice::from_ref(&enc));
|
||||
|
||||
let r = resolve_candidate(
|
||||
&KeyCandidate::Pk(ProcessingKey(pk)),
|
||||
&mkb,
|
||||
&inf,
|
||||
Some(vid),
|
||||
AacsVersion::V10,
|
||||
)
|
||||
.expect("planted PK resolves the full chain");
|
||||
assert_eq!(r.mk, Some(MediaKey(mk)), "PK recovers the planted MK");
|
||||
assert_eq!(r.unit_keys.len(), 1);
|
||||
assert_eq!(
|
||||
r.unit_keys[0].1, plain_uk,
|
||||
"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);
|
||||
assert_eq!(boiled.len(), 4, "every CPS unit boils to a key");
|
||||
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` parses the inf at the version it is given (from the shared
|
||||
/// `resolve_aacs_version`), not at its own guess from the MKB: an unreadable MKB on a
|
||||
/// UHD still reads the second key at +64, and a V10 cert outranks a 2.0 MKB.
|
||||
#[test]
|
||||
fn resolve_candidate_boils_at_the_resolved_version_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 boil = |mkb: &[u8], version| {
|
||||
resolve_candidate(&KeyCandidate::Vuk(vuk), mkb, &inf, None, version)
|
||||
.expect("boils the inf")
|
||||
.unit_keys[1]
|
||||
.1
|
||||
};
|
||||
let at64 = decrypt_unit_key(&vuk.0, &[0x20; 16]);
|
||||
let at48 = decrypt_unit_key(&vuk.0, &[0x10; 16]);
|
||||
// The stride follows the version handed in, never a re-read of the MKB: a UHD whose
|
||||
// MKB is unreadable (empty) still reads key 2 at +64 ...
|
||||
let uhd = resolve_aacs_version(None, &[], Some(true));
|
||||
assert_eq!(boil(&[], uhd), at64);
|
||||
// ... and when the certificate (V10) disagrees with the MKB (2.0), the cert wins.
|
||||
let bd = resolve_aacs_version(Some(1), &v20_mkb, Some(true));
|
||||
assert_eq!(bd, AacsVersion::V10);
|
||||
assert_eq!(boil(&v20_mkb, bd), at48);
|
||||
assert_eq!(
|
||||
boil(&v20_mkb, resolve_aacs_version(None, &v20_mkb, None)),
|
||||
at64
|
||||
);
|
||||
}
|
||||
|
||||
/// 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),
|
||||
AacsVersion::V10,
|
||||
)
|
||||
.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"
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
use super::*;
|
||||
use crate::spec::keys::KS_15_KCU_WRAP;
|
||||
use crate::test_util::unit_key_ro;
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
fn aes_e(key: &[u8; 16], block: &[u8; 16]) -> [u8; 16] {
|
||||
let mut b: Array<u8, _> = (*block).into();
|
||||
Aes128::new(&(*key).into()).encrypt_block(&mut b);
|
||||
b.into()
|
||||
}
|
||||
|
||||
/// per spec; do not change without a spec citation — KS-15 [BD] §3.9.3: "The CPS Unit
|
||||
/// Key is encrypted as follows: AES-128E( Kvu, Kcu )", so Kcu = AES-128D(Kvu, stored).
|
||||
#[test]
|
||||
fn kcu_is_aes128d_of_encrypted_key_under_kvu() {
|
||||
assert!(KS_15_KCU_WRAP.text.ends_with("AES-128E( Kvu, Kcu )"));
|
||||
let kvu: [u8; 16] = core::array::from_fn(|i| 0x30 + i as u8 * 7);
|
||||
let kcu = [[0x5Au8; 16], core::array::from_fn(|i| i as u8)];
|
||||
let wrapped = [aes_e(&kvu, &kcu[0]), aes_e(&kvu, &kcu[1])];
|
||||
let inf = unit_key_ro(AacsVersion::V10, &wrapped, &[1, 2]);
|
||||
let ukf = parse_unit_key_ro(&inf, AacsVersion::V10).unwrap();
|
||||
assert_eq!(derive_unit_keys(&ukf, &kvu), vec![(1, kcu[0]), (2, kcu[1])]);
|
||||
assert_eq!(decrypt_unit_key(&kvu, &wrapped[1]), kcu[1]);
|
||||
assert_ne!(
|
||||
derive_unit_keys(&ukf, &kcu[0])[0].1,
|
||||
kcu[0],
|
||||
"Kvu is the wrapping key"
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
//! HD DVD pack encryption: `[HD]` = AACS "HD DVD and DVD Pre-recorded Book", Final Rev 0.952.
|
||||
//!
|
||||
//! An EVOB is encrypted per 2048-byte pack (`[HD]` §4.3.2): bytes 0..128 are the Unencrypted
|
||||
//! Portion, bytes 128..2048 the Encrypted Portion, CBC-encrypted under the AACS IV with the
|
||||
//! Content Key `Kc = AES-G(Kt, Dtk || CPI_lsb96)`. `Dtk` is pack bytes 84..88 (Table 4-7).
|
||||
//! `Kt` is the Title Key that `TITLE_KEY_PTR` of the EVOBU's CPI names (Table 4-2), and the CPI
|
||||
//! rides in the GCI packet of the NV_PCK that leads every EVOBU. A pack is encrypted when its
|
||||
//! `PES_scrambling_control` is `01` (§4.3.2); an HL_PCK is always encrypted while KEY_VF is
|
||||
//! `01` or `10` (§4.3.2). NV_PCK and ADV_PCK are never encrypted (§4.3.1).
|
||||
|
||||
use super::crypto::{aes_cbc_decrypt, aes_g};
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
|
||||
/// One HD DVD pack: a 2048-byte sector.
|
||||
pub(crate) const PACK_LEN: usize = SECTOR_BYTES;
|
||||
/// `[HD]` §4.3.2: "the first 128 bytes are called the Unencrypted Portion".
|
||||
pub(crate) const CLEAR_LEN: usize = 128;
|
||||
// `[HD]` Table 4-7: Title Key Data (Dtk) is bytes 84..=87 of the pack.
|
||||
const DTK_OFF: usize = 84;
|
||||
const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
|
||||
const SYSTEM_HEADER: u8 = 0xBB;
|
||||
const PRIVATE_STREAM_1: u8 = 0xBD;
|
||||
const PADDING: u8 = 0xBE;
|
||||
const PRIVATE_STREAM_2: u8 = 0xBF;
|
||||
// private_stream_2 sub_stream_ids: the GCI packet of an NV_PCK, and an HL_PCK.
|
||||
const GCI_SUB_STREAM_ID: u8 = 0x04;
|
||||
const HLI_SUB_STREAM_ID: u8 = 0x08;
|
||||
// The CPI is 16 bytes at offset 12 of the GCI data (after its sub_stream_id). The HD DVD-Video
|
||||
// book that fixes it is not public; measured on encrypted discs (pack offset 60 of an NV_PCK).
|
||||
const CPI_IN_GCI: usize = 12;
|
||||
const SCRAMBLE_MASK: u8 = 0x30;
|
||||
|
||||
/// The 16-byte Content Protection Information of an EVOBU (`[HD]` §4.2, Tables 4-1 and 4-2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Cpi(pub(crate) [u8; 16]);
|
||||
|
||||
impl Cpi {
|
||||
/// `KEY_VF` (Table 4-2): `0b10` the Title Key Pointer is valid, `0b01` the Segment Key
|
||||
/// Pointer, `0b00` neither.
|
||||
pub(crate) fn key_vf(&self) -> u8 {
|
||||
self.0[0] >> 6
|
||||
}
|
||||
|
||||
/// `TITLE_KEY_PTR` (Table 4-2): the 1-based Title Key entry of the Title Key File.
|
||||
pub(crate) fn title_key_ptr(&self) -> u32 {
|
||||
u32::from(u16::from_be_bytes([self.0[1], self.0[2]]))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pack is, as far as decryption is concerned.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PackKind {
|
||||
/// An NV_PCK, with the CPI of its GCI packet when one parses.
|
||||
Nav(Option<Cpi>),
|
||||
/// A PES pack whose `PES_scrambling_control` is not `00`.
|
||||
Scrambled,
|
||||
/// An HL_PCK: encrypted whenever its EVOBU's KEY_VF is `01` or `10`.
|
||||
Highlight,
|
||||
/// Anything else: never encrypted.
|
||||
Clear,
|
||||
}
|
||||
|
||||
// Offset of the first packet after an MPEG-2 pack header (with its stuffing), or `None`.
|
||||
fn first_packet(pack: &[u8]) -> Option<usize> {
|
||||
if pack.len() < 14 || pack[..4] != PACK_START || pack[4] & 0xC0 != 0x40 {
|
||||
return None;
|
||||
}
|
||||
Some(14 + usize::from(pack[13] & 0x07))
|
||||
}
|
||||
|
||||
// Does stream id `sid` carry the optional PES header (with PES_scrambling_control)?
|
||||
fn has_pes_flags(sid: u8) -> bool {
|
||||
sid >= PRIVATE_STREAM_1 && !matches!(sid, PADDING | PRIVATE_STREAM_2)
|
||||
}
|
||||
|
||||
/// Classify one pack. A slice shorter than a pack is judged on the bytes it holds.
|
||||
pub(crate) fn classify(pack: &[u8]) -> PackKind {
|
||||
let Some(o) = first_packet(pack) else {
|
||||
return PackKind::Clear;
|
||||
};
|
||||
if pack.len() < o + 7 || pack[o..o + 3] != [0, 0, 1] {
|
||||
return PackKind::Clear;
|
||||
}
|
||||
match pack[o + 3] {
|
||||
SYSTEM_HEADER => PackKind::Nav(nav_cpi(pack, o)),
|
||||
PRIVATE_STREAM_2 if pack[o + 6] == HLI_SUB_STREAM_ID => PackKind::Highlight,
|
||||
sid if has_pes_flags(sid) && pack[o + 6] & 0xC0 == 0x80 => {
|
||||
match pack[o + 6] & SCRAMBLE_MASK {
|
||||
0 => PackKind::Clear,
|
||||
_ => PackKind::Scrambled,
|
||||
}
|
||||
}
|
||||
_ => PackKind::Clear,
|
||||
}
|
||||
}
|
||||
|
||||
// The CPI of the GCI packet in an NV_PCK whose first packet (the system header) is at `o`.
|
||||
fn nav_cpi(pack: &[u8], mut q: usize) -> Option<Cpi> {
|
||||
while q + 7 <= pack.len() && pack[q..q + 3] == [0, 0, 1] {
|
||||
let len = usize::from(u16::from_be_bytes([pack[q + 4], pack[q + 5]]));
|
||||
if pack[q + 3] == PRIVATE_STREAM_2 && pack[q + 6] == GCI_SUB_STREAM_ID {
|
||||
if len < 1 + CPI_IN_GCI + 16 {
|
||||
return None;
|
||||
}
|
||||
let at = q + 7 + CPI_IN_GCI;
|
||||
return Some(Cpi(pack.get(at..at + 16)?.try_into().ok()?));
|
||||
}
|
||||
q += 6 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `pack` must be decrypted under an EVOBU whose CPI is `cpi`.
|
||||
pub(crate) fn needs_key(kind: PackKind, cpi: Option<&Cpi>) -> bool {
|
||||
match kind {
|
||||
PackKind::Scrambled => true,
|
||||
PackKind::Highlight => cpi.is_some_and(|c| matches!(c.key_vf(), 0b01 | 0b10)),
|
||||
PackKind::Nav(_) | PackKind::Clear => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `[HD]` §4.3.2: `Kc = AES-G(Kt, Dtk || CPI_lsb96)`, `Dtk` = pack bytes 84..88.
|
||||
pub(crate) fn content_key(title_key: &[u8; 16], pack: &[u8], cpi: &Cpi) -> [u8; 16] {
|
||||
let mut d = [0u8; 16];
|
||||
d[..4].copy_from_slice(&pack[DTK_OFF..DTK_OFF + 4]);
|
||||
d[4..].copy_from_slice(&cpi.0[4..]);
|
||||
aes_g(title_key, &d)
|
||||
}
|
||||
|
||||
/// Decrypt one whole pack in place (`[HD]` §4.3.4 step 3) and mark a PES pack unscrambled.
|
||||
/// Bytes 0..128 are never touched but for that flag. A short slice is left alone.
|
||||
pub(crate) fn decrypt_pack(pack: &mut [u8], title_key: &[u8; 16], cpi: &Cpi) {
|
||||
if pack.len() != PACK_LEN {
|
||||
return;
|
||||
}
|
||||
let kc = content_key(title_key, pack, cpi);
|
||||
aes_cbc_decrypt(&kc, &mut pack[CLEAR_LEN..]);
|
||||
clear_scrambling(pack);
|
||||
}
|
||||
|
||||
// `[HD]` §4.3.2: "Otherwise, the PES_scrambling_control shall be 00₂": true of the plaintext.
|
||||
fn clear_scrambling(pack: &mut [u8]) {
|
||||
if let Some(o) = first_packet(pack)
|
||||
&& pack.len() >= o + 7
|
||||
&& has_pes_flags(pack[o + 3])
|
||||
&& pack[o + 6] & 0xC0 == 0x80
|
||||
{
|
||||
pack[o + 6] &= !SCRAMBLE_MASK;
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact inverse of [`decrypt_pack`] for test fixtures: sets the scrambling flag of a PES
|
||||
/// pack, then CBC-encrypts bytes 128..2048 (`[HD]` §4.3.2, `Ce = AES-128CBCE(Kc, C)`).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn encrypt_pack(pack: &mut [u8], title_key: &[u8; 16], cpi: &Cpi) {
|
||||
if let Some(o) = first_packet(pack)
|
||||
&& has_pes_flags(pack[o + 3])
|
||||
{
|
||||
pack[o + 6] = (pack[o + 6] & !SCRAMBLE_MASK) | 0x10;
|
||||
}
|
||||
let kc = content_key(title_key, pack, cpi);
|
||||
super::crypto::aes_cbc_encrypt(&kc, &mut pack[CLEAR_LEN..]);
|
||||
}
|
||||
|
||||
/// Structural proof that a pack's bytes past 128 are plaintext, for key proof and verify.
|
||||
///
|
||||
/// `Some(false)` when a test that random bytes pass about once in 2^16 fails: a PES or padding
|
||||
/// start code where the chain of packet lengths puts one at or past byte 128, or the AC-3 /
|
||||
/// E-AC-3 sync word where a `private_stream_1` first-access-unit pointer lands at or past it.
|
||||
/// `Some(true)` when such a test ran and passed, `None` when none applied (most video packs,
|
||||
/// whose one PES fills the pack).
|
||||
pub(crate) fn payload_check(pack: &[u8]) -> Option<bool> {
|
||||
if pack.len() != PACK_LEN {
|
||||
return None;
|
||||
}
|
||||
let mut q = first_packet(pack)?;
|
||||
let mut tested = false;
|
||||
while q + 6 <= PACK_LEN {
|
||||
if pack[q..q + 3] != [0, 0, 1] {
|
||||
// A broken chain in the clear head proves nothing about the key.
|
||||
return (q >= CLEAR_LEN).then_some(false).or(tested.then_some(true));
|
||||
}
|
||||
tested |= q >= CLEAR_LEN;
|
||||
let len = usize::from(u16::from_be_bytes([pack[q + 4], pack[q + 5]]));
|
||||
if pack[q + 3] == PRIVATE_STREAM_1 {
|
||||
match ac3_sync_check(pack, q, len) {
|
||||
Some(false) => return Some(false),
|
||||
Some(true) => tested = true,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
q += 6 + len;
|
||||
}
|
||||
tested.then_some(true)
|
||||
}
|
||||
|
||||
// The AC-3 / E-AC-3 sync word at the first access unit of a private_stream_1 packet at `q`.
|
||||
// Sub-stream ids 0x80-0x87 (AC-3) and 0xC0-0xC7 (E-AC-3); the pointer counts from its own
|
||||
// last byte, as on DVD.
|
||||
fn ac3_sync_check(pack: &[u8], q: usize, len: usize) -> Option<bool> {
|
||||
if pack.get(q + 6)? & 0xC0 != 0x80 {
|
||||
return None;
|
||||
}
|
||||
let hd = q + 9 + usize::from(*pack.get(q + 8)?);
|
||||
let sub = *pack.get(hd)?;
|
||||
if !matches!(sub, 0x80..=0x87 | 0xC0..=0xC7) || hd + 4 > PACK_LEN {
|
||||
return None;
|
||||
}
|
||||
let ptr = usize::from(u16::from_be_bytes([pack[hd + 2], pack[hd + 3]]));
|
||||
let at = hd + 3 + ptr;
|
||||
let end = (q + 6 + len).min(PACK_LEN);
|
||||
(ptr != 0 && at >= CLEAR_LEN && at + 2 <= end).then(|| pack[at..at + 2] == [0x0B, 0x77])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hddvd_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
@@ -1,176 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// A pack header with no stuffing, SCR and mux rate zero.
|
||||
fn pack_header() -> Vec<u8> {
|
||||
let mut v = PACK_START.to_vec();
|
||||
v.extend_from_slice(&[0x44, 0, 0x04, 0, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8]);
|
||||
v
|
||||
}
|
||||
|
||||
// A PES packet with an empty optional header, `payload` bytes long.
|
||||
fn pes(sid: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![0, 0, 1, sid];
|
||||
v.extend_from_slice(&((payload.len() + 3) as u16).to_be_bytes());
|
||||
v.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
// A padding packet filling the pack to 2048 bytes.
|
||||
fn pad_to_pack(mut v: Vec<u8>) -> Vec<u8> {
|
||||
let left = PACK_LEN - v.len() - 6;
|
||||
v.extend_from_slice(&[0, 0, 1, PADDING]);
|
||||
v.extend_from_slice(&(left as u16).to_be_bytes());
|
||||
v.resize(PACK_LEN, 0xFF);
|
||||
v
|
||||
}
|
||||
|
||||
/// A synthetic CPI: KEY_VF `10`, `TITLE_KEY_PTR` = `ptr`, CH_PTR = `ch`.
|
||||
pub(crate) fn cpi(ptr: u16, ch: u32) -> Cpi {
|
||||
let mut c = [0u8; 16];
|
||||
c[0] = 0x80;
|
||||
c[1..3].copy_from_slice(&ptr.to_be_bytes());
|
||||
c[4..8].copy_from_slice(&ch.to_be_bytes());
|
||||
c[8] = 0x80;
|
||||
Cpi(c)
|
||||
}
|
||||
|
||||
/// An NV_PCK: system header, then a GCI packet carrying `cpi` at GCI data offset 12.
|
||||
pub(crate) fn nav_pack(cpi: &Cpi) -> Vec<u8> {
|
||||
let mut v = pack_header();
|
||||
v.extend_from_slice(&[0, 0, 1, SYSTEM_HEADER, 0, 21]);
|
||||
v.extend_from_slice(&[0x80; 21]);
|
||||
let mut gci = vec![0, 0, 1, PRIVATE_STREAM_2, 0x01, 0x01, GCI_SUB_STREAM_ID];
|
||||
gci.extend_from_slice(&[0x11; CPI_IN_GCI]);
|
||||
gci.extend_from_slice(&cpi.0);
|
||||
gci.resize(6 + 0x101, 0);
|
||||
v.extend_from_slice(&gci);
|
||||
pad_to_pack(v)
|
||||
}
|
||||
|
||||
/// A plaintext E-AC-3 audio pack: its first access unit (sync `0B 77`) at byte 400, then
|
||||
/// padding from byte 1800. `seed` varies the Dtk bytes and the payload.
|
||||
pub(crate) fn audio_pack(seed: u8) -> Vec<u8> {
|
||||
let mut v = pack_header();
|
||||
let hd_len = 1800 - v.len() - 6 - 3;
|
||||
let mut body: Vec<u8> = (0..hd_len).map(|i| (i as u8).wrapping_mul(seed)).collect();
|
||||
body[0] = 0xC0;
|
||||
body[1] = 1;
|
||||
// The pointer counts from its own last byte (body[3], pack byte 26).
|
||||
let ptr = 400 - 26;
|
||||
body[2..4].copy_from_slice(&(ptr as u16).to_be_bytes());
|
||||
body[400 - 23..400 - 21].copy_from_slice(&[0x0B, 0x77]);
|
||||
v.extend_from_slice(&pes(PRIVATE_STREAM_1, &body));
|
||||
pad_to_pack(v)
|
||||
}
|
||||
|
||||
/// A plaintext video pack whose one PES fills the pack: no structural test applies.
|
||||
pub(crate) fn video_pack(seed: u8) -> Vec<u8> {
|
||||
let mut v = pack_header();
|
||||
let n = PACK_LEN - v.len() - 9;
|
||||
let body: Vec<u8> = (0..n).map(|i| (i as u8) ^ seed).collect();
|
||||
v.extend_from_slice(&pes(0xE0, &body));
|
||||
v
|
||||
}
|
||||
|
||||
fn hl_pack() -> Vec<u8> {
|
||||
let mut v = pack_header();
|
||||
v.extend_from_slice(&[0, 0, 1, PRIVATE_STREAM_2, 0x07, 0xEC, HLI_SUB_STREAM_ID]);
|
||||
v.resize(PACK_LEN, 0x5A);
|
||||
v
|
||||
}
|
||||
|
||||
const KT: [u8; 16] = [0x42; 16];
|
||||
|
||||
#[test]
|
||||
fn nav_pack_yields_its_cpi_and_fields() {
|
||||
let c = cpi(17, 9);
|
||||
assert_eq!(classify(&nav_pack(&c)), PackKind::Nav(Some(c)));
|
||||
assert_eq!(c.key_vf(), 0b10);
|
||||
assert_eq!(c.title_key_ptr(), 17);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_key_is_aes_g_of_dtk_and_cpi_lsb96() {
|
||||
let c = cpi(1, 0x0102_0304);
|
||||
let pack = audio_pack(3);
|
||||
let mut d = [0u8; 16];
|
||||
d[..4].copy_from_slice(&pack[84..88]);
|
||||
d[4..].copy_from_slice(&c.0[4..16]);
|
||||
assert_eq!(content_key(&KT, &pack, &c), aes_g(&KT, &d));
|
||||
// CPI msb bytes (KMI) are not key input; Dtk and CH_PTR are.
|
||||
let mut other = c;
|
||||
other.0[3] ^= 0xFF;
|
||||
assert_eq!(content_key(&KT, &pack, &other), aes_g(&KT, &d));
|
||||
assert_ne!(content_key(&KT, &pack, &cpi(1, 5)), aes_g(&KT, &d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_keeps_the_clear_head_and_decrypt_restores_the_pack() {
|
||||
let c = cpi(3, 77);
|
||||
let plain = audio_pack(5);
|
||||
let mut p = plain.clone();
|
||||
encrypt_pack(&mut p, &KT, &c);
|
||||
assert_eq!(classify(&p), PackKind::Scrambled);
|
||||
assert_eq!(p[..20], plain[..20]);
|
||||
assert_eq!(p[21..CLEAR_LEN], plain[21..CLEAR_LEN]);
|
||||
assert_ne!(p[CLEAR_LEN..], plain[CLEAR_LEN..]);
|
||||
assert_eq!(p[..4], PACK_START, "an encrypted pack keeps its start code");
|
||||
decrypt_pack(&mut p, &KT, &c);
|
||||
assert_eq!(p, plain);
|
||||
assert_eq!(classify(&p), PackKind::Clear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shifted_dtk_or_wrong_key_does_not_open_the_pack() {
|
||||
let c = cpi(3, 77);
|
||||
let mut p = audio_pack(5);
|
||||
encrypt_pack(&mut p, &KT, &c);
|
||||
let mut wrong = p.clone();
|
||||
decrypt_pack(&mut wrong, &[0x43; 16], &c);
|
||||
assert_eq!(payload_check(&wrong), Some(false));
|
||||
// Dtk one byte early (83..87) is the wrong key input.
|
||||
let mut shifted = p.clone();
|
||||
let mut d = [0u8; 16];
|
||||
d[..4].copy_from_slice(&shifted[83..87]);
|
||||
d[4..].copy_from_slice(&c.0[4..]);
|
||||
aes_cbc_decrypt(&aes_g(&KT, &d), &mut shifted[CLEAR_LEN..]);
|
||||
assert_eq!(payload_check(&shifted), Some(false));
|
||||
decrypt_pack(&mut p, &KT, &c);
|
||||
assert_eq!(payload_check(&p), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_check_judges_only_bytes_past_the_clear_head() {
|
||||
assert_eq!(payload_check(&audio_pack(9)), Some(true));
|
||||
assert_eq!(payload_check(&video_pack(9)), None);
|
||||
let mut bad = audio_pack(9);
|
||||
bad[400] ^= 1;
|
||||
assert_eq!(payload_check(&bad), Some(false));
|
||||
let mut bad_pad = audio_pack(9);
|
||||
bad_pad[1800] = 0x47;
|
||||
assert_eq!(payload_check(&bad_pad), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrambling_is_read_per_pack_from_the_pes_header() {
|
||||
let mut v = video_pack(1);
|
||||
assert_eq!(classify(&v), PackKind::Clear);
|
||||
v[20] |= 0x10;
|
||||
assert_eq!(classify(&v), PackKind::Scrambled);
|
||||
// A system header's byte 20 is rate_bound, never a scrambling flag.
|
||||
let mut nav = nav_pack(&cpi(1, 1));
|
||||
nav[20] = 0xB2;
|
||||
assert!(matches!(classify(&nav), PackKind::Nav(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_packs_need_a_key_only_under_a_valid_key_pointer() {
|
||||
let hl = hl_pack();
|
||||
assert_eq!(classify(&hl), PackKind::Highlight);
|
||||
assert!(needs_key(classify(&hl), Some(&cpi(1, 1))));
|
||||
let mut none = cpi(1, 1);
|
||||
none.0[0] = 0;
|
||||
assert!(!needs_key(classify(&hl), Some(&none)));
|
||||
assert!(!needs_key(classify(&hl), None));
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//! Host-certificate collection — the one libfreemkv-side concern left from the
|
||||
//! old in-tree AACS handshake. The cert mutual-auth itself now lives in the
|
||||
//! `freemkv-unlock` AACS unlocker; libfreemkv only gathers the certs (a
|
||||
//! keysource concern) and hands them across the seam.
|
||||
|
||||
/// Union the host certificates a scan can offer the drive: the explicit
|
||||
/// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are
|
||||
/// keysource-served, never compiled in. The scan passes `None` for `mkb`: the
|
||||
/// argument is reserved and will be removed at the next breaking window.
|
||||
pub fn collect_host_certs(
|
||||
opts: &crate::disc::ScanOptions,
|
||||
mkb: Option<u32>,
|
||||
) -> Vec<crate::aacs::types::HostCert> {
|
||||
let mut host_certs: Vec<crate::aacs::types::HostCert> = Vec::new();
|
||||
if let Some(c) = &opts.credentials {
|
||||
host_certs.extend(c.host_certs.iter().cloned());
|
||||
}
|
||||
for src in &opts.key_sources {
|
||||
host_certs.extend(src.host_certs(mkb));
|
||||
}
|
||||
host_certs
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
|
||||
//!
|
||||
//! A 2.1 disc resolves to exactly one forensic index (1..=32); `IndividualSegment.tbl`
|
||||
//! tags each segment with an index (see [`super::segment`]). Segments matching our
|
||||
//! index are kept, others dropped; everything outside a segment is ordinary (index-0)
|
||||
//! content. This module owns that classification only — no I/O, no keys, no cipher.
|
||||
//! Standalone helper: the live FMTS path is `keys::fmts` + `mux::resolve`, not this module.
|
||||
|
||||
use super::segment::{Segment, segment_for_unit};
|
||||
use super::types::UnitKey;
|
||||
|
||||
/// What the decode should do with one AACS aligned unit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnitDisposition {
|
||||
/// Outside every forensic segment: ordinary content, decrypt with the
|
||||
/// default (index-0) unit key.
|
||||
Default,
|
||||
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
|
||||
/// that index's key.
|
||||
Index(u8),
|
||||
/// Inside a forensic segment tagged with a DIFFERENT index: not our
|
||||
/// watermark, so it is not part of our output — drop it.
|
||||
DropForeignIndex(u8),
|
||||
/// Inside a forensic segment but no index key is held (the disc's index
|
||||
/// was never resolved): the segment cannot be decoded, so it is concealed
|
||||
/// as loss. Carries the segment's index for diagnostics.
|
||||
ForensicNoKey(u8),
|
||||
}
|
||||
|
||||
/// Resolve the disc's single forensic index from the keys we hold.
|
||||
///
|
||||
/// Scans for an index key (`index_number` in `1..=32`) and returns its
|
||||
/// index. `None` when only default (index-0) keys are held — i.e. no
|
||||
/// index source answered, so forensic segments are not decodable. A disc has
|
||||
/// exactly one index, so the first non-zero key decides; if several distinct
|
||||
/// index keys were somehow supplied the lowest wins (deterministic), which is
|
||||
/// only a defensive tiebreak — the probe/derivation yields one.
|
||||
pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option<u8> {
|
||||
unit_keys
|
||||
.iter()
|
||||
.map(|k| k.index_number)
|
||||
.filter(|&v| v != 0)
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
|
||||
/// the forensic segment map and the disc's resolved index (`None` if no
|
||||
/// index key is held).
|
||||
pub fn unit_disposition(
|
||||
unit_offset: u64,
|
||||
segments: &[Segment],
|
||||
disc_index: Option<u8>,
|
||||
) -> UnitDisposition {
|
||||
match segment_for_unit(segments, unit_offset) {
|
||||
// Not in any forensic segment → ordinary content.
|
||||
None => UnitDisposition::Default,
|
||||
// In a forensic segment → decide by whether it is our index.
|
||||
Some(seg) => {
|
||||
// `seg.index` is untrusted (real range 1..=32); compare in u16 space so a
|
||||
// crafted index above 255 can't truncate into a valid u8 and alias ours.
|
||||
// Diagnostics carry a saturated u8 since out-of-range is never ours.
|
||||
let seg_index = seg.index;
|
||||
let diag = seg_index.min(u8::MAX as u16) as u8;
|
||||
match disc_index {
|
||||
Some(v) if u16::from(v) == seg_index => UnitDisposition::Index(v),
|
||||
Some(_) => UnitDisposition::DropForeignIndex(diag),
|
||||
None => UnitDisposition::ForensicNoKey(diag),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "index_select_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,113 +0,0 @@
|
||||
use super::*;
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
|
||||
|
||||
/// Build a one-record segment table (index, start_spn, end_spn).
|
||||
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
|
||||
v.extend_from_slice(&16u16.to_be_bytes());
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes());
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
parse_individual_segments(&v).expect("parse")
|
||||
}
|
||||
|
||||
fn uk(idx: u32, index: u8) -> UnitKey {
|
||||
if index == 0 {
|
||||
UnitKey::new(idx, [0u8; 16])
|
||||
} else {
|
||||
UnitKey::forensic(idx, [index; 16], index)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_picks_the_single_index_key() {
|
||||
// Default keys only → no index resolved.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
|
||||
assert_eq!(resolve_disc_index(&[]), None);
|
||||
// One index key among defaults → that index.
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
|
||||
// Defensive: lowest of several distinct indexes (deterministic).
|
||||
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_segments_is_default() {
|
||||
let segs = tbl(&[(1, 343680, 346239)]);
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
// With no segments at all (1.0 / 2.0), everything is Default.
|
||||
assert_eq!(
|
||||
unit_disposition(off, &[], Some(1)),
|
||||
UnitDisposition::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_our_index_decrypts() {
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(7)),
|
||||
UnitDisposition::Index(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_in_foreign_index_drops() {
|
||||
// Segment tagged index 7, but our disc index is 3 → drop it.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(3)),
|
||||
UnitDisposition::DropForeignIndex(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forensic_unit_with_no_key_is_concealed() {
|
||||
// A forensic segment but we never resolved an index → conceal as loss.
|
||||
let segs = tbl(&[(7, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, None),
|
||||
UnitDisposition::ForensicNoKey(7)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_index_does_not_truncate_into_ours() {
|
||||
// Index 288 (0x0120) truncates to 32 in a u8, which would alias our
|
||||
// resolved index 32 under the old `as u8` compare; u16 compare must
|
||||
// classify it as foreign instead.
|
||||
let segs = tbl(&[(288, 100, 200)]);
|
||||
let off = 120u64 * SOURCE_PACKET_LEN;
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(32)),
|
||||
UnitDisposition::DropForeignIndex(255)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straddling_unit_still_classified_as_its_segment() {
|
||||
// A unit whose 32-packet span only tails into the segment still routes
|
||||
// to the segment (matches segment_for_unit's span test).
|
||||
let segs = tbl(&[(5, 100, 200)]);
|
||||
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
|
||||
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
assert!(80 + unit_packets > 100, "sanity: unit tails into seg");
|
||||
assert_eq!(
|
||||
unit_disposition(off, &segs, Some(5)),
|
||||
UnitDisposition::Index(5)
|
||||
);
|
||||
}
|
||||
-412
@@ -1,412 +0,0 @@
|
||||
//! AACS on-disc key-input files: `Unit_Key_RO.inf` parsing, the disc-hash
|
||||
//! keydb lookup key, the Content Certificate, and the in-drive MKB read.
|
||||
//! These turn raw disc files into the structures the key paths consume.
|
||||
|
||||
use super::mkb::*;
|
||||
|
||||
/// Parsed Unit_Key_RO.inf file.
|
||||
pub struct UnitKeyFile {
|
||||
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
|
||||
pub disc_hash: [u8; 20],
|
||||
/// Application type (1 = BD-ROM)
|
||||
pub app_type: u8,
|
||||
/// Number of BDMV directories
|
||||
pub num_bdmv_dir: u8,
|
||||
/// Whether SKB MKB is used
|
||||
pub use_skb_mkb: bool,
|
||||
/// AACS generation this file's stride matches
|
||||
pub version: AacsVersion,
|
||||
/// Encrypted unit keys (CPS unit number, encrypted key)
|
||||
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
||||
pub title_cps_unit: Vec<u16>,
|
||||
}
|
||||
|
||||
// Hand-written Debug that redacts the ENCRYPTED CPS unit keys a derive would print verbatim.
|
||||
impl std::fmt::Debug for UnitKeyFile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UnitKeyFile")
|
||||
// Disc hash is the public keydb lookup key, printed as hex.
|
||||
.field("disc_hash", &disc_hash_hex(&self.disc_hash))
|
||||
.field("app_type", &self.app_type)
|
||||
.field("num_bdmv_dir", &self.num_bdmv_dir)
|
||||
.field("use_skb_mkb", &self.use_skb_mkb)
|
||||
.field("version", &self.version)
|
||||
.field("encrypted_keys", &"<redacted>")
|
||||
.field("encrypted_keys_len", &self.encrypted_keys.len())
|
||||
.field("title_cps_unit", &self.title_cps_unit)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
|
||||
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
|
||||
use sha1::{Digest, Sha1};
|
||||
let hash = Sha1::digest(data);
|
||||
let mut out = [0u8; 20];
|
||||
out.copy_from_slice(&hash);
|
||||
out
|
||||
}
|
||||
|
||||
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
|
||||
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||
let mut s = String::with_capacity(42);
|
||||
s.push_str("0x");
|
||||
for b in hash {
|
||||
s.push_str(&format!("{b:02X}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse Unit_Key_RO.inf from raw bytes.
|
||||
///
|
||||
/// Format (from AACS spec):
|
||||
/// ```text
|
||||
/// [0..4] BE32: offset to key storage area (uk_pos)
|
||||
/// [16] app_type (1 = BD-ROM)
|
||||
/// [17] num_bdmv_dir
|
||||
/// [18] bit 7: use_skb_mkb
|
||||
/// [20..22] BE16: first_play CPS unit
|
||||
/// [22..24] BE16: top_menu CPS unit
|
||||
/// [24..26] BE16: num_titles
|
||||
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
|
||||
///
|
||||
/// Key storage at uk_pos:
|
||||
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
||||
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||
/// AACS 1.0: 48-byte stride
|
||||
/// AACS 2.0 / 2.1: 64-byte stride (48 + 16 extra)
|
||||
/// ```
|
||||
pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = disc_hash(data);
|
||||
|
||||
// Header
|
||||
let app_type = data[16];
|
||||
let num_bdmv_dir = data[17];
|
||||
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
|
||||
|
||||
// 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() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Number of unit keys
|
||||
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
|
||||
if num_uk == 0 {
|
||||
return Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys: Vec::new(),
|
||||
title_cps_unit: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
// Stride between keys
|
||||
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() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract encrypted keys (capacity clamped: num_uk is untrusted)
|
||||
let mut encrypted_keys = Vec::with_capacity(num_uk.min(data.len() / stride.max(1)));
|
||||
let mut pos = keys_start;
|
||||
for i in 0..num_uk {
|
||||
if pos + 16 > data.len() {
|
||||
break;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&data[pos..pos + 16]);
|
||||
encrypted_keys.push(((i + 1) as u32, key));
|
||||
pos += stride;
|
||||
}
|
||||
|
||||
// The loop `break`s if the buffer runs out mid-key; a short list means the .inf
|
||||
// is malformed/truncated — reject rather than silently mapping title CPS units
|
||||
// to fewer keys than the header declared.
|
||||
if encrypted_keys.len() != num_uk {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Title → CPS unit mapping (AACS Unit_Key_RO format): validates the on-disc
|
||||
// CPS value is in `1..=num_uk` (else zeroes it) and converts it from 1-based
|
||||
// to a safe, ready-to-use 0-based key index.
|
||||
let to_key_idx = |cps: u16| -> u16 {
|
||||
if cps >= 1 && cps as usize <= num_uk {
|
||||
cps - 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut title_cps_unit = Vec::new();
|
||||
if data.len() >= 26 {
|
||||
let first_play = u16::from_be_bytes([data[20], data[21]]);
|
||||
let top_menu = u16::from_be_bytes([data[22], data[23]]);
|
||||
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
|
||||
|
||||
title_cps_unit.push(to_key_idx(first_play));
|
||||
title_cps_unit.push(to_key_idx(top_menu));
|
||||
|
||||
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;
|
||||
}
|
||||
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||
title_cps_unit.push(to_key_idx(cps));
|
||||
}
|
||||
}
|
||||
|
||||
Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type,
|
||||
num_bdmv_dir,
|
||||
use_skb_mkb,
|
||||
version,
|
||||
encrypted_keys,
|
||||
title_cps_unit,
|
||||
})
|
||||
}
|
||||
|
||||
/// HD DVD Video Title Key File (`VTKF%%%.AACS`) magic — "DVD_HD_V_TKF".
|
||||
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
|
||||
/// Fixed header length before the first Title Key Entry (AACS HD DVD Book,
|
||||
/// Table 3-8).
|
||||
const VTKF_HEADER_LEN: usize = 0x80;
|
||||
/// Title Key Entry stride (Table 3-8): 1-byte `BIFO` + 3 reserved + 16-byte
|
||||
/// encrypted title key + 16-byte binding MAC = 36 bytes.
|
||||
const VTKF_ENTRY_LEN: usize = 0x24;
|
||||
/// Byte offset of the encrypted title key within an entry (after `BIFO` + 3
|
||||
/// reserved).
|
||||
const VTKF_KEY_OFF: usize = 4;
|
||||
/// Number of Title Key Entry slots in a VTKF (Table 3-8): a fixed 64.
|
||||
const VTKF_MAX_ENTRIES: usize = 64;
|
||||
/// `BIFO` bit 7 (`AV_FLG`): set = this slot carries an available title key.
|
||||
const VTKF_AV_FLG: u8 = 0x80;
|
||||
|
||||
/// Parse an HD DVD `VTKF%%%.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
|
||||
/// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` →
|
||||
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change.
|
||||
///
|
||||
/// Layout — AACS "HD DVD and DVD Pre-recorded Book" Table 3-8, a fixed
|
||||
/// 2480-byte file:
|
||||
/// ```text
|
||||
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
|
||||
/// [0x0C..0x10] BE32 HD_VTKF_SIZE (2480)
|
||||
/// [0x10..0x1C] associated playlist name ("VPLST%%%.XPL")
|
||||
/// [0x1C..0x80] reserved
|
||||
/// [0x80..] 64 entries × 36 bytes:
|
||||
/// BIFO (1) | reserved (3) | ENCRYPTED title key (16) | binding MAC (16)
|
||||
/// BIFO bit 7 (AV_FLG) set = this slot holds a title key
|
||||
/// (pre-recorded discs fill the binding MAC with 0xFF)
|
||||
/// [0x9A0..2480] 16-byte TKF MAC (CMAC keyed by Kvu — NOT a key)
|
||||
/// ```
|
||||
/// An absent slot is SKIPPED (not a terminator): the 1-based slot index IS
|
||||
/// the CPS unit number, so `title_cps_unit` is left empty (playlist-owned).
|
||||
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
|
||||
// A file cut inside the entry table would silently lose its later slots' keys.
|
||||
if data.len() < VTKF_HEADER_LEN + VTKF_MAX_ENTRIES * VTKF_ENTRY_LEN || &data[..12] != VTKF_MAGIC
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// SHA1 of the WHOLE file — the KEYDB lookup key. BackupHDDVD-family key
|
||||
// databases index an HD DVD disc by SHA1(VTKF000.AACS), the same role the
|
||||
// BD disc_hash plays for `Unit_Key_RO.inf`.
|
||||
let hash = disc_hash(data);
|
||||
|
||||
let mut encrypted_keys = Vec::new();
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
let pos = VTKF_HEADER_LEN + n * VTKF_ENTRY_LEN;
|
||||
// AV_FLG clear = empty slot: skip it, but keep the slot index as the CPS
|
||||
// number (do NOT break — a gap must not renumber the keys that follow).
|
||||
if data[pos] & VTKF_AV_FLG == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(&data[pos + VTKF_KEY_OFF..pos + VTKF_KEY_OFF + 16]);
|
||||
encrypted_keys.push((n as u32 + 1, key));
|
||||
}
|
||||
if encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(UnitKeyFile {
|
||||
disc_hash: hash,
|
||||
app_type: 0, // HD DVD VTKF carries no BD-ROM app_type
|
||||
num_bdmv_dir: 0, // BD-only concept
|
||||
use_skb_mkb: false,
|
||||
version: AacsVersion::V10, // HD DVD is always AACS 1.0
|
||||
encrypted_keys,
|
||||
title_cps_unit: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a disc's title-key file, dispatching on the self-describing magic:
|
||||
/// an HD DVD `VTKF000.AACS` (`DVD_HD_V_TKF`) → [`parse_vtkf`]; anything else is a
|
||||
/// BD/UHD `Unit_Key_RO.inf` → [`parse_unit_key_ro`]. Both return the same
|
||||
/// [`UnitKeyFile`], so every downstream AACS derivation stays container-agnostic
|
||||
/// — the single seam where BD-vs-HD-DVD key layout is resolved (mirrors the key
|
||||
/// service, which classifies HD DVD by the very same magic).
|
||||
pub fn parse_title_keys(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
|
||||
if data.len() >= 12 && &data[..12] == VTKF_MAGIC {
|
||||
parse_vtkf(data)
|
||||
} else {
|
||||
parse_unit_key_ro(data, version)
|
||||
}
|
||||
}
|
||||
|
||||
/// MKB disc structure format code.
|
||||
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||
|
||||
/// MKB pack buffer size.
|
||||
const MKB_PACK_SIZE: usize = 32772;
|
||||
|
||||
/// Read one MKB pack: returns (declared pack count, payload). A short transfer or a
|
||||
/// payload past the buffer is a drive fault, never a holed MKB.
|
||||
fn read_mkb_pack(
|
||||
session: &mut dyn crate::scsi::ScsiTransport,
|
||||
pack: u32,
|
||||
) -> crate::error::Result<(usize, Option<Vec<u8>>)> {
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||
|
||||
let mut cdb = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
MKB_DISC_STRUCTURE_FORMAT,
|
||||
(MKB_PACK_SIZE >> 8) as u8,
|
||||
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
// Pack number goes in address field
|
||||
cdb[2..6].copy_from_slice(&pack.to_be_bytes());
|
||||
|
||||
let mut buf = vec![0u8; MKB_PACK_SIZE];
|
||||
// Transport errors propagate: swallowing one would return a truncated MKB as Ok.
|
||||
let r = session.execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
||||
|
||||
// A GOOD status with no header transferred is a drive fault, not an empty MKB.
|
||||
if r.bytes_transferred < 4 {
|
||||
return Err(crate::error::Error::AacsKeyRead);
|
||||
}
|
||||
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||
let num_packs = buf[3] as usize;
|
||||
if data_len < 2 {
|
||||
return Ok((num_packs, None));
|
||||
}
|
||||
let len = data_len - 2;
|
||||
if len > MKB_PACK_SIZE - 4 || r.bytes_transferred < 4 + len {
|
||||
return Err(crate::error::Error::AacsKeyRead);
|
||||
}
|
||||
Ok((num_packs, Some(buf[4..4 + len].to_vec())))
|
||||
}
|
||||
|
||||
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||
/// Returns the concatenated MKB data from all packs. A short or over-long pack is a
|
||||
/// drive fault (`AacsKeyRead`), never a holed MKB; a header-only pack in a multi-pack MKB is
|
||||
/// `AacsKeyRead`; a single header-only pack is an empty MKB.
|
||||
pub fn read_mkb_from_drive(
|
||||
session: &mut dyn crate::scsi::ScsiTransport,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
let (num_packs, first) = read_mkb_pack(session, 0)?;
|
||||
let Some(mut mkb) = first else {
|
||||
// A declared multi-pack MKB whose first pack carries no data is a hole.
|
||||
if num_packs > 1 {
|
||||
return Err(crate::error::Error::AacsKeyRead);
|
||||
}
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
// A header-only pack in a multi-pack MKB is a hole, not an empty MKB.
|
||||
if num_packs > 1 && mkb.is_empty() {
|
||||
return Err(crate::error::Error::AacsKeyRead);
|
||||
}
|
||||
|
||||
// Read remaining packs
|
||||
for pack in 1..num_packs {
|
||||
let (_, body) = read_mkb_pack(session, pack as u32)?;
|
||||
let body = body.ok_or(crate::error::Error::AacsKeyRead)?;
|
||||
if body.is_empty() {
|
||||
return Err(crate::error::Error::AacsKeyRead);
|
||||
}
|
||||
mkb.extend_from_slice(&body);
|
||||
}
|
||||
|
||||
Ok(mkb)
|
||||
}
|
||||
|
||||
/// AACS Content Certificate — identifies disc AACS version and features.
|
||||
#[derive(Debug)]
|
||||
pub struct ContentCert {
|
||||
/// Bus encryption enabled flag
|
||||
pub bus_encryption: bool,
|
||||
/// Content Certificate ID (6 bytes)
|
||||
pub cc_id: [u8; 6],
|
||||
/// AACS generation indicated by the certificate type byte.
|
||||
///
|
||||
/// Cert type `0x00` → [`AacsVersion::V10`], `0x10` → [`AacsVersion::V20`];
|
||||
/// any other type does not parse. The certificate alone cannot distinguish
|
||||
/// V20 from V21 — Variant detection happens after the MKB walk.
|
||||
pub version: AacsVersion,
|
||||
}
|
||||
|
||||
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||
if data.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Layout: [0] type, [1] bit7 BEE flag, [14..20] cc_id. Only observed types
|
||||
// parse: 0x10 AACS2, 0x00 AACS1 (HD DVD's 0x00 is reverse-engineered from
|
||||
// retail discs); anything else is None.
|
||||
let version = match data[0] {
|
||||
0x00 => AacsVersion::V10,
|
||||
0x10 => AacsVersion::V20,
|
||||
_ => return None,
|
||||
};
|
||||
// The flag is bit 7 of byte 1, NOT bit 0. Reading bit 0 (the prior bug) made
|
||||
// a bus-encrypted cert (byte1=0x80) read as `false`, defeating the
|
||||
// AacsBusKeyUnavailable fail-loud gate in disc/encrypt.rs.
|
||||
let bus_encryption = (data[1] >> 7) & 1 == 1;
|
||||
let mut cc_id = [0u8; 6];
|
||||
cc_id.copy_from_slice(&data[14..20]);
|
||||
|
||||
Some(ContentCert {
|
||||
bus_encryption,
|
||||
cc_id,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inf_vtkf_tests.rs"]
|
||||
mod vtkf_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inf_read_mkb_tests.rs"]
|
||||
mod read_mkb_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inf_unit_key_ro_tests.rs"]
|
||||
mod unit_key_ro_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inf_content_cert_tests.rs"]
|
||||
mod content_cert_tests;
|
||||
@@ -1,18 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_content_cert_reads_cc_id_from_bytes_14_to_20() {
|
||||
let mut data = vec![0u8; 20];
|
||||
data[1] = 0x80;
|
||||
for (i, b) in data[14..20].iter_mut().enumerate() {
|
||||
*b = 0xA0 + i as u8;
|
||||
}
|
||||
data[13] = 0xEE;
|
||||
let cert = parse_content_cert(&data).expect("20-byte cert parses");
|
||||
assert_eq!(cert.cc_id, [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5]);
|
||||
assert!(cert.bus_encryption);
|
||||
assert!(
|
||||
parse_content_cert(&data[..19]).is_none(),
|
||||
"19 bytes is short"
|
||||
);
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
use super::*;
|
||||
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE, ScsiResult, ScsiTransport};
|
||||
|
||||
/// A drive that answers READ DISC STRUCTURE format 0x83 from a scripted set
|
||||
/// of packs and records every CDB it was handed.
|
||||
struct MkbDrive {
|
||||
/// One entry per pack: the pack's MKB payload bytes.
|
||||
packs: Vec<Vec<u8>>,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for MkbDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Pack number is carried in the CDB address field (bytes 2..6),
|
||||
// MMC-6 READ DISC STRUCTURE.
|
||||
let pack = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]) as usize;
|
||||
let body = self.packs.get(pack).cloned().unwrap_or_default();
|
||||
// Header: BE16 data length (counts the 2 header bytes that follow
|
||||
// it plus the payload), reserved byte, pack count, then payload.
|
||||
let data_len = body.len() + 2;
|
||||
data[0..2].copy_from_slice(&(data_len as u16).to_be_bytes());
|
||||
data[2] = 0x00;
|
||||
data[3] = self.packs.len() as u8;
|
||||
data[4..4 + body.len()].copy_from_slice(&body);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4 + body.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the CONTENT: concatenated pack payloads, in pack order, byte for byte.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_returns_the_concatenated_pack_payload() {
|
||||
let pack0: Vec<u8> = (0..600u32).map(|i| (i % 251) as u8).collect();
|
||||
let pack1: Vec<u8> = (0..300u32).map(|i| (i % 253) as u8 ^ 0xA5).collect();
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![pack0.clone(), pack1.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
|
||||
let mut expected = pack0.clone();
|
||||
expected.extend_from_slice(&pack1);
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
expected.len(),
|
||||
"every pack's payload must be concatenated, none dropped"
|
||||
);
|
||||
assert!(
|
||||
mkb == expected,
|
||||
"MKB bytes must be the drive's payload in pack order; first \
|
||||
mismatch at {:?}",
|
||||
(0..expected.len()).find(|&i| mkb[i] != expected[i])
|
||||
);
|
||||
|
||||
// MMC-6 READ DISC STRUCTURE with the AACS MKB format code, one command
|
||||
// per pack, pack number in the address field.
|
||||
assert_eq!(drive.cdbs.len(), 2, "one command per declared pack");
|
||||
for (i, cdb) in drive.cdbs.iter().enumerate() {
|
||||
assert_eq!(cdb[0], SCSI_READ_DISC_STRUCTURE, "opcode");
|
||||
assert_eq!(cdb[7], 0x83, "AACS MKB disc-structure format code");
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]),
|
||||
i as u32,
|
||||
"pack {i} must be requested by number"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pins the WHOLE 12-byte CDB (MMC-6 READ DISC STRUCTURE, AACS MKB format) so no field can
|
||||
// drift.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_issues_the_exact_mmc_cdb_for_each_pack() {
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![vec![0x11u8; 64], vec![0x22u8; 64], vec![0x33u8; 64]],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
|
||||
assert_eq!(drive.cdbs.len(), 3, "one command per declared pack");
|
||||
for (pack, cdb) in drive.cdbs.iter().enumerate() {
|
||||
let p = pack as u32;
|
||||
let expected: [u8; 12] = [
|
||||
SCSI_READ_DISC_STRUCTURE,
|
||||
0x01,
|
||||
(p >> 24) as u8,
|
||||
(p >> 16) as u8,
|
||||
(p >> 8) as u8,
|
||||
p as u8,
|
||||
0x00,
|
||||
0x83, // AACS MKB disc-structure format
|
||||
0x80, // allocation length 32772 = 0x8004, high byte
|
||||
0x04, // …low byte
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
assert_eq!(
|
||||
cdb.as_slice(),
|
||||
&expected[..],
|
||||
"CDB for pack {pack} must match the MMC-6 READ DISC STRUCTURE layout"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A full 32768-byte pack must come back whole; small payloads elsewhere never exercise this
|
||||
// bound.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_accepts_a_full_size_pack() {
|
||||
let full: Vec<u8> = (0..32768u32).map(|i| (i % 251) as u8).collect();
|
||||
let other: Vec<u8> = (0..32768u32).map(|i| (i % 241) as u8 ^ 0x5A).collect();
|
||||
// TWO maximal packs: the first-pack read and the per-pack loop carry
|
||||
// separate bounds, so both must accept a full-window payload.
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![full.clone(), other.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
65536,
|
||||
"neither maximal pack may be dropped at the size bound"
|
||||
);
|
||||
let mut expected = full.clone();
|
||||
expected.extend_from_slice(&other);
|
||||
assert!(mkb == expected, "both maximal packs' bytes must be intact");
|
||||
}
|
||||
|
||||
/// A header-only pack inside a multi-pack MKB is a hole: fail rather than return a
|
||||
/// silently shortened MKB. A single-pack header-only response stays an empty MKB.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_zero_length_pack_contributes_nothing() {
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![Vec::new(), vec![0xABu8; 32]],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
read_mkb_from_drive(&mut drive),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
));
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![vec![0xABu8; 32], Vec::new()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
read_mkb_from_drive(&mut drive),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
));
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![Vec::new()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
read_mkb_from_drive(&mut drive).expect("single pack"),
|
||||
Vec::<u8>::new()
|
||||
);
|
||||
}
|
||||
|
||||
// A drive-declared length past the 32772-byte buffer is a drive fault: the MKB
|
||||
// would have a hole, so fail rather than return a corrupt MKB as Ok.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_a_pack_declaring_more_than_the_buffer_holds() {
|
||||
/// Pack 0 is honest; pack 1 declares a 60000-byte payload it never sent.
|
||||
struct LyingDrive {
|
||||
honest: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for LyingDrive {
|
||||
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]]);
|
||||
data[3] = 2; // two packs declared
|
||||
if pack == 0 {
|
||||
let dl = self.honest.len() + 2;
|
||||
data[0..2].copy_from_slice(&(dl as u16).to_be_bytes());
|
||||
data[4..4 + self.honest.len()].copy_from_slice(&self.honest);
|
||||
} else {
|
||||
// A length far beyond the 32772-byte response buffer.
|
||||
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut drive = LyingDrive {
|
||||
honest: vec![0xC7u8; 256],
|
||||
};
|
||||
let err = read_mkb_from_drive(&mut drive).expect_err("a holed MKB is not Ok");
|
||||
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// The same over-declaration on the FIRST pack, which uses a separate bound
|
||||
/// from the loop's.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_a_first_pack_declaring_more_than_the_buffer() {
|
||||
struct LyingFirst;
|
||||
impl ScsiTransport for LyingFirst {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&60_000u16.to_be_bytes());
|
||||
data[3] = 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let err = read_mkb_from_drive(&mut LyingFirst).expect_err("over-declared first pack");
|
||||
assert_eq!(err.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// A drive declaring a payload but transferring fewer bytes (zero-filled tail) is a
|
||||
/// fault; so is a header-only pack after a real first pack (a hole).
|
||||
#[test]
|
||||
fn read_mkb_from_drive_rejects_short_transfer_and_holed_pack() {
|
||||
struct Scripted {
|
||||
declared: [u16; 2],
|
||||
xfer: usize,
|
||||
}
|
||||
impl ScsiTransport for Scripted {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
let pack = cdb[5] as usize;
|
||||
data[0..2].copy_from_slice(&self.declared[pack].to_be_bytes());
|
||||
data[3] = 2;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: if pack == 0 { self.xfer } else { 4 },
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let short = read_mkb_from_drive(&mut Scripted {
|
||||
declared: [102, 2],
|
||||
xfer: 4,
|
||||
})
|
||||
.expect_err("short transfer");
|
||||
assert_eq!(short.code(), crate::error::Error::AacsKeyRead.code());
|
||||
let hole = read_mkb_from_drive(&mut Scripted {
|
||||
declared: [102, 0],
|
||||
xfer: 104,
|
||||
})
|
||||
.expect_err("holed pack");
|
||||
assert_eq!(hole.code(), crate::error::Error::AacsKeyRead.code());
|
||||
}
|
||||
|
||||
/// A single-pack disc still yields that pack's bytes — the common case, and
|
||||
/// the one where a body returning an empty vector looks most plausible.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_returns_a_single_packs_payload() {
|
||||
let pack: Vec<u8> = (0..1024u32).map(|i| (i * 7 % 256) as u8).collect();
|
||||
let mut drive = MkbDrive {
|
||||
packs: vec![pack.clone()],
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let mkb = read_mkb_from_drive(&mut drive).expect("scripted drive answers");
|
||||
assert_eq!(mkb.len(), pack.len(), "single pack payload length");
|
||||
assert!(mkb == pack, "single pack payload bytes");
|
||||
}
|
||||
|
||||
/// A drive that reports a header-only response (`data_len < 2`) has no MKB
|
||||
/// to give. That must be an EMPTY vec, not a partial one — the distinction
|
||||
/// matters because the AACS paths treat a non-empty MKB as parseable.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_empty_response_is_empty() {
|
||||
struct NoMkb;
|
||||
impl ScsiTransport for NoMkb {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&0u16.to_be_bytes());
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 4,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mkb = read_mkb_from_drive(&mut NoMkb).expect("no-MKB drive still returns Ok");
|
||||
assert!(
|
||||
mkb.is_empty(),
|
||||
"a header-only response carries no MKB bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// A header the drive never transferred, or a zero-length first pack of a multi-pack MKB,
|
||||
/// is a drive fault: `AacsKeyRead`, not an empty MKB.
|
||||
#[test]
|
||||
fn read_mkb_from_drive_untransferred_header_and_holed_first_pack_fail() {
|
||||
struct Scripted {
|
||||
xfer: usize,
|
||||
num_packs: u8,
|
||||
}
|
||||
impl ScsiTransport for Scripted {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
data[0..2].copy_from_slice(&0u16.to_be_bytes());
|
||||
data[3] = self.num_packs;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.xfer,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
for (xfer, num_packs) in [(0, 0), (4, 2)] {
|
||||
assert!(
|
||||
matches!(
|
||||
read_mkb_from_drive(&mut Scripted { xfer, num_packs }),
|
||||
Err(crate::error::Error::AacsKeyRead)
|
||||
),
|
||||
"xfer {xfer}, packs {num_packs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport failure on the FIRST pack must propagate as an error — the
|
||||
/// MKB is the root of the whole AACS ladder, so an unreadable one cannot be
|
||||
/// downgraded to "an MKB with no records".
|
||||
#[test]
|
||||
fn read_mkb_from_drive_propagates_the_first_pack_failure() {
|
||||
struct DeadDrive;
|
||||
impl ScsiTransport for DeadDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::error::Result<ScsiResult> {
|
||||
Err(crate::error::Error::ScsiError {
|
||||
opcode: SCSI_READ_DISC_STRUCTURE,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
read_mkb_from_drive(&mut DeadDrive).is_err(),
|
||||
"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"
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Synthesize a `VTKF%%%.AACS`: magic, BE32 size, playlist name, reserved to 0x80, 64 entry
|
||||
// slots (first `keys.len()` present), MAC trailer.
|
||||
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
|
||||
const FILE_LEN: usize = 2480;
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(VTKF_MAGIC); // 0x00
|
||||
v.extend_from_slice(&(FILE_LEN as u32).to_be_bytes()); // 0x0C HD_VTKF_SIZE
|
||||
v.extend_from_slice(b"VPLST000.XPL"); // 0x10 playlist name
|
||||
v.resize(VTKF_HEADER_LEN, 0); // reserve to first entry (0x80)
|
||||
for n in 0..VTKF_MAX_ENTRIES {
|
||||
if let Some(k) = keys.get(n) {
|
||||
v.push(VTKF_AV_FLG); // BIFO: AV_FLG set (present)
|
||||
v.extend_from_slice(&[0, 0, 0]); // reserved
|
||||
v.extend_from_slice(k); // 16-byte encrypted title key
|
||||
v.extend_from_slice(&[0xFFu8; 16]); // binding MAC (0xFF, pre-recorded)
|
||||
} else {
|
||||
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]); // empty slot (AV_FLG clear)
|
||||
}
|
||||
}
|
||||
v.resize(FILE_LEN - 16, 0); // reserved gap before the trailer
|
||||
v.extend_from_slice(&[0xABu8; 16]); // TKF MAC (must NOT be read as a key)
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_present_entries_skips_empty_ignores_mac() {
|
||||
let k1 = [0x11u8; 16];
|
||||
let k2 = [0x22u8; 16];
|
||||
let k3 = [0x33u8; 16];
|
||||
let data = synth_vtkf(&[k1, k2, k3]);
|
||||
|
||||
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
|
||||
// Exactly the three present entries — empty slots and the trailing 16-byte
|
||||
// TKF MAC are not mistaken for keys. k2/k3 are read at the 36-byte stride
|
||||
// (0xA4, 0xC8); the old 32-byte stride misread them from the prior MAC.
|
||||
assert_eq!(ukf.encrypted_keys.len(), 3);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[0],
|
||||
(1, k1),
|
||||
"CPS units = 1-based slot index"
|
||||
);
|
||||
assert_eq!(ukf.encrypted_keys[1], (2, k2));
|
||||
assert_eq!(ukf.encrypted_keys[2], (3, k3));
|
||||
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
|
||||
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
|
||||
assert_eq!(ukf.disc_hash, disc_hash(&data));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_reads_a_full_64_entry_file() {
|
||||
// Real discs (Freedom, Dukes) carry all 64 slots present. Every key must
|
||||
// come back, none dropped and none drifted — the regression the 32-byte
|
||||
// stride failed.
|
||||
let keys: Vec<[u8; 16]> = (0..VTKF_MAX_ENTRIES).map(|n| [n as u8; 16]).collect();
|
||||
let ukf = parse_vtkf(&synth_vtkf(&keys)).expect("64-entry VTKF");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 64);
|
||||
assert_eq!(
|
||||
ukf.encrypted_keys[63],
|
||||
(64, [63u8; 16]),
|
||||
"entry 64 at 0x{:x}",
|
||||
VTKF_HEADER_LEN + 63 * VTKF_ENTRY_LEN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_a_file_cut_inside_the_entry_table() {
|
||||
let full = synth_vtkf(&[[0x11u8; 16], [0x22u8; 16]]);
|
||||
let cut = VTKF_HEADER_LEN + 10 * VTKF_ENTRY_LEN;
|
||||
assert!(parse_vtkf(&full[..cut]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vtkf_rejects_non_magic() {
|
||||
let mut data = synth_vtkf(&[[0x11u8; 16]]);
|
||||
data[0] = b'X'; // corrupt magic
|
||||
assert!(
|
||||
parse_vtkf(&data).is_none(),
|
||||
"non-VTKF magic must be rejected"
|
||||
);
|
||||
assert!(
|
||||
parse_vtkf(&[0u8; 4]).is_none(),
|
||||
"too short must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_title_keys_dispatches_by_magic() {
|
||||
// VTKF magic → parse_vtkf.
|
||||
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
|
||||
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
|
||||
assert_eq!(ukf.encrypted_keys.len(), 2);
|
||||
|
||||
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
|
||||
// this proves it ROUTED to the BD parser rather than parse_vtkf).
|
||||
assert!(
|
||||
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
|
||||
"non-magic input must route to parse_unit_key_ro"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
|
||||
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
|
||||
/// no HD-DVD-specific crypto path.
|
||||
#[test]
|
||||
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
|
||||
let enc = [0x9Au8; 16];
|
||||
let data = synth_vtkf(&[enc]);
|
||||
let ukf = parse_vtkf(&data).unwrap();
|
||||
let vuk = [0x5Cu8; 16];
|
||||
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
|
||||
// Same as applying the shared unwrap directly to the stored enc key.
|
||||
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
|
||||
}
|
||||
|
||||
// Sentinel byte 0xD5 = decimal 213: a derived Debug would render it in decimal.
|
||||
#[test]
|
||||
fn unit_key_file_debug_is_redacted() {
|
||||
let f = UnitKeyFile {
|
||||
disc_hash: [0xD5; 20],
|
||||
app_type: 1,
|
||||
num_bdmv_dir: 1,
|
||||
use_skb_mkb: false,
|
||||
version: AacsVersion::V20,
|
||||
encrypted_keys: vec![(0, [0xD5; 16]), (1, [0xD5; 16])],
|
||||
title_cps_unit: vec![0, 1],
|
||||
};
|
||||
let dbg = format!("{f:?}");
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"UnitKeyFile Debug leaked key bytes (decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"UnitKeyFile Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
// Non-secret shape is still useful for diagnostics.
|
||||
assert!(dbg.contains("encrypted_keys_len: 2"), "{dbg}");
|
||||
}
|
||||
-360
@@ -1,360 +0,0 @@
|
||||
//! AACS Media Key Block — `[C]` Chapter 3.
|
||||
//!
|
||||
//! The MKB record format (framing walker, the `MkbRecord` view, record-body
|
||||
//! finders), the MKBType / AACS-generation classification, and MKB-file
|
||||
//! utilities (content length, trimming, version). Consolidated here so the one
|
||||
//! place that understands MKB bytes is `mkb`. Some duplicate record finders
|
||||
//! still live side by side pending a follow-up that collapses them.
|
||||
|
||||
// ── MKB record types ([C] Chapter 3) ──────────────────────────────────────
|
||||
// The ONE canonical set; every record-type comparison in `aacs` references these
|
||||
// so a type byte is never a bare literal scattered across files.
|
||||
|
||||
/// Type-and-Version — carries the 32-bit MKBType / AACS generation.
|
||||
pub(crate) const REC_TYPE_AND_VERSION: u8 = 0x10;
|
||||
/// Explicit Subset-Difference — the per-slot `(u_mask_shift, uv)` table.
|
||||
pub(crate) const REC_SUBSET_DIFFERENCE: u8 = 0x04;
|
||||
/// Media Key Data — the classical (1.0 / 2.0) per-subset cvalue table.
|
||||
pub(crate) const REC_MEDIA_KEY_DATA: u8 = 0x05;
|
||||
/// Subset-Difference Index — a search index into `0x04`, NOT a cvalue table.
|
||||
#[cfg(test)]
|
||||
pub(crate) const REC_SUBSET_DIFFERENCE_INDEX: u8 = 0x07;
|
||||
/// Media Key Variant Data (AACS 2.1) — the per-subset-difference `C` table
|
||||
/// (one 16-byte C per slot); the `Kmp` step reads C from HERE, not `0x2d`.
|
||||
pub(crate) const REC_MEDIA_KEY_VARIANT_DATA: u8 = 0x0c;
|
||||
/// Variant Data + Nonce (AACS 2.1) — the `VARIANTS[uv]` table (leading bytes)
|
||||
/// with the 16-byte `Kvn` Nonce at the tail.
|
||||
pub(crate) const REC_VARIANT_DATA_AND_NONCE: u8 = 0x2d;
|
||||
/// Variant Key Data table (AACS 2.1) — 65,535×16, indexed by the resolved VKD index.
|
||||
pub(crate) const REC_VKD_TABLE: u8 = 0x2f;
|
||||
/// Verify-Media-Key — AACS 1.0.
|
||||
pub(crate) const REC_VERIFY_MEDIA_KEY_V1: u8 = 0x81;
|
||||
/// Verify-Media-Key — AACS 2.x.
|
||||
pub(crate) const REC_VERIFY_MEDIA_KEY_V2: u8 = 0x86;
|
||||
|
||||
/// A single MKB record produced by [`walk_mkb`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MkbRecord {
|
||||
/// Byte offset of the record within the MKB.
|
||||
pub offset: usize,
|
||||
/// Record type byte.
|
||||
pub rec_type: u8,
|
||||
/// Record length in bytes (includes the 4-byte header).
|
||||
pub rec_len: usize,
|
||||
/// Record body (the bytes after the 4-byte header).
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Walk an MKB into a flat list of records.
|
||||
///
|
||||
/// MKB record framing per AACS: 1 byte type, 3 bytes BE length
|
||||
/// INCLUDING the 4-byte header, followed by payload. The walker stops
|
||||
/// at the first `(type=0, len=0)` end marker or at end of buffer.
|
||||
pub fn walk_mkb(mkb: &[u8]) -> Vec<MkbRecord> {
|
||||
mkb_records(mkb)
|
||||
.map(|(offset, rec_type, rec_len)| MkbRecord {
|
||||
offset,
|
||||
rec_type,
|
||||
rec_len,
|
||||
body: mkb[offset + 4..offset + rec_len].to_vec(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// THE single MKB record-framing walker: 4-byte header (type + BE24 length) then body, stopping
|
||||
// at the `00 000000` end marker. `walk_mkb` and every other MKB walk are built on this.
|
||||
pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)> + '_ {
|
||||
let mut pos = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
if pos + 4 > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = ((mkb[pos + 1] as usize) << 16)
|
||||
| ((mkb[pos + 2] as usize) << 8)
|
||||
| (mkb[pos + 3] as usize);
|
||||
if rec_type == 0 && rec_len == 0 {
|
||||
return None;
|
||||
}
|
||||
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
return None;
|
||||
}
|
||||
let here = pos;
|
||||
pos += rec_len;
|
||||
Some((here, rec_type, rec_len))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mkb_find_body(records: &[MkbRecord], rec_type: u8) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == rec_type && !r.body.is_empty())
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// AACS protection generation a disc carries.
|
||||
///
|
||||
/// The content cert type byte distinguishes V10 (`0x00`) from V20 (`0x10`; any other
|
||||
/// byte is rejected, see [`super::inf::parse_content_cert`]). A V21 disc carries a V20
|
||||
/// cert and is upgraded to `V21` only when the MKB has Variant records `0x2d`/`0x2f`.
|
||||
///
|
||||
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
|
||||
/// bytes for V20 / V21.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AacsVersion {
|
||||
/// AACS 1.0 — original BD-ROM.
|
||||
V10,
|
||||
/// AACS 2.0 — UHD-BD, classical Media Key derivation.
|
||||
V20,
|
||||
/// AACS 2.1 — UHD-BD with Media Key Variant chain on top of V20.
|
||||
V21,
|
||||
}
|
||||
|
||||
/// AACS major version as the small integer threaded through the scan / key
|
||||
/// paths (`AacsState.version`, `DiscInputs.version`, `DiscInputsCtx::new`):
|
||||
/// 1 = AACS 1.0 (BD), 2 = AACS 2.x (UHD). Centralised so the bare `1`/`2` — and
|
||||
/// the V10-vs-else stride choice it drives — lives in exactly one place.
|
||||
pub const AACS_MAJOR_BD: u8 = 1;
|
||||
|
||||
/// AACS major version 2 (UHD-BD, 2.0 / 2.1): see [`AACS_MAJOR_BD`].
|
||||
pub const AACS_MAJOR_UHD: u8 = 2;
|
||||
|
||||
impl AacsVersion {
|
||||
/// Stride (in bytes) between successive encrypted unit keys in
|
||||
/// `Unit_Key_RO.inf`.
|
||||
pub(crate) fn unit_key_stride(self) -> usize {
|
||||
match self {
|
||||
AacsVersion::V10 => 48,
|
||||
AacsVersion::V20 | AacsVersion::V21 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// This version as the major integer ([`AACS_MAJOR_BD`] / [`AACS_MAJOR_UHD`]).
|
||||
pub fn major(self) -> u8 {
|
||||
match self {
|
||||
AacsVersion::V10 => AACS_MAJOR_BD,
|
||||
AacsVersion::V20 | AacsVersion::V21 => AACS_MAJOR_UHD,
|
||||
}
|
||||
}
|
||||
|
||||
/// The version a bare major integer selects for stride purposes: only the
|
||||
/// BD major is V10; every other value takes the V20/V21 64-byte stride.
|
||||
pub fn from_major(major: u8) -> Self {
|
||||
if major == AACS_MAJOR_BD {
|
||||
AacsVersion::V10
|
||||
} else {
|
||||
AacsVersion::V20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The AACS generation that sets the `Unit_Key_RO.inf` stride, from the strongest signal
|
||||
/// present: the content certificate's major version, then a pre-recorded MKB's type, then
|
||||
/// `index.bdmv` (`INDX0300` is UHD). With none of them, UHD's 64-byte stride. The one
|
||||
/// resolver for every path that parses the unit-key file, so they cannot disagree.
|
||||
pub fn resolve_aacs_version(
|
||||
cert_major: Option<u8>,
|
||||
mkb: &[u8],
|
||||
index_is_uhd: Option<bool>,
|
||||
) -> AacsVersion {
|
||||
if let Some(major) = cert_major {
|
||||
return AacsVersion::from_major(major);
|
||||
}
|
||||
if let Some(t @ (MkbType::Prerecorded | MkbType::CategoryC20 | MkbType::CategoryC21)) =
|
||||
mkb_type(mkb)
|
||||
{
|
||||
return t.generation();
|
||||
}
|
||||
match index_is_uhd {
|
||||
Some(false) => AacsVersion::V10,
|
||||
_ => AacsVersion::V20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
|
||||
/// 0x81: `[C]` §3.2.5.1.4. 0x86 (AACS 2.x): `[RE]` — not in the public spec (from real 2.x MKBs).
|
||||
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
|
||||
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
|
||||
let found = mkb_records(mkb).find(|&(_, rt, len)| {
|
||||
(rt == REC_VERIFY_MEDIA_KEY_V1 || rt == REC_VERIFY_MEDIA_KEY_V2) && len >= 20
|
||||
});
|
||||
match found {
|
||||
Some((o, rec_type, rec_len)) => {
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[o + 4..o + 20]);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
rec_type,
|
||||
pos = o,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
Some(dv)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
"no 0x81/0x86 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Subset-Difference records (type 0x04) in MKB. `[C]` §3.2.5.1.5.
|
||||
pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
find_record_body(mkb, REC_SUBSET_DIFFERENCE)
|
||||
}
|
||||
|
||||
// Find the Media Key Data Record (cvalues table) in an MKB. `[C]` §3.2.4 / §3.2.5.1.7. 0x05 is
|
||||
// the pre-recorded / Category C table (libaacs `mkb_cvalues`); a Class II MKB carries 0x0c
|
||||
// instead (see `check_mkb_class`). 0x07 is the Subset-Difference Index.
|
||||
pub(crate) fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||
find_record_body(mkb, REC_MEDIA_KEY_DATA)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the payload (header stripped) of the first
|
||||
/// record matching `rec_type`. Returns `None` if no such record exists or
|
||||
/// the record is empty.
|
||||
pub(crate) fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
find_record_slice(mkb, rec_type_wanted).map(<[u8]>::to_vec)
|
||||
}
|
||||
|
||||
// Borrowing form of [`find_record_body`]: the body in place, no copy.
|
||||
pub(crate) fn find_record_slice(mkb: &[u8], rec_type_wanted: u8) -> Option<&[u8]> {
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == rec_type_wanted && len > 4)
|
||||
.map(|(o, _, len)| &mkb[o + 4..o + len])
|
||||
}
|
||||
|
||||
/// Real content length of an MKB: the byte offset where the record stream
|
||||
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
|
||||
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
|
||||
/// the front and the rest zero padding. Walking records (type+len) and stopping
|
||||
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
|
||||
/// actual size so callers can trim off megabytes of zeros before sending or
|
||||
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
|
||||
pub fn mkb_content_len(mkb: &[u8]) -> usize {
|
||||
// End of the last framed record = where the fixed-region zero padding begins.
|
||||
// (The `00 000000` terminator / overrun stops the walk; real MKBs pad with
|
||||
// zeros, so this matches the prior "stop at the first padding byte".)
|
||||
mkb_records(mkb)
|
||||
.last()
|
||||
.map(|(o, _, len)| o + len)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Trim an MKB's trailing fixed-region padding to its real content length —
|
||||
/// but ONLY when [`mkb_content_len`] actually found one. It returns 0 for an
|
||||
/// MKB whose first record cannot be parsed; truncating to 0 in that case would
|
||||
/// hand downstream consumers (and the online key service) an EMPTY MKB that can
|
||||
/// never resolve. So a 0 (or a length that isn't strictly inside the buffer)
|
||||
/// leaves the MKB untouched. A 0.31.0 regression dropped this guard and
|
||||
/// `truncate`-d unconditionally, zeroing unrecognised MKBs.
|
||||
pub fn trim_mkb(mut mkb: Vec<u8>) -> Vec<u8> {
|
||||
let n = mkb_content_len(&mkb);
|
||||
if n > 0 && n < mkb.len() {
|
||||
mkb.truncate(n);
|
||||
}
|
||||
mkb
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Layout: 4-byte record header at `pos` (type + BE24 length), then the
|
||||
/// record body starts at `pos + 4`. The body holds the BE u32 Type field at
|
||||
/// body offset 0 (`pos + 4`), then the BE u32 version at body offset 4
|
||||
/// (`pos + 8`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): version is the BE u32 at body offset 4
|
||||
// (record offset 8). Needs rec_len >= 12 (4 header + 4 type + 4 version).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 12)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 8], mkb[o + 9], mkb[o + 10], mkb[o + 11]]))
|
||||
}
|
||||
|
||||
/// `0x00031003` — recordable media MKB (Class I & II compute Km directly).
|
||||
pub const MKB_TYPE_3_RECORDABLE: u32 = 0x0003_1003;
|
||||
|
||||
/// `0x00041003` — AACS 1.0 pre-recorded content MKB (KCD-based). Standard BD.
|
||||
pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
|
||||
|
||||
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
|
||||
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
|
||||
|
||||
/// `0x48141003` — AACS 2.0 Category C (UHD content) MKB type value.
|
||||
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
|
||||
|
||||
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
|
||||
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
|
||||
|
||||
/// The AACS MKB Type field, decoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MkbType {
|
||||
/// Type 3 — recordable media.
|
||||
Recordable,
|
||||
/// Type 4 — AACS 1.0 pre-recorded content (KCD). Standard Blu-ray.
|
||||
Prerecorded,
|
||||
/// Type 10 — Class II / Unified (SKB).
|
||||
ClassII,
|
||||
/// AACS 2.0 Category C — UHD content.
|
||||
CategoryC20,
|
||||
/// AACS 2.1 Category C — UHD content.
|
||||
CategoryC21,
|
||||
/// Unrecognized MKBType value (raw field preserved).
|
||||
Other(u32),
|
||||
}
|
||||
|
||||
impl MkbType {
|
||||
pub(crate) fn from_raw(raw: u32) -> Self {
|
||||
match raw {
|
||||
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,
|
||||
other => MkbType::Other(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// AACS generation this MKB belongs to (Category C → 2.0/2.1, else 1.0).
|
||||
pub fn generation(self) -> AacsVersion {
|
||||
match self {
|
||||
MkbType::CategoryC21 => AacsVersion::V21,
|
||||
MkbType::CategoryC20 => AacsVersion::V20,
|
||||
_ => AacsVersion::V10,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` for UHD (AACS 2.x Category C); `false` for Blu-ray (AACS 1.x).
|
||||
pub fn is_uhd(self) -> bool {
|
||||
matches!(self, MkbType::CategoryC20 | MkbType::CategoryC21)
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw 32-bit MKBType field from the Type-and-Version record (0x10), bytes
|
||||
/// 4-7. `None` if no 0x10 record is present. `[C]` §3.2.5.1.1 Table 3-2.
|
||||
pub fn mkb_type_raw(mkb: &[u8]) -> Option<u32> {
|
||||
// Type-and-Version record (0x10): the 32-bit MKBType is bytes 4-7 (body
|
||||
// offset 0). Needs rec_len >= 8 (4 header + 4 type).
|
||||
mkb_records(mkb)
|
||||
.find(|&(_, rt, len)| rt == REC_TYPE_AND_VERSION && len >= 8)
|
||||
.map(|(o, _, _)| u32::from_be_bytes([mkb[o + 4], mkb[o + 5], mkb[o + 6], mkb[o + 7]]))
|
||||
}
|
||||
|
||||
/// Decode an MKB's Type field. `None` if no Type-and-Version record is present.
|
||||
pub fn mkb_type(mkb: &[u8]) -> Option<MkbType> {
|
||||
mkb_type_raw(mkb).map(MkbType::from_raw)
|
||||
}
|
||||
|
||||
/// `Some(true)` if this MKB is a UHD (AACS 2.x Category C) block, `Some(false)`
|
||||
/// for Blu-ray (AACS 1.x), `None` if the Type record is absent.
|
||||
pub fn mkb_is_uhd(mkb: &[u8]) -> Option<bool> {
|
||||
mkb_type(mkb).map(MkbType::is_uhd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mkb_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,297 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// One stride resolver: cert, then a pre-recorded MKB's type, then index.bdmv, then UHD.
|
||||
// Each later signal is consulted only when every earlier one is missing.
|
||||
#[test]
|
||||
fn resolve_aacs_version_ranks_cert_then_mkb_then_index() {
|
||||
let rec = |t: u32| {
|
||||
let mut m = vec![0x10, 0x00, 0x00, 0x0C];
|
||||
m.extend_from_slice(&t.to_be_bytes());
|
||||
m.extend_from_slice(&[0, 0, 0, 1]);
|
||||
m
|
||||
};
|
||||
let (bd, uhd21) = (rec(MKB_TYPE_4_PRERECORDED), rec(MKB_21_CATEGORY_C));
|
||||
let class2 = rec(MKB_TYPE_10_CLASS_II);
|
||||
use AacsVersion::*;
|
||||
// The certificate wins over a disagreeing MKB and index.
|
||||
assert_eq!(
|
||||
resolve_aacs_version(Some(AACS_MAJOR_BD), &uhd21, Some(true)),
|
||||
V10
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_aacs_version(Some(AACS_MAJOR_UHD), &bd, Some(false)),
|
||||
V20
|
||||
);
|
||||
// No cert: the MKB type wins over a disagreeing index.
|
||||
assert_eq!(resolve_aacs_version(None, &bd, Some(true)), V10);
|
||||
assert_eq!(resolve_aacs_version(None, &uhd21, Some(false)), V21);
|
||||
// No cert, no pre-recorded MKB type: index.bdmv decides.
|
||||
assert_eq!(resolve_aacs_version(None, &[], Some(false)), V10);
|
||||
assert_eq!(resolve_aacs_version(None, &class2, Some(false)), V10);
|
||||
assert_eq!(resolve_aacs_version(None, &[], Some(true)), V20);
|
||||
// Nothing at all: UHD's 64-byte stride.
|
||||
assert_eq!(resolve_aacs_version(None, &[], None), V20);
|
||||
}
|
||||
|
||||
/// One MKB record: 1 type byte + big-endian 24-bit total length + body.
|
||||
fn rec(rec_type: u8, body: &[u8]) -> Vec<u8> {
|
||||
let len = 4 + body.len();
|
||||
let mut v = vec![rec_type, (len >> 16) as u8, (len >> 8) as u8, len as u8];
|
||||
v.extend_from_slice(body);
|
||||
v
|
||||
}
|
||||
|
||||
/// Type-and-Version record (0x10): body = 4-byte MKBType + 4-byte version.
|
||||
fn type_and_version(mkb_type: u32, version: u32) -> Vec<u8> {
|
||||
let mut body = mkb_type.to_be_bytes().to_vec();
|
||||
body.extend_from_slice(&version.to_be_bytes());
|
||||
rec(REC_TYPE_AND_VERSION, &body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_frames_records_and_stops_at_end_marker() {
|
||||
let mut mkb = type_and_version(MKB_20_CATEGORY_C, 77);
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0xAA; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 8])); // must NOT be walked (past the marker)
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "walk stops at the 00 000000 end marker");
|
||||
assert_eq!(recs[0].rec_type, REC_TYPE_AND_VERSION);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0xAA; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walker_stops_on_malformed_or_out_of_bounds_length() {
|
||||
// A record whose declared length runs past the buffer end must terminate
|
||||
// the walk rather than panic or read OOB.
|
||||
let mkb = vec![REC_VKD_TABLE, 0x00, 0xFF, 0xFF, 0x01, 0x02]; // len=0xFFFF, only 6 bytes
|
||||
assert!(
|
||||
walk_mkb(&mkb).is_empty(),
|
||||
"over-long record yields no records"
|
||||
);
|
||||
// A sub-4 length (shorter than the header itself) is also rejected.
|
||||
let short = vec![REC_VKD_TABLE, 0x00, 0x00, 0x02];
|
||||
assert!(walk_mkb(&short).is_empty(), "sub-4 length is rejected");
|
||||
// A truncated header (< 4 bytes) yields nothing.
|
||||
assert!(walk_mkb(&[0x10, 0x00]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_type_and_version_decode_from_the_type_record() {
|
||||
let mut mkb = type_and_version(MKB_21_CATEGORY_C, 100);
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]);
|
||||
assert_eq!(mkb_type_raw(&mkb), Some(MKB_21_CATEGORY_C));
|
||||
assert_eq!(mkb_version(&mkb), Some(100));
|
||||
assert_eq!(mkb_is_uhd(&mkb), Some(true), "2.1 Category C is UHD");
|
||||
|
||||
let bd = type_and_version(MKB_TYPE_4_PRERECORDED, 68);
|
||||
assert_eq!(
|
||||
mkb_is_uhd(&bd),
|
||||
Some(false),
|
||||
"AACS 1.0 prerecorded is not UHD"
|
||||
);
|
||||
// No Type record → None (not a panic, not a fabricated value).
|
||||
assert_eq!(mkb_version(&rec(REC_VKD_TABLE, &[0; 16])), None);
|
||||
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);
|
||||
let content_len = mkb.len(); // the single framed record, no end marker
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // end marker
|
||||
mkb.extend([0xDE; 4096]); // trailing padding past the end marker
|
||||
let trimmed = trim_mkb(mkb);
|
||||
assert_eq!(
|
||||
trimmed.len(),
|
||||
content_len,
|
||||
"trim keeps the framed records, dropping the end marker and padding"
|
||||
);
|
||||
}
|
||||
|
||||
// BE24 length field (all THREE bytes): a walker that dropped the high byte would mis-frame
|
||||
// every large record (real cvalue/variant tables).
|
||||
#[test]
|
||||
fn mkb_records_honors_the_high_byte_of_the_be24_length() {
|
||||
const TOTAL: usize = 0x0001_0004; // 65_540 — high byte 0x01
|
||||
let mut mkb = vec![REC_VKD_TABLE, 0x01, 0x00, 0x04];
|
||||
mkb.resize(TOTAL, 0xAB);
|
||||
// A second record follows, so a walker that mis-read the length would
|
||||
// frame a different number of records rather than merely a short one.
|
||||
mkb.extend(rec(REC_TYPE_AND_VERSION, &[0x11; 8]));
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "the big record must be framed as ONE record");
|
||||
assert_eq!(
|
||||
recs[0].rec_len, TOTAL,
|
||||
"rec_len must include the high BE24 byte"
|
||||
);
|
||||
assert_eq!(recs[0].body.len(), TOTAL - 4);
|
||||
assert_eq!(
|
||||
recs[1].rec_type, REC_TYPE_AND_VERSION,
|
||||
"the following record must start where the big one ends"
|
||||
);
|
||||
}
|
||||
|
||||
// Header-only records and the exact end marker: `rec_len == 4` is a well-formed HEADER-ONLY
|
||||
// record, including one at the buffer end.
|
||||
#[test]
|
||||
fn mkb_records_yields_a_header_only_record_at_the_buffer_end() {
|
||||
let mut mkb = rec(REC_TYPE_AND_VERSION, &[0xAA, 0xBB]);
|
||||
mkb.extend([REC_VKD_TABLE, 0x00, 0x00, 0x04]); // 4-byte, empty body, at EOF
|
||||
assert_eq!(
|
||||
mkb.len(),
|
||||
10,
|
||||
"sanity: the last record ends at the buffer end"
|
||||
);
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(recs.len(), 2, "the trailing header-only record is a record");
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].rec_len, 4);
|
||||
assert!(recs[1].body.is_empty());
|
||||
}
|
||||
|
||||
// ONLY the exact `00 00 00 00` marker ends the walk. A type-0 record with a
|
||||
// real length is a record, not the end — stopping there would truncate
|
||||
// everything after it, including the cvalue/verify records.
|
||||
#[test]
|
||||
fn mkb_records_stops_only_on_the_all_zero_end_marker() {
|
||||
// A type-0 record of length 8, then a normal record, then the marker.
|
||||
let mut mkb = vec![0x00, 0x00, 0x00, 0x08, 1, 2, 3, 4];
|
||||
mkb.extend(rec(REC_VKD_TABLE, &[0x55; 16]));
|
||||
mkb.extend([0x00, 0x00, 0x00, 0x00]); // the real end marker
|
||||
mkb.extend(rec(0x99, &[0xFF; 4])); // past the marker: not walked
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
assert_eq!(
|
||||
recs.len(),
|
||||
2,
|
||||
"a type-0 record with a non-zero length is a record, not the end"
|
||||
);
|
||||
assert_eq!(recs[0].rec_type, 0x00);
|
||||
assert_eq!(recs[0].rec_len, 8);
|
||||
assert_eq!(recs[1].rec_type, REC_VKD_TABLE);
|
||||
assert_eq!(recs[1].body, vec![0x55; 16]);
|
||||
}
|
||||
|
||||
// `mkb_type_raw` reports the 32-bit MKBType field verbatim, including unrecognised values;
|
||||
// all four bytes must come from the record body.
|
||||
#[test]
|
||||
fn mkb_type_raw_reads_all_four_body_bytes() {
|
||||
const RAW: u32 = 0xDEAD_BEEF;
|
||||
let mkb = type_and_version(RAW, 7);
|
||||
assert_eq!(
|
||||
mkb_type_raw(&mkb),
|
||||
Some(RAW),
|
||||
"every byte of the MKBType field must come from the record body"
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
//! AACS primitives: MKB, title-key file, derivation, unit crypto and the resolution trace.
|
||||
//! Keys are acquired only through [`crate::keys::KeyRing::acquire`]; title keys decrypt
|
||||
//! m2ts stream content (AES-128-CBC).
|
||||
|
||||
pub mod content;
|
||||
pub mod crypto;
|
||||
pub mod derive;
|
||||
// HD DVD per-pack content encryption (`[HD]` §4.3).
|
||||
pub(crate) mod hddvd;
|
||||
pub mod host_certs;
|
||||
// No production caller; not the live FMTS path.
|
||||
#[doc(hidden)]
|
||||
pub mod index_select;
|
||||
pub mod inf;
|
||||
pub mod mkb;
|
||||
pub mod segment;
|
||||
// No production caller; not the live FMTS path.
|
||||
#[doc(hidden)]
|
||||
pub mod segment_key;
|
||||
pub mod trace;
|
||||
pub mod types;
|
||||
pub mod variant;
|
||||
|
||||
// On-disc UDF paths to the AACS key-input files; each AacsRole resolves to an ordered
|
||||
// candidate list walked by read_first (see role_paths, find_hddvd_aacs_dir).
|
||||
/// Primary Unit Key file path.
|
||||
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
|
||||
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
|
||||
pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
|
||||
pub const PATH_MKB_RO_DUPLICATE: &str = "/AACS/DUPLICATE/MKB_RO.inf";
|
||||
/// The recordable-media MKB — a DIFFERENT MKB, never a fallback for `MKB_RO`.
|
||||
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
|
||||
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||
|
||||
/// An AACS key-input role. [`role_paths`] maps it to an ordered candidate path
|
||||
/// list (BD/UHD constants, then the discovered HD DVD files).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AacsRole {
|
||||
/// Title-key file: BD/UHD `Unit_Key_RO.inf`, HD DVD `VTKF*.AACS`
|
||||
/// (magic `DVD_HD_V_TKF`). The disc_hash is `SHA1` of this file.
|
||||
UnitKey,
|
||||
/// Media Key Block: BD/UHD `MKB_RO.inf` (then its DUPLICATE copy), HD DVD `MKBROM.AACS`.
|
||||
Mkb,
|
||||
/// Content certificate: BD/UHD `Content000/001.cer`, HD DVD
|
||||
/// `CONTENT_CERT.AACS` (byte 0 gives the AACS major).
|
||||
ContentCert,
|
||||
}
|
||||
|
||||
// The HD DVD AACS directory in a parsed UDF tree, if present: any root directory holding
|
||||
// MKBROM.AACS, whatever its name (`ANY!`, `AAC!`, plain `AACS` on "300"). A `*_BAK` mirror
|
||||
// is used only when no primary copy exists.
|
||||
pub(crate) fn find_hddvd_aacs_dir(udf: &crate::udf::UdfFs) -> Option<&crate::udf::DirEntry> {
|
||||
let mut dirs = udf.root.entries.iter().filter(|e| {
|
||||
e.is_dir
|
||||
&& e.entries
|
||||
.iter()
|
||||
.any(|c| !c.is_dir && c.name.eq_ignore_ascii_case("MKBROM.AACS"))
|
||||
});
|
||||
let is_bak = |e: &crate::udf::DirEntry| e.name.to_ascii_uppercase().ends_with("_BAK");
|
||||
let first = dirs.next()?;
|
||||
if !is_bak(first) {
|
||||
return Some(first);
|
||||
}
|
||||
dirs.find(|e| !is_bak(e)).or(Some(first))
|
||||
}
|
||||
|
||||
// Ordered candidate paths for an AACS key role: fixed BD/UHD `/AACS/…` paths
|
||||
// first, then discovered HD DVD files (see `find_hddvd_aacs_dir`). UnitKey
|
||||
// appends every VTKF*.AACS found, sorted, since a disc may ship >1 variant.
|
||||
pub(crate) fn role_paths(udf: &crate::udf::UdfFs, role: AacsRole) -> Vec<String> {
|
||||
let mut v: Vec<String> = match role {
|
||||
AacsRole::UnitKey => vec![PATH_UNIT_KEY_RO, PATH_UNIT_KEY_RO_DUPLICATE],
|
||||
AacsRole::Mkb => vec![PATH_MKB_RO, PATH_MKB_RO_DUPLICATE],
|
||||
AacsRole::ContentCert => vec![PATH_CONTENT_CERT, PATH_CONTENT_CERT_ALT],
|
||||
}
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
if let Some(dir) = find_hddvd_aacs_dir(udf) {
|
||||
let d = &dir.name;
|
||||
match role {
|
||||
AacsRole::Mkb => v.push(format!("/{d}/MKBROM.AACS")),
|
||||
AacsRole::ContentCert => v.push(format!("/{d}/CONTENT_CERT.AACS")),
|
||||
AacsRole::UnitKey => {
|
||||
// Glob VTKF*.AACS (not fixed at VTKF000; Freedom ships VTKF090 +
|
||||
// VTKF100), sorted for a deterministic try order. Each is bound to
|
||||
// ONE playlist; caller tries in order, uses whichever's keys verify.
|
||||
let mut names: Vec<&str> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir)
|
||||
.filter(|e| {
|
||||
let u = e.name.to_ascii_uppercase();
|
||||
u.starts_with("VTKF") && u.ends_with(".AACS")
|
||||
})
|
||||
.map(|e| e.name.as_str())
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
v.extend(names.into_iter().map(|n| format!("/{d}/{n}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Walk an AACS role's candidate paths, returning the first that reads.
|
||||
///
|
||||
/// AACS ships a `/AACS/DUPLICATE/` copy of every managed file so a bad primary
|
||||
/// read can fall through to the backup. A missing candidate (`UdfNotFound`) and a
|
||||
/// failed read (`DiscRead`) are both retriable: the walk moves on rather than
|
||||
/// aborting. After all candidates are tried, a remembered `DiscRead` propagates
|
||||
/// (more informative than `AacsNoKeys`); if every candidate was merely absent, the
|
||||
/// result is `AacsNoKeys`. Any other error is a hard failure (parse, corruption)
|
||||
/// and propagates immediately.
|
||||
pub(crate) fn read_first<S, F>(candidates: &[S], mut read: F) -> crate::error::Result<Vec<u8>>
|
||||
where
|
||||
S: AsRef<str>,
|
||||
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
|
||||
{
|
||||
let mut deferred: Option<crate::error::Error> = None;
|
||||
for path in candidates {
|
||||
match read(path.as_ref()) {
|
||||
Ok(buf) => return Ok(buf),
|
||||
Err(crate::error::Error::UdfNotFound { .. }) => continue,
|
||||
Err(e @ crate::error::Error::DiscRead { .. }) => {
|
||||
deferred = Some(e);
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(deferred.unwrap_or(crate::error::Error::AacsNoKeys))
|
||||
}
|
||||
|
||||
// The module structure IS the public API — consumers import from the owning module
|
||||
// (e.g. `aacs::content::is_clean`). A small set of flat re-exports below is kept
|
||||
// for typed key primitives and content-decrypt entry points that downstream crates rely on.
|
||||
pub use content::ALIGNED_UNIT_LEN;
|
||||
pub use derive::derive_vuk;
|
||||
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,352 +0,0 @@
|
||||
//! Surface guards. The public API is the module tree itself (no facade).
|
||||
//! Touching one representative item per module keeps these as a
|
||||
//! compile-time contract that the module paths stay stable.
|
||||
|
||||
use super::content::ALIGNED_UNIT_LEN;
|
||||
use super::inf::{disc_hash, disc_hash_hex};
|
||||
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
|
||||
use super::variant::is_variant_mkb;
|
||||
|
||||
#[test]
|
||||
fn aligned_unit_len_is_three_2048_byte_sectors() {
|
||||
// ALIGNED_UNIT_LEN is the AACS aligned-unit size (3 × 2048 = 6144);
|
||||
// pin it here so the public constant tracks the spec.
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 6144);
|
||||
assert_eq!(ALIGNED_UNIT_LEN, 3 * 2048);
|
||||
}
|
||||
|
||||
// Any error but UdfNotFound / DiscRead is a hard failure: a good DUPLICATE copy must not
|
||||
// mask it.
|
||||
#[test]
|
||||
fn read_first_propagates_a_hard_error_without_trying_the_duplicate() {
|
||||
use crate::error::Error;
|
||||
let candidates = ["/AACS/Unit_Key_RO.inf", "/AACS/DUPLICATE/Unit_Key_RO.inf"];
|
||||
let mut tried = Vec::new();
|
||||
let out = super::read_first(&candidates, |p| {
|
||||
tried.push(p.to_string());
|
||||
if p == "/AACS/DUPLICATE/Unit_Key_RO.inf" {
|
||||
Ok(vec![0xAA])
|
||||
} else {
|
||||
Err(Error::DecryptFailed)
|
||||
}
|
||||
});
|
||||
assert!(matches!(out, Err(Error::DecryptFailed)));
|
||||
assert_eq!(tried, vec!["/AACS/Unit_Key_RO.inf".to_string()]);
|
||||
}
|
||||
|
||||
// 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;
|
||||
// confirm the enum re-export is usable and variants are distinct.
|
||||
assert_ne!(AacsVersion::V10, AacsVersion::V20);
|
||||
assert_ne!(AacsVersion::V20, AacsVersion::V21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_helpers_are_callable_by_module_path() {
|
||||
// Touch a representative function from each module so a dropped/renamed
|
||||
// item fails to compile. Smoke calls, not behavioural assertions.
|
||||
let _ =
|
||||
!crate::aacs::content::is_clean(&[0u8; ALIGNED_UNIT_LEN], crate::disc::ContentFormat::BdTs);
|
||||
let _ = mkb_content_len(&[]);
|
||||
let _ = is_variant_mkb(&walk_mkb(&[]));
|
||||
let _ = disc_hash_hex(&disc_hash(b"x"));
|
||||
let _ = super::derive::resolve_candidate(
|
||||
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
super::mkb::AacsVersion::V10,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HD DVD AACS directory / filename discovery ────────────────────────
|
||||
// Dir/filename are authoring-specific (previously hardcoded to `/ANY!/VTKF000.AACS`);
|
||||
// verify against Freedom (`AAC!` + `VTKF090`/`VTKF100`) and a BD/UHD disc (no HD DVD dir).
|
||||
|
||||
#[test]
|
||||
fn role_paths_discovers_hddvd_dir_and_globs_all_vtkf_variants() {
|
||||
use crate::udf::fixture::*;
|
||||
// Freedom-shaped: an `AAC!` dir (NOT `ANY!`) holding MKBROM + two VTKF
|
||||
// variants (090/100, NOT 000) + a VTUF usage file (must be excluded),
|
||||
// plus the `AAC!_BAK` mirror (must NOT be picked as the AACS dir).
|
||||
let mut disc = MemDisc::new();
|
||||
let aacs_files = vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF100.AACS", 102, 5200, 2048, true),
|
||||
file("VTKF090.AACS", 103, 5300, 2048, true),
|
||||
file("VTUF090.AACS", 104, 5400, 2048, true),
|
||||
];
|
||||
let bak_files = vec![file("MKBROM.AACS", 110, 6000, 4096, true)];
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
DirSpec {
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: aacs_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
DirSpec {
|
||||
name: "AAC!_BAK".to_string(),
|
||||
icb_lba: 30,
|
||||
dir_data_lba: 31,
|
||||
files: bak_files,
|
||||
subdirs: vec![],
|
||||
},
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
// Discovered structurally (holds MKBROM.AACS) — the real
|
||||
// AACS dir, never the `_BAK` mirror.
|
||||
let dir = super::find_hddvd_aacs_dir(&udf).expect("aacs dir");
|
||||
assert_eq!(dir.name, "AAC!");
|
||||
|
||||
// UnitKey: BD/UHD paths first, then EVERY VTKF*.AACS in sorted order
|
||||
// (090 before 100) — NOT hardcoded VTKF000; VTUF (usage) excluded.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
"/AAC!/VTKF090.AACS".to_string(),
|
||||
"/AAC!/VTKF100.AACS".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/MKBROM.AACS"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::ContentCert)
|
||||
.last()
|
||||
.unwrap(),
|
||||
"/AAC!/CONTENT_CERT.AACS"
|
||||
);
|
||||
}
|
||||
|
||||
// "300" HD DVDs keep MKBROM/VTKF/CONTENT_CERT in a plain `/AACS` (mirror `AACS_BAK`):
|
||||
// found by MKBROM.AACS, not the name, and the mirror is skipped even when listed first.
|
||||
#[test]
|
||||
fn a_plain_aacs_directory_holding_mkbrom_is_the_hddvd_aacs_directory() {
|
||||
use crate::udf::fixture::*;
|
||||
let mut disc = MemDisc::new();
|
||||
let dir = |name: &str, lba: u32, files| DirSpec {
|
||||
name: name.to_string(),
|
||||
icb_lba: lba,
|
||||
dir_data_lba: lba + 1,
|
||||
files,
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![
|
||||
dir(
|
||||
"AACS_BAK",
|
||||
30,
|
||||
vec![file("MKBROM.AACS", 110, 6000, 4096, true)],
|
||||
),
|
||||
dir(
|
||||
"AACS",
|
||||
20,
|
||||
vec![
|
||||
file("MKBROM.AACS", 100, 5000, 4096, true),
|
||||
file("CONTENT_CERT.AACS", 101, 5100, 2048, true),
|
||||
file("VTKF000.AACS", 102, 5200, 2048, true),
|
||||
],
|
||||
),
|
||||
],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
assert_eq!(super::find_hddvd_aacs_dir(&udf).expect("dir").name, "AACS");
|
||||
let mkb = super::role_paths(&udf, super::AacsRole::Mkb);
|
||||
assert_eq!(mkb.last().unwrap(), "/AACS/MKBROM.AACS");
|
||||
let uk = super::role_paths(&udf, super::AacsRole::UnitKey);
|
||||
assert_eq!(uk.last().unwrap(), "/AACS/VTKF000.AACS");
|
||||
}
|
||||
|
||||
// A mirror alone is still used: a damaged primary must not leave the disc unkeyable.
|
||||
#[test]
|
||||
fn a_lone_bak_mirror_is_used_when_no_primary_exists() {
|
||||
use crate::udf::fixture::*;
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "ANY!_BAK".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![file("MKBROM.AACS", 100, 5000, 4096, true)],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
assert_eq!(
|
||||
super::find_hddvd_aacs_dir(&udf).expect("dir").name,
|
||||
"ANY!_BAK"
|
||||
);
|
||||
}
|
||||
|
||||
// MKBROM.AACS presence is required — else the HD DVD path resolves key files under a
|
||||
// dir holding none.
|
||||
#[test]
|
||||
fn a_bang_suffixed_directory_without_mkbrom_is_not_the_aacs_directory() {
|
||||
use crate::udf::fixture::*;
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
// Ends in '!' — but carries no MKBROM.AACS, so it is not the
|
||||
// HD DVD AACS directory.
|
||||
name: "AAC!".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("VTKF090.AACS", 102, 5200, 2048, true),
|
||||
file("CONTENT_CERT.AACS", 103, 5300, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(
|
||||
super::find_hddvd_aacs_dir(&udf).is_none(),
|
||||
"a '!' directory without MKBROM.AACS is not the AACS directory"
|
||||
);
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
],
|
||||
"no HD DVD candidates may be appended from a directory that was \
|
||||
never identified as the AACS directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_paths_bd_uhd_disc_yields_no_hddvd_candidates() {
|
||||
use crate::udf::fixture::*;
|
||||
// A `/AACS/` tree (BD/UHD) has no '!' directory → discovery finds none
|
||||
// and the candidate list is exactly the static BD/UHD paths.
|
||||
let mut disc = MemDisc::new();
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "AACS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("Unit_Key_RO.inf", 100, 5000, 2048, true),
|
||||
file("MKB_RO.inf", 101, 5100, 2048, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert!(super::find_hddvd_aacs_dir(&udf).is_none());
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::UnitKey),
|
||||
vec![
|
||||
super::PATH_UNIT_KEY_RO.to_string(),
|
||||
super::PATH_UNIT_KEY_RO_DUPLICATE.to_string(),
|
||||
]
|
||||
);
|
||||
// MKB fallback is the DUPLICATE copy of MKB_RO (libaacs `_mkb_open`), never
|
||||
// MKB_RW.inf — a different MKB that derives a different Media Key.
|
||||
assert_eq!(
|
||||
super::role_paths(&udf, super::AacsRole::Mkb),
|
||||
vec![
|
||||
"/AACS/MKB_RO.inf".to_string(),
|
||||
"/AACS/DUPLICATE/MKB_RO.inf".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
|
||||
//!
|
||||
//! An FMTS main feature interleaves short forensic **segments**, each with an
|
||||
//! **index** (1..32) selecting one of 32 forensic index keys to decrypt that
|
||||
//! segment's units in place of the ordinary CPS Unit Key. This table says
|
||||
//! WHERE the segments live and which index each carries.
|
||||
//!
|
||||
//! Format (validated against a retail AACS 2.1 disc):
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
|
||||
//! record[count] (16 bytes each):
|
||||
//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1)
|
||||
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
|
||||
//! ```
|
||||
|
||||
/// Fixed size of one `IndividualSegment.tbl` record.
|
||||
pub const SEGMENT_RECORD_LEN: usize = 16;
|
||||
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
|
||||
pub const SOURCE_PACKET_LEN: u64 = crate::consts::BD_SOURCE_PACKET_BYTES as u64;
|
||||
|
||||
/// One forensic segment: the inclusive source-packet range it occupies in the
|
||||
/// FMTS clip.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Segment {
|
||||
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
|
||||
/// table rather than counting up — it selects WHICH of the 32 index keys
|
||||
/// decrypts this range. (`0` is not used here; the default/non-forensic
|
||||
/// content carries no segment record at all.)
|
||||
pub index: u16,
|
||||
/// First source packet of the segment (inclusive).
|
||||
pub start_spn: u32,
|
||||
/// Last source packet of the segment (inclusive).
|
||||
pub end_spn: u32,
|
||||
}
|
||||
|
||||
impl Segment {
|
||||
/// Source-packet count in this (inclusive) segment.
|
||||
pub fn packet_count(&self) -> u32 {
|
||||
self.end_spn
|
||||
.saturating_sub(self.start_spn)
|
||||
.saturating_add(1)
|
||||
}
|
||||
|
||||
/// Byte offset of the segment start within the clip (`start_spn * 192`).
|
||||
pub fn start_byte(&self) -> u64 {
|
||||
self.start_spn as u64 * SOURCE_PACKET_LEN
|
||||
}
|
||||
|
||||
/// Byte length of the segment (`packet_count * 192`).
|
||||
pub fn byte_len(&self) -> u64 {
|
||||
self.packet_count() as u64 * SOURCE_PACKET_LEN
|
||||
}
|
||||
|
||||
/// True when source packet `spn` falls inside this segment.
|
||||
pub fn contains_spn(&self, spn: u32) -> bool {
|
||||
spn >= self.start_spn && spn <= self.end_spn
|
||||
}
|
||||
|
||||
/// True when the inclusive source-packet span `[first, last]` overlaps this
|
||||
/// segment. Used to decide whether an aligned unit (which spans several
|
||||
/// packets) touches the segment at all, not just whether one packet does.
|
||||
pub fn overlaps_spn(&self, first: u32, last: u32) -> bool {
|
||||
first <= self.end_spn && last >= self.start_spn
|
||||
}
|
||||
}
|
||||
|
||||
/// Source packets spanned by one AACS aligned unit: `6144 / 192 = 32`.
|
||||
pub const PACKETS_PER_UNIT: u32 =
|
||||
(crate::aacs::content::ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32;
|
||||
|
||||
/// Byte offset within the clip of a clip-relative 2048-byte sector `lba`. The
|
||||
/// FMTS decode reads the clip file directly, so `lba` 0 is the clip's first
|
||||
/// byte and this offset lines up with the source-packet grid the segment map
|
||||
/// uses.
|
||||
pub fn lba_byte_offset(lba: u32) -> u64 {
|
||||
lba as u64 * crate::consts::SECTOR_BYTES_U64
|
||||
}
|
||||
|
||||
/// The forensic segment an AACS aligned unit belongs to, if any, given the
|
||||
/// unit's clip-relative byte offset.
|
||||
///
|
||||
/// A unit overlapping a segment must be opened with that segment's index key (selected by
|
||||
/// `index`), not the CPS Unit Key.
|
||||
///
|
||||
/// Tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a unit that
|
||||
/// only partly overlaps a segment edge still counts as forensic.
|
||||
pub fn segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
|
||||
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
|
||||
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
|
||||
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
|
||||
segments.iter().find(|s| s.overlaps_spn(first, last))
|
||||
}
|
||||
|
||||
/// Parse `IndividualSegment.tbl` into its forensic segments, in table
|
||||
/// order. Returns `None` when the header is malformed, the record size is not
|
||||
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
|
||||
/// so a truncated / foreign table degrades to "no segment map" rather than
|
||||
/// yielding bogus ranges.
|
||||
pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
|
||||
if tbl.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let count = u16::from_be_bytes([tbl[4], tbl[5]]) as usize;
|
||||
let record_size = u16::from_be_bytes([tbl[6], tbl[7]]) as usize;
|
||||
if record_size != SEGMENT_RECORD_LEN {
|
||||
return None;
|
||||
}
|
||||
if 8usize.checked_add(count.checked_mul(record_size)?)? > tbl.len() {
|
||||
return None;
|
||||
}
|
||||
let mut segments = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let o = 8 + i * record_size;
|
||||
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
|
||||
let index = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
|
||||
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
|
||||
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
|
||||
segments.push(Segment {
|
||||
index,
|
||||
start_spn,
|
||||
end_spn,
|
||||
});
|
||||
}
|
||||
Some(segments)
|
||||
}
|
||||
|
||||
/// Map a clip-relative byte offset to the absolute LBA that holds it, by walking
|
||||
/// the title's extents (the `.fmts` clip's sectors in file order). Segment
|
||||
/// offsets in [`Segment`] are clip-relative source-packet numbers, so this is how
|
||||
/// a segment's `spn` range becomes disc LBAs. `None` if the offset is past the
|
||||
/// clip.
|
||||
pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Option<u32> {
|
||||
let mut cum = 0u64;
|
||||
for e in extents {
|
||||
let len = e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64;
|
||||
if clip_byte < cum + len {
|
||||
let sector_in_ext = ((clip_byte - cum) / crate::consts::SECTOR_BYTES as u64) as u32;
|
||||
return Some(e.start_lba.saturating_add(sector_in_ext));
|
||||
}
|
||||
cum += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map.
|
||||
///
|
||||
/// Each forensic segment's clip-relative source-packet span becomes an absolute LBA range
|
||||
/// tagged with the key its `index` selects (via `index_to_key_idx`). Ranges outside every
|
||||
/// segment are left for the map's default (the Unit Key). A segment straddling a UDF extent
|
||||
/// boundary is skipped rather than emitting a wrong span.
|
||||
///
|
||||
/// Not used by the live FMTS path (`keys::fmts` + `mux::resolve`); kept as a public
|
||||
/// helper for callers that build their own key map from the segment table.
|
||||
#[doc(hidden)]
|
||||
pub fn fmts_key_ranges(
|
||||
segments: &[Segment],
|
||||
extents: &[crate::disc::Extent],
|
||||
index_to_key_idx: &dyn Fn(u16) -> usize,
|
||||
) -> Vec<(u32, u32, usize)> {
|
||||
let mut ranges = Vec::new();
|
||||
for s in segments {
|
||||
// SPNs are untrusted (from IndividualSegment.tbl); an inverted record
|
||||
// (start_spn > end_spn) would underflow `end_byte - 1 - start_byte` below.
|
||||
if s.start_spn > s.end_spn {
|
||||
continue;
|
||||
}
|
||||
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
|
||||
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
|
||||
// Map first/last sector to LBAs. Segments (~480 KB) rarely cross the
|
||||
// GB-sized extent boundary, but if the two ends land in different
|
||||
// extents, skip rather than emit a wrong span (falls back to Unit Key).
|
||||
let (Some(a), Some(b)) = (
|
||||
clip_byte_to_lba(extents, start_byte),
|
||||
clip_byte_to_lba(extents, end_byte - 1),
|
||||
) 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 {
|
||||
ranges.push((a, b + 1, index_to_key_idx(s.index)));
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "segment_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,95 +0,0 @@
|
||||
//! AACS 2.1 FMTS forensic segment keys, `AACS/SegmentKeyNNNNN.tbl`.
|
||||
//!
|
||||
//! One file per CPS unit. On-disc key store for the forensic variant segments
|
||||
//! mapped by [`super::segment`]; a device derives a 16-bit variant selector
|
||||
//! from the Media Key Variant chain (see [`super::variant`]) and uses it to
|
||||
//! index this table.
|
||||
//!
|
||||
//! Container format:
|
||||
//! ```text
|
||||
//! header (8 bytes): u32 tag | u16 index_space | u16 record_size
|
||||
//! record[index_space] (record_size bytes each)
|
||||
//! ```
|
||||
//! Record layout beyond an 8-byte sub-header is not yet reversed.
|
||||
|
||||
/// Bytes of the fixed file header.
|
||||
pub const HEADER_LEN: usize = 8;
|
||||
|
||||
/// The on-disc segment-key table container. Borrows the file bytes; a record is
|
||||
/// looked up by the 16-bit variant selector.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SegmentKeyTable<'a> {
|
||||
data: &'a [u8],
|
||||
/// Number of records (the selector index space, e.g. 65536).
|
||||
count: usize,
|
||||
/// Bytes per record (e.g. 536).
|
||||
record_size: usize,
|
||||
}
|
||||
|
||||
impl<'a> SegmentKeyTable<'a> {
|
||||
/// Parse and validate the container header against the buffer length.
|
||||
///
|
||||
/// Returns `None` when the buffer is too small, or the declared
|
||||
/// `count * record_size` (plus header) does not match the buffer, so a
|
||||
/// truncated or foreign table degrades to "no segment keys" rather than
|
||||
/// handing back bogus records. `index_space` of `0xffff` is read as the full
|
||||
/// 65536-entry space (a device selector is a full 16-bit value).
|
||||
pub fn parse(data: &'a [u8]) -> Option<Self> {
|
||||
if data.len() < HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
let index_space = u16::from_be_bytes([data[4], data[5]]);
|
||||
let record_size = u16::from_be_bytes([data[6], data[7]]) as usize;
|
||||
// 0xffff means the full 16-bit selector space (65536 records).
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
if record_size == 0 {
|
||||
return None;
|
||||
}
|
||||
let body = count.checked_mul(record_size)?;
|
||||
if HEADER_LEN.checked_add(body)? != data.len() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
data,
|
||||
count,
|
||||
record_size,
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of records (the selector index space).
|
||||
pub fn record_count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
/// Bytes per record.
|
||||
pub fn record_size(&self) -> usize {
|
||||
self.record_size
|
||||
}
|
||||
|
||||
/// The raw record for a 16-bit variant `selector`, including its 8-byte
|
||||
/// sub-header. `None` if the selector is past the table (only possible when
|
||||
/// `index_space` was not the full 16-bit space).
|
||||
pub fn record(&self, selector: u16) -> Option<&'a [u8]> {
|
||||
let idx = selector as usize;
|
||||
if idx >= self.count {
|
||||
return None;
|
||||
}
|
||||
let start = HEADER_LEN + idx * self.record_size;
|
||||
self.data.get(start..start + self.record_size)
|
||||
}
|
||||
|
||||
/// The encrypted key payload for a selector: the record with its 8-byte
|
||||
/// sub-header stripped. The internal layout of these bytes is not yet
|
||||
/// reversed (see module docs).
|
||||
pub fn record_payload(&self, selector: u16) -> Option<&'a [u8]> {
|
||||
self.record(selector).and_then(|r| r.get(HEADER_LEN..))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "segment_key_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,58 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Build a container with `record_size` and the given `index_space`, filling
|
||||
/// each record with a distinguishable byte so lookups can be checked.
|
||||
fn build(index_space: u16, record_size: u16) -> Vec<u8> {
|
||||
let count = if index_space == 0xffff {
|
||||
0x1_0000
|
||||
} else {
|
||||
index_space as usize
|
||||
};
|
||||
let mut v = Vec::with_capacity(HEADER_LEN + count * record_size as usize);
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // tag
|
||||
v.extend_from_slice(&index_space.to_be_bytes());
|
||||
v.extend_from_slice(&record_size.to_be_bytes());
|
||||
for i in 0..count {
|
||||
let mut rec = vec![(i & 0xff) as u8; record_size as usize];
|
||||
// sub-header, as seen on disc
|
||||
rec[..8].copy_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
v.extend_from_slice(&rec);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_retail_container_geometry() {
|
||||
// The real disc: 0xffff index space, 536-byte records, 35,127,304 total.
|
||||
let data = build(0xffff, 536);
|
||||
assert_eq!(
|
||||
data.len(),
|
||||
35_127_304,
|
||||
"matches the retail file size exactly"
|
||||
);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 65_536);
|
||||
assert_eq!(t.record_size(), 536);
|
||||
let rec = t.record(0x1234).expect("record");
|
||||
assert_eq!(rec.len(), 536);
|
||||
assert_eq!(rec[8], 0x34, "record 0x1234 carries its own fixture tag");
|
||||
assert_eq!(&rec[..8], &[0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x02]);
|
||||
assert_eq!(t.record_payload(0x1234).unwrap().len(), 528);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_index_space_bounds_lookups() {
|
||||
let data = build(4, 32);
|
||||
let t = SegmentKeyTable::parse(&data).expect("parse");
|
||||
assert_eq!(t.record_count(), 4);
|
||||
assert!(t.record(3).is_some());
|
||||
assert!(t.record(4).is_none(), "selector past the table is None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_size_mismatch_and_truncation() {
|
||||
assert!(SegmentKeyTable::parse(&[0u8; 4]).is_none());
|
||||
let mut data = build(4, 32);
|
||||
data.truncate(data.len() - 1); // body no longer matches header
|
||||
assert!(SegmentKeyTable::parse(&data).is_none());
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
|
||||
/// records. `recs` are `(index, start_spn, end_spn)`.
|
||||
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
|
||||
v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); // count
|
||||
v.extend_from_slice(&(SEGMENT_RECORD_LEN as u16).to_be_bytes()); // record_size
|
||||
for &(n, s, e) in recs {
|
||||
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // marker
|
||||
v.extend_from_slice(&n.to_be_bytes());
|
||||
v.extend_from_slice(&1u16.to_be_bytes()); // flag
|
||||
v.extend_from_slice(&s.to_be_bytes());
|
||||
v.extend_from_slice(&e.to_be_bytes());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
|
||||
use crate::disc::Extent;
|
||||
// One big clip extent starting at LBA 1000. Clip byte B lives at
|
||||
// LBA 1000 + B/2048.
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
|
||||
let segs = vec![
|
||||
Segment {
|
||||
index: 5,
|
||||
start_spn: 100,
|
||||
end_spn: 199,
|
||||
},
|
||||
Segment {
|
||||
index: 7,
|
||||
start_spn: 10_000,
|
||||
end_spn: 10_099,
|
||||
},
|
||||
];
|
||||
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert_eq!(ranges.len(), 2, "one LBA range per segment");
|
||||
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
|
||||
// → LBA 1009..1019, key index 5.
|
||||
assert_eq!(ranges[0], (1009, 1019, 5));
|
||||
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
|
||||
// sectors 937..=946 → LBA 1937..1947, key index 7.
|
||||
assert_eq!(ranges[1], (1937, 1947, 7));
|
||||
|
||||
// The ranges drive a positive AacsKeyMap: an LBA in no range has no key.
|
||||
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges);
|
||||
assert_eq!(map.key_idx_for(500), None, "outside any segment → no key");
|
||||
assert_eq!(
|
||||
map.key_idx_for(1012),
|
||||
Some(5),
|
||||
"inside index-5 segment → key 5"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1940),
|
||||
Some(7),
|
||||
"inside index-7 segment → key 7"
|
||||
);
|
||||
assert_eq!(
|
||||
map.key_idx_for(1019),
|
||||
None,
|
||||
"segment end is exclusive → no key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_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;
|
||||
let extents = vec![Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 1_000_000,
|
||||
}];
|
||||
// start_spn == end_spn + 1: `end_byte - 1 - start_byte` would underflow.
|
||||
// The record must be skipped rather than panic (debug) / wrap (release).
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 200,
|
||||
end_spn: 199,
|
||||
}];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "inverted segment yields no range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmts_key_ranges_skips_a_segment_straddling_non_adjacent_extents() {
|
||||
use crate::disc::Extent;
|
||||
// Clip bytes [0, 20480) at LBA 100.., [20480, 40960) at LBA 500.. (or reversed).
|
||||
// Packets 104..=109 span bytes 19968..21119, crossing the extent boundary.
|
||||
let segs = vec![Segment {
|
||||
index: 5,
|
||||
start_spn: 104,
|
||||
end_spn: 109,
|
||||
}];
|
||||
for (first, second) in [(100, 500), (500, 100)] {
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: first,
|
||||
sector_count: 10,
|
||||
},
|
||||
Extent {
|
||||
start_lba: second,
|
||||
sector_count: 10,
|
||||
},
|
||||
];
|
||||
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
|
||||
assert!(ranges.is_empty(), "{first}/{second}: {ranges:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_edges_are_inclusive() {
|
||||
let seg = Segment {
|
||||
index: 1,
|
||||
start_spn: 100,
|
||||
end_spn: 200,
|
||||
};
|
||||
assert!(seg.contains_spn(100) && seg.contains_spn(200));
|
||||
assert!(!seg.contains_spn(99) && !seg.contains_spn(201));
|
||||
// A unit touching exactly one edge packet overlaps.
|
||||
assert!(seg.overlaps_spn(69, 100));
|
||||
assert!(seg.overlaps_spn(200, 231));
|
||||
assert!(!seg.overlaps_spn(68, 99));
|
||||
assert!(!seg.overlaps_spn(201, 232));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_byte_to_lba_walks_extents() {
|
||||
use crate::disc::Extent;
|
||||
let extents = vec![
|
||||
Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [0, 20480)
|
||||
Extent {
|
||||
start_lba: 500,
|
||||
sector_count: 10,
|
||||
}, // clip bytes [20480, 40960)
|
||||
];
|
||||
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
|
||||
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
|
||||
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_real_disc_layout() {
|
||||
// First three records observed on retail 2.1: the variant
|
||||
// field counts 1,2,3,… (it wraps at 32 further into the table — see
|
||||
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
|
||||
let tbl = build_tbl(&[
|
||||
(1, 343680, 346239),
|
||||
(2, 695616, 698175),
|
||||
(3, 1051840, 1054399),
|
||||
]);
|
||||
let segs = parse_individual_segments(&tbl).expect("parse");
|
||||
assert_eq!(segs.len(), 3);
|
||||
assert_eq!(segs[0].index, 1);
|
||||
assert_eq!(segs[1].index, 2);
|
||||
assert_eq!(segs[2].index, 3);
|
||||
assert_eq!(segs[0].start_spn, 343680);
|
||||
assert_eq!(segs[0].end_spn, 346239);
|
||||
assert_eq!(segs[0].packet_count(), 2560);
|
||||
assert_eq!(segs[0].byte_len(), 2560 * 192);
|
||||
assert_eq!(segs[0].start_byte(), 343680 * 192);
|
||||
assert!(segs[0].contains_spn(345000));
|
||||
assert!(!segs[0].contains_spn(343679));
|
||||
assert!(!segs[0].contains_spn(346240));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_record_size() {
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[6..8].copy_from_slice(&20u16.to_be_bytes()); // record_size != 16
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_and_overrun() {
|
||||
assert!(parse_individual_segments(&[0u8; 4]).is_none()); // < header
|
||||
let mut tbl = build_tbl(&[(1, 0, 10)]);
|
||||
tbl[4..6].copy_from_slice(&99u16.to_be_bytes()); // claims 99 recs, has 1
|
||||
assert!(parse_individual_segments(&tbl).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_table_is_empty_not_none() {
|
||||
let tbl = build_tbl(&[]);
|
||||
assert_eq!(parse_individual_segments(&tbl), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_per_unit_is_thirty_two() {
|
||||
// 6144-byte aligned unit / 192-byte source packet.
|
||||
assert_eq!(PACKETS_PER_UNIT, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_inside_segment_routes_to_index() {
|
||||
// A real first-record segment: packets [343680, 346239].
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
|
||||
let off = 344000u64 * SOURCE_PACKET_LEN;
|
||||
let hit = segment_for_unit(&segs, off).expect("inside the segment");
|
||||
assert_eq!(hit.index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_field_cycles_one_to_thirty_two() {
|
||||
// Reality on a retail 2.1 disc: field@4 is the index, cycling 1..=32 in file
|
||||
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
|
||||
let mut recs = Vec::new();
|
||||
let mut spn = 1000u32;
|
||||
for row in 0..2 {
|
||||
for v in 1..=32u16 {
|
||||
recs.push((v, spn, spn + 2559));
|
||||
spn += 50_000; // ~one segment every ~67 MB
|
||||
}
|
||||
let _ = row;
|
||||
}
|
||||
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
|
||||
assert_eq!(segs.len(), 64);
|
||||
assert_eq!(segs[31].index, 32); // end of first cycle
|
||||
assert_eq!(segs[32].index, 1); // wraps, does not become 33
|
||||
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_outside_every_segment_is_unit_key_miss() {
|
||||
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
|
||||
// A unit well before the segment is ordinary content → None (unit-key path).
|
||||
let off = 1000u64 * SOURCE_PACKET_LEN;
|
||||
assert!(segment_for_unit(&segs, off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_straddling_a_segment_edge_counts_as_forensic() {
|
||||
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
|
||||
// packets straddle the boundary) must still route to the index key,
|
||||
// because part of its ciphertext is forensic-encrypted.
|
||||
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
|
||||
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
|
||||
let off = 80u64 * SOURCE_PACKET_LEN;
|
||||
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
|
||||
assert_eq!(hit.index, 7);
|
||||
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
|
||||
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
|
||||
assert!(segment_for_unit(&segs, before).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_segments_never_routes_to_index() {
|
||||
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
|
||||
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lba_maps_to_the_packet_grid() {
|
||||
// A unit is 3 sectors (6144 bytes) = 32 packets. Clip-relative LBA 3 is
|
||||
// the second aligned unit, which starts at packet 32.
|
||||
let off = lba_byte_offset(3);
|
||||
assert_eq!(off / SOURCE_PACKET_LEN, 32);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
//! Structured resolution trace — what the unlock + key-resolution attempt did.
|
||||
//!
|
||||
//! No user-facing English: every step's STATE is a typed enum variant that applications render
|
||||
//! into localized text. The `who` field of each step is a stable identifier (e.g. a
|
||||
//! `label()`/`name()`), never prose, carried verbatim.
|
||||
|
||||
/// The full trace of a resolution attempt: the unlock phase, then the
|
||||
/// key-resolution phase.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ResolutionTrace {
|
||||
/// One step per unlocker consulted, in consultation order.
|
||||
pub unlock: Vec<UnlockStep>,
|
||||
/// One step per key source consulted, in consultation order.
|
||||
pub keys: Vec<KeyStep>,
|
||||
}
|
||||
|
||||
impl ResolutionTrace {
|
||||
/// An empty trace (no steps recorded).
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unlock phase ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One unlocker's contribution to the unlock phase. `who` is the unlocker's
|
||||
/// `name()` (a stable, product-neutral identifier), carried verbatim.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UnlockStep {
|
||||
pub who: String,
|
||||
pub outcome: UnlockOutcome,
|
||||
}
|
||||
|
||||
/// What an unlocker did.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UnlockOutcome {
|
||||
/// The drive was unlocked (or already usable) and a VID is available.
|
||||
Unlocked,
|
||||
/// This unlocker cannot unlock this drive's firmware.
|
||||
FirmwareNotUnlockable,
|
||||
/// No non-revoked host cert was usable for the auth attempt. `mkb` is the
|
||||
/// disc MKB generation when known.
|
||||
NoUsableHostCert { mkb: Option<u32> },
|
||||
/// Every available host cert was revoked on this drive's HRL. `mkb` is the
|
||||
/// disc MKB generation when known.
|
||||
CertRevoked { mkb: Option<u32> },
|
||||
/// The drive rejected the auth handshake (non-revocation rejection / wedge).
|
||||
HandshakeRejected,
|
||||
/// Auth succeeded (or was skipped) but the Volume ID could not be read.
|
||||
VidUnavailable,
|
||||
}
|
||||
|
||||
// ── Key-resolution phase ────────────────────────────────────────────────────
|
||||
|
||||
/// One key source's contribution to the key-resolution phase, including the
|
||||
/// derivation path it walked. `who` is the source's `label()` (a stable
|
||||
/// identifier, e.g. `"keydb"` / `"online"`), carried verbatim.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
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
|
||||
/// every path hits every node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyNode {
|
||||
/// The source matched this disc (by hash / VID).
|
||||
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.
|
||||
FoundVuk,
|
||||
/// A Media Key was found.
|
||||
FoundMediaKey,
|
||||
/// A VID is required to proceed.
|
||||
NeedVid,
|
||||
/// The VID came from the unlock phase.
|
||||
VidFromUnlock,
|
||||
/// The VID came from the keydb entry.
|
||||
VidFromKeydb,
|
||||
/// No VID was available.
|
||||
NoVid,
|
||||
/// A VUK was derived (from MK + VID).
|
||||
DerivedVuk,
|
||||
/// Unit keys were derived (from VUK).
|
||||
DerivedUnitKeys,
|
||||
}
|
||||
|
||||
/// [`KeyStep::who`] of the single step recorded when no source was consulted because
|
||||
/// the disc is bus-blocked (its reason is on `Disc::aacs_error`).
|
||||
pub const BUS_BLOCKED: &str = "bus_blocked";
|
||||
|
||||
/// The terminal outcome of a source's resolution attempt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyOutcome {
|
||||
/// Usable unit keys were produced.
|
||||
Resolved,
|
||||
/// Derivation material existed but no VID was available to finish.
|
||||
MissingVid,
|
||||
/// No usable key from this source.
|
||||
NoKey,
|
||||
}
|
||||
|
||||
/// Where an acquired key came from, for the ring's trace and the engine's key status.
|
||||
/// Variants are ordered root-of-trust → per-disc leaf.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyOrigin {
|
||||
/// MKB + device keys → subset-difference tree → VUK
|
||||
DeviceKey,
|
||||
/// MKB + processing keys → media key → VUK
|
||||
ProcessingKey,
|
||||
/// Media key + Volume ID from KEYDB → derived VUK
|
||||
KeyDbDerived,
|
||||
/// VUK found directly in KEYDB by disc hash
|
||||
KeyDb,
|
||||
/// Pre-decrypted unit keys taken directly from KEYDB by disc hash.
|
||||
/// No VUK present in the entry — `AacsState::vuk` is `None`.
|
||||
KeyDbUnitKeys,
|
||||
/// Unit key supplied directly by the caller (the external Unit Key path).
|
||||
/// No keydb, no derivation — `AacsState::vuk` is `None`.
|
||||
ExternalUk,
|
||||
}
|
||||
|
||||
// No `KeyOrigin::name()`: the library holds ZERO user-facing English. Callers
|
||||
// map variants to display text (see freemkv's `disc_info::key_origin_label`).
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "trace_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,35 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// The trace types are constructible, derive the required traits, and an
|
||||
/// empty trace round-trips. Pins the structural contract apps build against.
|
||||
#[test]
|
||||
fn trace_is_constructible_and_comparable() {
|
||||
let t = ResolutionTrace {
|
||||
unlock: vec![UnlockStep {
|
||||
who: "AACS cert".to_string(),
|
||||
outcome: UnlockOutcome::NoUsableHostCert { mkb: Some(68) },
|
||||
}],
|
||||
keys: vec![KeyStep {
|
||||
who: "keydb".to_string(),
|
||||
path: vec![
|
||||
KeyNode::MatchedDisc,
|
||||
KeyNode::FoundVuk,
|
||||
KeyNode::DerivedUnitKeys,
|
||||
],
|
||||
outcome: KeyOutcome::Resolved,
|
||||
matched_entry: None,
|
||||
store_entries: None,
|
||||
}],
|
||||
};
|
||||
// Clone + PartialEq (derive contract the renderers rely on).
|
||||
assert_eq!(t.clone(), t);
|
||||
// `who` is the source's name carried verbatim.
|
||||
assert_eq!(t.keys[0].who, "keydb");
|
||||
assert_eq!(t.unlock[0].who, "AACS cert");
|
||||
// Apps match on this id.
|
||||
assert_eq!(BUS_BLOCKED, "bus_blocked");
|
||||
// Default / new is empty.
|
||||
assert_eq!(ResolutionTrace::new(), ResolutionTrace::default());
|
||||
assert!(ResolutionTrace::new().unlock.is_empty());
|
||||
assert!(ResolutionTrace::new().keys.is_empty());
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
//! AACS primitive types shared across the resolve chain.
|
||||
//!
|
||||
//! These structs describe AACS key material (device keys, host
|
||||
//! certificates, per-disc entries). They carry no parsing logic — the
|
||||
//! keydb.cfg format lives in the `freemkv-keysources` crate. libfreemkv
|
||||
//! owns only the crypto and these value types that flow through it.
|
||||
|
||||
/// A device key for MKB subset-difference tree processing.
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceKey {
|
||||
pub key: [u8; 16],
|
||||
pub node: u16,
|
||||
pub uv: u32,
|
||||
pub u_mask_shift: u8,
|
||||
}
|
||||
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0 host private key, 20 bytes (AACS 2.0 uses `private_key_v2`).
|
||||
pub private_key: [u8; 20],
|
||||
/// AACS 1.0 host certificate, 92 bytes (AACS 2.0 uses `certificate_v2`).
|
||||
pub certificate: Vec<u8>,
|
||||
/// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only.
|
||||
pub private_key_v2: Option<[u8; 32]>,
|
||||
/// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only.
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vid(pub [u8; 16]);
|
||||
|
||||
/// Media Key (Km, 16 bytes) — the MKB-scoped key derived from device keys.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MediaKey(pub [u8; 16]);
|
||||
|
||||
/// Volume Unique Key (VUK / Kvu, 16 bytes) — derived from `MediaKey` + `Vid`,
|
||||
/// decrypts the per-disc encrypted title keys in `Unit_Key_RO.inf`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||
/// device-key walk derives at its matching SD node.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
/// handed to the VUK→UK step (i.e. its order in `Unit_Key_RO.inf`'s key-storage
|
||||
/// area). The CPS-unit *number* association is a higher-level concern owned by
|
||||
/// [`super::inf::parse_unit_key_ro`], which pairs each positional key with its
|
||||
/// declared CPS unit; this primitive only does the AES, so it surfaces position.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct UnitKey {
|
||||
pub idx: u32,
|
||||
pub key: [u8; 16],
|
||||
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
|
||||
///
|
||||
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
|
||||
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
|
||||
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
|
||||
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
|
||||
/// (the 65536-value device selector), which is a separate MKB-layer concern.
|
||||
pub index_number: u8,
|
||||
}
|
||||
|
||||
impl UnitKey {
|
||||
/// An ordinary (non-forensic) unit key: `index_number == 0`. The value
|
||||
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
|
||||
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
index_number: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A forensic index key: `index_number` in `1..=32`, decrypting the
|
||||
/// `IndividualSegment.tbl` segments tagged with that index.
|
||||
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
key,
|
||||
index_number,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
|
||||
pub const fn is_default_index(&self) -> bool {
|
||||
self.index_number == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
#[derive(Clone)]
|
||||
pub struct DiscEntry {
|
||||
/// Disc hash (20 bytes, hex)
|
||||
pub disc_hash: String,
|
||||
/// Disc title
|
||||
pub title: String,
|
||||
/// Media Key (16 bytes) — from MKB processing
|
||||
pub media_key: Option<[u8; 16]>,
|
||||
/// Disc ID (16 bytes)
|
||||
pub disc_id: Option<[u8; 16]>,
|
||||
/// Volume Unique Key (16 bytes) — decrypts title keys
|
||||
pub vuk: Option<[u8; 16]>,
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
// ── Redacting `Debug` impls: types above carry AACS secrets; derived `Debug`
|
||||
// would leak key bytes via `debug!`/panics. These print only non-secret shape,
|
||||
// kept (not omitted) since used in `assert_eq!`. See `redaction_tests`.
|
||||
|
||||
impl std::fmt::Debug for DeviceKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeviceKey")
|
||||
.field("key", &"<redacted>")
|
||||
.field("node", &self.node)
|
||||
.field("uv", &self.uv)
|
||||
.field("u_mask_shift", &self.u_mask_shift)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HostCert {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HostCert")
|
||||
.field("private_key", &"<redacted>")
|
||||
.field("certificate_len", &self.certificate.len())
|
||||
.field("private_key_v2", &self.private_key_v2.map(|_| "<redacted>"))
|
||||
.field(
|
||||
"certificate_v2_len",
|
||||
&self.certificate_v2.as_ref().map(|c| c.len()),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vid {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vid(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MediaKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("MediaKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Vuk {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Vuk(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProcessingKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("ProcessingKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for UnitKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("UnitKey")
|
||||
.field("idx", &self.idx)
|
||||
.field("key", &"<redacted>")
|
||||
.field("index_number", &self.index_number)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DiscEntry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DiscEntry")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("title", &self.title)
|
||||
.field("media_key", &self.media_key.map(|_| "<redacted>"))
|
||||
.field("disc_id", &self.disc_id.map(|_| "<redacted>"))
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "types_unit_key_tests.rs"]
|
||||
mod unit_key_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "types_redaction_tests.rs"]
|
||||
mod redaction_tests;
|
||||
@@ -1,65 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Sentinel byte 0xD5 = decimal 213: a derived `Debug` prints `[u8;N]` as
|
||||
// decimal, so a leak surfaces "213" (no non-secret field is 213). Each type
|
||||
// must also carry a "redacted" marker so re-adding `#[derive(Debug)]` fails.
|
||||
const S: u8 = 0xD5;
|
||||
|
||||
fn assert_redacted(what: &str, dbg: &str) {
|
||||
assert!(
|
||||
!dbg.contains("213"),
|
||||
"{what}: Debug leaked key bytes (found decimal 213): {dbg}"
|
||||
);
|
||||
assert!(
|
||||
dbg.contains("redacted"),
|
||||
"{what}: Debug missing redaction marker: {dbg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_key_debug_is_redacted() {
|
||||
let d = DeviceKey {
|
||||
key: [S; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 3,
|
||||
};
|
||||
assert_redacted("DeviceKey", &format!("{d:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_cert_debug_is_redacted() {
|
||||
let h = HostCert {
|
||||
private_key: [S; 20],
|
||||
certificate: vec![0u8; 92],
|
||||
private_key_v2: Some([S; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
assert_redacted("HostCert", &format!("{h:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newtype_keys_debug_is_redacted() {
|
||||
assert_redacted("Vid", &format!("{:?}", Vid([S; 16])));
|
||||
assert_redacted("MediaKey", &format!("{:?}", MediaKey([S; 16])));
|
||||
assert_redacted("Vuk", &format!("{:?}", Vuk([S; 16])));
|
||||
assert_redacted("ProcessingKey", &format!("{:?}", ProcessingKey([S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_key_debug_is_redacted() {
|
||||
assert_redacted("UnitKey", &format!("{:?}", UnitKey::new(0, [S; 16])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_entry_debug_is_redacted() {
|
||||
let e = DiscEntry {
|
||||
disc_hash: "0xAA".into(),
|
||||
title: "T".into(),
|
||||
media_key: Some([S; 16]),
|
||||
disc_id: Some([S; 16]),
|
||||
vuk: Some([S; 16]),
|
||||
unit_keys: vec![(1, [S; 16])],
|
||||
};
|
||||
assert_redacted("DiscEntry", &format!("{e:?}"));
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
// Pinned against the two named constructors: `UnitKey::new` is the ordinary key,
|
||||
// `UnitKey::forensic` is an index key.
|
||||
#[test]
|
||||
fn is_default_index_separates_the_two_constructors() {
|
||||
let ordinary = UnitKey::new(0, [0xAA; 16]);
|
||||
assert!(
|
||||
ordinary.is_default_index(),
|
||||
"UnitKey::new builds the ordinary (index-0) key"
|
||||
);
|
||||
|
||||
// Every forensic index the spec allows must be reported as NOT default.
|
||||
for n in 1u8..=32 {
|
||||
let k = UnitKey::forensic(0, [0xAA; 16], n);
|
||||
assert!(
|
||||
!k.is_default_index(),
|
||||
"UnitKey::forensic({n}) is an index key, not the default key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Must agree with `resolve_disc_index`, the one consumer of `index_number`.
|
||||
#[test]
|
||||
fn is_default_index_agrees_with_the_forensic_index_resolver() {
|
||||
use crate::aacs::index_select::resolve_disc_index;
|
||||
|
||||
let keys = [
|
||||
UnitKey::new(0, [0x11; 16]),
|
||||
UnitKey::forensic(1, [0x22; 16], 7),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_disc_index(&keys),
|
||||
Some(7),
|
||||
"sanity: the resolver picks the forensic key's index"
|
||||
);
|
||||
|
||||
let non_default: Vec<u8> = keys
|
||||
.iter()
|
||||
.filter(|k| !k.is_default_index())
|
||||
.map(|k| k.index_number)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
non_default,
|
||||
vec![7],
|
||||
"exactly the key the resolver picked must be non-default"
|
||||
);
|
||||
|
||||
// An all-ordinary key set resolves no index, and every key must report
|
||||
// itself default.
|
||||
let plain = [UnitKey::new(0, [0x11; 16]), UnitKey::new(1, [0x22; 16])];
|
||||
assert_eq!(resolve_disc_index(&plain), None);
|
||||
assert!(plain.iter().all(|k| k.is_default_index()));
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
//! AACS Media Key Variant chain.
|
||||
//!
|
||||
//! On AACS 2.1 the Media Key derivation gains a second stage on top of the classical
|
||||
//! subset-difference walk: the walk yields a Media Key Precursor (Kmp), combined with disc VKD
|
||||
//! and a per-licensee KCD constant to produce the Media Key. Entry point:
|
||||
//! [`derive_media_key_variant`] (`Kp -> Km`); `Kp` comes from [`walk_processing_key`]. No
|
||||
//! `0x2d`/`0x2f`/`0x0c` records falls back to [`super::derive`].
|
||||
//!
|
||||
//! ```text
|
||||
//! Kmp = AES-128D(Kp, C) XOR uv
|
||||
//! Kpnew = Kmp XOR KCD
|
||||
//! Kvn = AES-G(Kp, Nonce) & 0xFFFF (low 16 bits, BE)
|
||||
//! VKD_idx = Kvn XOR VARIANTS[uv]
|
||||
//! VKD = vkd_table[VKD_idx * 16 .. +16]
|
||||
//! Km = AES-128D(Kpnew, VKD) XOR uv
|
||||
//! ```
|
||||
|
||||
use super::crypto::{aes_ecb_decrypt, aes_g};
|
||||
use super::mkb::*;
|
||||
use super::types::DeviceKey;
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Zero placeholder KCD, NOT real key material — PER-LICENSEE.
|
||||
const KEY_CORRECTION_DATA: [u8; 16] = [0u8; 16];
|
||||
|
||||
// ── MKB record walking ────────────────────────────────────────────────────
|
||||
|
||||
/// True iff `records` contains at least one Media Key Variant record.
|
||||
///
|
||||
/// The real AACS 2.1 Variant markers — confirmed against a live variant MKB —
|
||||
/// are `0x2d` (Encrypted Media Key Variant Data / C) and `0x2f` (Variant Key
|
||||
/// Data table, 65,535×16). Both are absent from non-variant 1.0/2.0 MKBs (which
|
||||
/// carry only the classical `0x05` Media Key Data and no `0x0c`/`0x2d`/`0x2f`).
|
||||
/// The earlier `0x82`/`0x83` guess was speculative and never appeared in any
|
||||
/// real MKB.
|
||||
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
||||
records
|
||||
.iter()
|
||||
.any(|r| matches!(r.rec_type, REC_VARIANT_DATA_AND_NONCE | REC_VKD_TABLE))
|
||||
}
|
||||
|
||||
/// Body of the `0x2d` record: `VARIANTS` table + trailing 16-byte Nonce. NOT the C used for
|
||||
/// `Kmp` (that's `0x0c`'s per-slot block).
|
||||
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == REC_VARIANT_DATA_AND_NONCE)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)` — the trailing 16 bytes of the
|
||||
/// `0x2d` record ([`variant_data_record`]).
|
||||
///
|
||||
/// The Nonce-at-tail placement is consistent across both reference MKBs (the
|
||||
/// leading `body-16` bytes form the `VARIANTS` table exactly), but head-vs-tail
|
||||
/// is only truly pinned by running the full chain against the `0x86` verify with
|
||||
/// a covering key. Until then a wrong nonce can only fail that final gate, never
|
||||
/// emit a bad key.
|
||||
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let body = variant_data_record(records)?;
|
||||
if body.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&body[body.len() - 16..]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The Variant Key Data (VKD) table — record type `0x2f`. Disc-public data.
|
||||
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == REC_VKD_TABLE && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
// ── Subset-difference walk that exposes (Kp, uv) ──────────────────────────
|
||||
|
||||
// Shared with the classical walk in super::derive to keep the SD tree byte-identical.
|
||||
use super::derive::{VERIFY_MAGIC, calc_pk_from_dk, calc_v_mask};
|
||||
|
||||
/// Outcome of a subset-difference walk against an MKB. Carries the
|
||||
/// processing key and the matching `uv` slot — both needed as inputs
|
||||
/// to the variant chain.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ProcessingKeyMatch {
|
||||
/// Processing Key.
|
||||
pub kp: [u8; 16],
|
||||
/// Subset-difference node number that matched.
|
||||
pub uv: u32,
|
||||
/// 16-byte cvalue that the matched uv selected.
|
||||
pub cvalue: [u8; 16],
|
||||
/// Index of the matching cvalue within the cvalues record.
|
||||
pub cvalue_index: usize,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `kp` (a Processing Key) and `cvalue` are secret, never
|
||||
// printed. `uv` / `cvalue_index` are non-secret coordinates. Guarded by
|
||||
// `processing_key_match_debug_is_redacted`.
|
||||
impl std::fmt::Debug for ProcessingKeyMatch {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProcessingKeyMatch")
|
||||
.field("kp", &"<redacted>")
|
||||
.field("uv", &self.uv)
|
||||
.field("cvalue", &"<redacted>")
|
||||
.field("cvalue_index", &self.cvalue_index)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn records_find_mk_dv(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records.iter().find(|r| {
|
||||
(r.rec_type == REC_VERIFY_MEDIA_KEY_V1 || r.rec_type == REC_VERIFY_MEDIA_KEY_V2)
|
||||
&& r.body.len() >= 16
|
||||
})?;
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&r.body[..16]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Walk an MKB and return the first `(Kp, uv, cvalue)` that
|
||||
/// `device_keys` covers. Returns `None` if no DK walks any uv.
|
||||
///
|
||||
/// This is the AACS-2.1 **variant** walk (classical walk: [`super::derive`]). Kept separate on
|
||||
/// purpose — different cvalue-record order (`0x0c` first) and input framing; do NOT route the
|
||||
/// classical DK path through this function.
|
||||
pub fn walk_processing_key(
|
||||
records: &[MkbRecord],
|
||||
device_keys: &[DeviceKey],
|
||||
) -> Option<ProcessingKeyMatch> {
|
||||
let mk_dv = records_find_mk_dv(records)?;
|
||||
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
|
||||
// Real variant MKBs carry per-uv cvalues in record `0x0c` (46,101x16, one
|
||||
// per `0x04` slot); fall back to `0x05` (never the `0x07` SD index).
|
||||
let cvalues = mkb_find_body(records, REC_MEDIA_KEY_VARIANT_DATA)
|
||||
.or_else(|| mkb_find_body(records, REC_MEDIA_KEY_DATA))?;
|
||||
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
for dk in device_keys {
|
||||
let device_number = dk.node as u32;
|
||||
|
||||
for uvs_idx in 0..num_uvs {
|
||||
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||
// `num_uvs` came from `take_while(.. (c[0] & 0xC0) == 0)`, so every
|
||||
// chunk in `0..num_uvs` already has clear revoked-marker bits.
|
||||
let u_mask_shift = uvs[5 * uvs_idx];
|
||||
|
||||
// 0x20..=0x3F pass the take_while but are out of range for a u32
|
||||
// shift; `wrapping_shl` would silently wrap (shift % 32) and match
|
||||
// a wrong uv slot. Disc-controlled byte: skip the slot instead.
|
||||
if u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(u_mask_shift as u32);
|
||||
let v_mask = calc_v_mask(uv);
|
||||
|
||||
if ((device_number & u_mask) == (uv & u_mask))
|
||||
&& ((device_number & v_mask) != (uv & v_mask))
|
||||
{
|
||||
// dk.u_mask_shift is a u8 from keydb with no range check; guard
|
||||
// it the same way before the wrapping_shl below.
|
||||
if dk.u_mask_shift >= 32 {
|
||||
continue;
|
||||
}
|
||||
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||
let dev_key_u_mask: u32 = 0xFFFF_FFFFu32.wrapping_shl(dk.u_mask_shift as u32);
|
||||
|
||||
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
|
||||
|
||||
if uvs_idx >= cvalues.len() / 16 {
|
||||
continue;
|
||||
}
|
||||
let mut cv = [0u8; 16];
|
||||
cv.copy_from_slice(&cvalues[uvs_idx * 16..(uvs_idx + 1) * 16]);
|
||||
|
||||
// Validate: AES-D(Kp, cv), XOR uv into low 4 bytes,
|
||||
// then AES-D(.., mk_dv) must reveal the verify magic.
|
||||
let mut km_candidate = aes_ecb_decrypt(&pk, &cv);
|
||||
let uv_bytes = uv.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
km_candidate[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
let dec_vd = aes_ecb_decrypt(&km_candidate, &mk_dv);
|
||||
// On classical MKBs this magic must match. On variant MKBs
|
||||
// it won't — `km_candidate` is really Kmp, so the magic
|
||||
// check is moot; the chain enforces semantics downstream.
|
||||
let classical_ok = dec_vd[..8] == VERIFY_MAGIC;
|
||||
let variant_present = is_variant_mkb(records);
|
||||
if !(classical_ok || variant_present) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Some(ProcessingKeyMatch {
|
||||
kp: pk,
|
||||
uv,
|
||||
cvalue: cv,
|
||||
cvalue_index: uvs_idx,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Error reporting ───────────────────────────────────────────────────────
|
||||
|
||||
/// Outcome of [`derive_media_key_variant`] when the chain cannot
|
||||
/// produce a Media Key. Every variant is a classification only — no
|
||||
/// strings, no Display impl beyond the error code.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum MediaKeyVariantError {
|
||||
/// MKB carries no Variant records. Caller should fall back to the
|
||||
/// classical single-stage derivation.
|
||||
NotVariantMkb,
|
||||
/// MKB is missing a required record (mk_dv, subset-difference,
|
||||
/// cvalues, variant data, or variant nonce).
|
||||
MkbIncomplete,
|
||||
/// `device_keys` did not cover any uv slot in this MKB.
|
||||
ProcessingKeyUnavailable,
|
||||
/// `Kmp[15]` carries bit `0x02`: the soft-correction path applies
|
||||
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
|
||||
SoftCorrectionRequired,
|
||||
/// `Kmp[15]` carries bit `0x04`: the online-challenge path applies
|
||||
/// for this Precursor. Out of scope for the hardcoded-KCD chain.
|
||||
OnlineChallengeRequired,
|
||||
/// `VARIANTS[uv]` could not be read from the `0x2d` record for the
|
||||
/// matched slot.
|
||||
VariantsTableUnavailable,
|
||||
/// VKD index resolved out of the supplied `vkd_table`.
|
||||
VkdIndexOutOfRange,
|
||||
/// The derived Media Key failed the MKB's Verify-Media-Key relation.
|
||||
/// On the variant path this final gate replaces the per-match magic
|
||||
/// check (which does not hold for a Precursor).
|
||||
MediaKeyVerifyFailed,
|
||||
}
|
||||
|
||||
impl MediaKeyVariantError {
|
||||
/// The stable numeric error code (the `E71xx` family).
|
||||
pub fn code(&self) -> u16 {
|
||||
use crate::error::*;
|
||||
match self {
|
||||
MediaKeyVariantError::NotVariantMkb => E_MKB_VARIANT_NOT_VARIANT,
|
||||
MediaKeyVariantError::MkbIncomplete => E_MKB_VARIANT_INCOMPLETE,
|
||||
MediaKeyVariantError::ProcessingKeyUnavailable => E_MKB_VARIANT_PK_UNAVAILABLE,
|
||||
MediaKeyVariantError::SoftCorrectionRequired => E_MKB_VARIANT_SOFT_CORRECTION,
|
||||
MediaKeyVariantError::OnlineChallengeRequired => E_MKB_VARIANT_ONLINE_CHALLENGE,
|
||||
MediaKeyVariantError::VariantsTableUnavailable => E_MKB_VARIANT_TABLE_UNAVAILABLE,
|
||||
MediaKeyVariantError::VkdIndexOutOfRange => E_MKB_VARIANT_VKD_RANGE,
|
||||
MediaKeyVariantError::MediaKeyVerifyFailed => E_MKB_VARIANT_VERIFY_FAILED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MediaKeyVariantError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "E{}", self.code())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MediaKeyVariantError {}
|
||||
|
||||
// ── Chain ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Look up the per-slot `VARIANTS[sd_slot_index]` (leading bytes of the `0x2d` body, before the
|
||||
/// tail Nonce — see [`variant_nonce`]).
|
||||
fn variants_for_uv(records: &[MkbRecord], sd_slot_index: usize) -> Option<u16> {
|
||||
let body = variant_data_record(records)?;
|
||||
// VARIANTS table is the leading bytes; the 16-byte Kvn Nonce is packed at the
|
||||
// TAIL (see [`variant_nonce`]). Bound reads to the table region so a near-end
|
||||
// slot never reads Nonce bytes (no header: v70 `0x2d` body = 46_100*2+16).
|
||||
const NONCE: usize = 16;
|
||||
let table_len = body.len().checked_sub(NONCE)?;
|
||||
let off = sd_slot_index.checked_mul(2)?;
|
||||
if off + 2 > table_len {
|
||||
return None;
|
||||
}
|
||||
Some(u16::from_be_bytes([body[off], body[off + 1]]))
|
||||
}
|
||||
|
||||
/// Enumerate `(uv, slot_index)` pairs of a variant MKB's `0x04` record, in
|
||||
/// table order — so a bare Processing Key can be tried against each slot.
|
||||
fn variant_uv_slots(records: &[MkbRecord]) -> Option<Vec<(u32, usize)>> {
|
||||
let uvs = mkb_find_body(records, REC_SUBSET_DIFFERENCE)?;
|
||||
let mut out = Vec::new();
|
||||
let mut idx = 0usize;
|
||||
while (idx + 1) * 5 <= uvs.len() {
|
||||
let u_mask_shift = uvs[5 * idx];
|
||||
// The `0xC0` revoked-marker terminates the table (matches the walk's
|
||||
// `take_while`). Shifts ≥ 32 are out of range and skipped, never wrapped.
|
||||
if u_mask_shift & 0xC0 != 0 {
|
||||
break;
|
||||
}
|
||||
let p_uv = &uvs[1 + 5 * idx..];
|
||||
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||
if uv != 0 && u_mask_shift < 32 {
|
||||
out.push((uv, idx));
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The MKB-derived inputs the variant chain needs for every slot it tries against
|
||||
/// a given Processing Key. Fetched once by [`derive_media_key_variant`] so the
|
||||
/// per-slot body stays a lean `(Kp, uv, slot)` call.
|
||||
struct VariantMkb<'a> {
|
||||
records: &'a [MkbRecord],
|
||||
vkd_table: &'a [u8],
|
||||
/// Per-slot C table from `0x0c` (16 bytes/slot), same index
|
||||
/// [`walk_processing_key`] uses. NOT `0x2d` (VARIANTS + Nonce).
|
||||
cvalues: &'a [u8],
|
||||
mk_dv: [u8; 16],
|
||||
/// `Kvn = AES-G(Kp, Nonce) & 0xFFFF` (`[C]` §2.1.3 / this module's chain doc). Depends only
|
||||
/// on `Kp` and the MKB's Nonce — both loop-invariant across every slot a given `Kp` is tried
|
||||
/// against (L103) — so [`derive_media_key_variant`] computes it ONCE, not per slot.
|
||||
///
|
||||
/// The closest published precedent for this `Kvn` shape is `[C]` §3.2.5.2.2 "Variant
|
||||
/// Number Record" (Table 3-12), PDF p.29: "Kvn = [AES-G(Kp, Nonce)]lsb_10" (10-bit, for a
|
||||
/// content-variation number, record type `0x0D`). This module's `0x2d`/`0x2f` chain is a
|
||||
/// distinct, reverse-engineered AACS 2.1 scheme — same `AES-G(Kp, Nonce)` shape, 16-bit.
|
||||
kvn: u16,
|
||||
}
|
||||
|
||||
/// Derive+verify the Media Key for ONE known `(Kp, uv, slot)`. VID-free —
|
||||
/// the VUK is a separate [`super::derive::derive_vuk`] step.
|
||||
fn variant_km_for_slot(
|
||||
m: &VariantMkb<'_>,
|
||||
kp: &[u8; 16],
|
||||
uv: u32,
|
||||
slot_index: usize,
|
||||
) -> Result<[u8; 16], MediaKeyVariantError> {
|
||||
// C for THIS subset-difference: the slot's 16-byte block in the `0x0c`
|
||||
// Encrypted-Media-Key-Variant-Data table (same index that selected the
|
||||
// cvalue in `walk_processing_key`). `0x2d` is VARIANTS + Nonce, not C.
|
||||
let cv_off = slot_index
|
||||
.checked_mul(16)
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let c_slice = m
|
||||
.cvalues
|
||||
.get(cv_off..cv_off + 16)
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mut c_block = [0u8; 16];
|
||||
c_block.copy_from_slice(c_slice);
|
||||
|
||||
let variants_uv = variants_for_uv(m.records, slot_index);
|
||||
km_from_slot_inputs(kp, &c_block, uv, variants_uv, m.kvn, m.vkd_table, &m.mk_dv)
|
||||
}
|
||||
|
||||
/// Shared Kp -> Km chain (Kmp, correction bits, Kpnew, VKD, Km, Verify-Media-Key gate) for one
|
||||
/// slot's explicit inputs. `variants_uv` is `VARIANTS[uv]`; `None` means the table is short.
|
||||
fn km_from_slot_inputs(
|
||||
kp: &[u8; 16],
|
||||
c_block: &[u8; 16],
|
||||
uv: u32,
|
||||
variants_uv: Option<u16>,
|
||||
kvn: u16,
|
||||
vkd_table: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
) -> Result<[u8; 16], MediaKeyVariantError> {
|
||||
// Kmp = AES-128D(Kp, C) XOR uv (uv into the low 4 bytes).
|
||||
let mut kmp = aes_ecb_decrypt(kp, c_block);
|
||||
let uv_bytes = uv.to_be_bytes();
|
||||
for i in 0..4 {
|
||||
kmp[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
|
||||
// Bits 0x02 (SoftKCD) / 0x04 (online challenge) need out-of-band data we don't model.
|
||||
if kmp[15] & 0b0000_0010 != 0 {
|
||||
return Err(MediaKeyVariantError::SoftCorrectionRequired);
|
||||
}
|
||||
if kmp[15] & 0b0000_0100 != 0 {
|
||||
return Err(MediaKeyVariantError::OnlineChallengeRequired);
|
||||
}
|
||||
|
||||
// Kpnew = Kmp XOR KCD.
|
||||
let mut kpnew = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
kpnew[i] = kmp[i] ^ KEY_CORRECTION_DATA[i];
|
||||
}
|
||||
|
||||
// VKD_idx = Kvn XOR VARIANTS[uv]; Kvn is loop-invariant for a Kp, hoisted by the caller.
|
||||
let variants_uv = variants_uv.ok_or(MediaKeyVariantError::VariantsTableUnavailable)?;
|
||||
let off = ((kvn ^ variants_uv) as usize) * 16;
|
||||
if off + 16 > vkd_table.len() {
|
||||
return Err(MediaKeyVariantError::VkdIndexOutOfRange);
|
||||
}
|
||||
let mut vkd = [0u8; 16];
|
||||
vkd.copy_from_slice(&vkd_table[off..off + 16]);
|
||||
|
||||
// Km = AES-128D(Kpnew, VKD) XOR uv.
|
||||
let mut km = aes_ecb_decrypt(&kpnew, &vkd);
|
||||
for i in 0..4 {
|
||||
km[12 + i] ^= uv_bytes[i];
|
||||
}
|
||||
|
||||
// Authoritative gate: Km must reproduce the MKB's Verify-Media-Key magic.
|
||||
if aes_ecb_decrypt(&km, mk_dv)[..8] != VERIFY_MAGIC {
|
||||
return Err(MediaKeyVariantError::MediaKeyVerifyFailed);
|
||||
}
|
||||
Ok(km)
|
||||
}
|
||||
|
||||
/// Derive the AACS 2.1 variant **Media Key** from a Processing Key.
|
||||
///
|
||||
/// The one deterministic `Kp → Km` derivation for a variant MKB: tries `pk` (which arrives
|
||||
/// without its slot) against every slot and returns the Km for the slot whose full chain passes
|
||||
/// the Verify-Media-Key record, so an unverified key is never returned. VID-free — derive the
|
||||
/// VUK via [`super::derive::derive_vuk`]; `Kp` comes from [`walk_processing_key`]. Errors:
|
||||
/// `NotVariantMkb`, `MkbIncomplete` (also a slot's short tables), `ProcessingKeyUnavailable`
|
||||
/// (no slot verified), or, when a slot needs it, `SoftCorrectionRequired` /
|
||||
/// `OnlineChallengeRequired` / `VariantsTableUnavailable`.
|
||||
pub fn derive_media_key_variant(
|
||||
mkb_records: &[MkbRecord],
|
||||
pk: &[u8; 16],
|
||||
) -> Result<[u8; 16], MediaKeyVariantError> {
|
||||
if !is_variant_mkb(mkb_records) {
|
||||
return Err(MediaKeyVariantError::NotVariantMkb);
|
||||
}
|
||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
// C for Kmp is the per-slot `0x0c` table, same source/index as
|
||||
// `walk_processing_key` uses; `0x2d` holds VARIANTS + Nonce, not C. Fall
|
||||
// back to `0x05` (never the `0x07` SD index).
|
||||
let cvalues = mkb_find_body(mkb_records, REC_MEDIA_KEY_VARIANT_DATA)
|
||||
.or_else(|| mkb_find_body(mkb_records, REC_MEDIA_KEY_DATA))
|
||||
.ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mk_dv = records_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let slots = variant_uv_slots(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
// Kvn = AES-G(Kp, Nonce) & 0xFFFF depends only on `pk` and the MKB's Nonce — neither
|
||||
// varies per slot — so compute it ONCE here rather than once per slot in
|
||||
// `variant_km_for_slot` (L103: was tens of ms/disc across ~46k slots).
|
||||
let kvn_block = aes_g(pk, &nonce);
|
||||
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
|
||||
let m = VariantMkb {
|
||||
records: mkb_records,
|
||||
vkd_table,
|
||||
cvalues,
|
||||
mk_dv,
|
||||
kvn,
|
||||
};
|
||||
|
||||
// Try `pk` against each slot; return the first verified Km. If none verify,
|
||||
// surface a correction-mode error, then a key-independent STRUCTURAL fault (the
|
||||
// MKB's tables are short for a slot), over the generic miss.
|
||||
let mut correction: Option<MediaKeyVariantError> = None;
|
||||
let mut structural: Option<MediaKeyVariantError> = None;
|
||||
for (uv, slot_index) in slots {
|
||||
match variant_km_for_slot(&m, pk, uv, slot_index) {
|
||||
Ok(km) => return Ok(km),
|
||||
Err(e @ MediaKeyVariantError::SoftCorrectionRequired)
|
||||
| Err(e @ MediaKeyVariantError::OnlineChallengeRequired) => {
|
||||
correction.get_or_insert(e);
|
||||
}
|
||||
Err(e @ MediaKeyVariantError::MkbIncomplete)
|
||||
| Err(e @ MediaKeyVariantError::VariantsTableUnavailable) => {
|
||||
structural.get_or_insert(e);
|
||||
}
|
||||
// Verify failure / VKD index out of range depend on `pk`: a plain miss.
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Err(correction
|
||||
.or(structural)
|
||||
.unwrap_or(MediaKeyVariantError::ProcessingKeyUnavailable))
|
||||
}
|
||||
|
||||
/// Run the variant chain from a caller-supplied Processing Key and EXPLICIT
|
||||
/// per-slot inputs (`0x0c` C block, `uv`, `VARIANTS[uv]`) — bypasses the
|
||||
/// device-key walk and the on-MKB `VARIANTS[uv]` lookup; the MKB still
|
||||
/// supplies the Nonce, VKD table, and Verify-Media-Key value.
|
||||
///
|
||||
/// Returns `(Km, Kvu)`. The terminal gate is identical to [`derive_media_key_variant`]: a wrong
|
||||
/// input returns [`MediaKeyVariantError::MediaKeyVerifyFailed`].
|
||||
pub fn media_key_variant_from_kp(
|
||||
kp: &[u8; 16],
|
||||
c_block: &[u8; 16],
|
||||
uv: u32,
|
||||
variants_uv: u16,
|
||||
mkb_records: &[MkbRecord],
|
||||
vid: &[u8; 16],
|
||||
) -> Result<([u8; 16], [u8; 16]), MediaKeyVariantError> {
|
||||
let nonce = variant_nonce(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let vkd_table = variant_key_data(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
let mk_dv = records_find_mk_dv(mkb_records).ok_or(MediaKeyVariantError::MkbIncomplete)?;
|
||||
|
||||
let kvn_block = aes_g(kp, &nonce);
|
||||
let kvn = u16::from_be_bytes([kvn_block[14], kvn_block[15]]);
|
||||
let km = km_from_slot_inputs(kp, c_block, uv, Some(variants_uv), kvn, vkd_table, &mk_dv)?;
|
||||
|
||||
// Kvu = AES-G(Km, VID).
|
||||
let kvu = aes_g(&km, vid);
|
||||
Ok((km, kvu))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "variant_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,231 +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_directory: 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_directory.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");
|
||||
|
||||
/// 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
|
||||
// num_app is a u8 read, so it is bounded at 255 by construction.
|
||||
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_directory = 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_directory,
|
||||
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)]
|
||||
#[path = "bdjo_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,283 +0,0 @@
|
||||
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,
|
||||
}
|
||||
|
||||
// Optional content the minimal fixture leaves empty, laid out per the BD-J
|
||||
// Object (BDJO) file format: cache items, accessible playlists, and
|
||||
// per-app profiles / application_name bytes / parameter bytes.
|
||||
#[derive(Default)]
|
||||
struct Extras {
|
||||
cache_items: usize,
|
||||
playlists: usize,
|
||||
profiles: usize,
|
||||
name_bytes: usize,
|
||||
param_bytes: usize,
|
||||
}
|
||||
|
||||
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> {
|
||||
build_bdjo_with(apps, &Extras::default())
|
||||
}
|
||||
|
||||
fn build_bdjo_with(apps: &[AppSpec], x: &Extras) -> 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) + pad(1), then 12-byte items:
|
||||
// type(1) + ref_to_name(5) + lang_code(3) + pad(3).
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.push(x.cache_items as u8);
|
||||
b.push(0);
|
||||
for _ in 0..x.cache_items {
|
||||
b.push(1);
|
||||
b.extend_from_slice(b"00007eng");
|
||||
b.extend_from_slice(&[0u8; 3]);
|
||||
}
|
||||
|
||||
// AccessiblePlaylists: length(4) + [num_pl(11)+flags(2)+pad(19)], then
|
||||
// 6-byte entries: name(5) + pad(1).
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
b.extend_from_slice(&(((x.playlists as u32) << 21) | (1 << 20)).to_be_bytes());
|
||||
for _ in 0..x.playlists {
|
||||
b.extend_from_slice(b"00800\0");
|
||||
}
|
||||
|
||||
// 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)
|
||||
// num_profile(4) + pad(12), then 6-byte profiles:
|
||||
// profile(2) + major(1) + minor(1) + micro(1) + pad(1).
|
||||
b.push((x.profiles as u8) << 4);
|
||||
b.push(0);
|
||||
for _ in 0..x.profiles {
|
||||
b.extend_from_slice(&[0, 1, 1, 0, 0, 0]);
|
||||
}
|
||||
b.push(0); // priority(8)
|
||||
b.push(0); // binding(2)+visibility(2)+reserved(4)
|
||||
// application_name: data_length(16) + bytes, word-aligned (pad when odd).
|
||||
b.extend_from_slice(&(x.name_bytes as u16).to_be_bytes());
|
||||
b.extend(std::iter::repeat_n(b'n', x.name_bytes));
|
||||
if !x.name_bytes.is_multiple_of(2) {
|
||||
b.push(0);
|
||||
}
|
||||
// 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) + bytes, word-aligned (pad when even).
|
||||
b.push(x.param_bytes as u8);
|
||||
b.extend(std::iter::repeat_n(b'p', x.param_bytes));
|
||||
if x.param_bytes.is_multiple_of(2) {
|
||||
b.push(0);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
// Every skipped-by-width region populated (odd and even name/param lengths):
|
||||
// a wrong item/profile/padding width misaligns the strings that follow.
|
||||
#[test]
|
||||
fn parses_apps_with_cache_items_playlists_profiles_names_and_params() {
|
||||
for (name_bytes, param_bytes) in [(5, 3), (6, 4)] {
|
||||
let x = Extras {
|
||||
cache_items: 2,
|
||||
playlists: 3,
|
||||
profiles: 2,
|
||||
name_bytes,
|
||||
param_bytes,
|
||||
};
|
||||
let bytes = build_bdjo_with(
|
||||
&[
|
||||
AppSpec {
|
||||
control_code: 2,
|
||||
base_dir: "00009",
|
||||
classpath_extension: "",
|
||||
initial_class: "com.studio.Helper",
|
||||
},
|
||||
AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "00000",
|
||||
classpath_extension: "00001",
|
||||
initial_class: "com.studio.MainXlet",
|
||||
},
|
||||
],
|
||||
&x,
|
||||
);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps.len(), 2);
|
||||
assert_eq!(apps[0].initial_class, "com.studio.Helper");
|
||||
assert_eq!(apps[1].jar_ids(), vec!["00000", "00001"]);
|
||||
assert_eq!(apps[1].initial_class, "com.studio.MainXlet");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_strings_drop_trailing_nul_padding() {
|
||||
let bytes = build_bdjo(&[AppSpec {
|
||||
control_code: 1,
|
||||
base_dir: "0000\0",
|
||||
classpath_extension: "",
|
||||
initial_class: "a.B\0\0",
|
||||
}]);
|
||||
let apps = parse(&bytes).expect("parses");
|
||||
assert_eq!(apps[0].base_directory, "0000");
|
||||
assert_eq!(apps[0].initial_class, "a.B");
|
||||
assert_eq!(apps[0].jar_ids(), vec!["0000"]);
|
||||
}
|
||||
|
||||
#[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_directory, "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, never panic or a partial Some.
|
||||
for cut in 0..bytes.len() {
|
||||
assert_eq!(parse(&bytes[..cut]), None, "cut={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_directory: "00000".into(),
|
||||
classpath_extension: "00000;;00003; ".into(),
|
||||
initial_class: "X".into(),
|
||||
};
|
||||
assert_eq!(a.jar_ids(), vec!["00000", "00003"]);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
//! Parse `/BDMV/index.bdmv` — the BD navigation index (First Play, Top Menu,
|
||||
//! and the title table), per its documented binary layout. This is read as a
|
||||
//! documented binary format, never executed: every field is bounds-checked and
|
||||
//! any malformed input yields `None` (the nav resolver then abstains).
|
||||
|
||||
use super::be_u16;
|
||||
|
||||
/// One playback/title object in `index.bdmv`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PlaybackObj {
|
||||
/// HDMV title. `id_ref` indexes `MovieObject.bdmv`; `0xffff` means "no
|
||||
/// object".
|
||||
Hdmv { id_ref: u16 },
|
||||
/// BD-J title (a Java Xlet chooses what plays — the nav VM cannot resolve
|
||||
/// it and must abstain).
|
||||
BdJ,
|
||||
/// Unrecognised object type.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The parsed index: the two entry objects plus the title table (title numbers
|
||||
/// `1..=titles.len()` map to `titles[i-1]`; title 0 is the Top Menu).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Index {
|
||||
pub first_play: PlaybackObj,
|
||||
pub top_menu: PlaybackObj,
|
||||
pub titles: Vec<PlaybackObj>,
|
||||
}
|
||||
|
||||
const OBJ_LEN: usize = 12;
|
||||
/// Sanity cap on the title count (real discs have well under this).
|
||||
const MAX_TITLES: usize = 4096;
|
||||
|
||||
fn be_u32(d: &[u8], o: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes([
|
||||
*d.get(o)?,
|
||||
*d.get(o + 1)?,
|
||||
*d.get(o + 2)?,
|
||||
*d.get(o + 3)?,
|
||||
]))
|
||||
}
|
||||
|
||||
/// Parse one 12-byte object starting at `o`. `object_type` is the top two bits
|
||||
/// of byte 0; the object body starts at byte 4, and an HDMV `id_ref` is the
|
||||
/// big-endian u16 at body offset +2 (object offset +6).
|
||||
fn parse_obj(d: &[u8], o: usize) -> Option<PlaybackObj> {
|
||||
let b0 = *d.get(o)?;
|
||||
d.get(o + OBJ_LEN - 1)?; // require the whole 12-byte record
|
||||
Some(match (b0 >> 6) & 0x3 {
|
||||
1 => PlaybackObj::Hdmv {
|
||||
id_ref: be_u16(d, o + 6)?,
|
||||
},
|
||||
2 => PlaybackObj::BdJ,
|
||||
_ => PlaybackObj::Unknown,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `index.bdmv`. Returns `None` on any structural problem.
|
||||
pub(crate) fn parse(d: &[u8]) -> Option<Index> {
|
||||
if d.get(0..4)? != b"INDX" {
|
||||
return None;
|
||||
}
|
||||
// version at 4..8 ("0100"/"0200"/"0240"/"0300") is not load-bearing for resolution.
|
||||
let indexes_start = be_u32(d, 8)? as usize;
|
||||
// At indexes_start: u32 index_len, First-Play(12), Top-Menu(12), u16 titles.
|
||||
let mut o = indexes_start.checked_add(4)?;
|
||||
let first_play = parse_obj(d, o)?;
|
||||
o = o.checked_add(OBJ_LEN)?;
|
||||
let top_menu = parse_obj(d, o)?;
|
||||
o = o.checked_add(OBJ_LEN)?;
|
||||
let num_titles = be_u16(d, o)? as usize;
|
||||
o = o.checked_add(2)?;
|
||||
if num_titles == 0 || num_titles > MAX_TITLES {
|
||||
return None;
|
||||
}
|
||||
let span = num_titles.checked_mul(OBJ_LEN)?;
|
||||
if o.checked_add(span)? > d.len() {
|
||||
return None;
|
||||
}
|
||||
let mut titles = Vec::with_capacity(num_titles);
|
||||
for i in 0..num_titles {
|
||||
titles.push(parse_obj(d, o + i * OBJ_LEN)?);
|
||||
}
|
||||
Some(Index {
|
||||
first_play,
|
||||
top_menu,
|
||||
titles,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "index_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,81 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Build a 12-byte object record: `object_type` in the top two bits of
|
||||
/// byte 0, HDMV `id_ref` big-endian at offset 6.
|
||||
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 1 << 6;
|
||||
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
|
||||
b
|
||||
}
|
||||
fn bdj_obj() -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 2 << 6;
|
||||
b
|
||||
}
|
||||
|
||||
fn build(first: [u8; 12], top: [u8; 12], titles: &[[u8; 12]]) -> Vec<u8> {
|
||||
let indexes_start = 48u32;
|
||||
let mut d = vec![0u8; indexes_start as usize];
|
||||
d[0..4].copy_from_slice(b"INDX");
|
||||
d[4..8].copy_from_slice(b"0300");
|
||||
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
|
||||
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
|
||||
d.extend_from_slice(&first);
|
||||
d.extend_from_slice(&top);
|
||||
d.extend_from_slice(&(titles.len() as u16).to_be_bytes());
|
||||
for t in titles {
|
||||
d.extend_from_slice(t);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hdmv_first_play_and_titles() {
|
||||
let d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj(), bdj_obj()]);
|
||||
let idx = parse(&d).expect("parses");
|
||||
assert_eq!(idx.first_play, PlaybackObj::Hdmv { id_ref: 0 });
|
||||
assert_eq!(idx.top_menu, PlaybackObj::BdJ);
|
||||
assert_eq!(idx.titles.len(), 2);
|
||||
assert_eq!(idx.titles[0], PlaybackObj::BdJ);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic_and_truncation() {
|
||||
assert!(parse(b"NOPE").is_none());
|
||||
let d = build(hdmv_obj(3), hdmv_obj(0), &[hdmv_obj(1)]);
|
||||
// Truncating below the declared title span must yield None, never panic.
|
||||
assert!(parse(&d[..d.len() - 4]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_title_count_over_the_cap() {
|
||||
let mut d = build(hdmv_obj(0), bdj_obj(), &[bdj_obj()]);
|
||||
// num_titles (u16) is at 48 (indexes_start) + 4 (index_len) + 12 + 12 = 76.
|
||||
// Overwrite it with MAX_TITLES + 1: the cap must reject before any record
|
||||
// read, so an attacker-huge count can't drive a 4097-entry parse/alloc.
|
||||
d[76..78].copy_from_slice(&((MAX_TITLES as u16) + 1).to_be_bytes());
|
||||
assert!(
|
||||
parse(&d).is_none(),
|
||||
"title count over MAX_TITLES must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_titles() {
|
||||
let d = build(hdmv_obj(0), bdj_obj(), &[]);
|
||||
assert!(parse(&d).is_none(), "num_titles == 0 must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_object_types_parse_as_unknown() {
|
||||
let mut none = [0u8; 12]; // object_type 0
|
||||
let mut reserved = [0u8; 12];
|
||||
reserved[0] = 3 << 6; // object_type 3
|
||||
none[0] = 0x3F; // low bits set must not leak into the type
|
||||
let d = build(none, reserved, &[reserved]);
|
||||
let idx = parse(&d).expect("parses");
|
||||
assert_eq!(idx.first_play, PlaybackObj::Unknown);
|
||||
assert_eq!(idx.top_menu, PlaybackObj::Unknown);
|
||||
assert_eq!(idx.titles[0], PlaybackObj::Unknown);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
//! Parse `/BDMV/MovieObject.bdmv` — the HDMV navigation programs, per their
|
||||
//! documented binary layout. Read as a documented format, never executed here:
|
||||
//! bounds-checked, never panics.
|
||||
//!
|
||||
//! Layout: `"MOBJ"` + version(4) + reserved… ; `MovieObjects()` at byte 40 is
|
||||
//! `length`(u32) + reserved(u32) + `num_objects`(u16 @48), then objects from
|
||||
//! byte 50. Each object is `flags`(1) + reserved(1) + `num_cmds`(u16) followed
|
||||
//! by `num_cmds` 12-byte navigation commands.
|
||||
|
||||
use super::be_u16;
|
||||
|
||||
// One decoded 12-byte navigation command.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Cmd {
|
||||
pub op_cnt: u8,
|
||||
pub grp: u8,
|
||||
pub sub_grp: u8,
|
||||
pub imm_op1: bool,
|
||||
pub imm_op2: bool,
|
||||
pub branch_opt: u8,
|
||||
pub cmp_opt: u8,
|
||||
pub set_opt: u8,
|
||||
pub dst: u32,
|
||||
pub src: u32,
|
||||
}
|
||||
|
||||
/// One HDMV navigation program.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MovieObject {
|
||||
pub cmds: Vec<Cmd>,
|
||||
}
|
||||
|
||||
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.
|
||||
const MAX_OBJECTS: usize = 4096;
|
||||
|
||||
/// Decode one 12-byte command. Caller guarantees `b.len() == 12`.
|
||||
fn decode_cmd(b: &[u8]) -> Cmd {
|
||||
let (b0, b1, b2, b3) = (b[0], b[1], b[2], b[3]);
|
||||
Cmd {
|
||||
op_cnt: (b0 >> 5) & 0x7,
|
||||
grp: (b0 >> 3) & 0x3,
|
||||
sub_grp: b0 & 0x7,
|
||||
imm_op1: (b1 >> 7) & 1 == 1,
|
||||
imm_op2: (b1 >> 6) & 1 == 1,
|
||||
branch_opt: b1 & 0x0f,
|
||||
cmp_opt: b2 & 0x0f,
|
||||
set_opt: b3 & 0x1f,
|
||||
dst: u32::from_be_bytes([b[4], b[5], b[6], b[7]]),
|
||||
src: u32::from_be_bytes([b[8], b[9], b[10], b[11]]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `MovieObject.bdmv` into its programs. Returns `None` on any structural
|
||||
/// problem.
|
||||
pub(crate) fn parse(d: &[u8]) -> Option<Vec<MovieObject>> {
|
||||
if d.get(0..4)? != b"MOBJ" {
|
||||
return None;
|
||||
}
|
||||
let num = be_u16(d, 48)? as usize;
|
||||
if num > MAX_OBJECTS {
|
||||
return None;
|
||||
}
|
||||
let mut off = 50usize;
|
||||
let mut objs = Vec::with_capacity(num);
|
||||
for _ in 0..num {
|
||||
// Object header: flags(1) + reserved(1) + num_cmds(u16).
|
||||
let num_cmds = be_u16(d, off + 2)? as usize;
|
||||
off = off.checked_add(4)?;
|
||||
let span = num_cmds.checked_mul(CMD_LEN)?;
|
||||
let end = off.checked_add(span)?;
|
||||
if end > d.len() {
|
||||
return None;
|
||||
}
|
||||
let mut cmds = Vec::with_capacity(num_cmds);
|
||||
for i in 0..num_cmds {
|
||||
let s = off + i * CMD_LEN;
|
||||
cmds.push(decode_cmd(&d[s..s + CMD_LEN]));
|
||||
}
|
||||
off = end;
|
||||
objs.push(MovieObject { cmds });
|
||||
}
|
||||
Some(objs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mobj_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
@@ -1,75 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Encode one command from its opcode bytes + operands.
|
||||
pub(crate) fn cmd(b0: u8, b1: u8, b2: u8, b3: u8, dst: u32, src: u32) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = b0;
|
||||
b[1] = b1;
|
||||
b[2] = b2;
|
||||
b[3] = b3;
|
||||
b[4..8].copy_from_slice(&dst.to_be_bytes());
|
||||
b[8..12].copy_from_slice(&src.to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub(crate) fn build(objects: &[&[[u8; 12]]]) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 50];
|
||||
d[0..4].copy_from_slice(b"MOBJ");
|
||||
d[48..50].copy_from_slice(&(objects.len() as u16).to_be_bytes());
|
||||
for obj in objects {
|
||||
d.push(0); // flags
|
||||
d.push(0); // reserved
|
||||
d.extend_from_slice(&(obj.len() as u16).to_be_bytes());
|
||||
for c in *obj {
|
||||
d.extend_from_slice(c);
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_objects_and_commands() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
let b0 = (1 << 5) | 2;
|
||||
let play = cmd(b0, 0x80, 0, 0, 11, 0);
|
||||
let d = build(&[&[play]]);
|
||||
let objs = parse(&d).expect("parses");
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert_eq!(objs[0].cmds.len(), 1);
|
||||
assert_eq!(objs[0].cmds[0].dst, 11);
|
||||
assert_eq!(objs[0].cmds[0].sub_grp, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncation() {
|
||||
let play = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let d = build(&[&[play]]);
|
||||
assert!(parse(&d[..d.len() - 3]).is_none());
|
||||
assert!(parse(b"NOPE").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_object_count_over_the_cap() {
|
||||
// A real (non-truncated) buffer declaring MAX_OBJECTS + 1 empty objects:
|
||||
// if the cap were removed, this would parse fine (nothing to truncate on),
|
||||
// so only the cap itself can reject it.
|
||||
let empty: Vec<&[[u8; 12]]> = vec![&[]; MAX_OBJECTS + 1];
|
||||
let d = build(&empty);
|
||||
assert!(
|
||||
parse(&d).is_none(),
|
||||
"object count over MAX_OBJECTS must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
#[test]
|
||||
fn decode_cmd_masks_ignore_reserved_bits() {
|
||||
// b2/b3 upper bits are reserved and must not leak into cmp_opt/set_opt.
|
||||
let c = decode_cmd(&cmd(0xFF, 0xFF, 0xF3, 0xE5, 0, 0));
|
||||
assert_eq!((c.op_cnt, c.grp, c.sub_grp), (7, 3, 7));
|
||||
assert!(c.imm_op1 && c.imm_op2);
|
||||
assert_eq!((c.branch_opt, c.cmp_opt, c.set_opt), (0x0f, 0x03, 0x05));
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
//! BD/UHD HDMV navigation resolver — "mimic a real player" for main-feature
|
||||
//! title selection. It reads `/BDMV/index.bdmv` + `/BDMV/MovieObject.bdmv` and
|
||||
//! runs a faithful, bounded HDMV navigation VM to find the playlist the disc's
|
||||
//! own First-Play navigation plays as the feature.
|
||||
//!
|
||||
//!
|
||||
//! 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;
|
||||
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
pub(super) fn be_u16(d: &[u8], o: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes([*d.get(o)?, *d.get(o + 1)?]))
|
||||
}
|
||||
|
||||
// Resolve the playlist id First-Play navigation plays as the feature, among ids the caller
|
||||
// marks as feature candidates (`is_feature_candidate`). Returns `None` for BD-J discs,
|
||||
// malformed nav data, or non-convergence.
|
||||
pub(crate) fn resolve_feature(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
is_feature_candidate: impl Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
// Belt-and-suspenders: the parsers and VM are written panic-free and bounded,
|
||||
// but a navigation resolver must NEVER take down a scan — swallow any
|
||||
// unexpected panic and abstain.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
resolve_inner(reader, udf, &is_feature_candidate)
|
||||
}))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn resolve_inner(
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
is_feature_candidate: &dyn Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
let index = index::parse(&udf.read_file(reader, "/BDMV/index.bdmv").ok()?)?;
|
||||
let mobjs = mobj::parse(&udf.read_file(reader, "/BDMV/MovieObject.bdmv").ok()?)?;
|
||||
vm::resolve(&index, &mobjs, is_feature_candidate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,106 +0,0 @@
|
||||
use super::mobj::tests::{build as build_mobj, cmd};
|
||||
use super::*;
|
||||
use crate::udf::fixture::{DirSpec, MemDisc, build_udf_skeleton, file_with, lay_dir};
|
||||
|
||||
// Encode one 12-byte `index.bdmv` playback object: `object_type` in the top
|
||||
// two bits of byte 0 (1 = HDMV, `id_ref` big-endian @6; 2 = BD-J). Mirrors
|
||||
// index.rs's own private test builder (kept local).
|
||||
fn hdmv_obj(id_ref: u16) -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 1 << 6;
|
||||
b[6..8].copy_from_slice(&id_ref.to_be_bytes());
|
||||
b
|
||||
}
|
||||
fn bdj_obj() -> [u8; 12] {
|
||||
let mut b = [0u8; 12];
|
||||
b[0] = 2 << 6;
|
||||
b
|
||||
}
|
||||
|
||||
/// Build a minimal, valid `index.bdmv`: First-Play = HDMV object 0,
|
||||
/// Top Menu = BD-J, one (unused) title. Layout per `index::parse`.
|
||||
fn build_index(first_play: [u8; 12]) -> Vec<u8> {
|
||||
let indexes_start = 48u32;
|
||||
let mut d = vec![0u8; indexes_start as usize];
|
||||
d[0..4].copy_from_slice(b"INDX");
|
||||
d[4..8].copy_from_slice(b"0300");
|
||||
d[8..12].copy_from_slice(&indexes_start.to_be_bytes());
|
||||
d.extend_from_slice(&0u32.to_be_bytes()); // index_len (unused by parser)
|
||||
d.extend_from_slice(&first_play);
|
||||
d.extend_from_slice(&bdj_obj()); // top_menu
|
||||
d.extend_from_slice(&1u16.to_be_bytes()); // num_titles
|
||||
d.extend_from_slice(&bdj_obj()); // titles[0] (not exercised here)
|
||||
d
|
||||
}
|
||||
|
||||
// Smoke/e2e test of the resolver: minimal index.bdmv + MovieObject.bdmv on
|
||||
// an in-memory UDF disc, resolved via read_file + index::parse +
|
||||
// mobj::parse + vm::resolve. First-Play HDMV object 0 unconditionally PlayPLs playlist 11.
|
||||
#[test]
|
||||
fn resolve_feature_end_to_end_resolves_playlist() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
|
||||
let index_bytes = build_index(hdmv_obj(0));
|
||||
|
||||
let mut disc = MemDisc::new();
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: vec![
|
||||
file_with("index.bdmv", 14, 500, index_bytes, true),
|
||||
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert_eq!(
|
||||
resolve_feature(&mut disc, &udf, |id| id == 11),
|
||||
Some(11),
|
||||
"First-Play's unconditional PlayPL 11 must resolve as the feature"
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed index (`index.bdmv` truncated to just its magic) must make
|
||||
/// the whole resolver abstain, not panic — proving `resolve_feature`
|
||||
/// really is wired through `index::parse`'s failure path end-to-end.
|
||||
#[test]
|
||||
fn resolve_feature_abstains_on_malformed_index() {
|
||||
let play_pl_11 = cmd((1 << 5) | 2, 0x80, 0, 0, 11, 0);
|
||||
let mobj_bytes = build_mobj(&[&[play_pl_11]]);
|
||||
|
||||
let mut disc = MemDisc::new();
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 12,
|
||||
dir_data_lba: 13,
|
||||
files: vec![
|
||||
file_with("index.bdmv", 14, 500, b"INDX".to_vec(), true),
|
||||
file_with("MovieObject.bdmv", 15, 600, mobj_bytes, true),
|
||||
],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
assert_eq!(resolve_feature(&mut disc, &udf, |id| id == 11), None);
|
||||
}
|
||||
-293
@@ -1,293 +0,0 @@
|
||||
//! The HDMV navigation VM — a bounded re-implementation of the subset of HDMV
|
||||
//! navigation semantics needed to follow First-Play to the feature
|
||||
//! `PlayPlayList`. Never panics; hard step/switch caps.
|
||||
//!
|
||||
//! It resolves generically — there is no per-disc special-casing. When
|
||||
//! First-Play is a BD-J title (the feature is chosen by a Java Xlet), or the
|
||||
//! program does not reach a `PlayPL` on a caller-approved feature candidate, it
|
||||
//! abstains (`None`) and selection falls back to the structural/heuristic order.
|
||||
|
||||
use super::index::{Index, PlaybackObj};
|
||||
use super::mobj::MovieObject;
|
||||
|
||||
/// Top bit of an operand selects PSR (else GPR).
|
||||
const PSR_FLAG: u32 = 0x8000_0000;
|
||||
/// Upper bound on VM steps before declaring non-convergence.
|
||||
const MAX_STEPS: usize = 1_000_000;
|
||||
/// Cap on object/title switches — a dispatcher runs in one object; this only
|
||||
/// bounds pathological jump chains.
|
||||
const MAX_SWITCHES: usize = 1024;
|
||||
|
||||
// BD player power-on register defaults (indices 0..=61); unlisted = 0.
|
||||
// Load-bearing: PSR4/5 = 0xffff ("no title/chapter selected"), PSR6/7/8 = 0 —
|
||||
// these steer dispatcher compares; rest set for fidelity with real player.
|
||||
fn psr_init() -> [u32; 128] {
|
||||
let mut p = [0u32; 128];
|
||||
let vals: &[(usize, u32)] = &[
|
||||
(0, 1),
|
||||
(1, 0xff),
|
||||
(2, 0x0fff_0fff),
|
||||
(3, 1),
|
||||
(4, 0xffff),
|
||||
(5, 0xffff),
|
||||
(10, 0xffff),
|
||||
(12, 0xff),
|
||||
(13, 0xff),
|
||||
(14, 0xffff),
|
||||
(15, 0xAAAA),
|
||||
(16, 0x00ff_ffff),
|
||||
(17, 0x00ff_ffff),
|
||||
(18, 0x00ff_ffff),
|
||||
(19, 0xffff),
|
||||
(20, 2),
|
||||
(29, 0x0000_0003),
|
||||
(30, 0x0001_ffff),
|
||||
(31, 0x0003_0200),
|
||||
(36, 0xffff),
|
||||
(37, 0xffff),
|
||||
(42, 0xffff),
|
||||
(44, 0xff),
|
||||
];
|
||||
for &(i, v) in vals {
|
||||
p[i] = v;
|
||||
}
|
||||
for slot in p.iter_mut().take(62).skip(48) {
|
||||
*slot = 0xffff_ffff;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
struct Vm<'a> {
|
||||
mobjs: &'a [MovieObject],
|
||||
index: &'a Index,
|
||||
gpr: [u32; 4096],
|
||||
psr: [u32; 128],
|
||||
// Per-GPR: value derived from RND, so any branch on it is undecidable.
|
||||
gpr_random: Box<[bool; 4096]>,
|
||||
}
|
||||
|
||||
impl<'a> Vm<'a> {
|
||||
fn new(mobjs: &'a [MovieObject], index: &'a Index) -> Self {
|
||||
Self {
|
||||
mobjs,
|
||||
index,
|
||||
gpr: [0; 4096],
|
||||
psr: psr_init(),
|
||||
gpr_random: Box::new([false; 4096]),
|
||||
}
|
||||
}
|
||||
// Whether a non-immediate operand reads an RND-derived GPR.
|
||||
fn random(&self, imm: bool, raw: u32) -> bool {
|
||||
!imm && raw & PSR_FLAG == 0 && self.gpr_random[(raw & 0xfff) as usize]
|
||||
}
|
||||
fn set_random(&mut self, raw: u32, r: bool) {
|
||||
if raw & PSR_FLAG == 0 {
|
||||
self.gpr_random[(raw & 0xfff) as usize] = r;
|
||||
}
|
||||
}
|
||||
fn rd(&self, val: u32) -> u32 {
|
||||
if val & PSR_FLAG != 0 {
|
||||
self.psr[(val & 0x7f) as usize]
|
||||
} else {
|
||||
self.gpr[(val & 0xfff) as usize]
|
||||
}
|
||||
}
|
||||
fn wr(&mut self, val: u32, x: u32) {
|
||||
// Per the HDMV nav spec, a store to a PSR-tagged register is refused;
|
||||
// only GPR writes take effect. PSR values come only from power-on init,
|
||||
// never from a nav SET/SWAP operand.
|
||||
if val & PSR_FLAG == 0 {
|
||||
self.gpr[(val & 0xfff) as usize] = x;
|
||||
}
|
||||
}
|
||||
fn fetch(&self, imm: bool, raw: u32) -> u32 {
|
||||
if imm { raw } else { self.rd(raw) }
|
||||
}
|
||||
/// Resolve a JumpTitle target: title 0 = Top Menu, `1..=N` = `titles[i-1]`,
|
||||
/// `0xFFFF` = First Play (BD-ROM title numbering). Any other value is an
|
||||
/// invalid title reference and abstains (`None`).
|
||||
fn title_obj(&self, title: u32) -> Option<PlaybackObj> {
|
||||
let n = self.index.titles.len() as u32;
|
||||
if title == 0 {
|
||||
Some(self.index.top_menu)
|
||||
} else if title <= n {
|
||||
self.index.titles.get((title - 1) as usize).copied()
|
||||
} else if title == 0xFFFF {
|
||||
Some(self.index.first_play)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run First-Play and return the first `PlayPL`/`PlayPL_PM`/`PlayPL_PI` whose
|
||||
/// playlist id passes `is_feature`. `None` = BD-J boundary, non-convergence, or
|
||||
/// no approved feature playlist reached.
|
||||
pub(crate) fn resolve(
|
||||
index: &Index,
|
||||
mobjs: &[MovieObject],
|
||||
is_feature: &dyn Fn(u16) -> bool,
|
||||
) -> Option<u16> {
|
||||
// Enter at First-Play when it is an HDMV object that exists; otherwise abstain
|
||||
// (BD-J boundary, or `0xffff` "no object"). We don't chase Top-Menu's "Play
|
||||
// Movie" target here — it lives in menu .m2ts IG button commands (future work).
|
||||
let start = match index.first_play {
|
||||
PlaybackObj::Hdmv { id_ref } if (id_ref as usize) < mobjs.len() => id_ref as usize,
|
||||
_ => return None,
|
||||
};
|
||||
let mut vm = Vm::new(mobjs, index);
|
||||
run(&mut vm, start, is_feature)
|
||||
}
|
||||
|
||||
fn run(vm: &mut Vm, mut obj_id: usize, is_feature: &dyn Fn(u16) -> bool) -> Option<u16> {
|
||||
let mut pc = 0usize;
|
||||
let mut steps = 0usize;
|
||||
let mut switches = 0usize;
|
||||
loop {
|
||||
steps += 1;
|
||||
if steps > MAX_STEPS {
|
||||
return None;
|
||||
}
|
||||
let obj = vm.mobjs.get(obj_id)?;
|
||||
// Ran off the end without a feature PlayPL → abstain.
|
||||
let c = *obj.cmds.get(pc)?;
|
||||
let mut npc = pc + 1;
|
||||
let dst = if c.op_cnt > 0 {
|
||||
vm.fetch(c.imm_op1, c.dst)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let src = if c.op_cnt > 1 {
|
||||
vm.fetch(c.imm_op2, c.src)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let dst_rnd = c.op_cnt > 0 && vm.random(c.imm_op1, c.dst);
|
||||
let src_rnd = c.op_cnt > 1 && vm.random(c.imm_op2, c.src);
|
||||
// A branch, play or compare on an RND-derived value is undecidable.
|
||||
if (c.grp == 0 && dst_rnd) || (c.grp == 1 && (dst_rnd || src_rnd)) {
|
||||
return None;
|
||||
}
|
||||
match c.grp {
|
||||
// BRANCH
|
||||
0 => match c.sub_grp {
|
||||
// GOTO
|
||||
0 => match c.branch_opt {
|
||||
0x01 => npc = dst as usize, // GOTO
|
||||
0x02 => return None, // BREAK — terminate
|
||||
_ => {} // NOP / other
|
||||
},
|
||||
// JUMP
|
||||
1 => match c.branch_opt {
|
||||
0x00 | 0x02 => {
|
||||
// JumpObject / CallObject (return address not modelled —
|
||||
// for feature resolution we only follow the play path).
|
||||
switches += 1;
|
||||
if switches > MAX_SWITCHES || dst as usize >= vm.mobjs.len() {
|
||||
return None;
|
||||
}
|
||||
obj_id = dst as usize;
|
||||
pc = 0;
|
||||
continue;
|
||||
}
|
||||
0x01 | 0x03 => {
|
||||
// JumpTitle / CallTitle — resolve through the index table.
|
||||
switches += 1;
|
||||
if switches > MAX_SWITCHES {
|
||||
return None;
|
||||
}
|
||||
match vm.title_obj(dst) {
|
||||
Some(PlaybackObj::Hdmv { id_ref })
|
||||
if (id_ref as usize) < vm.mobjs.len() =>
|
||||
{
|
||||
obj_id = id_ref as usize;
|
||||
pc = 0;
|
||||
continue;
|
||||
}
|
||||
// BD-J / unknown / invalid title → abstain.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
_ => {} // RESUME / other — fall through
|
||||
},
|
||||
// PLAY: PlayPL / PlayPL_PI / PlayPL_PM emit a playlist id (the
|
||||
// rest — Terminate / Link — fall through). A logo/pre-roll whose
|
||||
// id isn't a feature candidate lets autoplay resume at pc + 1.
|
||||
2 if matches!(c.branch_opt, 0x00..=0x02) => {
|
||||
// A playlist id is 16-bit; an operand that doesn't fit is not one.
|
||||
if let Ok(id) = u16::try_from(dst)
|
||||
&& is_feature(id)
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
// CMP — skip the next command when the compare is false.
|
||||
1 => {
|
||||
let truth = match c.cmp_opt {
|
||||
0x01 => (dst & !src) == 0, // BC: every dst bit set in src (hdmv_vm.c)
|
||||
0x02 => dst == src,
|
||||
0x03 => dst != src,
|
||||
0x04 => dst >= src,
|
||||
0x05 => dst > src,
|
||||
0x06 => dst <= src,
|
||||
0x07 => dst < src,
|
||||
_ => true,
|
||||
};
|
||||
if !truth {
|
||||
npc = pc + 2;
|
||||
}
|
||||
}
|
||||
// SET (sub_grp 0). SETSYSTEM (sub_grp 1) only mutates system PSRs
|
||||
// whose values the feature path does not branch on — skip it.
|
||||
2 if c.sub_grp == 0 => {
|
||||
let r: Option<u32> = match c.set_opt {
|
||||
0x01 => Some(src), // MOVE
|
||||
0x02 => {
|
||||
// SWAP exchanges the two operands; a store to an operand
|
||||
// flagged immediate is refused, and `wr` already drops
|
||||
// PSR stores.
|
||||
if !c.imm_op1 {
|
||||
vm.wr(c.dst, src);
|
||||
vm.set_random(c.dst, src_rnd);
|
||||
}
|
||||
if !c.imm_op2 {
|
||||
vm.wr(c.src, dst);
|
||||
vm.set_random(c.src, dst_rnd);
|
||||
}
|
||||
None
|
||||
}
|
||||
0x03 => Some(dst.saturating_add(src)), // libbluray ADD_u32 saturates
|
||||
0x04 => Some(dst.saturating_sub(src)),
|
||||
0x05 => Some(dst.saturating_mul(src)), // libbluray MUL_u32 saturates
|
||||
0x06 => Some(dst.checked_div(src).unwrap_or(0xffff_ffff)),
|
||||
0x07 => Some(dst.checked_rem(src).unwrap_or(0xffff_ffff)),
|
||||
0x08 => Some(dst), // RND: value unknown, tainted below
|
||||
0x09 => Some(dst & src),
|
||||
0x0a => Some(dst | src),
|
||||
0x0b => Some(dst ^ src),
|
||||
// libbluray: bit numbers / shift counts >= 32 are not masked.
|
||||
0x0c => Some(1u32.checked_shl(src).map_or(dst, |b| dst | b)),
|
||||
0x0d => Some(1u32.checked_shl(src).map_or(dst, |b| dst & !b)),
|
||||
0x0e => Some(dst.checked_shl(src).unwrap_or(0)),
|
||||
0x0f => Some(dst.checked_shr(src).unwrap_or(0)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = r
|
||||
&& !c.imm_op1
|
||||
{
|
||||
vm.wr(c.dst, r);
|
||||
let rnd = c.set_opt == 0x08 || src_rnd || (c.set_opt != 0x01 && dst_rnd);
|
||||
vm.set_random(c.dst, rnd);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pc = npc;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "vm_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,472 +0,0 @@
|
||||
use super::super::index::{Index, PlaybackObj};
|
||||
use super::super::mobj::{self, tests::build, tests::cmd};
|
||||
use super::*;
|
||||
|
||||
fn play_pl(id: u16) -> [u8; 12] {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=PLAY(2), branch_opt=PLAY_PL(0), imm dst.
|
||||
cmd((1 << 5) | 2, 0x80, 0, 0, id as u32, 0)
|
||||
}
|
||||
fn jump_object(obj: u16) -> [u8; 12] {
|
||||
// grp=BRANCH(0), sub_grp=JUMP(1), branch_opt=JUMP_OBJECT(0), imm dst.
|
||||
cmd((1 << 5) | 1, 0x80, 0, 0, obj as u32, 0)
|
||||
}
|
||||
fn jump_title(title: u16) -> [u8; 12] {
|
||||
// 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).
|
||||
cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, reg as u32, imm as u32)
|
||||
}
|
||||
fn play_pl_reg(reg: u16) -> [u8; 12] {
|
||||
// PLAY_PL with dst = GPR[reg] (op_cnt=1, imm_op1=0).
|
||||
cmd((1 << 5) | 2, 0x00, 0, 0, reg as u32, 0)
|
||||
}
|
||||
|
||||
fn idx(first: PlaybackObj, titles: Vec<PlaybackObj>) -> Index {
|
||||
Index {
|
||||
first_play: first,
|
||||
top_menu: PlaybackObj::BdJ,
|
||||
titles,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_immediate_playpl() {
|
||||
// First-Play HDMV obj0 → JumpObject 1 → PlayPL 11.
|
||||
let d = build(&[&[jump_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 skips_non_feature_playpl_then_returns_feature() {
|
||||
// Autoplay chain: logo PlayPL 99 (not a candidate) → feature PlayPL 1.
|
||||
let d = build(&[&[play_pl(99), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// Only 1 is a feature candidate; 99 is a logo.
|
||||
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
|
||||
// GPR and JumpTitles to it; the target title is BD-J → abstain (None).
|
||||
let d = build(&[&[set_move_gpr(0xFEB & 0xfff, 2), {
|
||||
// JumpTitle with dst = GPR[0xFEB].
|
||||
cmd((1 << 5) | 1, 0x01, 0, 0, 0x0000_0FEB, 0)
|
||||
}]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
// title 2 → titles[1] = BD-J.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 0 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 5 }, PlaybackObj::BdJ],
|
||||
);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_when_first_play_is_bdj() {
|
||||
let d = build(&[&[play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::BdJ, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_first_play_is_0xffff_not_n_plus_1() {
|
||||
// BD-ROM title numbering: 0 = Top Menu, 0xFFFF = First Play,
|
||||
// 1..=N = titles. `N+1` is an INVALID title ref.
|
||||
let index = idx(
|
||||
PlaybackObj::Hdmv { id_ref: 7 },
|
||||
vec![PlaybackObj::Hdmv { id_ref: 1 }],
|
||||
);
|
||||
let vm = Vm::new(&[], &index);
|
||||
assert_eq!(vm.title_obj(0), Some(index.top_menu), "title 0 = Top Menu");
|
||||
assert_eq!(
|
||||
vm.title_obj(1),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 1 }),
|
||||
"title 1 = titles[0]"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(0xFFFF),
|
||||
Some(PlaybackObj::Hdmv { id_ref: 7 }),
|
||||
"0xFFFF = First Play (abstained wrongly before the fix)"
|
||||
);
|
||||
assert_eq!(
|
||||
vm.title_obj(2),
|
||||
None,
|
||||
"N+1 is an invalid title ref, not First Play (resolved wrongly before the fix)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_to_psr_is_refused() {
|
||||
// SET MOVE to PSR6 must be a no-op (PSR stores are refused by spec).
|
||||
// Program: MOVE PSR6<-5; CMP PSR6==5; PlayPL 100 else 200. Refused write
|
||||
// keeps PSR6 at 0 -> PlayPL 200; an illegal write would wrongly pick 100.
|
||||
let set_psr6 = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x01, 0x8000_0006, 5);
|
||||
let cmp_psr6_eq_5 = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0006, 5);
|
||||
let d = build(&[&[set_psr6, cmp_psr6_eq_5, play_pl(100), play_pl(200)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 100 || id == 200),
|
||||
Some(200),
|
||||
"PSR store must be refused, so the compare is false and 200 is reached"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_fold_and_cmp_skip_reach_playpl() {
|
||||
// SET GPR[3]=7; CMP GPR[3]==7 (true → do NOT skip); PlayPL GPR[3].
|
||||
// grp=CMP(1), op_cnt=2, imm_op2=1 (src=7 immediate), cmp_opt=EQ(2); dst=GPR3.
|
||||
let cmp_eq_reg = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 3, 0x0000_0007);
|
||||
let d = build(&[&[set_move_gpr(3, 7), cmp_eq_reg, play_pl_reg(3), play_pl(999)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// If the compare wrongly skipped, we'd hit PlayPL 999 instead of 7.
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 7 || id == 999), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumptitle_namespace_differs_from_jumpobject() {
|
||||
// JumpTitle 1 → titles[0] = HDMV obj 1 (which PlayPLs 42). Proves title
|
||||
// numbers resolve through the index, not as object indices.
|
||||
let d = build(&[&[jump_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));
|
||||
}
|
||||
|
||||
// ADD/MUL saturate at 0xFFFFFFFF (libbluray hdmv_vm.c ADD_u32/MUL_u32,
|
||||
// reverse-engineered player behaviour), like SUB and DIV/MOD by 0 already do.
|
||||
#[test]
|
||||
fn add_and_mul_saturate() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let cmp_eq_0 = |reg: u32| cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, reg, 0);
|
||||
for ops in [
|
||||
[set(0x01, 0, 0xffff_ffff), set(0x03, 0, 1)],
|
||||
[set(0x01, 0, 0x1_0000), set(0x05, 0, 0x1_0000)],
|
||||
] {
|
||||
// A wrap to 0 makes the compare true and plays 1; saturation plays 800.
|
||||
let d = build(&[&[ops[0], ops[1], cmp_eq_0(0), play_pl(1), play_pl(800)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
|
||||
Some(800)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_exchanges_two_registers() {
|
||||
// MOVE g0=11; MOVE g1=22; SWAP g0<->g1; PlayPL GPR[0]. SWAP: grp=SET(2),
|
||||
// sub_grp=SET(0), set_opt=SWAP(2), both operands registers (imm_op*=0).
|
||||
let swap01 = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x02, 0, 1);
|
||||
let d = build(&[&[
|
||||
set_move_gpr(0, 11),
|
||||
set_move_gpr(1, 22),
|
||||
swap01,
|
||||
play_pl_reg(0),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
// After the swap GPR0 holds the old GPR1 (22) and PlayPL plays it.
|
||||
assert_eq!(run(&mut vm, 0, &|id| id == 22), Some(22));
|
||||
// Full exchange: GPR1 now holds the old GPR0 (11).
|
||||
assert_eq!(vm.gpr[1], 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_convergent_self_goto_bails_via_max_steps() {
|
||||
// An unconditional GOTO to its own line never reaches a PlayPL: the
|
||||
// MAX_STEPS guard must bail (None) rather than loop forever. If the
|
||||
// guard were removed this would hang instead of returning.
|
||||
let self_goto = cmd(1 << 5, 0x81, 0, 0, 0, 0);
|
||||
let d = build(&[&[self_goto]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goto_lands_on_its_target_and_break_terminates() {
|
||||
// op_cnt=1, grp=BRANCH(0), sub_grp=GOTO(0), imm dst; branch_opt GOTO(1) / BREAK(2).
|
||||
let goto = |line: u32| cmd(1 << 5, 0x81, 0, 0, line, 0);
|
||||
let brk = cmd(1 << 5, 0x82, 0, 0, 0, 0);
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
// GOTO line 2 jumps over the logo PlayPL 99 (a candidate) to the feature PlayPL 1.
|
||||
let d = build(&[&[goto(2), play_pl(99), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), Some(1));
|
||||
let d = build(&[&[brk, play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_play_pl_family_emits_a_playlist() {
|
||||
// sub_grp=PLAY(2); branch_opt 3 is Terminate/Link, which never plays dst.
|
||||
let d = build(&[&[cmd((1 << 5) | 2, 0x83, 0, 0, 11, 0)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setsystem_does_not_execute_as_a_gpr_set() {
|
||||
// grp=SET(2), sub_grp=SETSYSTEM(1), set_opt=MOVE(1): must not write GPR5.
|
||||
let setsys = cmd((2 << 5) | (2 << 3) | 1, 0x40, 0, 0x01, 5, 77);
|
||||
let d = build(&[&[setsys]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(vm.gpr[5], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_immediate_operand_is_not_written_back() {
|
||||
// SWAP dst=GPR0 with src=IMMEDIATE 5 (imm_op1=0, imm_op2=1) must refuse
|
||||
// writing to the aliased register: GPR[5] stays 77, only GPR0 gets 5.
|
||||
// Dropping the `imm_op1`/`imm_op2` guard makes this assertion fail.
|
||||
let swap_imm = cmd((2 << 5) | (2 << 3), 0x40, 0, 0x02, 0, 5);
|
||||
let d = build(&[&[set_move_gpr(5, 77), swap_imm]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
// No PlayPL → program runs off the end (None); inspect registers after.
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(
|
||||
vm.gpr[5], 77,
|
||||
"immediate operand must not be a write target"
|
||||
);
|
||||
assert_eq!(vm.gpr[0], 5, "register operand receives the immediate");
|
||||
}
|
||||
|
||||
// libbluray hdmv_vm.c: BITSET/BITCLR with a bit number >= 32 are no-ops and
|
||||
// SHL/SHR by >= 32 give 0 (no `& 31` masking).
|
||||
#[test]
|
||||
fn bit_ops_and_shifts_past_31_follow_libbluray() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let d = build(&[&[
|
||||
set(0x0c, 0, 32),
|
||||
set(0x01, 1, 1),
|
||||
set(0x0e, 1, 32),
|
||||
set(0x01, 2, 0x8000_0000),
|
||||
set(0x0f, 2, 40),
|
||||
set(0x01, 3, 0xffff_ffff),
|
||||
set(0x0d, 3, 33),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
assert_eq!(vm.gpr[..4], [0, 0, 0, 0xffff_ffff]);
|
||||
}
|
||||
|
||||
// RND is non-deterministic: a compare on its result abstains.
|
||||
#[test]
|
||||
fn compare_on_rnd_result_abstains() {
|
||||
let set = |opt: u8, reg: u32, imm: u32| cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm);
|
||||
let cmp_eq = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0, 5);
|
||||
let d = build(&[&[
|
||||
set(0x01, 0, 5),
|
||||
set(0x08, 0, 10),
|
||||
cmp_eq,
|
||||
play_pl(1),
|
||||
play_pl(800),
|
||||
]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|id| id == 1 || id == 800), None);
|
||||
}
|
||||
|
||||
fn run_gpr0(prog: &[[u8; 12]], gprs: usize) -> Vec<u32> {
|
||||
let d = build(&[prog]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let mut vm = Vm::new(&mobjs, &index);
|
||||
assert_eq!(run(&mut vm, 0, &|_| false), None);
|
||||
vm.gpr[..gprs].to_vec()
|
||||
}
|
||||
|
||||
fn set_imm(opt: u8, reg: u32, imm: u32) -> [u8; 12] {
|
||||
cmd((2 << 5) | (2 << 3), 0x40, 0, opt, reg, imm)
|
||||
}
|
||||
|
||||
// CMP dst(GPR0 = a) against imm b: true falls through to PlayPL 1, false skips to 2.
|
||||
fn cmp_picks_true(opt: u8, a: u32, b: u32) -> bool {
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x40, opt, 0, 0, b);
|
||||
let d = build(&[&[set_imm(0x01, 0, a), cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 2) == Some(1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmp_ops_ge_gt_le_lt_ne() {
|
||||
// (cmp_opt, a, b, expected)
|
||||
let cases = [
|
||||
(0x04, 5, 5, true),
|
||||
(0x04, 4, 5, false),
|
||||
(0x04, 6, 5, true),
|
||||
(0x05, 5, 5, false),
|
||||
(0x05, 6, 5, true),
|
||||
(0x05, 4, 5, false),
|
||||
(0x06, 5, 5, true),
|
||||
(0x06, 6, 5, false),
|
||||
(0x06, 4, 5, true),
|
||||
(0x07, 5, 5, false),
|
||||
(0x07, 4, 5, true),
|
||||
(0x07, 6, 5, false),
|
||||
(0x03, 5, 5, false),
|
||||
(0x03, 5, 6, true),
|
||||
];
|
||||
for (opt, a, b, want) in cases {
|
||||
assert_eq!(cmp_picks_true(opt, a, b), want, "opt {opt:#x} {a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
// libbluray INSN_BC skips the next command when `dst & ~src` is non-zero:
|
||||
// true iff every bit of dst is set in src.
|
||||
#[test]
|
||||
fn cmp_bc_tests_dst_bits_within_src() {
|
||||
assert!(!cmp_picks_true(0x01, 0b1110, 0b0110));
|
||||
assert!(cmp_picks_true(0x01, 0b0100, 0b0110));
|
||||
assert!(cmp_picks_true(0x01, 0b0110, 0b1110));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_arithmetic_and_logic_ops() {
|
||||
// (set_opt, a, b, expected)
|
||||
let cases = [
|
||||
(0x03, 2, 3, 5),
|
||||
(0x04, 10, 3, 7),
|
||||
(0x04, 3, 10, 0),
|
||||
(0x05, 2, 3, 6),
|
||||
(0x06, 10, 3, 3),
|
||||
(0x06, 10, 0, 0xffff_ffff),
|
||||
(0x07, 10, 3, 1),
|
||||
(0x07, 10, 0, 0xffff_ffff),
|
||||
(0x09, 0b1100, 0b1010, 0b1000),
|
||||
(0x0a, 0b1100, 0b1010, 0b1110),
|
||||
(0x0b, 0b1100, 0b1010, 0b0110),
|
||||
(0x0c, 0, 3, 8),
|
||||
(0x0d, 0xf, 1, 0xd),
|
||||
(0x0e, 1, 4, 16),
|
||||
(0x0f, 0x100, 4, 0x10),
|
||||
];
|
||||
for (opt, a, b, want) in cases {
|
||||
let g = run_gpr0(&[set_imm(0x01, 0, a), set_imm(opt, 0, b)], 1);
|
||||
assert_eq!(g[0], want, "set_opt {opt:#x} on {a}, {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_or_compare_on_rnd_value_abstains() {
|
||||
// GPR0 = RND; then PlayPL GPR0 (branch grp, dst tainted).
|
||||
let rnd = set_imm(0x08, 0, 10);
|
||||
let d = build(&[&[rnd, play_pl_reg(0), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
// Compare with a clean dst (GPR1) and a tainted register src (GPR0).
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x00, 0x02, 0, 1, 0);
|
||||
let d = build(&[&[rnd, cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_from_rnd_source_taints_destination() {
|
||||
// GPR0 = RND; GPR1 (clean) += GPR0 (register src); PlayPL GPR1 must abstain.
|
||||
let rnd = set_imm(0x08, 0, 10);
|
||||
let add = cmd((2 << 5) | (2 << 3), 0x00, 0, 0x03, 1, 0);
|
||||
let d = build(&[&[rnd, add, play_pl_reg(1), play_pl(1)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(resolve(&index, &mobjs, &|_| true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playpl_operand_over_16_bits_is_not_a_playlist_id() {
|
||||
// 0x1_0001 must not alias playlist 1; the VM falls through to 800.
|
||||
let wide = cmd((1 << 5) | 2, 0x80, 0, 0, 0x1_0001, 0);
|
||||
let d = build(&[&[wide, play_pl(800)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
assert_eq!(
|
||||
resolve(&index, &mobjs, &|id| id == 1 || id == 800),
|
||||
Some(800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn power_on_psr_defaults_are_visible_to_compares() {
|
||||
// (psr, expected power-on value)
|
||||
let cases: [(u32, u32); 12] = [
|
||||
(0, 1),
|
||||
(1, 0xff),
|
||||
(3, 1),
|
||||
(4, 0xffff),
|
||||
(5, 0xffff),
|
||||
(6, 0),
|
||||
(7, 0),
|
||||
(8, 0),
|
||||
(20, 2),
|
||||
(31, 0x0003_0200),
|
||||
(48, 0xffff_ffff),
|
||||
(62, 0),
|
||||
];
|
||||
for (psr, want) in cases {
|
||||
// CMP PSR == want (imm): true plays 1, false plays 2.
|
||||
let cmp = cmd((2 << 5) | (1 << 3), 0x40, 0x02, 0, 0x8000_0000 | psr, want);
|
||||
let d = build(&[&[cmp, play_pl(1), play_pl(2)]]);
|
||||
let mobjs = mobj::parse(&d).unwrap();
|
||||
let index = idx(PlaybackObj::Hdmv { id_ref: 0 }, vec![]);
|
||||
let got = resolve(&index, &mobjs, &|id| id == 1 || id == 2);
|
||||
assert_eq!(got, Some(1), "PSR{psr} != {want:#x}");
|
||||
}
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
//! CLPI clip info parser (BD-ROM Clip Information).
|
||||
//!
|
||||
//! Each .clpi file in BDMV/CLIPINF/ describes one M2TS clip. This parser reads
|
||||
//! two things from it: `source_packet_count` (the clip's total 192-byte packet
|
||||
//! count) and the ProgramInfo stream table (PID/coding-type/language), which
|
||||
//! cross-validates the MPLS STN view (see `labels/clpi_audit.rs`). The CPI/EP
|
||||
//! map is NOT parsed — reading is whole-clip via UDF and trimming is done at the
|
||||
//! playlist/timeline (PTS) level, not by EP-map SPN→sector seeking.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Parsed CLPI clip info.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ClipInfo {
|
||||
/// Total source packets in the m2ts (each 192 bytes)
|
||||
pub source_packet_count: u32,
|
||||
/// Per-stream metadata from the ProgramInfo section (BD spec).
|
||||
/// Cross-validates the MPLS STN view — see `labels/clpi_audit.rs`.
|
||||
/// Empty when program_info is missing or malformed.
|
||||
pub streams: Vec<ClpiStream>,
|
||||
}
|
||||
|
||||
/// One stream descriptor from the CLPI ProgramInfo / stream_coding_info
|
||||
/// table. Mirrors the same fields the MPLS STN table carries — see
|
||||
/// `mpls::StreamEntry` for the playlist-side equivalent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ClpiStream {
|
||||
/// PID of the stream in the MPEG-TS (matches MPLS).
|
||||
pub pid: u16,
|
||||
/// BD stream coding type byte (0x80 LPCM, 0x83 TrueHD, 0x86 DTS-HD MA,
|
||||
/// 0x90 PG, etc.). See `labels::mpls_universal::coding_type_to_codec_hint`.
|
||||
pub coding_type: u8,
|
||||
/// ISO 639-2 3-char language code. Empty for video streams.
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
/// Parse a CLPI file from raw bytes. A file too short to hold the packet count still
|
||||
/// parses (count 0); only a bad magic or a head under 16 bytes errors.
|
||||
pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
// 16 bytes reach the section offsets; below that, nothing usable.
|
||||
if data.len() < 16 || &data[0..4] != b"HDMV" {
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
|
||||
// Header offsets
|
||||
let prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
|
||||
// ClipInfo section at offset 40
|
||||
// source_packet_count at offset 40 + 4(len) + 2(reserved) + 1(stream_type) + 1(app_type) + 4(reserved) + 4(ts_rate)
|
||||
// A file cut before [56..60] keeps its streams and reports 0 (unknown) packets.
|
||||
let source_packet_count = data
|
||||
.get(56..60)
|
||||
.map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]));
|
||||
|
||||
// Parse ProgramInfo (per-stream language + codec), best-effort: malformed
|
||||
// program_info doesn't fail the parse, just yields an empty streams list.
|
||||
let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() {
|
||||
parse_program_info(&data[prog_info_start..])
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(ClipInfo {
|
||||
source_packet_count,
|
||||
streams,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse the ProgramInfo section: per-stream (pid, coding_type, language, codec sub-fields).
|
||||
// On a structural mismatch, returns the fully validated entries parsed so far.
|
||||
fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
|
||||
use crate::consts::coding_type as c;
|
||||
let mut out = Vec::new();
|
||||
if data.len() < 6 {
|
||||
return out;
|
||||
}
|
||||
// length: 4 bytes (skipped — we trust the section bounds in the
|
||||
// caller's slice and read the bytes that follow). Reserved 1 byte
|
||||
// at offset 4. num_programs at offset 5.
|
||||
let num_programs = data[5] as usize;
|
||||
let mut pos = 6usize;
|
||||
for _ in 0..num_programs {
|
||||
// Program header: 4 (spn) + 2 (pmt_pid) + 1 (num_streams) + 1 (num_groups) = 8 bytes
|
||||
if pos + 8 > data.len() {
|
||||
return out;
|
||||
}
|
||||
let num_streams = data[pos + 6] as usize;
|
||||
pos += 8;
|
||||
|
||||
for _ in 0..num_streams {
|
||||
// Stream header: 2 (pid) + 1 (sci_length) + sci bytes
|
||||
if pos + 3 > data.len() {
|
||||
return out;
|
||||
}
|
||||
let pid = u16::from_be_bytes([data[pos], data[pos + 1]]);
|
||||
let sci_len = data[pos + 2] as usize;
|
||||
let sci_end = pos + 3 + sci_len;
|
||||
if sci_end > data.len() || sci_len < 1 {
|
||||
return out;
|
||||
}
|
||||
let sci = &data[pos + 3..sci_end];
|
||||
let coding_type = sci[0];
|
||||
|
||||
let mut language = String::new();
|
||||
|
||||
match coding_type {
|
||||
// Video — MPEG-2, H.264, HEVC
|
||||
c::MPEG2_VIDEO | c::H264 | c::HEVC => {}
|
||||
// Primary audio — LPCM, AC-3, DTS, TrueHD, AC-3+, DTS-HD HR, DTS-HD MA
|
||||
// and secondary audio (AC-3+ secondary, DTS-HD secondary)
|
||||
c::LPCM..=c::DTS_HD_MA | c::AC3_PLUS_SECONDARY | c::DTS_HD_SECONDARY => {
|
||||
if sci.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sci[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
// TextST: coding_type + character_code + 3-byte language
|
||||
c::TEXT_SUBTITLE if sci.len() >= 5 => {
|
||||
language = String::from_utf8_lossy(&sci[2..5]).to_string();
|
||||
}
|
||||
// PG, IG: coding_type + 3-byte language [+ char_code for PG]
|
||||
c::PG | c::IG if sci.len() >= 4 => {
|
||||
language = String::from_utf8_lossy(&sci[1..4]).to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
out.push(ClpiStream {
|
||||
pid,
|
||||
coding_type,
|
||||
language,
|
||||
});
|
||||
|
||||
pos = sci_end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clpi_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,402 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal CLPI binary.
|
||||
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
|
||||
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
|
||||
// Need at least 60 bytes for the header area: "HDMV"/"0200" magic,
|
||||
// seq_info_start/prog_info_start (unused here), cpi_start, reserved
|
||||
// padding, then the ClipInfo section ending in source_packet_count.
|
||||
|
||||
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
|
||||
|
||||
let mut buf = vec![0u8; 60];
|
||||
// Magic + version
|
||||
buf[0..4].copy_from_slice(b"HDMV");
|
||||
buf[4..8].copy_from_slice(b"0200");
|
||||
// seq_info_start = 0
|
||||
// prog_info_start = 0
|
||||
// cpi_start
|
||||
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
|
||||
// source_packet_count at offset 56
|
||||
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
|
||||
|
||||
if let Some(cpi) = cpi_data {
|
||||
buf.extend_from_slice(cpi);
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_truncated_clipinfo_parses_with_unknown_count() {
|
||||
let full = build_clpi(1000, None);
|
||||
for len in 16..60usize {
|
||||
let c = parse(&full[..len]).unwrap_or_else(|e| panic!("len {len}: {e:?}"));
|
||||
assert_eq!(c.source_packet_count, 0, "len {len}");
|
||||
}
|
||||
assert_eq!(parse(&full[..60]).expect("60").source_packet_count, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_magic() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[0] = b'X';
|
||||
data[1] = b'X';
|
||||
data[2] = b'X';
|
||||
data[3] = b'X';
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
// Added hardening tests, grounded in the BD-ROM CLPI spec byte layout. Build a ProgramInfo
|
||||
// section; `streams` = Vec<(pid, sci_bytes)>.
|
||||
fn build_program_info(streams: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = Vec::new();
|
||||
body.push(0); // reserved (offset 4)
|
||||
body.push(1); // num_programs = 1 (offset 5)
|
||||
// program 0 header (8 bytes)
|
||||
body.extend_from_slice(&0u32.to_be_bytes()); // spn_program_sequence_start
|
||||
body.extend_from_slice(&0u16.to_be_bytes()); // program_map_pid
|
||||
body.push(streams.len() as u8); // num_streams
|
||||
body.push(0); // num_groups
|
||||
for (pid, sci) in streams {
|
||||
body.extend_from_slice(&pid.to_be_bytes());
|
||||
body.push(sci.len() as u8);
|
||||
body.extend_from_slice(sci);
|
||||
}
|
||||
// Prepend length(4) = bytes after the length field.
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a CLPI with a ProgramInfo section. prog_info_start is placed
|
||||
/// right after the 60-byte header; cpi (if any) follows program_info.
|
||||
fn build_clpi_with_proginfo(
|
||||
source_packet_count: u32,
|
||||
prog_info: &[u8],
|
||||
cpi_data: Option<&[u8]>,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 60];
|
||||
buf[0..4].copy_from_slice(b"HDMV");
|
||||
buf[4..8].copy_from_slice(b"0200");
|
||||
let prog_info_start: u32 = 60;
|
||||
buf[12..16].copy_from_slice(&prog_info_start.to_be_bytes());
|
||||
let cpi_start: u32 = if cpi_data.is_some() {
|
||||
(60 + prog_info.len()) as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
|
||||
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
|
||||
buf.extend_from_slice(prog_info);
|
||||
if let Some(cpi) = cpi_data {
|
||||
buf.extend_from_slice(cpi);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// source_packet_count is a big-endian u32 at offset [56..60]. Verify
|
||||
/// BE decode of a value with all four bytes distinct (not LE / wrong
|
||||
/// offset).
|
||||
#[test]
|
||||
fn source_packet_count_big_endian_offset_56() {
|
||||
let data = build_clpi(0x01020304, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.source_packet_count, 0x01020304);
|
||||
}
|
||||
|
||||
/// Magic must be exactly "HDMV" at [0..4]. Anything else → ClpiParse.
|
||||
/// Spec: CLPI files begin with the type_indicator "HDMV".
|
||||
#[test]
|
||||
fn wrong_magic_rejected() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[0..4].copy_from_slice(b"INDX");
|
||||
assert!(parse(&data).is_err());
|
||||
}
|
||||
|
||||
/// Input shorter than the 16-byte header is rejected before any field read.
|
||||
#[test]
|
||||
fn under_16_bytes_rejected() {
|
||||
assert!(parse(&[0u8; 15]).is_err());
|
||||
assert!(parse(b"HDMV0200").is_err());
|
||||
assert!(parse(&[]).is_err());
|
||||
}
|
||||
|
||||
/// ProgramInfo: a video stream (coding 0x1B = H.264) carries
|
||||
/// format/rate in sci[1] nibbles and NO language. Verify the video
|
||||
/// arm: format hi-nibble, rate lo-nibble, language stays empty.
|
||||
#[test]
|
||||
fn program_info_video_stream() {
|
||||
// sci = coding_type(0x1B) + format_rate(0x61 → fmt 6, rate 1)
|
||||
let sci = vec![0x1Bu8, 0x61];
|
||||
let pi = build_program_info(&[(0x1011, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
assert_eq!(clip.streams[0].pid, 0x1011);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x1B);
|
||||
assert_eq!(clip.streams[0].language, "");
|
||||
}
|
||||
|
||||
/// ProgramInfo primary-audio (coding 0x80..=0x86): sci[1] = format/rate
|
||||
/// nibbles, sci[2..5] = ISO 639 language. Verify TrueHD (0x83) at
|
||||
/// offset, 5.1 / 48kHz, language "eng".
|
||||
#[test]
|
||||
fn program_info_audio_stream_lang_offset() {
|
||||
// sci = 0x83 + 0x61 (fmt 6, rate 1) + "eng"
|
||||
let sci = vec![0x83u8, 0x61, b'e', b'n', b'g'];
|
||||
let pi = build_program_info(&[(0x1100, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x83);
|
||||
assert_eq!(clip.streams[0].language, "eng");
|
||||
}
|
||||
|
||||
/// An audio or TextST sci shorter than 5 bytes has no language field: the stream is
|
||||
/// kept, the language stays empty, and nothing indexes past the sci.
|
||||
#[test]
|
||||
fn program_info_short_audio_and_textst_sci_keep_the_stream_without_a_language() {
|
||||
let pi = build_program_info(&[
|
||||
(0x1100, vec![0x83u8, 0x61, b'e', b'n']),
|
||||
(0x1200, vec![0x92u8, 0x01, b'e', b'n']),
|
||||
]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 2);
|
||||
assert!(clip.streams.iter().all(|s| s.language.is_empty()));
|
||||
}
|
||||
|
||||
/// ProgramInfo PG (0x90)/IG (0x91): layout is coding_type(1)+lang(3),
|
||||
/// so language is at sci[1..4] (NOT sci[2..5] like audio). Verify the
|
||||
/// PG arm reads from the right offset.
|
||||
#[test]
|
||||
fn program_info_pg_lang_offset() {
|
||||
// sci = 0x90 + "fra" (lang directly after coding_type)
|
||||
let sci = vec![0x90u8, b'f', b'r', b'a'];
|
||||
let pi = build_program_info(&[(0x1200, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x90);
|
||||
assert_eq!(clip.streams[0].language, "fra");
|
||||
// Audio nibbles must NOT be populated for a PG stream.
|
||||
}
|
||||
|
||||
/// ProgramInfo with multiple streams: PID and coding for each must be
|
||||
/// read from the correct per-stream offset (pid(2)+sci_len(1)+sci).
|
||||
/// Three mixed streams must all parse with distinct PIDs in order.
|
||||
#[test]
|
||||
fn program_info_multiple_streams_advance_correctly() {
|
||||
let v = (0x1011u16, vec![0x24u8, 0x81]); // HEVC video
|
||||
let a = (0x1100u16, vec![0x86u8, 0x61, b'e', b'n', b'g']); // DTS-HD MA
|
||||
let s = (0x1200u16, vec![0x90u8, b'j', b'p', b'n']); // PG
|
||||
let pi = build_program_info(&[v, a, s]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 3);
|
||||
assert_eq!(clip.streams[0].pid, 0x1011);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x24);
|
||||
assert_eq!(clip.streams[1].pid, 0x1100);
|
||||
assert_eq!(clip.streams[1].coding_type, 0x86);
|
||||
assert_eq!(clip.streams[1].language, "eng");
|
||||
assert_eq!(clip.streams[2].pid, 0x1200);
|
||||
assert_eq!(clip.streams[2].language, "jpn");
|
||||
}
|
||||
|
||||
// Best-effort: a stream whose sci_len runs past the section end
|
||||
// (`sci_end > data.len()`) returns streams collected so far (none
|
||||
// here), never panics — source returns `out` early on overflow.
|
||||
#[test]
|
||||
fn program_info_truncated_sci_no_panic() {
|
||||
// One stream claiming sci_len = 200 but with no body.
|
||||
let mut body = Vec::new();
|
||||
body.push(0); // reserved
|
||||
body.push(1); // num_programs
|
||||
body.extend_from_slice(&0u32.to_be_bytes());
|
||||
body.extend_from_slice(&0u16.to_be_bytes());
|
||||
body.push(1); // num_streams
|
||||
body.push(0); // num_groups
|
||||
body.extend_from_slice(&0x1011u16.to_be_bytes()); // pid
|
||||
body.push(200); // sci_len = 200, no body follows
|
||||
let mut pi = Vec::new();
|
||||
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pi.extend_from_slice(&body);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should not panic");
|
||||
assert!(clip.streams.is_empty());
|
||||
}
|
||||
|
||||
/// parse_program_info rejects sci_len == 0 (`sci_len < 1` → return).
|
||||
/// A zero-length stream_coding_info is unusable.
|
||||
#[test]
|
||||
fn program_info_zero_sci_len_yields_no_stream() {
|
||||
let mut body = Vec::new();
|
||||
body.push(0);
|
||||
body.push(1);
|
||||
body.extend_from_slice(&0u32.to_be_bytes());
|
||||
body.extend_from_slice(&0u16.to_be_bytes());
|
||||
body.push(1);
|
||||
body.push(0);
|
||||
body.extend_from_slice(&0x1011u16.to_be_bytes());
|
||||
body.push(0); // sci_len = 0
|
||||
let mut pi = Vec::new();
|
||||
pi.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
pi.extend_from_slice(&body);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert!(clip.streams.is_empty());
|
||||
}
|
||||
|
||||
// Section-offset gates in `parse`: prog_info_start == 0 means "no ProgramInfo section" —
|
||||
// offset-0 bytes must NOT be reinterpreted as a table.
|
||||
#[test]
|
||||
fn prog_info_start_zero_does_not_parse_header_as_program_info() {
|
||||
let mut data = build_clpi(1000, None);
|
||||
data[5] = 2; // num_programs = 2 if read from offset 0
|
||||
// program 0 header = data[6..14]; its num_streams byte is data[12],
|
||||
// which is prog_info_start's first byte and must stay 0.
|
||||
data[20] = 1; // program 1 (header data[14..22]) declares 1 stream
|
||||
data[22..24].copy_from_slice(&0x1011u16.to_be_bytes()); // pid
|
||||
data[24] = 2; // sci_len
|
||||
data[25] = 0x1B; // coding_type H.264
|
||||
data[26] = 0x61; // video format 6 / rate 1
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert!(
|
||||
clip.streams.is_empty(),
|
||||
"prog_info_start == 0 must mean absent, got {:?}",
|
||||
clip.streams
|
||||
);
|
||||
}
|
||||
|
||||
// parse_program_info: secondary audio (0xA1/0xA2) shares primary
|
||||
// audio's sci layout — sci[1] = presentation-type/rate nibbles,
|
||||
// sci[2..5] = ISO 639-2 language. Both sub-fields must be populated.
|
||||
#[test]
|
||||
fn program_info_secondary_audio_fields() {
|
||||
for coding in [c_ac3_plus_secondary(), c_dts_hd_secondary()] {
|
||||
let sci = vec![coding, 0x61, b'd', b'e', b'u'];
|
||||
let pi = build_program_info(&[(0x1A00, sci)]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should parse");
|
||||
assert_eq!(clip.streams.len(), 1, "coding {coding:#04x}");
|
||||
let s = &clip.streams[0];
|
||||
assert_eq!(s.coding_type, coding);
|
||||
// 0x61: high nibble 6, low nibble 1 — distinct values, so a
|
||||
// swapped/ORed/XORed nibble extraction cannot pass.
|
||||
assert_eq!(s.language, "deu", "coding {coding:#04x}");
|
||||
}
|
||||
}
|
||||
|
||||
fn c_ac3_plus_secondary() -> u8 {
|
||||
crate::consts::coding_type::AC3_PLUS_SECONDARY
|
||||
}
|
||||
fn c_dts_hd_secondary() -> u8 {
|
||||
crate::consts::coding_type::DTS_HD_SECONDARY
|
||||
}
|
||||
|
||||
// A 1-byte sci (coding_type only) is the accepted minimum: PID and
|
||||
// coding_type get recorded, all sub-fields needing more bytes stay
|
||||
// empty — a PG stream must NOT read sci[1..4] when only sci[0] exists.
|
||||
#[test]
|
||||
fn program_info_sci_len_one_yields_bare_stream() {
|
||||
let pi = build_program_info(&[(0x1200, vec![0x90u8])]);
|
||||
let data = build_clpi_with_proginfo(100, &pi, None);
|
||||
let clip = parse(&data).expect("should not panic");
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
assert_eq!(clip.streams[0].pid, 0x1200);
|
||||
assert_eq!(clip.streams[0].coding_type, 0x90);
|
||||
assert_eq!(clip.streams[0].language, "");
|
||||
}
|
||||
|
||||
/// Below the 6-byte ProgramInfo header (length(4)+reserved(1)+
|
||||
/// num_programs(1)) there is nothing to read; the length guard must fire
|
||||
/// before `data[5]`.
|
||||
#[test]
|
||||
fn program_info_below_header_size_is_empty() {
|
||||
for len in 0..6usize {
|
||||
assert!(parse_program_info(&vec![0u8; len]).is_empty(), "len={len}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A declared program whose 8-byte header runs past the section end must
|
||||
/// stop before reading num_streams at `data[pos + 6]`.
|
||||
#[test]
|
||||
fn program_info_truncated_program_header_is_empty() {
|
||||
// length(4) + reserved(1) + num_programs=1 (1) + only 4 of the 8
|
||||
// program-header bytes.
|
||||
let mut data = vec![0u8; 6];
|
||||
data[5] = 1;
|
||||
data.extend_from_slice(&[0u8; 4]);
|
||||
assert!(parse_program_info(&data).is_empty());
|
||||
}
|
||||
|
||||
/// A declared stream whose 3-byte header (pid(2)+sci_len(1)) runs past
|
||||
/// the section end must stop before reading the PID.
|
||||
#[test]
|
||||
fn program_info_truncated_stream_header_is_empty() {
|
||||
let mut data = vec![0u8; 6];
|
||||
data[5] = 1; // num_programs
|
||||
data.extend_from_slice(&[0u8; 8]); // program header
|
||||
data[6 + 6] = 1; // num_streams = 1
|
||||
data.extend_from_slice(&[0u8; 2]); // only 2 of the 3 stream bytes
|
||||
assert_eq!(data.len(), 16);
|
||||
assert!(parse_program_info(&data).is_empty());
|
||||
}
|
||||
|
||||
/// TextST (0x92): coding_type + character_code + 3-byte language, so the
|
||||
/// language sits at sci[2..5].
|
||||
#[test]
|
||||
fn program_info_text_subtitle_lang_offset() {
|
||||
let sci = vec![0x92u8, 0x01, b'd', b'e', b'u'];
|
||||
let pi = build_program_info(&[(0x1800, sci)]);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams[0].coding_type, 0x92);
|
||||
assert_eq!(clip.streams[0].language, "deu");
|
||||
}
|
||||
|
||||
/// IG (0x91) shares the PG layout: language at sci[1..4].
|
||||
#[test]
|
||||
fn program_info_ig_lang_offset() {
|
||||
let sci = vec![0x91u8, b'e', b's', b'p'];
|
||||
let pi = build_program_info(&[(0x1400, sci)]);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams[0].language, "esp");
|
||||
}
|
||||
|
||||
/// Streams from every program are collected, in order.
|
||||
#[test]
|
||||
fn program_info_multiple_programs_all_collected() {
|
||||
let mut body = vec![0u8, 2]; // reserved, num_programs = 2
|
||||
for (pid, lang) in [(0x1200u16, b"fra"), (0x1201u16, b"jpn")] {
|
||||
body.extend_from_slice(&[0u8; 6]); // spn + pmt_pid
|
||||
body.extend_from_slice(&[1, 0]); // num_streams, num_groups
|
||||
body.extend_from_slice(&pid.to_be_bytes());
|
||||
body.push(4);
|
||||
body.push(0x90);
|
||||
body.extend_from_slice(lang);
|
||||
}
|
||||
let mut pi = (body.len() as u32).to_be_bytes().to_vec();
|
||||
pi.extend_from_slice(&body);
|
||||
let clip = parse(&build_clpi_with_proginfo(100, &pi, None)).expect("parse");
|
||||
assert_eq!(clip.streams.len(), 2);
|
||||
assert_eq!(clip.streams[0].language, "fra");
|
||||
assert_eq!(clip.streams[1].pid, 0x1201);
|
||||
assert_eq!(clip.streams[1].language, "jpn");
|
||||
}
|
||||
|
||||
/// A trailing CPI section after ProgramInfo does not disturb parsing.
|
||||
#[test]
|
||||
fn cpi_section_present_still_parses() {
|
||||
let pi = build_program_info(&[(0x1200, vec![0x90u8, b'f', b'r', b'a'])]);
|
||||
let cpi = [0u8, 0, 0, 2, 0, 0];
|
||||
let data = build_clpi_with_proginfo(777, &pi, Some(&cpi));
|
||||
let clip = parse(&data).expect("parse");
|
||||
assert_eq!(clip.source_packet_count, 777);
|
||||
assert_eq!(clip.streams.len(), 1);
|
||||
let clip = parse(&build_clpi(5, Some(&cpi))).expect("parse");
|
||||
assert_eq!(clip.source_packet_count, 5);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// parse_cpi — low-level fixtures
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
//! Physical media constants — the single source of truth.
|
||||
//!
|
||||
//! Naming convention: a constant is prefixed by the **narrowest scope where it
|
||||
//! is valid**. A value common to all optical media carries no prefix; a value
|
||||
//! specific to a container/format/disc-type is prefixed by it
|
||||
//! (`TS_`, `BD_`, …). Define each physical quantity here exactly once and import
|
||||
//! it — never re-declare a bare literal or a local copy.
|
||||
|
||||
/// Bytes per logical sector on every optical medium freemkv reads
|
||||
/// (Blu-ray, DVD-Video, CD-ROM Mode 1). Universal — hence unprefixed.
|
||||
///
|
||||
/// `usize` because its dominant use is buffer sizing and slice indexing, where
|
||||
/// Rust *requires* `usize` (`vec![0u8; SECTOR_BYTES]`, `buf.len() < SECTOR_BYTES`).
|
||||
/// For byte-offset / capacity arithmetic — which is `u64` because a disc can
|
||||
/// exceed 4 GiB — use [`SECTOR_BYTES_U64`] instead of casting at each site.
|
||||
pub const SECTOR_BYTES: usize = 2048;
|
||||
|
||||
/// [`SECTOR_BYTES`] as `u64`, for byte-offset and capacity arithmetic. The
|
||||
/// single `usize → u64` boundary cast lives here, once, so offset math across
|
||||
/// the workspace reads as `sectors * SECTOR_BYTES_U64` with no per-site cast.
|
||||
pub const SECTOR_BYTES_U64: u64 = SECTOR_BYTES as u64;
|
||||
|
||||
/// Milliseconds per second. For turning a byte count ÷ bytes-per-second into a
|
||||
/// movie-time figure (`bytes / bps * MILLIS_PER_SEC`) without a bare `1000.0`.
|
||||
pub const MILLIS_PER_SEC: f64 = 1_000.0;
|
||||
|
||||
/// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just
|
||||
/// Blu-ray — prefixed by the format, not a disc type.
|
||||
pub const TS_PACKET_BYTES: usize = 188;
|
||||
|
||||
/// Bytes in an MPEG-2 transport-stream packet header: sync byte, the
|
||||
/// flags/PID word, and the adaptation/continuity byte.
|
||||
pub const TS_HEADER_BYTES: usize = 4;
|
||||
|
||||
/// Bytes in the arrival-timestamp prefix a Blu-ray M2TS prepends to each TS
|
||||
/// packet to form a source packet. Same width as a TS header but a distinct
|
||||
/// quantity ([`TS_HEADER_BYTES`]) — do not conflate.
|
||||
pub const BD_TIMESTAMP_PREFIX_BYTES: usize = 4;
|
||||
|
||||
/// Bytes of payload in an MPEG-2 transport-stream packet:
|
||||
/// [`TS_PACKET_BYTES`] minus the [`TS_HEADER_BYTES`] header.
|
||||
pub const TS_PAYLOAD_BYTES: usize = TS_PACKET_BYTES - TS_HEADER_BYTES;
|
||||
|
||||
/// Bytes per Blu-ray M2TS *source packet*: a TS packet ([`TS_PACKET_BYTES`])
|
||||
/// prefixed with the [`BD_TIMESTAMP_PREFIX_BYTES`] arrival-timestamp header.
|
||||
/// A BDAV/M2TS construct only — DVD VOBs have no source packets — hence `BD_`.
|
||||
pub const BD_SOURCE_PACKET_BYTES: usize = TS_PACKET_BYTES + BD_TIMESTAMP_PREFIX_BYTES;
|
||||
|
||||
/// Elementary-stream coding-type codes — the single source of truth for the
|
||||
/// byte that identifies a stream's codec.
|
||||
///
|
||||
/// Shared registry for the MPEG-TS PMT `stream_type` and the Blu-ray STN/CLPI
|
||||
/// `stream_coding_type`, which use the same value space. Unprefixed because
|
||||
/// every consumer of either spec reads or writes this one byte. Each constant
|
||||
/// is `u8`: the spec defines an 8-bit field, so the code compares it directly
|
||||
/// against a buffer byte with no casts.
|
||||
pub mod coding_type {
|
||||
/// MPEG-2 video (ISO/IEC 13818-1 Table 2-34).
|
||||
pub const MPEG2_VIDEO: u8 = 0x02;
|
||||
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
|
||||
pub const H264: u8 = 0x1B;
|
||||
/// H.264 / MVC dependent view (Blu-ray 3D right-eye substream). Carried in
|
||||
/// the SSIF interleaved stream under its own PID; the base view is [`H264`].
|
||||
/// ISO/IEC 13818-1 stream_type 0x20 (MVC video sub-bitstream).
|
||||
pub const H264_MVC: u8 = 0x20;
|
||||
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
|
||||
pub const HEVC: u8 = 0x24;
|
||||
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
|
||||
pub const VC1: u8 = 0xEA;
|
||||
|
||||
/// LPCM audio (BD-ROM convention).
|
||||
pub const LPCM: u8 = 0x80;
|
||||
/// Dolby Digital (AC-3) audio (BD-ROM / ATSC A/52 convention).
|
||||
pub const AC3: u8 = 0x81;
|
||||
/// DTS audio (BD-ROM convention).
|
||||
pub const DTS: u8 = 0x82;
|
||||
/// Dolby TrueHD audio (BD-ROM convention).
|
||||
pub const TRUEHD: u8 = 0x83;
|
||||
/// Dolby Digital Plus (E-AC-3 / AC-3+) audio (BD-ROM convention).
|
||||
pub const AC3_PLUS: u8 = 0x84;
|
||||
/// DTS-HD High Resolution audio (BD-ROM Part 3-1).
|
||||
pub const DTS_HD_HR: u8 = 0x85;
|
||||
/// DTS-HD Master Audio (lossless) (BD-ROM Part 3-1).
|
||||
pub const DTS_HD_MA: u8 = 0x86;
|
||||
|
||||
/// Presentation Graphics — PG subtitle stream (BD-ROM HDMV).
|
||||
pub const PG: u8 = 0x90;
|
||||
/// Interactive Graphics — IG / BD-J menu overlay, NOT a subtitle (BD-ROM HDMV).
|
||||
pub const IG: u8 = 0x91;
|
||||
/// Text subtitle stream (BD-ROM HDMV).
|
||||
pub const TEXT_SUBTITLE: u8 = 0x92;
|
||||
|
||||
/// Secondary Dolby Digital Plus audio (BD-ROM convention).
|
||||
pub const AC3_PLUS_SECONDARY: u8 = 0xA1;
|
||||
/// Secondary DTS-HD audio — DTS Express / DTS-HD LBR, a LOSSY low-bitrate
|
||||
/// stream for picture-in-picture and BD-J mixing (BD-ROM Part 3
|
||||
/// `stream_coding_type` table). The lossless primary is [`DTS_HD_MA`]
|
||||
/// (0x86); this code is its lossy secondary counterpart, parallel to
|
||||
/// [`AC3_PLUS_SECONDARY`] (0xA1) on the Dolby side.
|
||||
pub const DTS_HD_SECONDARY: u8 = 0xA2;
|
||||
}
|
||||
|
||||
/// MPEG PES `stream_id` codes — the byte after the `00 00 01` start-code prefix
|
||||
/// that identifies an elementary stream's role in a PES packet (ISO/IEC
|
||||
/// 13818-1 Table 2-22). Shared by the program-stream demuxer and the TS/M2TS
|
||||
/// muxers, so defined here once. Each is `u8` (matches the byte on the wire).
|
||||
pub mod pes_stream_id {
|
||||
/// Video stream (`1110 xxxx`; freemkv emits the base id `0xE0`).
|
||||
pub const VIDEO: u8 = 0xE0;
|
||||
/// MPEG audio stream (`110x xxxx`; 11172-3/13818-3/13818-7, freemkv emits `0xC0`).
|
||||
pub const MPEG_AUDIO: u8 = 0xC0;
|
||||
/// system_header start code — the MPEG-PS `00 00 01 BB` structural header
|
||||
/// (rate/bound bounds), never an elementary stream. On a DVD NAV pack it
|
||||
/// follows the pack header, so it lands at sector offset 0x11.
|
||||
pub const SYSTEM_HEADER: u8 = 0xBB;
|
||||
/// program_stream_map — the `00 00 01 BC` structural table (MS-8).
|
||||
pub const PROGRAM_STREAM_MAP: u8 = 0xBC;
|
||||
/// private_stream_1 — AC-3 / DTS / LPCM / PGS subtitle payloads.
|
||||
pub const PRIVATE_STREAM_1: u8 = 0xBD;
|
||||
/// padding_stream — stuffing bytes only, no payload to demux.
|
||||
pub const PADDING_STREAM: u8 = 0xBE;
|
||||
/// private_stream_2 — DVD navigation (PCI/DSI); carries no muxable ES.
|
||||
pub const PRIVATE_STREAM_2: u8 = 0xBF;
|
||||
|
||||
/// Highest video stream_id — the `1110 xxxx` video range tops out at 0xEF.
|
||||
pub const VIDEO_MAX: u8 = 0xEF;
|
||||
|
||||
/// Inclusive range of every PES `stream_id` that carries demuxable payload:
|
||||
/// [`PRIVATE_STREAM_1`] (0xBD) through [`VIDEO_MAX`] (0xEF) — i.e. private
|
||||
/// stream 1/2, padding, MPEG audio (0xC0-0xDF) and video (0xE0-0xEF). The
|
||||
/// pack (0xBA), system-header (0xBB) and program-end (0xB9) codes sit below
|
||||
/// this range and are deliberately excluded: they're structural, not ES.
|
||||
pub const PAYLOAD_RANGE: core::ops::RangeInclusive<u8> = PRIVATE_STREAM_1..=VIDEO_MAX;
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
//! CSS title-key recovery — a divide-and-conquer known-plaintext attack,
|
||||
//! implemented from the openly published CSS cryptanalysis literature. It
|
||||
//! recovers the 5-byte CSS title key from a single scrambled DVD sector with
|
||||
//! no player keys and no disc-key crack, using only known plaintext.
|
||||
//! Implemented from that public description; nothing here is copied or
|
||||
//! translated from any particular CSS software.
|
||||
|
||||
use super::lfsr::descramble_sector;
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
const ENCRYPTED_START: usize = 0x80; // byte 128
|
||||
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
|
||||
const FLAG_BYTE: usize = 0x14;
|
||||
|
||||
/// LFSR0's output tap: the 24-bit register folded down to the byte it feeds
|
||||
/// into TAB4. Used both to validate a candidate against the keystream and to
|
||||
/// re-derive the byte shifted in during the backward-clocking search.
|
||||
fn lfsr0_output_tap(x: u32) -> u32 {
|
||||
(((((((x >> 3) ^ x) >> 1) ^ x) >> 8) ^ x) >> 5) & 0xff
|
||||
}
|
||||
|
||||
// Recover the title key from cipher + known plaintext. `seed` is sector[0x54..0x59].
|
||||
fn recover_title_key_from_plain(
|
||||
crypted: &[u8],
|
||||
decrypted: &[u8],
|
||||
seed: &[u8; 5],
|
||||
) -> Option<[u8; 5]> {
|
||||
if crypted.len() < 10 || decrypted.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// buf[i] = TAB1[cipher[i]] ^ plain[i] — the per-byte content keystream.
|
||||
let mut buffer = [0u8; 10];
|
||||
for (i, b) in buffer.iter_mut().enumerate() {
|
||||
*b = TAB1[crypted[i] as usize] ^ decrypted[i];
|
||||
}
|
||||
|
||||
let mut key = [0u8; 5];
|
||||
let mut found = false;
|
||||
|
||||
for i_try in 0u32..0x1_0000 {
|
||||
let mut i_t1 = (i_try >> 8) | 0x100;
|
||||
let mut i_t2 = i_try & 0xff;
|
||||
let mut i_t3: u32 = 0; // not needed yet
|
||||
let mut i_t5: u32 = 0;
|
||||
|
||||
// Iterate the cipher 4 times to reconstruct LFSR0 (i_t3).
|
||||
for &b in buffer.iter().take(4) {
|
||||
let i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
let i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
|
||||
// Deduce i_t6 (LFSR0 output, pre-TAB4) and the carry.
|
||||
let mut i_t6 = b as u32;
|
||||
if i_t5 != 0 {
|
||||
i_t6 = (i_t6 + 0xff) & 0xff;
|
||||
}
|
||||
if i_t6 < i_t4 {
|
||||
i_t6 += 0x100;
|
||||
}
|
||||
i_t6 -= i_t4;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
let i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t5 >>= 8;
|
||||
}
|
||||
|
||||
let i_candidate = i_t3;
|
||||
|
||||
// Iterate 6 more times to validate the candidate.
|
||||
let mut i = 4usize;
|
||||
while i < 10 {
|
||||
let i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
|
||||
i_t2 = i_t1 >> 1;
|
||||
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
|
||||
let i_t4 = TAB5[i_t4 as usize] as u32;
|
||||
let mut i_t6 = lfsr0_output_tap(i_t3);
|
||||
i_t3 = (i_t3 << 8) | i_t6;
|
||||
i_t6 = TAB4[i_t6 as usize] as u32;
|
||||
i_t5 += i_t6 + i_t4;
|
||||
if (i_t5 & 0xff) as u8 != buffer[i] {
|
||||
break;
|
||||
}
|
||||
i_t5 >>= 8;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if i != 10 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Four backward steps of iterating i_t3 to deduce the initial state.
|
||||
i_t3 = i_candidate;
|
||||
for _ in 0..4 {
|
||||
let i_t1_byte = i_t3 & 0xff;
|
||||
i_t3 >>= 8;
|
||||
// Brute-force the byte shifted in (top byte of the 24-bit reg).
|
||||
for j in 0u32..256 {
|
||||
i_t3 = (i_t3 & 0x1_ffff) | (j << 17);
|
||||
let i_t6 = lfsr0_output_tap(i_t3);
|
||||
if i_t6 == i_t1_byte {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Undo `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
|
||||
let i_t4 = (i_t3 >> 1).wrapping_sub(4);
|
||||
for i_t5 in 0u32..8 {
|
||||
let val = i_t4.wrapping_add(i_t5);
|
||||
if val.wrapping_mul(2).wrapping_add(8).wrapping_sub(val & 7) == i_t3 {
|
||||
key[0] = (i_try >> 8) as u8;
|
||||
key[1] = (i_try & 0xff) as u8;
|
||||
key[2] = (val & 0xff) as u8;
|
||||
key[3] = ((val >> 8) & 0xff) as u8;
|
||||
key[4] = ((val >> 16) & 0xff) as u8;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// First fully-validated candidate wins. The 48-bit keystream constraint
|
||||
// makes a second match cryptographically negligible on real sectors, but
|
||||
// continuing would let a later spurious match overwrite a correct key.
|
||||
if found {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
for (k, &s) in key.iter_mut().zip(seed.iter()) {
|
||||
*k ^= s;
|
||||
}
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the CSS title key from a scrambled sector via known plaintext.
|
||||
///
|
||||
/// `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: production cracks via [`crack_title_key`], which derives its own
|
||||
/// crib. This helper is used by unit tests and `tests/crypto_tests.rs`; it stays
|
||||
/// `pub` (not `#[cfg(test)]`) so that separate integration-test crate can reach it.
|
||||
#[doc(hidden)]
|
||||
pub fn recover_title_key(sector: &[u8], plain: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_BYTES || plain.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let seed = sector_seed(sector);
|
||||
|
||||
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||
let key = recover_title_key_from_plain(crypted, plain, &seed)?;
|
||||
|
||||
if descramble_matches(sector, &key, plain) {
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sector_seed(sector: &[u8]) -> [u8; 5] {
|
||||
let mut seed = [0u8; 5];
|
||||
seed.copy_from_slice(§or[SEED_OFFSET..SEED_OFFSET + 5]);
|
||||
seed
|
||||
}
|
||||
|
||||
/// Verify a title key by descrambling a copy of `sector` and checking the
|
||||
/// known plaintext reappears at byte 0x80.
|
||||
fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
|
||||
let mut test = sector.to_vec();
|
||||
test[FLAG_BYTE] |= 0x10; // ensure scramble flag set for the descrambler
|
||||
descramble_sector(title, &mut test);
|
||||
let n = plain.len().min(SECTOR_BYTES - ENCRYPTED_START);
|
||||
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
|
||||
}
|
||||
|
||||
/// Crack the CSS title key from one scrambled sector with no external crib.
|
||||
/// The crib is the periodic cleartext run before 0x80 (see `attack_crib`),
|
||||
/// continued into the encrypted region and fed to `recover_title_key_from_plain`.
|
||||
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
if sector.len() < SECTOR_BYTES {
|
||||
return None;
|
||||
}
|
||||
if sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Runaway guard: this crack is a bounded 2^16 LFSR search, done well under 1s
|
||||
// on any modern CPU. If it ever exceeds ~2s, something pathological is
|
||||
// happening — log it so a hang is never silent.
|
||||
let crack_t0 = std::time::Instant::now();
|
||||
|
||||
let result = crack_title_key_inner(sector);
|
||||
|
||||
let elapsed = crack_t0.elapsed();
|
||||
if elapsed.as_secs_f64() > 2.0 {
|
||||
tracing::warn!(
|
||||
target: "freemkv::css",
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
found = result.is_some(),
|
||||
"css crack: single-sector recovery exceeded 2s (runaway guard)"
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// Crib: the predicted 10-byte plaintext at byte 0x80, from the longest periodic run in the
|
||||
// clear header. Also the decrypt path's cached-key oracle.
|
||||
pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> {
|
||||
if sector.len() < SECTOR_BYTES || sector[FLAG_BYTE] & 0x30 == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut best_plen: usize = 0;
|
||||
let mut best_p: usize = 0;
|
||||
|
||||
// For all cycle lengths from 2 to 0x2F.
|
||||
for i in 2usize..0x30 {
|
||||
// Count bytes that repeat with cycle length i, scanning backward from
|
||||
// 0x7F. `sec[0x7F - (j % i)] == sec[0x7F - j]`.
|
||||
let mut j = i + 1;
|
||||
while j < 0x80 && sector[0x7f - (j % i)] == sector[0x7f - j] {
|
||||
if j > best_plen {
|
||||
best_plen = j;
|
||||
best_p = i;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Need at least a few repeated bytes and at least one full cycle.
|
||||
if best_plen > 3 && best_p > 0 && best_plen / best_p >= 2 {
|
||||
// Crib starts at `0x80 - cycles*best_p`; bytes at/after 0x80 are
|
||||
// predicted by the run repeating with period best_p.
|
||||
let cycles = best_plen / best_p;
|
||||
let plain_start = 0x80 - cycles * best_p;
|
||||
|
||||
// Must wrap within the period (not read `&sec[plain_start..+10]`
|
||||
// directly), since past 0x80 the raw byte is ciphertext, not plaintext.
|
||||
let mut plain = [0u8; 10];
|
||||
for (i, p) in plain.iter_mut().enumerate() {
|
||||
*p = sector[plain_start + (i % best_p)];
|
||||
}
|
||||
Some(plain)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
|
||||
let plain = attack_crib(sector)?;
|
||||
let seed = sector_seed(sector);
|
||||
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||
if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) {
|
||||
// Verify against the same predicted plaintext.
|
||||
if descramble_matches(sector, &key, &plain) {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "keyless_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,660 +0,0 @@
|
||||
use super::super::lfsr::scramble_sector;
|
||||
use super::*;
|
||||
|
||||
// Synthesize a scrambled sector for a title key/seed with `plain` at byte 0x80.
|
||||
fn synth_sector(title_key: &[u8; 5], seed: &[u8; 5], plain: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[0..4].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||
plaintext[4] = 0x44; // '01': a 13818-1 pack
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
plaintext[ENCRYPTED_START..ENCRYPTED_START + plain.len()].copy_from_slice(plain);
|
||||
|
||||
let body = plaintext.clone();
|
||||
|
||||
// scramble_sector turns the plaintext body into ciphertext and sets
|
||||
// the scramble flag.
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
// Synthesize a sector whose cleartext ends in a periodic run continuing into the encrypted
|
||||
// region.
|
||||
fn synth_periodic_sector(title_key: &[u8; 5], seed: &[u8; 5], period: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut plaintext = vec![0u8; SECTOR_BYTES];
|
||||
plaintext[FLAG_BYTE] = 0x10;
|
||||
|
||||
// Periodic run over the cleartext header tail (RUN_START..0x80) into
|
||||
// the encrypted region, mirroring a real VOB. Must not overlap the seed
|
||||
// bytes (0x54..0x59); RUN_START=0x59 gives >=2 cycles of every period.
|
||||
const RUN_START: usize = 0x59;
|
||||
let pat: Vec<u8> = (0..period)
|
||||
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
|
||||
.collect();
|
||||
for (i, b) in plaintext.iter_mut().enumerate().skip(RUN_START) {
|
||||
*b = pat[i % period];
|
||||
}
|
||||
|
||||
// Seed sits below the run, undisturbed.
|
||||
plaintext[SEED_OFFSET..SEED_OFFSET + 5].copy_from_slice(seed);
|
||||
|
||||
let body = plaintext.clone();
|
||||
scramble_sector(title_key, &mut plaintext);
|
||||
(plaintext, body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_unscrambled_returns_none() {
|
||||
let sector = vec![0u8; SECTOR_BYTES];
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_too_short_returns_none() {
|
||||
let sector = vec![0u8; 100];
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_needs_min_plain() {
|
||||
let sector = vec![0u8; SECTOR_BYTES];
|
||||
let short_plain = [0u8; 4];
|
||||
assert!(recover_title_key(§or, &short_plain).is_none());
|
||||
}
|
||||
|
||||
/// The known plaintext used at byte 0x80 for the direct-recovery tests.
|
||||
/// A realistic MPEG-2 PES header start.
|
||||
const PES: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
|
||||
|
||||
// MANDATORY round-trip (Task C.1): recovered key must descramble the body back to
|
||||
// plaintext.
|
||||
#[test]
|
||||
fn recover_round_trips_known_keys() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x01, 0x02, 0x03, 0x04, 0x05],
|
||||
[0xDE, 0xAD, 0xBE, 0xEF, 0x42],
|
||||
),
|
||||
(
|
||||
[0xFE, 0xDC, 0xBA, 0x98, 0x76],
|
||||
[0x00, 0xFF, 0x80, 0x7F, 0x01],
|
||||
),
|
||||
(
|
||||
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||
),
|
||||
];
|
||||
for (title_key, seed) in cases {
|
||||
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||
let recovered = recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"recovered key did not descramble the full body for \
|
||||
title={title_key:02x?} seed={seed:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// descramble_matches is the ONLY gate between the LFSR search and a key handed to the
|
||||
// caller.
|
||||
#[test]
|
||||
fn descramble_matches_accepts_only_the_key_the_sector_was_scrambled_with() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
|
||||
|
||||
assert!(
|
||||
descramble_matches(§or, &title_key, &PES),
|
||||
"the key the sector was scrambled with must be accepted"
|
||||
);
|
||||
|
||||
for byte in 0..5usize {
|
||||
for bit in 0..8u32 {
|
||||
let mut wrong = title_key;
|
||||
wrong[byte] ^= 1u8 << bit;
|
||||
assert!(
|
||||
!descramble_matches(§or, &wrong, &PES),
|
||||
"key differing only in byte {byte} bit {bit} must be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gate must verify against a COPY, never mutate the caller's sector.
|
||||
#[test]
|
||||
fn descramble_matches_does_not_disturb_the_caller_s_sector() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _body) = synth_sector(&title_key, &seed, &PES);
|
||||
let before = sector.clone();
|
||||
|
||||
assert!(descramble_matches(§or, &title_key, &PES));
|
||||
let mut wrong = title_key;
|
||||
wrong[0] ^= 0x01;
|
||||
assert!(!descramble_matches(§or, &wrong, &PES));
|
||||
|
||||
assert_eq!(
|
||||
sector, before,
|
||||
"verification must leave the sector byte-for-byte unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
|
||||
/// no plaintext supplied — recovers a round-tripping key when the
|
||||
/// cleartext ends in a periodic run that continues into 0x80.
|
||||
#[test]
|
||||
fn crack_title_key_recovers_via_attack_pattern() {
|
||||
for &period in &[2usize, 3, 5, 8, 16] {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, body) = synth_periodic_sector(&title_key, &seed, period);
|
||||
|
||||
let cracked = crack_title_key(§or)
|
||||
.unwrap_or_else(|| panic!("crack_title_key returned None for period {period}"));
|
||||
let mut test = sector.clone();
|
||||
descramble_sector(&cracked, &mut test);
|
||||
assert_eq!(
|
||||
&test[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"crack_title_key key did not round-trip the body (period {period})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
|
||||
/// a known body, hand back the keystream-derived key, and the recovered
|
||||
/// key (XOR-back included) reproduces the plaintext.
|
||||
#[test]
|
||||
fn recovered_key_descrambles_back_to_plaintext() {
|
||||
let cases: &[([u8; 5], [u8; 5])] = &[
|
||||
(
|
||||
[0x42, 0x13, 0x37, 0xBE, 0xEF],
|
||||
[0x11, 0x22, 0x33, 0x44, 0x55],
|
||||
),
|
||||
(
|
||||
[0x9A, 0x78, 0x56, 0x34, 0x12],
|
||||
[0xA5, 0x5A, 0x0F, 0xF0, 0xCC],
|
||||
),
|
||||
(
|
||||
[0xFF, 0xFF, 0xFF, 0xFF, 0xFF],
|
||||
[0x01, 0x01, 0x01, 0x01, 0x01],
|
||||
),
|
||||
];
|
||||
for (title_key, seed) in cases {
|
||||
let (mut sector, body) = synth_sector(title_key, seed, &PES);
|
||||
let recovered = recover_title_key(§or, &PES).expect("recover_title_key returned None");
|
||||
descramble_sector(&recovered, &mut sector);
|
||||
assert_eq!(
|
||||
§or[ENCRYPTED_START..SECTOR_BYTES],
|
||||
&body[ENCRYPTED_START..SECTOR_BYTES],
|
||||
"descramble with recovered key did not reproduce the body \
|
||||
for title={title_key:02x?} seed={seed:02x?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── early-return guards ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_sector_one_byte_short() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
assert!(recover_title_key(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_rejects_unscrambled_sector() {
|
||||
let sector = vec![0x00u8; SECTOR_BYTES];
|
||||
assert!(recover_title_key(§or, &PES).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_high_flag_bits_are_not_scramble() {
|
||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = flag;
|
||||
assert!(
|
||||
recover_title_key(§or, &PES).is_none(),
|
||||
"flag {flag:#04x} has scramble bits clear; recover must return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_high_flag_bits_are_not_scramble() {
|
||||
for &flag in &[0x40u8, 0x80, 0xC0] {
|
||||
let mut sector = vec![0x11u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = flag;
|
||||
assert!(
|
||||
crack_title_key(§or).is_none(),
|
||||
"flag {flag:#04x} clear scramble bits -> crack must return None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crack_rejects_sector_one_byte_short() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES - 1];
|
||||
if sector.len() > FLAG_BYTE {
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
}
|
||||
assert!(crack_title_key(§or).is_none());
|
||||
}
|
||||
|
||||
/// crack_title_key must never panic on a fully scrambled sector with
|
||||
/// arbitrary (non-periodic) content — it just returns None.
|
||||
#[test]
|
||||
fn crack_full_path_never_panics() {
|
||||
for seed in 0u32..3 {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(7);
|
||||
for b in sector.iter_mut().skip(0x80) {
|
||||
x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
|
||||
*b = (x >> 16) as u8;
|
||||
}
|
||||
for (i, b) in sector[SEED_OFFSET..SEED_OFFSET + 5].iter_mut().enumerate() {
|
||||
*b = (seed.wrapping_add(i as u32) ^ 0xA5) as u8;
|
||||
}
|
||||
let _ = crack_title_key(§or);
|
||||
}
|
||||
}
|
||||
|
||||
// ── entry-point guards on caller- and disc-supplied lengths: a sector buffer that ENDS
|
||||
// inside the encrypted region must be refused, not sliced.
|
||||
#[test]
|
||||
fn recover_rejects_a_sector_that_ends_inside_the_encrypted_region() {
|
||||
for len in [0x81usize, 0x85, 0x89] {
|
||||
let mut sector = vec![0x11u8; len];
|
||||
sector[FLAG_BYTE] = 0x30; // scrambled, so no other guard fires first
|
||||
assert!(
|
||||
recover_title_key(§or, &PES).is_none(),
|
||||
"a {len}-byte buffer cannot supply ten ciphertext bytes at 0x80"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A buffer LONGER than one sector is still one sector: both entry points must recover from
|
||||
// the first SECTOR_BYTES.
|
||||
#[test]
|
||||
fn a_buffer_longer_than_one_sector_still_yields_its_key() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
let mut padded = sector.clone();
|
||||
padded.extend_from_slice(&[0xA7u8; 512]);
|
||||
assert_eq!(
|
||||
recover_title_key(&padded, &PES),
|
||||
Some(title_key),
|
||||
"a two-and-a-bit-sector buffer must still recover the first sector's key"
|
||||
);
|
||||
|
||||
let (periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
|
||||
let mut padded = periodic.clone();
|
||||
padded.extend_from_slice(&[0xA7u8; 512]);
|
||||
assert_eq!(
|
||||
crack_title_key(&padded),
|
||||
crack_title_key(&periodic),
|
||||
"padding past the sector must not change the crack result"
|
||||
);
|
||||
assert!(crack_title_key(&padded).is_some());
|
||||
}
|
||||
|
||||
// recover_title_key accepts and uses MORE than ten bytes of known plaintext (ten is a
|
||||
// MINIMUM).
|
||||
#[test]
|
||||
fn recover_accepts_more_than_ten_bytes_of_known_plaintext() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let long_plain: Vec<u8> = (0..64u8)
|
||||
.map(|k| k.wrapping_mul(37).wrapping_add(5))
|
||||
.collect();
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &long_plain);
|
||||
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &long_plain),
|
||||
Some(title_key),
|
||||
"64 bytes of known plaintext must be accepted, not rejected as \
|
||||
'more than ten'"
|
||||
);
|
||||
}
|
||||
|
||||
// The scramble-flag gate on a sector whose BODY really is ciphertext and whose key IS
|
||||
// recoverable.
|
||||
#[test]
|
||||
fn a_recoverable_sector_with_the_scramble_bits_cleared_is_still_refused() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
|
||||
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &PES),
|
||||
Some(title_key),
|
||||
"fixture check: with the flag set this sector's key IS recoverable"
|
||||
);
|
||||
sector[FLAG_BYTE] = 0x00;
|
||||
assert_eq!(
|
||||
recover_title_key(§or, &PES),
|
||||
None,
|
||||
"scramble bits clear → no title key, even though one could be found"
|
||||
);
|
||||
|
||||
let (mut periodic, _) = synth_periodic_sector(&title_key, &seed, 5);
|
||||
assert!(
|
||||
crack_title_key(&periodic).is_some(),
|
||||
"fixture check: with the flag set this sector cracks"
|
||||
);
|
||||
assert!(
|
||||
attack_crib(&periodic).is_some(),
|
||||
"fixture check: with the flag set this sector has a usable crib"
|
||||
);
|
||||
periodic[FLAG_BYTE] = 0x00;
|
||||
assert_eq!(
|
||||
crack_title_key(&periodic),
|
||||
None,
|
||||
"scramble bits clear → no crack, even though one would succeed"
|
||||
);
|
||||
// `attack_crib` carries its own copy of the same gate and is the one that
|
||||
// actually stops the crack (`crack_title_key`'s is defensive duplication);
|
||||
// it also doubles as the decrypt path's cached-key oracle.
|
||||
assert_eq!(
|
||||
attack_crib(&periodic),
|
||||
None,
|
||||
"an unscrambled sector has no predicted plaintext to offer"
|
||||
);
|
||||
}
|
||||
|
||||
// ── descramble_matches: the verification gate's own mechanics — the gate must verify
|
||||
// against the sector's CIPHERTEXT regardless of the flag byte.
|
||||
#[test]
|
||||
fn descramble_matches_forces_the_scramble_flag_on_its_own_copy() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (mut sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
sector[FLAG_BYTE] = 0x00;
|
||||
|
||||
assert!(
|
||||
descramble_matches(§or, &title_key, &PES),
|
||||
"the body is ciphertext and the key is right — the gate must \
|
||||
descramble it even though the flag byte says otherwise"
|
||||
);
|
||||
let mut wrong = title_key;
|
||||
wrong[0] ^= 0x01;
|
||||
assert!(!descramble_matches(§or, &wrong, &PES));
|
||||
}
|
||||
|
||||
// The gate compares the WHOLE supplied plaintext, clamped to the encrypted region.
|
||||
#[test]
|
||||
fn descramble_matches_compares_all_of_the_plaintext_and_no_more_than_the_sector() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let body: Vec<u8> = (0..64u8)
|
||||
.map(|k| k.wrapping_mul(29).wrapping_add(3))
|
||||
.collect();
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &body);
|
||||
|
||||
assert!(descramble_matches(§or, &title_key, &body));
|
||||
|
||||
// A crib agreeing for the first 16 bytes and diverging after must be
|
||||
// rejected: the comparison window is the crib's length, not a fixed
|
||||
// prefix.
|
||||
let mut tail_wrong = body.clone();
|
||||
tail_wrong[40] ^= 0xFF;
|
||||
assert!(
|
||||
!descramble_matches(§or, &title_key, &tail_wrong),
|
||||
"a crib that diverges at byte 40 must not match"
|
||||
);
|
||||
assert_eq!(
|
||||
tail_wrong[..16],
|
||||
body[..16],
|
||||
"fixture check: the first 16 bytes are identical, so only a \
|
||||
comparison that runs past them can tell these apart"
|
||||
);
|
||||
|
||||
// A crib LONGER than the encrypted region: the comparison is clamped to
|
||||
// the sector, not run off the end of it.
|
||||
let plain_len = SECTOR_BYTES - ENCRYPTED_START;
|
||||
let mut over_long = vec![0u8; plain_len + 10];
|
||||
let (full_sector, full_body) = synth_sector(&title_key, &seed, &[0x00u8; 10]);
|
||||
over_long[..plain_len].copy_from_slice(&full_body[ENCRYPTED_START..]);
|
||||
assert!(
|
||||
descramble_matches(&full_sector, &title_key, &over_long),
|
||||
"a crib longer than the encrypted region must be clamped, not \
|
||||
compared past the end of the sector"
|
||||
);
|
||||
}
|
||||
|
||||
// ── attack_crib: known-answer vectors. Sector whose clear header ends in
|
||||
// a period-length run of run_len bytes before 0x80, rest 0xFF.
|
||||
fn sector_with_trailing_run(period: usize, run_len: usize) -> Vec<u8> {
|
||||
assert!(
|
||||
run_len < ENCRYPTED_START,
|
||||
"the run lives in the clear header"
|
||||
);
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x10;
|
||||
for b in sector[ENCRYPTED_START..].iter_mut() {
|
||||
*b = 0xFF;
|
||||
}
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
for x in (ENCRYPTED_START - run_len)..ENCRYPTED_START {
|
||||
sector[x] = pat[x % period];
|
||||
}
|
||||
sector
|
||||
}
|
||||
|
||||
/// The crib the run PREDICTS: the periodic pattern continued past 0x80.
|
||||
fn expected_crib(period: usize) -> [u8; 10] {
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
let mut out = [0u8; 10];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
*o = pat[(ENCRYPTED_START + i) % period];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// KNOWN ANSWER: the crib is the period-5 run continued forward, the same
|
||||
// ten bytes for every run length.
|
||||
#[test]
|
||||
fn attack_crib_predicts_the_periodic_run_continuing_past_0x80() {
|
||||
for &run_len in &[11usize, 12, 13, 14, 15, 16, 20, 31] {
|
||||
let sector = sector_with_trailing_run(5, run_len);
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
Some(expected_crib(5)),
|
||||
"period-5 run of {run_len} bytes must predict the run continuing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The same known answer across several periods, including a period that
|
||||
/// does NOT divide 0x80 (so the crib's phase is non-zero and a body that
|
||||
/// restarted the pattern at index 0 gives a different answer).
|
||||
#[test]
|
||||
fn attack_crib_recovers_the_run_period_and_phase() {
|
||||
// 0x80 % period: 3 for 5, 2 for 6, 2 for 7, 8 for 0x18 — all non-zero,
|
||||
// so the predicted first byte is NOT pat[0] in any of these cases.
|
||||
for &period in &[5usize, 6, 7, 0x18] {
|
||||
let sector = sector_with_trailing_run(period, 3 * period + 1);
|
||||
let crib = attack_crib(§or).unwrap_or_else(|| panic!("no crib for period {period}"));
|
||||
assert_eq!(crib, expected_crib(period), "period {period}");
|
||||
assert_ne!(
|
||||
crib[0], 0xD0,
|
||||
"period {period} does not divide 0x80, so the crib must not \
|
||||
start at pattern index 0"
|
||||
);
|
||||
assert!(
|
||||
crib.iter().all(|&b| b != 0xFF),
|
||||
"period {period}: the crib must never contain a byte read from \
|
||||
the encrypted region"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A run of exactly ONE cycle is not enough to predict forward; attack_crib requires two
|
||||
// full cycles.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_run_shorter_than_two_cycles() {
|
||||
// period 8, run of 9 bytes: best_plen = 8, 8 / 8 == 1 cycle.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(8, 9)), None);
|
||||
// period 0x18, run of 0x19 bytes: one cycle.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(0x18, 0x19)), None);
|
||||
// ...and one more byte of run does not conjure a second cycle either.
|
||||
assert_eq!(attack_crib(§or_with_trailing_run(8, 10)), None);
|
||||
}
|
||||
|
||||
/// A header with no repeating tail at all yields no crib. Asserted on a
|
||||
/// header whose bytes are pairwise distinct right up to 0x80, so no cycle
|
||||
/// length in 2..0x2F can match even one byte.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_header_with_no_periodic_tail() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x10;
|
||||
// 0x00..0x80 strictly increasing: sec[a] == sec[b] iff a == b, so the
|
||||
// detector's `sec[0x7f - (j % i)] == sec[0x7f - j]` needs j % i == j,
|
||||
// which the scan's starting `j = i + 1` already excludes.
|
||||
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
|
||||
*b = x as u8;
|
||||
}
|
||||
assert_eq!(attack_crib(§or), None);
|
||||
// And the cracker built on it reports no key rather than guessing.
|
||||
assert_eq!(crack_title_key(§or), None);
|
||||
}
|
||||
|
||||
// attack_crib indexes sector[0x7f - j] unbounded, so its own length guard is the only thing
|
||||
// stopping an out-of-bounds read.
|
||||
#[test]
|
||||
fn attack_crib_refuses_a_buffer_shorter_than_a_sector() {
|
||||
for len in [0x15usize, 0x40, 0x7F, SECTOR_BYTES - 1] {
|
||||
let mut sector = vec![0x11u8; len];
|
||||
sector[FLAG_BYTE] = 0x30; // scrambled, so the flag half cannot fire
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
None,
|
||||
"a {len}-byte buffer is not a sector"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A header periodic ALL THE WAY to offset 0 must not walk the backward scan off the front
|
||||
// of the sector.
|
||||
#[test]
|
||||
fn attack_crib_survives_a_header_that_is_periodic_to_offset_zero() {
|
||||
let mut sector = vec![0u8; SECTOR_BYTES];
|
||||
sector[FLAG_BYTE] = 0x30;
|
||||
let period = 5usize;
|
||||
let pat: Vec<u8> = (0..period).map(|k| 0xD0u8 + k as u8).collect();
|
||||
for (x, b) in sector[..ENCRYPTED_START].iter_mut().enumerate() {
|
||||
*b = pat[x % period];
|
||||
}
|
||||
for b in sector[ENCRYPTED_START..].iter_mut() {
|
||||
*b = 0xFF;
|
||||
}
|
||||
// The FLAG byte at 0x14 would interrupt the pattern, but j only needs to
|
||||
// reach 0x7f - 0x14 = 0x6b, well short of the bound. So instead put the
|
||||
// scramble flag bits into a byte value that IS the pattern's.
|
||||
sector[FLAG_BYTE] = pat[FLAG_BYTE % period];
|
||||
assert_ne!(
|
||||
sector[FLAG_BYTE] & 0x30,
|
||||
0,
|
||||
"fixture check: the pattern byte at 0x14 must itself carry \
|
||||
scramble bits, so the header stays unbroken"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
attack_crib(§or),
|
||||
Some(expected_crib(period)),
|
||||
"a fully periodic header must predict its own continuation, and \
|
||||
the backward scan must stop at offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
// The crib is read from the CLEAR header only, never the encrypted region.
|
||||
#[test]
|
||||
fn attack_crib_is_independent_of_the_encrypted_region() {
|
||||
let base = sector_with_trailing_run(5, 11);
|
||||
let crib = attack_crib(&base).expect("crib");
|
||||
for fill in [0x00u8, 0x5A, 0xD1, 0xFF] {
|
||||
let mut s = base.clone();
|
||||
for b in s[ENCRYPTED_START..].iter_mut() {
|
||||
*b = fill;
|
||||
}
|
||||
assert_eq!(
|
||||
attack_crib(&s),
|
||||
Some(crib),
|
||||
"the crib must not depend on the encrypted region (fill {fill:#04x})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── recover_title_key_from_plain: input-length guard is the only thing standing between a
|
||||
// short slice and an index-out-of-bounds PANIC.
|
||||
#[test]
|
||||
fn recover_title_key_from_plain_refuses_fewer_than_ten_bytes_of_either_input() {
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let full = [0xA5u8; 10];
|
||||
for n in 0..10usize {
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(&full[..n], &full, &seed),
|
||||
None,
|
||||
"{n} ciphertext bytes is fewer than the ten the cipher iterates"
|
||||
);
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(&full, &full[..n], &seed),
|
||||
None,
|
||||
"{n} plaintext bytes is fewer than the ten the cipher iterates"
|
||||
);
|
||||
}
|
||||
// Exactly ten of each is ACCEPTED — the boundary is `< 10`, not `<= 10`.
|
||||
// What must not happen is an early `None` from the guard; proven via the
|
||||
// round-trip fixture, whose inputs are exactly ten bytes.
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(§or[ENCRYPTED_START..ENCRYPTED_START + 10], &PES, &seed),
|
||||
Some(title_key),
|
||||
"exactly ten bytes of each input must run the search, not trip the guard"
|
||||
);
|
||||
}
|
||||
|
||||
// The seed XOR-back turns the recovered LFSR key into the TITLE key: key ^= sector_seed.
|
||||
#[test]
|
||||
fn recover_title_key_from_plain_xors_the_sector_seed_back_out() {
|
||||
let title_key = [0x42u8, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||
let (sector, _) = synth_sector(&title_key, &seed, &PES);
|
||||
let crypted = §or[ENCRYPTED_START..ENCRYPTED_START + 10];
|
||||
|
||||
// The cipher is seeded from `title_key XOR seed`, so re-running the SAME
|
||||
// ciphertext/plaintext against a seed differing in one byte must return
|
||||
// a title key differing in exactly that byte — the XOR is a bijection.
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(crypted, &PES, &seed),
|
||||
Some(title_key)
|
||||
);
|
||||
for byte in 0..5usize {
|
||||
for bit in [0u32, 3, 7] {
|
||||
let mut alt_seed = seed;
|
||||
alt_seed[byte] ^= 1u8 << bit;
|
||||
let mut expected = title_key;
|
||||
expected[byte] ^= 1u8 << bit;
|
||||
assert_eq!(
|
||||
recover_title_key_from_plain(crypted, &PES, &alt_seed),
|
||||
Some(expected),
|
||||
"seed byte {byte} bit {bit} must XOR straight through to the \
|
||||
title key"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
//! CSS content cipher (constants in [`super::tables`]). Two table-driven
|
||||
//! linear-feedback circuits:
|
||||
//! - **LFSR1** — 17-bit register seeded from `key[0..2] XOR seed[0..2]`,
|
||||
//! stepped through `TAB2`/`TAB3`/`TAB5`.
|
||||
//! - **LFSR0** — 24-bit register seeded from `key[2..5] XOR seed[2..5]`,
|
||||
//! stepped through a feedback polynomial and `TAB4`.
|
||||
//!
|
||||
//! Output byte = sum-with-carry of both registers; body byte recovered as `plain = TAB1[cipher]
|
||||
//! ^ keystream`.
|
||||
|
||||
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
|
||||
|
||||
/// Descramble a CSS-encrypted DVD sector in place.
|
||||
///
|
||||
/// Seeded from `title_key XOR sector_seed` (bytes `0x54..0x59`); transforms only
|
||||
/// the body `0x80..0x800`: `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
|
||||
/// Flag bits 4-5 at byte `0x14` are cleared afterwards.
|
||||
///
|
||||
/// No-op if `sector.len() < 2048` or the scramble flag bits are already zero.
|
||||
#[doc(hidden)]
|
||||
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
if sector.len() < 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Not scrambled (flag bits 4-5 clear) → nothing to do.
|
||||
if sector[0x14] & 0x30 == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
|
||||
// set bit 8 (`| 0x100`) as its running marker.
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
|
||||
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
|
||||
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
// Keystream accumulator; the low byte is the current keystream byte and the
|
||||
// high bits carry into the next iteration.
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
// Step LFSR1: its output byte `o1`.
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
// Step LFSR0: its output byte `o0`.
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
|
||||
// Combine (sum with carry) and recover the plaintext byte.
|
||||
acc += o0 + o1;
|
||||
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// Clear the scramble bits so downstream code and tests can tell a sector was
|
||||
// descrambled; bits 6-7 of byte 0x14 are preserved.
|
||||
sector[0x14] &= 0xCF;
|
||||
}
|
||||
|
||||
// Exact inverse of descramble_sector, for test use only (builds known ciphertext).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
|
||||
if sector.len() < 2048 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
|
||||
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
|
||||
let mut r0: u32 = (((title_key[2] as u32)
|
||||
| ((title_key[3] as u32) << 8)
|
||||
| ((title_key[4] as u32) << 16))
|
||||
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
|
||||
& 0xFF_FFFF;
|
||||
r0 = r0 * 2 + 8 - (r0 & 7);
|
||||
|
||||
let mut acc: u32 = 0;
|
||||
|
||||
for byte in sector.iter_mut().take(2048).skip(128) {
|
||||
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
|
||||
r1b = r1a >> 1;
|
||||
r1a = ((r1a & 1) << 8) ^ o1;
|
||||
o1 = TAB5[o1 as usize] as u32;
|
||||
|
||||
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
|
||||
r0 = (r0 << 8) | o0;
|
||||
o0 = TAB4[o0 as usize] as u32;
|
||||
acc += o0 + o1;
|
||||
|
||||
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
|
||||
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
|
||||
acc >>= 8;
|
||||
}
|
||||
|
||||
// Mark the sector scrambled so the descrambler will process it.
|
||||
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
|
||||
}
|
||||
|
||||
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
|
||||
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
|
||||
#[cfg(test)]
|
||||
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
|
||||
let mut inv = [0u8; 256];
|
||||
for (i, &v) in TAB1.iter().enumerate() {
|
||||
inv[v as usize] = i as u8;
|
||||
}
|
||||
inv
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lfsr_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,308 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn descramble_skips_unscrambled() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = 0x00;
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(sector, original);
|
||||
}
|
||||
|
||||
// Regression vector pinning this implementation's own output (NOT an independent CSS
|
||||
// known-answer). key = 42 13 37 BE EF, seed = DE AD BE EF 42, body = 0xAA.
|
||||
#[test]
|
||||
fn descramble_output_is_pinned_regression_vector() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
§or[0x80..0x90],
|
||||
&[
|
||||
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
|
||||
0xA0, 0x31
|
||||
],
|
||||
"descramble body head changed from the pinned output"
|
||||
);
|
||||
assert_eq!(
|
||||
§or[0x7F8..0x800],
|
||||
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
|
||||
"descramble body tail changed from the pinned output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descramble_modifies_scrambled() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag set
|
||||
// Set a sector seed
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Header (0..128) unchanged except byte 0x14 (flag cleared)
|
||||
for i in 0..128 {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||
}
|
||||
// Encrypted region should be different
|
||||
assert_ne!(§or[128..256], &original[128..256]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descramble_clears_flags() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0x00; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00);
|
||||
}
|
||||
|
||||
// Not a plain XOR involution (TAB1 applies to ciphertext); scramble_sector is the true
|
||||
// inverse.
|
||||
#[test]
|
||||
fn css_descramble_inverts_scramble_over_body() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
|
||||
let mut sector = vec![0xAAu8; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
|
||||
let original = sector.clone();
|
||||
|
||||
// Scramble the plaintext body into ciphertext.
|
||||
scramble_sector(&title_key, &mut sector);
|
||||
// Header (0..128) unchanged except the flag byte (set by scramble).
|
||||
for i in 0..128 {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(sector[i], original[i], "header byte {} changed", i);
|
||||
}
|
||||
// Encrypted region modified
|
||||
assert_ne!(§or[128..256], &original[128..256]);
|
||||
|
||||
// Descramble restores the plaintext body byte-for-byte.
|
||||
descramble_sector(&title_key, &mut sector);
|
||||
assert_eq!(sector[0x14] & 0x30, 0x00, "flag cleared after descramble");
|
||||
assert_eq!(
|
||||
§or[128..2048],
|
||||
&original[128..2048],
|
||||
"descramble(scramble(body)) did not restore the body"
|
||||
);
|
||||
}
|
||||
|
||||
// TAB1 is a substitution table; verify it is a permutation of 0..255.
|
||||
#[test]
|
||||
fn css_tab1_is_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for tab1_val in &TAB1 {
|
||||
let v = *tab1_val as usize;
|
||||
assert!(!seen[v], "TAB1 maps two inputs to {:#04x}", v);
|
||||
seen[v] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// css_tab4_is_bit_reversal
|
||||
///
|
||||
/// TAB4 reverses the bits of each byte: TAB4[0x01] = 0x80, TAB4[0x80] = 0x01, etc.
|
||||
#[test]
|
||||
fn css_tab4_is_bit_reversal() {
|
||||
for i in 0u16..256 {
|
||||
let expected = (0..8).fold(0u8, |acc, bit| acc | (((i as u8 >> bit) & 1) << (7 - bit)));
|
||||
assert_eq!(
|
||||
TAB4[i as usize], expected,
|
||||
"TAB4[{:#04x}] = {:#04x}, expected {:#04x} (bit reversal)",
|
||||
i, TAB4[i as usize], expected
|
||||
);
|
||||
}
|
||||
// Also verify TAB4 is an involution: TAB4[TAB4[x]] == x
|
||||
for i in 0..256 {
|
||||
assert_eq!(
|
||||
TAB4[TAB4[i] as usize], i as u8,
|
||||
"TAB4 is not an involution at {:#04x}",
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scramble-flag detection (byte 0x14, bits 4-5): only bits 4-5 are the flag (mask 0x30);
|
||||
// 0x40/0x80 must read UNSCRAMBLED.
|
||||
#[test]
|
||||
fn descramble_treats_high_bits_of_0x14_as_clear() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
for &flag in &[0x40u8, 0x80, 0xC0, 0x0F, 0x4F, 0x8F] {
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = flag;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
sector, original,
|
||||
"byte 0x14 = {flag:#04x} has flag bits 4-5 clear; sector must be untouched"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Each scramble bit (4 and 5) independently marks a sector encrypted: 0x10 and 0x20 must
|
||||
// both trigger descrambling.
|
||||
#[test]
|
||||
fn descramble_triggers_on_either_flag_bit() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
for &flag in &[0x10u8, 0x20, 0x30] {
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = flag;
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_ne!(
|
||||
§or[128..256],
|
||||
&original[128..256],
|
||||
"flag {flag:#04x} (bits 4-5 nonzero) must descramble the body"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only the two scramble bits are cleared (`& 0xCF`); bits 6-7 of byte 0x14 survive: 0xF0
|
||||
// becomes 0xC0, NOT 0x00.
|
||||
#[test]
|
||||
fn descramble_clear_preserves_high_bits_of_0x14() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0x00; 2048];
|
||||
sector[0x14] = 0xF0; // bits 4-7 set; bits 4-5 are the flag
|
||||
sector[0x54..0x59].copy_from_slice(&[0x00; 5]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
assert_eq!(
|
||||
sector[0x14], 0xC0,
|
||||
"scramble bits cleared, bits 6-7 preserved (0xF0 & 0xCF)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
|
||||
|
||||
// Encrypted region is exactly 0x80..0x800; header bytes 0x00..0x80 (incl. the seed at
|
||||
// 0x54..0x59) must stay untouched.
|
||||
#[test]
|
||||
fn descramble_leaves_header_and_seed_intact() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0x5Au8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
sector[0x54..0x59].copy_from_slice(&seed);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
for i in 0..0x80usize {
|
||||
if i == 0x14 {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
sector[i], original[i],
|
||||
"header byte {i:#04x} must be untouched"
|
||||
);
|
||||
}
|
||||
assert_eq!(§or[0x54..0x59], &seed, "sector seed must survive");
|
||||
}
|
||||
|
||||
// The descrambler must touch the WHOLE body, including the final byte (index 2047),
|
||||
// guarding the `.take(2048)` bound.
|
||||
#[test]
|
||||
fn descramble_covers_final_body_byte() {
|
||||
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let mut sector = vec![0x00u8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
descramble_sector(&key, &mut sector);
|
||||
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
|
||||
// the keystream reaches the final byte.
|
||||
assert_ne!(
|
||||
§or[2040..2048],
|
||||
&[0u8; 8][..],
|
||||
"the tail of the body must be descrambled (loop must reach index 2047)"
|
||||
);
|
||||
}
|
||||
|
||||
// The length guard is a FLOOR, not a ceiling: descramble_sector processes the first sector
|
||||
// of any over-long buffer.
|
||||
#[test]
|
||||
fn descramble_processes_the_first_sector_of_an_over_long_buffer() {
|
||||
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
|
||||
// Two sectors' worth of buffer; only the first is a sector.
|
||||
let mut buf = vec![0xAAu8; 4096];
|
||||
buf[0x14] = 0x30;
|
||||
buf[0x54..0x59].copy_from_slice(&seed);
|
||||
let original = buf.clone();
|
||||
|
||||
descramble_sector(&title_key, &mut buf);
|
||||
|
||||
assert_ne!(
|
||||
&buf[0x80..0x800],
|
||||
&original[0x80..0x800],
|
||||
"the first sector's body must be descrambled"
|
||||
);
|
||||
assert_eq!(buf[0x14] & 0x30, 0x00, "and its scramble flag cleared");
|
||||
assert_eq!(
|
||||
&buf[2048..4096],
|
||||
&original[2048..4096],
|
||||
"bytes past the first sector must be left untouched"
|
||||
);
|
||||
|
||||
// The result must equal what a caller gets by passing exactly one
|
||||
// sector — the same transform, not a length-dependent one.
|
||||
let mut one = original[..2048].to_vec();
|
||||
descramble_sector(&title_key, &mut one);
|
||||
assert_eq!(
|
||||
&buf[..2048],
|
||||
&one[..],
|
||||
"the first sector must descramble identically either way"
|
||||
);
|
||||
}
|
||||
|
||||
// Descramble is keyed by `title_key XOR seed`: two different title keys must produce two
|
||||
// different bodies.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_title_key() {
|
||||
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
||||
let make = |k: &[u8; 5]| {
|
||||
let mut s = vec![0x00u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
s[0x54..0x59].copy_from_slice(&seed);
|
||||
descramble_sector(k, &mut s);
|
||||
s
|
||||
};
|
||||
let a = make(&[0x01, 0x02, 0x03, 0x04, 0x05]);
|
||||
let b = make(&[0x01, 0x02, 0x03, 0x04, 0x06]); // differs in last byte
|
||||
assert_ne!(
|
||||
&a[128..2048],
|
||||
&b[128..2048],
|
||||
"different title keys must descramble differently"
|
||||
);
|
||||
}
|
||||
|
||||
// Descramble is keyed by the sector seed too: same title key, different seed -> different
|
||||
// body.
|
||||
#[test]
|
||||
fn descramble_output_depends_on_seed() {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let make = |seed: [u8; 5]| {
|
||||
let mut s = vec![0x00u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
s[0x54..0x59].copy_from_slice(&seed);
|
||||
descramble_sector(&key, &mut s);
|
||||
s
|
||||
};
|
||||
let a = make([0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let b = make([0x11, 0x22, 0x33, 0x44, 0x56]);
|
||||
assert_ne!(
|
||||
&a[128..2048],
|
||||
&b[128..2048],
|
||||
"different seeds must descramble differently"
|
||||
);
|
||||
}
|
||||
-516
@@ -1,516 +0,0 @@
|
||||
//! CSS (Content Scramble System) — DVD disc encryption.
|
||||
//!
|
||||
//! CSS uses a weak 40-bit LFSR stream cipher (broken since 1999).
|
||||
//!
|
||||
//! The title key is recovered keylessly: [`crack_key_outcome`] recovers it
|
||||
//! directly from the scrambled data (see the [`keyless`] module), needing no
|
||||
//! player keys, disc-key recovery, or external key file. Sectors are then
|
||||
//! decrypted with [`descramble_sector`].
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```rust,ignore
|
||||
//! match css::crack_key_outcome(reader, extents, batch, None) {
|
||||
//! CrackOutcome::Cracked(state) => css::descramble_sector(&state, &mut sector),
|
||||
//! _ => { /* unencrypted, uncrackable, unreadable, or halted */ }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod keyless;
|
||||
pub mod lfsr;
|
||||
pub(crate) mod tables;
|
||||
|
||||
use crate::disc::Extent;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
// Consecutive CSS-locked reads before the crack scan early-bails, instead of grinding the full
|
||||
// 50_000-sector budget. Resets to 0 on any readable batch.
|
||||
const CSS_LOCKED_BAIL: u32 = 64;
|
||||
|
||||
// Sector budget for one crack scan.
|
||||
const MAX_TRIES: u32 = 50_000;
|
||||
|
||||
// Test-only: how many times descramble_region called the expensive re-crack.
|
||||
// Pins a WORK bound (at most one per contiguous false-positive run).
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static RECRACK_ATTEMPTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// CSS decryption state for a DVD title.
|
||||
#[derive(Clone)]
|
||||
pub struct CssState {
|
||||
/// 5-byte CSS title key (from SCSI auth or the crack fallback).
|
||||
pub title_key: [u8; 5],
|
||||
/// LBA half-open span `[start, end)` of the extent set this key was
|
||||
/// cracked from. CSS title keys are per-VTS: a key cracked from one
|
||||
/// VTS does NOT descramble a title living in a different VTS. The mux
|
||||
/// path checks whether the title being opened overlaps this span; if
|
||||
/// not, it re-cracks from that title's own extents. `None` for keys
|
||||
/// of unknown provenance (e.g. test fixtures) — treated as "applies
|
||||
/// everywhere" for backward compatibility.
|
||||
pub crack_span: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
// Redacting `Debug`: `CssState` is reachable via the public `Disc.css` field, so
|
||||
// a `{:?}` on a `Disc` would otherwise print the raw CSS title key. Print only
|
||||
// the (non-secret) crack span. Guarded by `css_state_debug_is_redacted`.
|
||||
impl std::fmt::Debug for CssState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CssState")
|
||||
.field("title_key", &"<redacted>")
|
||||
.field("crack_span", &self.crack_span)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
|
||||
/// running the known-plaintext attack (see the [`keyless`] module).
|
||||
///
|
||||
/// Scans a bounded sector budget across `extents` and returns the first sector
|
||||
/// that yields a key — no player keys, no disc-key crack. Works on a live
|
||||
/// drive and on disc images alike.
|
||||
///
|
||||
/// This convenience form runs to completion (no cancellation); callers needing a cancel token,
|
||||
/// or the three-way [`CrackOutcome`], use [`crack_key_outcome`].
|
||||
#[deprecated(
|
||||
since = "1.8.0",
|
||||
note = "collapses Halted/Unreadable to None, hiding a cancellation or a read \
|
||||
error as a plain crack failure; use crack_key_outcome and match on \
|
||||
CrackOutcome instead"
|
||||
)]
|
||||
pub fn crack_key(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
) -> Option<CssState> {
|
||||
crack_key_outcome(reader, extents, batch_sectors, None).into_state()
|
||||
}
|
||||
|
||||
/// Outcome of a CSS crack scan. Only `Unencrypted` lets a caller treat the data as
|
||||
/// clear; every other non-`Cracked` outcome MUST hard-error.
|
||||
///
|
||||
/// - `Cracked` — a scrambled sector yielded a title key.
|
||||
/// - `Unencrypted` — sectors were read and none was scrambled.
|
||||
/// - `ScrambledUncracked` — scrambled (or CSS-locked) sectors, no key recovered.
|
||||
/// - `Unreadable` — no sector could be read; carries the first read error.
|
||||
/// - `Halted` — cancelled by the halt token or a read returning `Halted`.
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum CrackOutcome {
|
||||
Cracked(CssState),
|
||||
Unencrypted,
|
||||
ScrambledUncracked,
|
||||
Unreadable(crate::error::Error),
|
||||
Halted,
|
||||
}
|
||||
|
||||
impl CrackOutcome {
|
||||
/// The cracked `CssState`, if any. `None` for every other outcome. Lets the
|
||||
/// `Option`-returning wrappers stay thin.
|
||||
pub fn into_state(self) -> Option<CssState> {
|
||||
match self {
|
||||
CrackOutcome::Cracked(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when scrambled sectors were seen but no key was recovered — the
|
||||
/// case callers must surface as a hard error instead of "unencrypted".
|
||||
pub fn is_scrambled_uncracked(&self) -> bool {
|
||||
matches!(self, CrackOutcome::ScrambledUncracked)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crack_key`] returning the full [`CrackOutcome`] so callers can
|
||||
/// distinguish "genuinely unencrypted" from "encrypted but uncrackable" — the
|
||||
/// latter must become a hard error, never a silent fall-through to plaintext.
|
||||
///
|
||||
/// Takes an optional cooperative-cancellation token: polls `halt` once per
|
||||
/// batch (the same cadence sweep/patch use) and emits a `freemkv::heartbeat`
|
||||
/// beat ("css_crack") each batch so a stuck scan over bad sectors stays
|
||||
/// visible in the log rather than hanging silently.
|
||||
pub fn crack_key_outcome(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> CrackOutcome {
|
||||
crack_key_scan(reader, extents, batch_sectors, halt)
|
||||
}
|
||||
|
||||
// A DVD title's key when the caller supplied none: [`crack_title_key`] over its extents. A
|
||||
// scrambled-but-uncrackable title is a hard, skippable per-title CssKeyMissing (another VTS may
|
||||
// still crack).
|
||||
pub(crate) fn resolve_dvd_title_key(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
keys: &mut crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
format: crate::disc::ContentFormat,
|
||||
raw: bool,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> std::io::Result<()> {
|
||||
// `--raw` = deliberate ciphertext passthrough: never crack or descramble, and
|
||||
// never hard-fail on scrambled-uncrackable. Caller hands us `None` on
|
||||
// purpose; without this guard we'd silently DECRYPT or abort a raw mux.
|
||||
if raw {
|
||||
return Ok(());
|
||||
}
|
||||
// CSS is DVD-Video's alone: an HD DVD `.evo` or a plain program stream is never cracked.
|
||||
if matches!(keys, crate::decrypt::DecryptKeys::None)
|
||||
&& format == crate::disc::ContentFormat::DvdPs
|
||||
{
|
||||
*keys = crack_title_key(reader, extents, batch_sectors, halt)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// The SINGLE place every read path (disc, image, loose PS file) cracks a CSS title key: the
|
||||
// keys, or `None` for clear content. `halt` lets /api/stop interrupt a long scan.
|
||||
pub(crate) fn crack_title_key(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> std::io::Result<crate::decrypt::DecryptKeys> {
|
||||
match crack_key_scan(reader, extents, batch_sectors, halt) {
|
||||
CrackOutcome::Cracked(state) => Ok(crate::decrypt::DecryptKeys::Css {
|
||||
title_key: state.title_key,
|
||||
}),
|
||||
CrackOutcome::ScrambledUncracked => Err(crate::error::Error::CssKeyMissing.into()),
|
||||
CrackOutcome::Unreadable(e) => Err(e.into()),
|
||||
// A cancelled crack is a TRUNCATED scan, not a verdict.
|
||||
CrackOutcome::Halted => Err(crate::error::Error::Halted.into()),
|
||||
CrackOutcome::Unencrypted => Ok(crate::decrypt::DecryptKeys::None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a 13818-1 pack's first PES header flags byte sits, `Some` only when that PES is
|
||||
/// scrambled: the ONE CSS pack test, disc and file alike. Leading packets with no PES header (a
|
||||
/// system header, a map, padding) are walked by their length; only the first PES is judged.
|
||||
pub(crate) fn scrambled_at(sector: &[u8]) -> Option<usize> {
|
||||
if sector.len() < 2048 || sector[..4] != PACK_START || sector[4] >> 6 != 0b01 {
|
||||
return None;
|
||||
}
|
||||
let mut p = 0x0E + usize::from(sector[0x0D] & 0x07);
|
||||
loop {
|
||||
let h = sector.get(p..p + 7)?;
|
||||
if h[..3] != [0, 0, 1] {
|
||||
return None;
|
||||
}
|
||||
match h[3] {
|
||||
// MS-31: "if (stream_id != program_stream_map && stream_id != padding_stream &&
|
||||
// stream_id != private_stream_2 && stream_id != ECM && stream_id != EMM && …"
|
||||
// (0xBB, the system header, is no PES packet at all).
|
||||
0xBB | 0xBC | 0xBE | 0xBF | 0xF0 | 0xF1 | 0xF2 | 0xF8 | 0xFF => {
|
||||
p += 6 + usize::from(u16::from_be_bytes([h[4], h[5]]));
|
||||
}
|
||||
0xBD..=0xFE => {
|
||||
let at = p + 6;
|
||||
// CSS leaves bytes before 0x80 clear; a header past them cannot be read.
|
||||
return (at < 0x80 && h[6] >> 6 == 0b10 && (h[6] >> 4) & 0x03 != 0).then_some(at);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run `f` on `sector` with its flags byte `at` presented at 0x14, where the LFSR and the
|
||||
// keyless crack read it; both bytes are put back after (a stuffed pack, m1/m2).
|
||||
fn with_flags_at_0x14<T>(sector: &mut [u8], at: usize, f: impl FnOnce(&mut [u8]) -> T) -> T {
|
||||
if at == 0x14 {
|
||||
return f(sector);
|
||||
}
|
||||
let b14 = sector[0x14];
|
||||
sector[0x14] = sector[at];
|
||||
let out = f(sector);
|
||||
sector[at] = sector[0x14];
|
||||
sector[0x14] = b14;
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Crack scans run on this thread (test-only: the mpg:// path must scan once).
|
||||
pub(crate) static CRACK_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
// The crack scan over `extents`, judging each sector with [`scrambled_at`].
|
||||
fn crack_key_scan(
|
||||
reader: &mut dyn SectorSource,
|
||||
extents: &[Extent],
|
||||
batch_sectors: u16,
|
||||
halt: Option<&crate::halt::Halt>,
|
||||
) -> CrackOutcome {
|
||||
#[cfg(test)]
|
||||
CRACK_SCANS.with(|n| n.set(n.get() + 1));
|
||||
// Batch the reads: a live drive at 1 sector/read is glacial. `batch_sectors`
|
||||
// MUST be sized to the source — a drive rejects a READ(10) larger than its
|
||||
// per-command max, and `Drive::read` does not chunk an over-large batch.
|
||||
let batch = (batch_sectors.max(1)) as u32;
|
||||
// Record the LBA span the key is being cracked from so the per-title mux
|
||||
// path can tell whether a later title lives in the same VTS (overlaps the
|
||||
// span → key applies) or a different one (→ re-crack). Half-open [min,max).
|
||||
let crack_span = extents
|
||||
.iter()
|
||||
.filter(|e| e.sector_count > 0)
|
||||
.map(|e| (e.start_lba, e.start_lba.saturating_add(e.sector_count)))
|
||||
.reduce(|(amin, amax), (bmin, bmax)| (amin.min(bmin), amax.max(bmax)));
|
||||
let mut tried = 0u32;
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
let mut hb = crate::progress::Heartbeat::new("css_crack");
|
||||
// Track whether ANY scrambled sector was observed: if the budget is
|
||||
// exhausted with scrambled data seen but no key recovered, that's a HARD
|
||||
// failure, not "unencrypted". See `CrackOutcome::ScrambledUncracked`.
|
||||
let mut saw_scrambled = false;
|
||||
// Sense `05/6F/03` is positive proof of CSS encryption. Consecutive locked
|
||||
// reads mean the (global) bus-auth gate is shut, so the scan early-bails;
|
||||
// this resets on any readable batch, so an open-gate title never trips it.
|
||||
let mut saw_locked = false;
|
||||
let mut consecutive_locked = 0u32;
|
||||
// Sectors actually inspected: zero means no verdict (never Unencrypted).
|
||||
let mut inspected = 0u32;
|
||||
// Sectors lost to non-locked read failures: more lost than inspected is no clear verdict.
|
||||
let mut failed = 0u32;
|
||||
let mut first_err: Option<crate::error::Error> = None;
|
||||
|
||||
'outer: for (extent_idx, ext) in extents.iter().enumerate() {
|
||||
let mut i = 0u32;
|
||||
while i < ext.sector_count && tried < MAX_TRIES {
|
||||
// Cooperative cancellation — poll once per batch, the same cadence
|
||||
// sweep/patch use, so a Stop / watchdog can interrupt the scan.
|
||||
if halt.is_some_and(|h| h.is_cancelled()) {
|
||||
return CrackOutcome::Halted;
|
||||
}
|
||||
// Saturating: `start_lba` comes from a crafted IFO/UDF extent.
|
||||
let lba = ext.start_lba.saturating_add(i);
|
||||
// Liveness beacon: a long scan over a damaged disc stays visible.
|
||||
// The heartbeat is time-throttled; only when it actually beats do
|
||||
// we emit the crack-specific context (tried/lba/extent_idx).
|
||||
if hb.tick(tried as u64, MAX_TRIES as u64) {
|
||||
tracing::debug!(
|
||||
target: "freemkv::heartbeat",
|
||||
phase = "css_crack",
|
||||
tried,
|
||||
lba,
|
||||
extent_idx,
|
||||
"scanning"
|
||||
);
|
||||
}
|
||||
let n = (ext.sector_count - i).min(batch);
|
||||
let want = n as usize * 2048;
|
||||
// Set from bytes actually READ on the Ok path so a short read is
|
||||
// RETRIED from where it stopped, not skipped — else those sectors
|
||||
// go unexamined on exactly the damaged media a key needs most.
|
||||
let mut advance = n;
|
||||
match reader.read_sectors(lba, n as u16, &mut buf[..want], true) {
|
||||
Ok(got) => {
|
||||
// A readable batch: the gate is open — reset the locked run.
|
||||
consecutive_locked = 0;
|
||||
// Inspect only what was READ: `buf` is reused across
|
||||
// batches, so its tail may hold the PREVIOUS batch's
|
||||
// sectors, risking a key crack for the wrong extent/VTS.
|
||||
let usable = (got / 2048).min(n as usize);
|
||||
// At least one, so a source returning Ok(0) cannot spin here.
|
||||
advance = (usable as u32).max(1);
|
||||
if usable == 0 {
|
||||
// Nothing inspected, but the cursor still moves one
|
||||
// sector — charge it here or `tried` stays frozen
|
||||
// (mirrors the `Err` arm's `tried += n`).
|
||||
tried += 1;
|
||||
}
|
||||
for s in 0..usable {
|
||||
tried += 1;
|
||||
inspected += 1;
|
||||
let sect = &buf[s * 2048..(s + 1) * 2048];
|
||||
// HARDENED pack-gated check: a clear stub sector with
|
||||
// stray bits at 0x14 must NOT count as scramble evidence,
|
||||
// or an unencrypted title falsely reports E7023.
|
||||
if let Some(at) = scrambled_at(sect) {
|
||||
saw_scrambled = true;
|
||||
// A stuffed pack's flags sit past 0x14 (m1); a DVD-Video pack's never do.
|
||||
let key = if at == 0x14 {
|
||||
keyless::crack_title_key(sect)
|
||||
} else {
|
||||
let mut copy = sect.to_vec();
|
||||
with_flags_at_0x14(&mut copy, at, |c| keyless::crack_title_key(c))
|
||||
};
|
||||
if let Some(key) = key {
|
||||
return CrackOutcome::Cracked(CssState {
|
||||
title_key: key,
|
||||
crack_span,
|
||||
});
|
||||
}
|
||||
}
|
||||
if tried >= MAX_TRIES {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
// A drive-level Stop ends the scan now — it is not a failed batch.
|
||||
Err(crate::error::Error::Halted) => return CrackOutcome::Halted,
|
||||
// A failed batch still counts toward the budget so a damaged
|
||||
// region can't loop forever. A CSS-locked failure proves
|
||||
// encryption; a long enough run means the gate is shut.
|
||||
Err(e) => {
|
||||
tried += n;
|
||||
if e.scsi_sense().is_some_and(|s| s.is_css_locked()) {
|
||||
saw_locked = true;
|
||||
consecutive_locked += 1;
|
||||
if consecutive_locked >= CSS_LOCKED_BAIL {
|
||||
break 'outer;
|
||||
}
|
||||
} else {
|
||||
consecutive_locked = 0;
|
||||
failed += n;
|
||||
first_err.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
i += advance;
|
||||
}
|
||||
}
|
||||
|
||||
// ENCRYPTED-but-uncracked if a scrambled sector was seen or reads were CSS-locked. Nothing
|
||||
// inspected, or more unreadable than inspected, is no verdict: fail closed with the read
|
||||
// error (else as uncracked).
|
||||
if saw_scrambled || saw_locked {
|
||||
CrackOutcome::ScrambledUncracked
|
||||
} else if tried > 0 && (inspected == 0 || failed > inspected) {
|
||||
first_err.map_or(CrackOutcome::ScrambledUncracked, CrackOutcome::Unreadable)
|
||||
} else {
|
||||
CrackOutcome::Unencrypted
|
||||
}
|
||||
}
|
||||
|
||||
/// Descramble a single CSS-encrypted sector in place.
|
||||
///
|
||||
/// A no-op unless the sector is a scrambled MPEG-2 PS PACK ([`is_scrambled_pack`]): byte 0x14
|
||||
/// alone is unreliable outside a pack. Making the guard part of the function, rather than
|
||||
/// something each caller must remember, is what keeps the safe path the easy one.
|
||||
pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
|
||||
let Some(at) = scrambled_at(sector) else {
|
||||
return;
|
||||
};
|
||||
with_flags_at_0x14(sector, at, |s| lfsr::descramble_sector(&state.title_key, s));
|
||||
}
|
||||
|
||||
// How many mismatches a failed re-crack's "false positive" verdict covers before the next retry —
|
||||
// bounded so a genuine key change right after a false-positive run is still picked up.
|
||||
const RECRACK_RETRY_EVERY: u32 = 16;
|
||||
|
||||
/// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB region boundary.
|
||||
/// `title_key` is a CACHE of the last crack: validated against the clear-header crib on every
|
||||
/// scrambled sector and re-cracked on a miss. A crib-less sector rides the cached key.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Never returns `Err` and always returns `Ok(0)` (CSS drops no sectors); `Result` only
|
||||
/// matches the decrypt seam this is dispatched from (see [`crate::decrypt::decrypt_sectors`]).
|
||||
pub fn descramble_region(buf: &mut [u8], title_key: &mut [u8; 5]) -> crate::error::Result<usize> {
|
||||
// Consecutive crib mismatches since the last re-crack attempt (0 = none
|
||||
// pending). Reset by a validated cache hit; a fresh run always attempts
|
||||
// on its first mismatch, then at most once every RECRACK_RETRY_EVERY.
|
||||
let mut mismatches_since_attempt: u32 = 0;
|
||||
// The pack test, NOT raw byte 0x14: this sees arbitrary regions (IFO/UDF/ISO 9660).
|
||||
// Measured: an IFO misread by 0x14 alone was destroyed, dropping titles 38→10.
|
||||
for chunk in buf.chunks_mut(2048) {
|
||||
let Some(at) = (chunk.len() >= 2048).then(|| scrambled_at(chunk)).flatten() else {
|
||||
continue;
|
||||
};
|
||||
with_flags_at_0x14(chunk, at, |chunk| {
|
||||
descramble_one(chunk, title_key, &mut mismatches_since_attempt)
|
||||
});
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
// One scrambled pack (flags at 0x14), validated against its crib and re-cracked on a miss.
|
||||
fn descramble_one(chunk: &mut [u8], title_key: &mut [u8; 5], mismatches_since_attempt: &mut u32) {
|
||||
{
|
||||
let crib = keyless::attack_crib(chunk);
|
||||
// Snapshot the ciphertext (chunk is exactly 2048 here) only when there is
|
||||
// a crib to validate against, so the common cache-hit path costs no
|
||||
// per-sector heap allocation.
|
||||
let mut original = [0u8; 2048];
|
||||
if crib.is_some() {
|
||||
original.copy_from_slice(chunk);
|
||||
}
|
||||
lfsr::descramble_sector(title_key, chunk);
|
||||
let Some(crib) = crib else { return };
|
||||
if chunk[0x80..0x80 + 10] == crib[..] {
|
||||
*mismatches_since_attempt = 0; // validated: cached key still matches
|
||||
return;
|
||||
}
|
||||
*mismatches_since_attempt += 1;
|
||||
if *mismatches_since_attempt % RECRACK_RETRY_EVERY != 1 {
|
||||
// Within a suppressed run, not yet due for its periodic retry —
|
||||
// keep the cached key (already applied above).
|
||||
return;
|
||||
}
|
||||
// Due for an attempt: either the first mismatch of a run, or a
|
||||
// periodic retry into an ongoing one. Restore the ciphertext and
|
||||
// crack this sector's own key.
|
||||
chunk.copy_from_slice(&original);
|
||||
#[cfg(test)]
|
||||
RECRACK_ATTEMPTS.with(|c| c.set(c.get() + 1));
|
||||
match keyless::crack_title_key(chunk) {
|
||||
Some(fresh) => {
|
||||
*title_key = fresh;
|
||||
lfsr::descramble_sector(title_key, chunk);
|
||||
*mismatches_since_attempt = 0;
|
||||
}
|
||||
None => {
|
||||
// Re-crack found nothing; descramble with the CACHED key
|
||||
// anyway — mismatch+failure signals a crib false positive,
|
||||
// not a stale key (`DecryptFailed` here made discs unrippable).
|
||||
lfsr::descramble_sector(title_key, chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether bits 4-5 of the sub-header byte 0x14 are set. NOTHING MORE.
|
||||
///
|
||||
/// This is deliberately NOT called `is_scrambled`: byte 0x14 is only meaningful inside an
|
||||
/// MPEG-2 PS pack, and treating the flag alone as proof of scrambling corrupted a real disc.
|
||||
///
|
||||
/// **Callers want [`is_scrambled_pack`].** It also requires the pack start
|
||||
/// code, which every genuinely scrambled VOB sector carries and no IFO sector
|
||||
/// does. This stays public only because an integration test asserts the flag
|
||||
/// extraction directly; it has no production callers.
|
||||
#[doc(hidden)]
|
||||
pub fn has_scramble_flag_bits(sector: &[u8]) -> bool {
|
||||
sector.len() >= 2048 && (sector[0x14] >> 4) & 0x03 != 0
|
||||
}
|
||||
|
||||
/// The 4-byte MPEG-2 Program Stream pack-start code (`00 00 01 BA`) every DVD
|
||||
/// video sector opens with. CSS leaves the clear header (`0x00..0x80`)
|
||||
/// 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 pack: a 13818-1 pack whose first PES header (past any
|
||||
/// pack stuffing and packets with no PES header: system header, map, padding, nav) is MPEG-2
|
||||
/// and carries scramble bits. The one test the crack scan and every descramble share, disc and
|
||||
/// file alike. An IFO/UDF sector, an 11172-1 pack and an MPEG-1 PES are never scrambled.
|
||||
pub fn is_scrambled_pack(sector: &[u8]) -> bool {
|
||||
scrambled_at(sector).is_some()
|
||||
}
|
||||
|
||||
/// Test fixture: the DVD-Video pack layout every real VOB sector has (13818-1 pack header, no
|
||||
/// stuffing, a `stream_id` PES at 0x0E with MPEG-2 flags at 0x14), keeping its scramble bits.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn dvd_pack_header(sector: &mut [u8], stream_id: u8) {
|
||||
sector[..4].copy_from_slice(&PACK_START);
|
||||
sector[4] = 0x44;
|
||||
sector[0x0D] = 0xF8;
|
||||
sector[0x0E..0x11].copy_from_slice(&[0, 0, 1]);
|
||||
sector[0x11] = stream_id;
|
||||
sector[0x12..0x14].copy_from_slice(&0x07ECu16.to_be_bytes());
|
||||
sector[0x14] = 0x80 | (sector[0x14] & 0x30);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(deprecated)] // this module exercises crack_key itself, deliberately
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,137 +0,0 @@
|
||||
//! CSS specification tables — mathematical constants defining the cipher.
|
||||
//!
|
||||
//! These 5 tables are the fixed permutations and substitutions of the
|
||||
//! Content Scramble System. They are mathematical constants derived from
|
||||
//! the CSS specification, published in academic literature since 1999.
|
||||
|
||||
/// Table 1: content-cipher substitution table, `plain = TAB1[cipher] ^ keystream`
|
||||
/// (used by descramble/recover in lfsr.rs and keyless.rs).
|
||||
pub const TAB1: [u8; 256] = [
|
||||
0x33, 0x73, 0x3b, 0x26, 0x63, 0x23, 0x6b, 0x76, 0x3e, 0x7e, 0x36, 0x2b, 0x6e, 0x2e, 0x66, 0x7b,
|
||||
0xd3, 0x93, 0xdb, 0x06, 0x43, 0x03, 0x4b, 0x96, 0xde, 0x9e, 0xd6, 0x0b, 0x4e, 0x0e, 0x46, 0x9b,
|
||||
0x57, 0x17, 0x5f, 0x82, 0xc7, 0x87, 0xcf, 0x12, 0x5a, 0x1a, 0x52, 0x8f, 0xca, 0x8a, 0xc2, 0x1f,
|
||||
0xd9, 0x99, 0xd1, 0x00, 0x49, 0x09, 0x41, 0x90, 0xd8, 0x98, 0xd0, 0x01, 0x48, 0x08, 0x40, 0x91,
|
||||
0x3d, 0x7d, 0x35, 0x24, 0x6d, 0x2d, 0x65, 0x74, 0x3c, 0x7c, 0x34, 0x25, 0x6c, 0x2c, 0x64, 0x75,
|
||||
0xdd, 0x9d, 0xd5, 0x04, 0x4d, 0x0d, 0x45, 0x94, 0xdc, 0x9c, 0xd4, 0x05, 0x4c, 0x0c, 0x44, 0x95,
|
||||
0x59, 0x19, 0x51, 0x80, 0xc9, 0x89, 0xc1, 0x10, 0x58, 0x18, 0x50, 0x81, 0xc8, 0x88, 0xc0, 0x11,
|
||||
0xd7, 0x97, 0xdf, 0x02, 0x47, 0x07, 0x4f, 0x92, 0xda, 0x9a, 0xd2, 0x0f, 0x4a, 0x0a, 0x42, 0x9f,
|
||||
0x53, 0x13, 0x5b, 0x86, 0xc3, 0x83, 0xcb, 0x16, 0x5e, 0x1e, 0x56, 0x8b, 0xce, 0x8e, 0xc6, 0x1b,
|
||||
0xb3, 0xf3, 0xbb, 0xa6, 0xe3, 0xa3, 0xeb, 0xf6, 0xbe, 0xfe, 0xb6, 0xab, 0xee, 0xae, 0xe6, 0xfb,
|
||||
0x37, 0x77, 0x3f, 0x22, 0x67, 0x27, 0x6f, 0x72, 0x3a, 0x7a, 0x32, 0x2f, 0x6a, 0x2a, 0x62, 0x7f,
|
||||
0xb9, 0xf9, 0xb1, 0xa0, 0xe9, 0xa9, 0xe1, 0xf0, 0xb8, 0xf8, 0xb0, 0xa1, 0xe8, 0xa8, 0xe0, 0xf1,
|
||||
0x5d, 0x1d, 0x55, 0x84, 0xcd, 0x8d, 0xc5, 0x14, 0x5c, 0x1c, 0x54, 0x85, 0xcc, 0x8c, 0xc4, 0x15,
|
||||
0xbd, 0xfd, 0xb5, 0xa4, 0xed, 0xad, 0xe5, 0xf4, 0xbc, 0xfc, 0xb4, 0xa5, 0xec, 0xac, 0xe4, 0xf5,
|
||||
0x39, 0x79, 0x31, 0x20, 0x69, 0x29, 0x61, 0x70, 0x38, 0x78, 0x30, 0x21, 0x68, 0x28, 0x60, 0x71,
|
||||
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
|
||||
];
|
||||
|
||||
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
|
||||
/// cipher (per the published algorithm).
|
||||
pub const TAB2: [u8; 256] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
|
||||
0x12, 0x13, 0x10, 0x11, 0x16, 0x17, 0x14, 0x15, 0x1b, 0x1a, 0x19, 0x18, 0x1f, 0x1e, 0x1d, 0x1c,
|
||||
0x24, 0x25, 0x26, 0x27, 0x20, 0x21, 0x22, 0x23, 0x2d, 0x2c, 0x2f, 0x2e, 0x29, 0x28, 0x2b, 0x2a,
|
||||
0x36, 0x37, 0x34, 0x35, 0x32, 0x33, 0x30, 0x31, 0x3f, 0x3e, 0x3d, 0x3c, 0x3b, 0x3a, 0x39, 0x38,
|
||||
0x49, 0x48, 0x4b, 0x4a, 0x4d, 0x4c, 0x4f, 0x4e, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47,
|
||||
0x5b, 0x5a, 0x59, 0x58, 0x5f, 0x5e, 0x5d, 0x5c, 0x52, 0x53, 0x50, 0x51, 0x56, 0x57, 0x54, 0x55,
|
||||
0x6d, 0x6c, 0x6f, 0x6e, 0x69, 0x68, 0x6b, 0x6a, 0x64, 0x65, 0x66, 0x67, 0x60, 0x61, 0x62, 0x63,
|
||||
0x7f, 0x7e, 0x7d, 0x7c, 0x7b, 0x7a, 0x79, 0x78, 0x76, 0x77, 0x74, 0x75, 0x72, 0x73, 0x70, 0x71,
|
||||
0x92, 0x93, 0x90, 0x91, 0x96, 0x97, 0x94, 0x95, 0x9b, 0x9a, 0x99, 0x98, 0x9f, 0x9e, 0x9d, 0x9c,
|
||||
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x89, 0x88, 0x8b, 0x8a, 0x8d, 0x8c, 0x8f, 0x8e,
|
||||
0xb6, 0xb7, 0xb4, 0xb5, 0xb2, 0xb3, 0xb0, 0xb1, 0xbf, 0xbe, 0xbd, 0xbc, 0xbb, 0xba, 0xb9, 0xb8,
|
||||
0xa4, 0xa5, 0xa6, 0xa7, 0xa0, 0xa1, 0xa2, 0xa3, 0xad, 0xac, 0xaf, 0xae, 0xa9, 0xa8, 0xab, 0xaa,
|
||||
0xdb, 0xda, 0xd9, 0xd8, 0xdf, 0xde, 0xdd, 0xdc, 0xd2, 0xd3, 0xd0, 0xd1, 0xd6, 0xd7, 0xd4, 0xd5,
|
||||
0xc9, 0xc8, 0xcb, 0xca, 0xcd, 0xcc, 0xcf, 0xce, 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7,
|
||||
0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa, 0xf9, 0xf8, 0xf6, 0xf7, 0xf4, 0xf5, 0xf2, 0xf3, 0xf0, 0xf1,
|
||||
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
|
||||
];
|
||||
|
||||
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries) — a fixed constant
|
||||
/// of the CSS cipher (per the published algorithm).
|
||||
///
|
||||
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
|
||||
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
|
||||
/// low 3 bits select the output — the high bits are ignored, hence the constant
|
||||
/// blocks. The 512-entry width simply lets the 9-bit index be used without
|
||||
/// masking.
|
||||
pub const TAB3: [u8; 512] = [
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff, 0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff,
|
||||
];
|
||||
|
||||
/// Table 4: permutes the LFSR0 output byte (`o0 = TAB4[o0]`).
|
||||
pub const TAB4: [u8; 256] = [
|
||||
0x00, 0x80, 0x40, 0xc0, 0x20, 0xa0, 0x60, 0xe0, 0x10, 0x90, 0x50, 0xd0, 0x30, 0xb0, 0x70, 0xf0,
|
||||
0x08, 0x88, 0x48, 0xc8, 0x28, 0xa8, 0x68, 0xe8, 0x18, 0x98, 0x58, 0xd8, 0x38, 0xb8, 0x78, 0xf8,
|
||||
0x04, 0x84, 0x44, 0xc4, 0x24, 0xa4, 0x64, 0xe4, 0x14, 0x94, 0x54, 0xd4, 0x34, 0xb4, 0x74, 0xf4,
|
||||
0x0c, 0x8c, 0x4c, 0xcc, 0x2c, 0xac, 0x6c, 0xec, 0x1c, 0x9c, 0x5c, 0xdc, 0x3c, 0xbc, 0x7c, 0xfc,
|
||||
0x02, 0x82, 0x42, 0xc2, 0x22, 0xa2, 0x62, 0xe2, 0x12, 0x92, 0x52, 0xd2, 0x32, 0xb2, 0x72, 0xf2,
|
||||
0x0a, 0x8a, 0x4a, 0xca, 0x2a, 0xaa, 0x6a, 0xea, 0x1a, 0x9a, 0x5a, 0xda, 0x3a, 0xba, 0x7a, 0xfa,
|
||||
0x06, 0x86, 0x46, 0xc6, 0x26, 0xa6, 0x66, 0xe6, 0x16, 0x96, 0x56, 0xd6, 0x36, 0xb6, 0x76, 0xf6,
|
||||
0x0e, 0x8e, 0x4e, 0xce, 0x2e, 0xae, 0x6e, 0xee, 0x1e, 0x9e, 0x5e, 0xde, 0x3e, 0xbe, 0x7e, 0xfe,
|
||||
0x01, 0x81, 0x41, 0xc1, 0x21, 0xa1, 0x61, 0xe1, 0x11, 0x91, 0x51, 0xd1, 0x31, 0xb1, 0x71, 0xf1,
|
||||
0x09, 0x89, 0x49, 0xc9, 0x29, 0xa9, 0x69, 0xe9, 0x19, 0x99, 0x59, 0xd9, 0x39, 0xb9, 0x79, 0xf9,
|
||||
0x05, 0x85, 0x45, 0xc5, 0x25, 0xa5, 0x65, 0xe5, 0x15, 0x95, 0x55, 0xd5, 0x35, 0xb5, 0x75, 0xf5,
|
||||
0x0d, 0x8d, 0x4d, 0xcd, 0x2d, 0xad, 0x6d, 0xed, 0x1d, 0x9d, 0x5d, 0xdd, 0x3d, 0xbd, 0x7d, 0xfd,
|
||||
0x03, 0x83, 0x43, 0xc3, 0x23, 0xa3, 0x63, 0xe3, 0x13, 0x93, 0x53, 0xd3, 0x33, 0xb3, 0x73, 0xf3,
|
||||
0x0b, 0x8b, 0x4b, 0xcb, 0x2b, 0xab, 0x6b, 0xeb, 0x1b, 0x9b, 0x5b, 0xdb, 0x3b, 0xbb, 0x7b, 0xfb,
|
||||
0x07, 0x87, 0x47, 0xc7, 0x27, 0xa7, 0x67, 0xe7, 0x17, 0x97, 0x57, 0xd7, 0x37, 0xb7, 0x77, 0xf7,
|
||||
0x0f, 0x8f, 0x4f, 0xcf, 0x2f, 0xaf, 0x6f, 0xef, 0x1f, 0x9f, 0x5f, 0xdf, 0x3f, 0xbf, 0x7f, 0xff,
|
||||
];
|
||||
|
||||
/// Table 5: LFSR1 output permutation used in the keystream combiner.
|
||||
/// `TAB5[i] == TAB4[i] ^ 0xFF` (bitwise complement of the TAB4 bit-reversal
|
||||
/// table). Applied on the normal descramble/recrypt path (lfsr.rs) as well as
|
||||
/// in the key-recovery fallback ([`super::keyless`]).
|
||||
pub const TAB5: [u8; 256] = [
|
||||
0xff, 0x7f, 0xbf, 0x3f, 0xdf, 0x5f, 0x9f, 0x1f, 0xef, 0x6f, 0xaf, 0x2f, 0xcf, 0x4f, 0x8f, 0x0f,
|
||||
0xf7, 0x77, 0xb7, 0x37, 0xd7, 0x57, 0x97, 0x17, 0xe7, 0x67, 0xa7, 0x27, 0xc7, 0x47, 0x87, 0x07,
|
||||
0xfb, 0x7b, 0xbb, 0x3b, 0xdb, 0x5b, 0x9b, 0x1b, 0xeb, 0x6b, 0xab, 0x2b, 0xcb, 0x4b, 0x8b, 0x0b,
|
||||
0xf3, 0x73, 0xb3, 0x33, 0xd3, 0x53, 0x93, 0x13, 0xe3, 0x63, 0xa3, 0x23, 0xc3, 0x43, 0x83, 0x03,
|
||||
0xfd, 0x7d, 0xbd, 0x3d, 0xdd, 0x5d, 0x9d, 0x1d, 0xed, 0x6d, 0xad, 0x2d, 0xcd, 0x4d, 0x8d, 0x0d,
|
||||
0xf5, 0x75, 0xb5, 0x35, 0xd5, 0x55, 0x95, 0x15, 0xe5, 0x65, 0xa5, 0x25, 0xc5, 0x45, 0x85, 0x05,
|
||||
0xf9, 0x79, 0xb9, 0x39, 0xd9, 0x59, 0x99, 0x19, 0xe9, 0x69, 0xa9, 0x29, 0xc9, 0x49, 0x89, 0x09,
|
||||
0xf1, 0x71, 0xb1, 0x31, 0xd1, 0x51, 0x91, 0x11, 0xe1, 0x61, 0xa1, 0x21, 0xc1, 0x41, 0x81, 0x01,
|
||||
0xfe, 0x7e, 0xbe, 0x3e, 0xde, 0x5e, 0x9e, 0x1e, 0xee, 0x6e, 0xae, 0x2e, 0xce, 0x4e, 0x8e, 0x0e,
|
||||
0xf6, 0x76, 0xb6, 0x36, 0xd6, 0x56, 0x96, 0x16, 0xe6, 0x66, 0xa6, 0x26, 0xc6, 0x46, 0x86, 0x06,
|
||||
0xfa, 0x7a, 0xba, 0x3a, 0xda, 0x5a, 0x9a, 0x1a, 0xea, 0x6a, 0xaa, 0x2a, 0xca, 0x4a, 0x8a, 0x0a,
|
||||
0xf2, 0x72, 0xb2, 0x32, 0xd2, 0x52, 0x92, 0x12, 0xe2, 0x62, 0xa2, 0x22, 0xc2, 0x42, 0x82, 0x02,
|
||||
0xfc, 0x7c, 0xbc, 0x3c, 0xdc, 0x5c, 0x9c, 0x1c, 0xec, 0x6c, 0xac, 0x2c, 0xcc, 0x4c, 0x8c, 0x0c,
|
||||
0xf4, 0x74, 0xb4, 0x34, 0xd4, 0x54, 0x94, 0x14, 0xe4, 0x64, 0xa4, 0x24, 0xc4, 0x44, 0x84, 0x04,
|
||||
0xf8, 0x78, 0xb8, 0x38, 0xd8, 0x58, 0x98, 0x18, 0xe8, 0x68, 0xa8, 0x28, 0xc8, 0x48, 0x88, 0x08,
|
||||
0xf0, 0x70, 0xb0, 0x30, 0xd0, 0x50, 0x90, 0x10, 0xe0, 0x60, 0xa0, 0x20, 0xc0, 0x40, 0x80, 0x00,
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tables_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,128 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
/// Pins the documented relationship `TAB5[i] == TAB4[i] ^ 0xFF` so the
|
||||
/// table doc cannot drift from the data.
|
||||
#[test]
|
||||
fn tab5_is_complement_of_tab4() {
|
||||
for i in 0..256 {
|
||||
assert_eq!(
|
||||
TAB5[i],
|
||||
TAB4[i] ^ 0xFF,
|
||||
"TAB5[{i:#04x}] != TAB4[{i:#04x}] ^ 0xFF"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TAB1 is a bijection on 0..256; CSS uses it as an invertible output permutation in
|
||||
// css_DecryptKey.
|
||||
#[test]
|
||||
fn tab1_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for (i, &v) in TAB1.iter().enumerate() {
|
||||
assert!(
|
||||
!seen[v as usize],
|
||||
"TAB1 maps two inputs to {v:#04x} (collision at index {i:#04x})"
|
||||
);
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB1's fixed structural anchors: TAB1[0x00] == 0x33 and TAB1[0x33] == 0x00, the canonical
|
||||
// CSS-spec landmarks pinning the table's orientation.
|
||||
#[test]
|
||||
fn tab1_known_spec_anchors() {
|
||||
assert_eq!(TAB1[0x00], 0x33, "TAB1[0] is the published 0x33");
|
||||
assert_eq!(TAB1[0x33], 0x00, "TAB1[0x33] is the published 0x00");
|
||||
}
|
||||
|
||||
// FNV-1a over a whole table: a swapped pair keeps a permutation and the anchors, so only
|
||||
// a full-content pin catches a mistyped or regenerated table.
|
||||
fn fnv1a(table: &[u8]) -> u64 {
|
||||
table.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &b| {
|
||||
(h ^ b as u64).wrapping_mul(0x0000_0100_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab1_and_tab2_contents_are_pinned() {
|
||||
assert_eq!(fnv1a(&TAB1), 0xcdb8_7228_1c08_c265, "TAB1 content changed");
|
||||
assert_eq!(fnv1a(&TAB2), 0x79e0_69d7_0f3d_2f25, "TAB2 content changed");
|
||||
}
|
||||
|
||||
// TAB2 is a permutation of 0..256 (LFSR1 high-byte feedback substitution); a non-bijective
|
||||
// table would bias the LFSR1 keystream.
|
||||
#[test]
|
||||
fn tab2_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for (i, &v) in TAB2.iter().enumerate() {
|
||||
assert!(
|
||||
!seen[v as usize],
|
||||
"TAB2 maps two inputs to {v:#04x} (collision at index {i:#04x})"
|
||||
);
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB3 is CSS's LFSR1 low-word table: BASE = [0x00,0x24,0x49,0x6d,0x92, 0xb6,0xdb,0xff]
|
||||
// repeated 64x (TAB3[i] == BASE[i & 7]); only the low 3 bits of the 9-bit index matter.
|
||||
#[test]
|
||||
fn tab3_matches_lfsr1_generating_formula() {
|
||||
const BASE: [u8; 8] = [0x00, 0x24, 0x49, 0x6d, 0x92, 0xb6, 0xdb, 0xff];
|
||||
for i in 0..512usize {
|
||||
let expected = BASE[i & 7];
|
||||
assert_eq!(
|
||||
TAB3[i], expected,
|
||||
"TAB3[{i:#05x}] = {:#04x}, formula BASE[i&7] = {expected:#04x}",
|
||||
TAB3[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TAB4 is the exact bit-reversal of each byte (CSS permutes LFSR0 bytes with it on
|
||||
// seed/output), hence also an involution: TAB4[TAB4[b]] == b.
|
||||
#[test]
|
||||
fn tab4_is_exact_bit_reversal_and_involution() {
|
||||
for b in 0u16..256 {
|
||||
let rev = (0..8).fold(0u8, |acc, k| acc | (((b as u8 >> k) & 1) << (7 - k)));
|
||||
assert_eq!(
|
||||
TAB4[b as usize], rev,
|
||||
"TAB4[{b:#04x}] is not the bit-reversal {rev:#04x}"
|
||||
);
|
||||
}
|
||||
for b in 0..256usize {
|
||||
assert_eq!(
|
||||
TAB4[TAB4[b] as usize], b as u8,
|
||||
"TAB4 not an involution at {b:#04x}"
|
||||
);
|
||||
}
|
||||
// Spec landmark entries.
|
||||
assert_eq!(TAB4[0x01], 0x80);
|
||||
assert_eq!(TAB4[0x80], 0x01);
|
||||
assert_eq!(TAB4[0x00], 0x00);
|
||||
assert_eq!(TAB4[0xFF], 0xFF);
|
||||
}
|
||||
|
||||
// TAB4 is a permutation (bit-reversal is bijective); distinct from the reversal test since
|
||||
// "reversal except swapped/duplicated entries" cases would pass one test but fail the
|
||||
// other.
|
||||
#[test]
|
||||
fn tab4_is_a_permutation() {
|
||||
let mut seen = [false; 256];
|
||||
for &v in TAB4.iter() {
|
||||
assert!(!seen[v as usize], "TAB4 maps two inputs to {v:#04x}");
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TAB5 is a permutation (complement of a bijection) with orientation anchors
|
||||
// TAB5[0x00]==0xFF and TAB5[0xFF]==0x00, independent of the complement-loop test.
|
||||
#[test]
|
||||
fn tab5_is_permutation_with_anchors() {
|
||||
let mut seen = [false; 256];
|
||||
for &v in TAB5.iter() {
|
||||
assert!(!seen[v as usize], "TAB5 maps two inputs to {v:#04x}");
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
assert_eq!(TAB5[0x00], 0xFF, "TAB5[0] = TAB4[0]^0xFF = 0xFF");
|
||||
assert_eq!(TAB5[0xFF], 0x00, "TAB5[0xFF] = TAB4[0xFF]^0xFF = 0x00");
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
//! The one run context (pipeline design §2.5): every stage of a run reads its stop token,
|
||||
//! reports through its events and counts loss into its stats.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use crate::event::{Event, Events, NoEvents};
|
||||
use crate::halt::Halt;
|
||||
|
||||
/// What every stage of one run shares: the stop token, the event sink, the loss counters
|
||||
/// and the diagnostic switches. Clones share all of them, so a clone observes the same run.
|
||||
#[derive(Clone)]
|
||||
pub struct Ctx {
|
||||
/// The run's one stop token. A stage that observes it returns [`Error::Halted`](crate::Error::Halted).
|
||||
pub halt: Halt,
|
||||
/// Where every stage reports progress.
|
||||
pub events: Arc<dyn Events>,
|
||||
/// Run-wide loss counters, cumulative over every stage built with this context.
|
||||
pub stats: Arc<Stats>,
|
||||
/// Developer diagnostics, fixed for the run.
|
||||
pub diag: Diag,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
/// A context over `halt` with no events, fresh stats and diagnostics off.
|
||||
pub fn new(halt: Halt) -> Self {
|
||||
Self {
|
||||
halt,
|
||||
events: Arc::new(NoEvents),
|
||||
stats: Arc::default(),
|
||||
diag: Diag::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// This context reporting to `events`.
|
||||
pub fn with_events(mut self, events: Arc<dyn Events>) -> Self {
|
||||
self.events = events;
|
||||
self
|
||||
}
|
||||
|
||||
/// This context with diagnostics `diag`.
|
||||
pub fn with_diag(mut self, diag: Diag) -> Self {
|
||||
self.diag = diag;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn emit(&self, e: Event<'_>) {
|
||||
self.events.event(&e);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Ctx {
|
||||
fn default() -> Self {
|
||||
Self::new(Halt::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Ctx {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Ctx")
|
||||
.field("halt", &self.halt)
|
||||
.field("stats", &self.stats)
|
||||
.field("diag", &self.diag)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Loss counters every source reports through, so a count means the same thing on every
|
||||
/// path. Atomic: the read producer and the mux pump update them while the run goes.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Stats {
|
||||
read_skips: AtomicU64,
|
||||
bytes_lost: AtomicU64,
|
||||
units_blanked: AtomicU64,
|
||||
resync_dropped: AtomicU64,
|
||||
}
|
||||
|
||||
impl Stats {
|
||||
/// The counters now.
|
||||
pub fn snapshot(&self) -> LossReport {
|
||||
LossReport {
|
||||
read_skips: self.read_skips.load(Ordering::Relaxed),
|
||||
bytes_lost: self.bytes_lost.load(Ordering::Relaxed),
|
||||
units_blanked: self.units_blanked.load(Ordering::Relaxed),
|
||||
resync_dropped: self.resync_dropped.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
// One read error zero-filled `bytes` of output.
|
||||
pub(crate) fn add_skip(&self, bytes: u64) {
|
||||
self.read_skips.fetch_add(1, Ordering::Relaxed);
|
||||
self.bytes_lost.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn add_blanked(&self, units: u64) {
|
||||
self.units_blanked.fetch_add(units, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn add_resync_dropped(&self, frames: u64) {
|
||||
self.resync_dropped.fetch_add(frames, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of [`Stats`].
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct LossReport {
|
||||
/// Zero-filled read-error skips: one per skipped read (live mux) or lossy batch
|
||||
/// (extract); the byte count is `bytes_lost`.
|
||||
pub read_skips: u64,
|
||||
/// Bytes those skips zero-filled (blanked AACS units are not included).
|
||||
pub bytes_lost: u64,
|
||||
/// Damaged AACS units blanked (each one whole 6144-byte unit of zeros).
|
||||
pub units_blanked: u64,
|
||||
/// Video frames dropped after a gap until the next keyframe.
|
||||
pub resync_dropped: u64,
|
||||
}
|
||||
|
||||
/// Developer diagnostics a run carries instead of stages reading the environment.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Diag {
|
||||
/// Bypass the codec parsers (raw demux throughput profiling).
|
||||
pub skip_parse: bool,
|
||||
/// Log per-stage read/feed/parse timings.
|
||||
pub profile: bool,
|
||||
}
|
||||
|
||||
impl Diag {
|
||||
/// `FREEMKV_SKIP_PARSE` and `FREEMKV_PROFILE` (set = on), read once by the caller.
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
profile: std::env::var_os("FREEMKV_PROFILE").is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ctx_tests.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user