mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac46e2382f | ||
|
|
862c1460c3 | ||
|
|
fea64d07e2 | ||
|
|
6e8da15c34 | ||
|
|
8898351cb1 | ||
|
|
7a03be5496 | ||
|
|
de94e12864 | ||
|
|
21ec1d3f80 | ||
|
|
6658a80819 | ||
|
|
3db11a192c | ||
|
|
a425a71cd5 | ||
|
|
f79745d815 | ||
|
|
5d8f77e049 | ||
|
|
79a16a9279 | ||
|
|
219a77b581 | ||
|
|
be378cbc34 | ||
|
|
63200a1d1f | ||
|
|
526dd3552b |
@@ -0,0 +1,48 @@
|
||||
version: 2
|
||||
|
||||
# Dependency updates land on `dev`, never on `main`.
|
||||
#
|
||||
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
|
||||
# commit on it would put work there that no tag contains, which is exactly the
|
||||
# state that aborted the 1.6.2 cascade at the last step -- so pointing
|
||||
# Dependabot at main would recreate that failure on a schedule.
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
# One PR per week for the routine bumps instead of one per crate. Eight
|
||||
# repos times a handful of crates is a volume nobody reads, and an
|
||||
# unread PR queue is indistinguishable from no updates at all.
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The freemkv crates depend on each other by GIT TAG, re-pinned by
|
||||
# release.sh as part of the release commit. Dependabot cannot see that
|
||||
# cascade, so a PR bumping one of these would fight the release process
|
||||
# and could pin a version whose tag does not exist yet.
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
|
||||
# The workflows are now real infrastructure -- the release cascade, the
|
||||
# cross-platform hash matrix, the disc gate -- so their actions need the same
|
||||
# attention as the crates.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
+42
-16
@@ -14,7 +14,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -77,9 +77,22 @@ jobs:
|
||||
working-directory: libfreemkv
|
||||
|
||||
check-macos:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
@@ -91,7 +104,7 @@ jobs:
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -104,9 +117,22 @@ jobs:
|
||||
working-directory: libfreemkv
|
||||
|
||||
check-windows:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
@@ -118,7 +144,7 @@ jobs:
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -149,21 +175,21 @@ jobs:
|
||||
consumers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { path: libfreemkv }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-unlock, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-unlock }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-keysources, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-keysources }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-engine, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-engine }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-i18n, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-i18n }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/autorip, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: autorip }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/bdemu, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: bdemu }
|
||||
- name: Point every dependent at THIS libfreemkv commit
|
||||
shell: bash
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
@@ -92,10 +92,10 @@ jobs:
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
@@ -117,10 +117,10 @@ jobs:
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# libfreemkv is a library — Cargo.lock isn't tracked, so --locked
|
||||
@@ -56,8 +56,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Update README version
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
|
||||
|
||||
- name: Update version in README
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
FILE="README.md"
|
||||
|
||||
# Update cargo dependency version (e.g. "0.2" -> "0.3")
|
||||
MAJOR_MINOR="${VERSION#v}"
|
||||
MAJOR_MINOR="${MAJOR_MINOR%.*}"
|
||||
sed -i "s|libfreemkv = \"[0-9]*\.[0-9]*\"|libfreemkv = \"${MAJOR_MINOR}\"|" "$FILE"
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet || git commit -m "Update to libfreemkv ${VERSION}"
|
||||
git push
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.4] — 2026-08-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **On a few Blu-ray/UHD titles the sound ran on for half a minute after the
|
||||
picture had ended.** A disc stores each part of a film as a clip, and the
|
||||
playlist marks exactly where that clip's content begins and ends. Where a
|
||||
title is a single clip, freemkv trimmed the picture to those marks but not the
|
||||
sound — and some discs leave extra audio in the file past the end mark (a
|
||||
quiet fade authored after the last frame of picture). That trailing audio was
|
||||
copied through, so the file claimed one running time while carrying up to ~36
|
||||
seconds more sound than picture. Measured on `The Bourne Supremacy`: the
|
||||
picture ends at 1:48:26 as declared, but every sound track ran to 1:49:02.
|
||||
Single-clip titles are now trimmed to their playlist marks the same way
|
||||
multi-clip titles already were, so sound and picture end together at the
|
||||
declared duration. A title that had no extra material past its marks is
|
||||
byte-for-byte unchanged. Multi-clip titles were never affected.
|
||||
|
||||
- **A multi-title-set CSS DVD could descramble one title set under another
|
||||
set's key.** When a DVD's second title set resisted the keyless title-key
|
||||
recovery, the decrypt fell back to the disc-wide key instead of failing —
|
||||
writing a corrupt title behind an intact header and reporting success at exit
|
||||
0. A failed recovery is now a hard error, the same as every other path in the
|
||||
crate already does; ordering makes the recovery more likely to succeed but
|
||||
cannot make a failed one safe.
|
||||
|
||||
- **A disc whose stream language field was all-zero could abort the whole track
|
||||
export.** An all-zero language code (the ordinary "undefined" value on real
|
||||
discs) put a NUL byte into a demux output filename and failed file creation
|
||||
before a single track opened. Control bytes in that field are now sanitised
|
||||
the same way the rest of the name already was.
|
||||
|
||||
### Security
|
||||
|
||||
- **Bounded the last unbounded attacker-controlled list in the DVD label
|
||||
parser.** A crafted IFO could grow the forced-subtitle index list without
|
||||
limit; it now carries the same positional cap as the neighbouring command
|
||||
lists. No effect on a well-formed disc.
|
||||
|
||||
## [1.6.3] — 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **Six crates that were never used have been removed, and nothing about reading
|
||||
a disc has changed.** They were declared as dependencies but referenced
|
||||
nowhere, so they were compiled into every build for nothing. The rest were
|
||||
aligned with the versions the other freemkv crates use.
|
||||
- **Two complete AES implementations were being built into the product.** Two
|
||||
different releases of the cipher crates had been pulled in by different parts
|
||||
of the tree and both were compiled. They are now one.
|
||||
|
||||
## [1.6.2] — 2026-08-08
|
||||
|
||||
### Fixed
|
||||
|
||||
+6
-12
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.6.2"
|
||||
version = "1.6.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
license = "MIT"
|
||||
@@ -22,20 +22,14 @@ codegen-units = 1
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
aes = "0.9"
|
||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.2" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.4" }
|
||||
rand = "0.10"
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.23"
|
||||
# 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
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
+10
-6
@@ -4,7 +4,7 @@
|
||||
#[cfg(test)]
|
||||
use aes::Aes128;
|
||||
#[cfg(test)]
|
||||
use aes::cipher::{KeyInit, generic_array::GenericArray};
|
||||
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
|
||||
@@ -418,7 +418,7 @@ pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
mod tests {
|
||||
use super::super::crypto::aes_ecb_decrypt;
|
||||
use super::*;
|
||||
use aes::cipher::BlockEncrypt; // test fixtures build ciphertext directly
|
||||
use aes::cipher::BlockCipherEncrypt; // test fixtures build ciphertext directly
|
||||
|
||||
/// [`encrypt_unit`] is the exact inverse of [`decrypt_unit`]: whatever an
|
||||
/// authoring caller encrypts, the read path must recover byte-for-byte.
|
||||
@@ -725,7 +725,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// CBC encrypt bytes 16..6143
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
||||
let cipher = Aes128::new(&encrypt_key.into());
|
||||
let mut prev = AACS_IV;
|
||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
@@ -733,7 +733,9 @@ mod tests {
|
||||
for j in 0..16 {
|
||||
plain[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]);
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&plain[off..off + 16]);
|
||||
let mut block: Array<u8, _> = chunk.into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
plain[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&plain[off..off + 16]);
|
||||
@@ -1496,7 +1498,7 @@ mod tests {
|
||||
let plain = unit.clone();
|
||||
|
||||
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
||||
let cipher = Aes128::new(&rdk.into());
|
||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
let mut prev = AACS_IV;
|
||||
let body = s + 16;
|
||||
@@ -1507,7 +1509,9 @@ mod tests {
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&unit[off..off + 16]);
|
||||
let mut blk: Array<u8, _> = chunk.into();
|
||||
cipher.encrypt_block(&mut blk);
|
||||
unit[off..off + 16].copy_from_slice(&blk);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
|
||||
+10
-8
@@ -8,7 +8,7 @@
|
||||
//! content / keys / variant modules.
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
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] = [
|
||||
@@ -39,13 +39,13 @@ pub(crate) fn new_cipher_for(key: &[u8; 16]) -> Aes128 {
|
||||
fn new_cipher(key: &[u8; 16]) -> Aes128 {
|
||||
#[cfg(test)]
|
||||
KEY_EXPANSIONS.with(|c| c.set(c.get() + 1));
|
||||
Aes128::new(GenericArray::from_slice(key))
|
||||
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(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
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);
|
||||
@@ -54,8 +54,8 @@ pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
|
||||
/// 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(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
@@ -88,7 +88,7 @@ pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
for j in 0..16 {
|
||||
block[j] = data[offset + j] ^ prev[j];
|
||||
}
|
||||
let mut ga = GenericArray::clone_from_slice(&block);
|
||||
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);
|
||||
@@ -140,7 +140,9 @@ pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) {
|
||||
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&data[offset..offset + 16]);
|
||||
let mut block: Array<u8, _> = chunk.into();
|
||||
cipher.decrypt_block(&mut block);
|
||||
for j in 0..16 {
|
||||
data[offset + j] = block[j] ^ prev[j];
|
||||
|
||||
+89
-21
@@ -85,7 +85,7 @@ pub fn crack_key(
|
||||
///
|
||||
/// - [`CrackOutcome::Cracked`] — a scrambled sector yielded a title key.
|
||||
/// - [`CrackOutcome::Unencrypted`] — NO scrambled sector was seen across the
|
||||
/// scanned extents (`is_scrambled` never true): the content is genuinely
|
||||
/// scanned extents (`is_scrambled_pack` never true): the content is genuinely
|
||||
/// plaintext, so proceeding without a key is correct.
|
||||
/// - [`CrackOutcome::ScrambledUncracked`] — scrambled sectors WERE seen but no
|
||||
/// key could be recovered (the Stevenson attack found no crackable crib, or
|
||||
@@ -515,7 +515,7 @@ pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
|
||||
/// Check if a sector is a CSS-scrambled DVD **video pack** — the HARDENED test
|
||||
/// the crack scan uses to set its `saw_scrambled` evidence flag (Fix 3).
|
||||
///
|
||||
/// [`is_scrambled`] keys solely on bits 4-5 of byte 0x14. That single byte is
|
||||
/// [`has_scramble_flag_bits`] keys solely on bits 4-5 of byte 0x14. That byte is
|
||||
/// only meaningful inside a real DVD sector — an MPEG-2 Program Stream pack,
|
||||
/// which ALWAYS begins with the 32-bit pack-start code `00 00 01 BA` at offset
|
||||
/// 0x00. A tiny clear / nav-only stub (a 0.5 s menu loop, an FBI-warning title)
|
||||
@@ -527,12 +527,15 @@ pub(crate) const PACK_START: [u8; 4] = [0x00, 0x00, 0x01, 0xBA];
|
||||
/// structurally a DVD video pack can be counted as scramble evidence. This does
|
||||
/// NOT weaken the genuine "encrypted but uncrackable" hard-fail: a real
|
||||
/// scrambled feature is made of valid PS packs, so its scrambled sectors still
|
||||
/// pass this check and still drive `ScrambledUncracked` when no key cracks. (The
|
||||
/// descramble loop keeps the looser [`is_scrambled`]: by the time it runs we
|
||||
/// already know the title is CSS, and it only needs to skip interleaved clear
|
||||
/// NAV packs — a wrongly-skipped or wrongly-included sector there is recoverable
|
||||
/// per-sector, whereas a false scramble verdict in the scan poisons the whole
|
||||
/// title's outcome.)
|
||||
/// pass this check and still drive `ScrambledUncracked` when no key cracks.
|
||||
///
|
||||
/// The DESCRAMBLE path gates on this same function — [`descramble_sector`] and
|
||||
/// [`descramble_region`] both call it, not the raw flag test — because the raw
|
||||
/// test does not merely mis-skip a sector there: it descrambles one that was
|
||||
/// never scrambled and destroys it. The measured case is written up on
|
||||
/// [`descramble_region`]: a `VIDEO_TS.IFO` sector holding 0x15 at offset 0x14
|
||||
/// lost 1912 of its 2048 bytes, taking TT_SRPT with it, and the disc's 38
|
||||
/// titles became 10 — silently, at exit 0. One gate, both paths.
|
||||
pub fn is_scrambled_pack(sector: &[u8]) -> bool {
|
||||
sector.len() >= 2048 && sector[0x00..0x04] == PACK_START && (sector[0x14] >> 4) & 0x03 != 0
|
||||
}
|
||||
@@ -618,9 +621,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── is_scrambled ───────────────────────────────────────────────────────
|
||||
// ── has_scramble_flag_bits ─────────────────────────────────────────────
|
||||
|
||||
/// is_scrambled returns false for any buffer shorter than one sector,
|
||||
/// has_scramble_flag_bits returns false for any buffer shorter than one sector,
|
||||
/// WITHOUT indexing byte 0x14 (which would panic on a tiny buffer). The
|
||||
/// length guard is short-circuited before the flag read.
|
||||
///
|
||||
@@ -630,13 +633,13 @@ mod tests {
|
||||
/// (`(sector[0x14]...) && sector.len() >= 2048`) -> panics indexing a
|
||||
/// 20-byte slice; this test catches it.
|
||||
#[test]
|
||||
fn is_scrambled_short_buffer_is_false_no_panic() {
|
||||
fn has_scramble_flag_bits_short_buffer_is_false_no_panic() {
|
||||
assert!(!has_scramble_flag_bits(&[]));
|
||||
assert!(!has_scramble_flag_bits(&[0u8; 20])); // shorter than 0x14+1 even
|
||||
assert!(!has_scramble_flag_bits(&[0xFFu8; 2047])); // one byte short of a sector
|
||||
}
|
||||
|
||||
/// is_scrambled keys on bits 4-5 of byte 0x14 (the CSS scramble field).
|
||||
/// has_scramble_flag_bits keys on bits 4-5 of byte 0x14 (the CSS scramble field).
|
||||
/// A full sector flagged 0x10/0x20/0x30 is scrambled; 0x00 and the
|
||||
/// high-bit-only values 0x40/0x80 are clear.
|
||||
///
|
||||
@@ -644,7 +647,7 @@ mod tests {
|
||||
/// Mutation: widen mask to `& 0x0F` -> 0x40 reports scrambled, the 0x40
|
||||
/// assert fails.
|
||||
#[test]
|
||||
fn is_scrambled_uses_bits_4_5_only() {
|
||||
fn has_scramble_flag_bits_uses_bits_4_5_only() {
|
||||
let mut s = vec![0u8; 2048];
|
||||
for (flag, expected) in [
|
||||
(0x00u8, false),
|
||||
@@ -665,14 +668,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// is_scrambled accepts exactly 2048 bytes as the minimum (boundary at the
|
||||
/// has_scramble_flag_bits accepts exactly 2048 bytes as the minimum (boundary at the
|
||||
/// inclusive value 2048).
|
||||
///
|
||||
/// Grounding: `sector.len() >= 2048`.
|
||||
/// Mutation: change `>= 2048` to `> 2048` -> an exact 2048-byte scrambled
|
||||
/// sector reports false; this fails.
|
||||
#[test]
|
||||
fn is_scrambled_exact_sector_length_accepted() {
|
||||
fn has_scramble_flag_bits_exact_sector_length_accepted() {
|
||||
let mut s = vec![0u8; 2048];
|
||||
s[0x14] = 0x30;
|
||||
assert!(
|
||||
@@ -686,8 +689,8 @@ mod tests {
|
||||
/// bits. A clear / nav-only stub whose bytes happen to set bits 4-5 of 0x14
|
||||
/// but lacks the pack-start is NOT counted as scramble evidence — without
|
||||
/// this the scan flips `saw_scrambled` and a genuinely unencrypted title
|
||||
/// reports `ScrambledUncracked` (the false E7023). The looser `is_scrambled`
|
||||
/// (descramble gate) still reads the same sector as flagged.
|
||||
/// reports `ScrambledUncracked` (the false E7023). The raw flag test
|
||||
/// `has_scramble_flag_bits` still reads the same sector as flagged.
|
||||
///
|
||||
/// Grounding: `sector[0x00..0x04] == 00 00 01 BA && (sector[0x14] >> 4)...`.
|
||||
/// Mutation: drop the pack-start clause -> the 0x14-only sector counts as a
|
||||
@@ -735,6 +738,11 @@ mod tests {
|
||||
/// instead of the uniform `flag_byte` fill. Lets the scan actually
|
||||
/// reach `CrackOutcome::Cracked` from a synthetic ISO.
|
||||
crackable: Option<(u32, Vec<u8>)>,
|
||||
/// Sectors actually filled per batch, however many were asked for —
|
||||
/// the SHORT READ a `recovery: true` source is allowed to return over
|
||||
/// a damaged region. `Some(0)` is the degenerate case that must not
|
||||
/// spin the scan.
|
||||
short_read: Option<usize>,
|
||||
}
|
||||
|
||||
impl MockSource {
|
||||
@@ -745,6 +753,7 @@ mod tests {
|
||||
fail_all: false,
|
||||
lock_all: false,
|
||||
crackable: None,
|
||||
short_read: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -795,14 +804,19 @@ mod tests {
|
||||
if self.fail_all {
|
||||
return Err(Error::DecryptFailed);
|
||||
}
|
||||
let n = count as usize * 2048;
|
||||
// A short read fills, and reports, fewer sectors than asked.
|
||||
let filled = match self.short_read {
|
||||
Some(k) => (k as u16).min(count),
|
||||
None => count,
|
||||
};
|
||||
let n = filled as usize * 2048;
|
||||
let end = n.min(buf.len());
|
||||
for b in buf[..end].iter_mut() {
|
||||
*b = 0;
|
||||
}
|
||||
// Fill each sector in the batch with the uniform flag byte, EXCEPT a
|
||||
// designated crackable LBA which gets the full synthetic sector.
|
||||
for s in 0..count as u32 {
|
||||
for s in 0..filled as u32 {
|
||||
let sect_lba = lba + s;
|
||||
let base = s as usize * 2048;
|
||||
if base + 2048 > end {
|
||||
@@ -814,7 +828,7 @@ mod tests {
|
||||
}
|
||||
_ => {
|
||||
// Real DVD video sectors always open with the MPEG-PS
|
||||
// pack-start code; `is_scrambled` (Fix 3) requires it
|
||||
// pack-start code; `is_scrambled_pack` (Fix 3) requires it
|
||||
// before trusting the 0x14 scramble bits, so the fixture
|
||||
// must include it for a `flag_byte` of 0x30 to register
|
||||
// as scrambled.
|
||||
@@ -827,6 +841,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Short reads: the branch nothing exercised ─────────────────────────
|
||||
//
|
||||
// `crack_key_scan` passes `recovery = true`, which is precisely the mode
|
||||
// where a `SectorSource` may return Ok with fewer bytes than asked. Every
|
||||
// source in this module returned the full request, so `usable`, `advance`
|
||||
// and the `.max(1)` anti-spin guard were dead code under test: reverting
|
||||
// `advance` to `n`, or dropping the `.max(1)`, left the whole suite green.
|
||||
|
||||
/// A short batch is RE-READ from where it stopped, not skipped. Skipping
|
||||
/// would quietly shrink the crack's coverage on exactly the damaged media
|
||||
/// where a key is hardest to find.
|
||||
#[test]
|
||||
fn a_short_read_resumes_from_where_it_stopped() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.short_read = Some(1);
|
||||
let ext = [crate::disc::Extent {
|
||||
start_lba: 100,
|
||||
sector_count: 4,
|
||||
}];
|
||||
let _ = crack_key_scan(&mut src, &ext, 4, None, false);
|
||||
let reads = src.reads.borrow().clone();
|
||||
assert_eq!(
|
||||
reads,
|
||||
vec![100, 101, 102, 103],
|
||||
"a source that filled one sector per batch must be asked for the \
|
||||
next one, not advanced a whole batch past it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A source that reads NOTHING must terminate. Without the `.max(1)` the
|
||||
/// cursor never moves and `tried` never increments — the budget cannot end
|
||||
/// the loop, so the scan spins forever inside a library whose whole job is
|
||||
/// surviving hostile input.
|
||||
#[test]
|
||||
fn a_source_that_returns_zero_sectors_terminates() {
|
||||
let mut src = MockSource::new(0x00);
|
||||
src.short_read = Some(0);
|
||||
let ext = [crate::disc::Extent {
|
||||
start_lba: 0,
|
||||
sector_count: 8,
|
||||
}];
|
||||
let outcome = crack_key_scan(&mut src, &ext, 4, None, false);
|
||||
assert!(
|
||||
matches!(outcome, CrackOutcome::Unencrypted),
|
||||
"nothing was read, so nothing scrambled was seen"
|
||||
);
|
||||
assert!(
|
||||
src.reads.borrow().len() <= 8,
|
||||
"the cursor must advance even on an empty read; got {} reads over \
|
||||
an 8-sector extent",
|
||||
src.reads.borrow().len()
|
||||
);
|
||||
}
|
||||
|
||||
/// crack_key caps total scanned sectors at 50_000 even when extents are
|
||||
/// far larger, and counts EVERY scanned sector (clear ones included)
|
||||
/// toward the budget. With one 200_000-sector extent of clear sectors, it
|
||||
@@ -1123,7 +1191,7 @@ mod tests {
|
||||
/// budget). With a small failing extent, every sector is attempted and the
|
||||
/// function returns None.
|
||||
///
|
||||
/// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled(...)` —
|
||||
/// Grounding: `if reader.read_sectors(...).is_ok() && is_scrambled_pack(...)` —
|
||||
/// an Err simply falls through to `i += 1`.
|
||||
/// Mutation: change the read-error handling to `reader.read_sectors(...)?`
|
||||
/// (propagate) -> crack_key would stop after the first error and read only
|
||||
|
||||
@@ -29,8 +29,12 @@ use super::layout::{DirNode, Layout};
|
||||
use crate::error::{Error, Result};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Logical block / sector size. Fixed for every optical profile this crate reads.
|
||||
pub(super) const SECTOR: usize = 2048;
|
||||
/// Logical block / sector size. Fixed for every optical profile this crate
|
||||
/// reads, and the same quantity as [`crate::consts::SECTOR_BYTES`] — aliased
|
||||
/// rather than re-declared so the two cannot drift apart. The short name is
|
||||
/// kept because it appears in ~25 extent and offset expressions across
|
||||
/// `dirimage`, where the longer one would bury the arithmetic.
|
||||
pub(super) use crate::consts::SECTOR_BYTES as SECTOR;
|
||||
|
||||
/// Descriptor version recorded in every tag. 2 = ECMA-167 2nd edition, which
|
||||
/// is what UDF revisions up to and including 2.00 require.
|
||||
|
||||
@@ -67,6 +67,15 @@ const MAX_SUBDIRS: usize = (u16::MAX - 1) as usize;
|
||||
#[cfg(test)]
|
||||
const MAX_SUBDIRS: usize = 4;
|
||||
|
||||
/// One entry per child plus the parent's own must still fit the 16-bit field.
|
||||
///
|
||||
/// At MODULE scope, and it has to be. This assertion previously sat inside
|
||||
/// `mod tests`, which is `#[cfg(test)]`, while carrying `#[cfg(not(test))]`
|
||||
/// itself — so it was compiled in NO configuration and could never fire, which
|
||||
/// made it exactly the dead gate the test above it was written to replace.
|
||||
#[cfg(not(test))]
|
||||
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
|
||||
|
||||
/// Largest image this planner will synthesize, in sectors (128 GiB).
|
||||
///
|
||||
/// A DVD title set records where its VOBS begins as an offset in its own IFO,
|
||||
@@ -998,11 +1007,6 @@ mod tests {
|
||||
"expected DirImageFanout, got {err:?}"
|
||||
);
|
||||
|
||||
// And the real production value is what ships: one per child plus the
|
||||
// parent's own entry must still fit in the 16-bit field.
|
||||
#[cfg(not(test))]
|
||||
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,6 +650,83 @@ fn an_aacs_folder_with_scrambled_content_is_rejected() {
|
||||
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_ENCRYPTED);
|
||||
}
|
||||
|
||||
// ── The OTHER door: `dir://` through the PES input path ─────────────────────
|
||||
//
|
||||
// `session::scan_dir` (covered above) and `mux::resolve::input("dir://…")` are
|
||||
// two doors into the same folder, and they once disagreed: a folder that ripped
|
||||
// fine through one failed through the other, because only `scan_dir` re-judged
|
||||
// the tree-shape encryption verdict from CONTENT. The fix was to share
|
||||
// `session::apply_folder_encryption_verdict` between them — see its doc
|
||||
// comment, which names this exact failure. Nothing tested the second door, so
|
||||
// dropping the `is_folder` argument at `mux::resolve`'s call site restored the
|
||||
// bug silently.
|
||||
|
||||
/// The two doors must AGREE. Same folder, same verdict, same extents.
|
||||
///
|
||||
/// Stated as a differential rather than a bare `is_ok()` so it cannot go
|
||||
/// vacuous: if the fixture ever stops producing an AACS state, a one-sided
|
||||
/// `Ok` assertion would still pass while guarding nothing, whereas "both doors
|
||||
/// see the same title" is the invariant the shared function actually exists to
|
||||
/// hold.
|
||||
#[test]
|
||||
fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() {
|
||||
// `s` MUST outlive `stream`: the pipeline's producer thread is still
|
||||
// reading the folder until the stream is dropped, and `Scratch::drop`
|
||||
// removes the directory out from under it.
|
||||
let s = playable_bdmv("dirdoor", false);
|
||||
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
|
||||
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
|
||||
|
||||
// Door 1 — the scan path.
|
||||
let (disc, _reader) =
|
||||
crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap();
|
||||
assert!(
|
||||
!disc.encrypted,
|
||||
"door 1 must judge the clear folder decrypted"
|
||||
);
|
||||
let scanned_extents = disc.titles[0].extents.clone();
|
||||
assert!(
|
||||
!scanned_extents.is_empty(),
|
||||
"fixture must have real extents"
|
||||
);
|
||||
|
||||
// Door 2 — the PES input path the CLI actually rips through.
|
||||
let url = format!("dir://{}", s.path().display());
|
||||
let stream = crate::input(&url, &crate::InputOptions::default())
|
||||
.expect("dir:// input must open a clear folder, exactly as scan_dir does");
|
||||
assert_eq!(
|
||||
stream.info().extents,
|
||||
scanned_extents,
|
||||
"the two doors selected different titles from the same folder"
|
||||
);
|
||||
drop(stream);
|
||||
}
|
||||
|
||||
/// The other verdict, through the same door: a folder whose content units are
|
||||
/// really scrambled is refused with the TYPED code, not muxed into garbage.
|
||||
///
|
||||
/// This is the load-bearing half. `E_DIR_IMAGE_ENCRYPTED` is produced at
|
||||
/// exactly one site in the crate (`session::apply_folder_encryption_verdict`),
|
||||
/// reachable from here only through the `is_folder` argument — so this test
|
||||
/// cannot pass if that argument is dropped, whatever else changes.
|
||||
#[test]
|
||||
fn a_scrambled_folder_is_refused_through_the_dir_url_door_too() {
|
||||
let s = playable_bdmv("dirdoorenc", true);
|
||||
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
|
||||
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
|
||||
|
||||
let url = format!("dir://{}", s.path().display());
|
||||
let err = match crate::input(&url, &crate::InputOptions::default()) {
|
||||
Ok(_) => panic!("a scrambled folder must not open as a PES source"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert_eq!(
|
||||
crate::error::error_code(&err),
|
||||
Some(crate::error::E_DIR_IMAGE_ENCRYPTED),
|
||||
"expected the typed dir-source-encrypted code, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── External oracle ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Write a synthesized image to a real file and ask the OS to mount it.
|
||||
|
||||
+108
-7
@@ -254,7 +254,7 @@ impl Disc {
|
||||
let key = match vts_keys.get(&vts) {
|
||||
Some(k) => k.clone(),
|
||||
None => {
|
||||
let k = self.resolve_vts_key(&vts, &planned, &mut dec, &base_keys);
|
||||
let k = self.resolve_vts_key(&vts, &planned, &mut dec, &base_keys)?;
|
||||
vts_keys.insert(vts.clone(), k.clone());
|
||||
k
|
||||
}
|
||||
@@ -291,13 +291,24 @@ impl Disc {
|
||||
/// VOB extents (keyless Stevenson attack). The disc-wide key is reused when
|
||||
/// it already covers this VTS (single-VTS discs, or this VTS's span). The
|
||||
/// reader is borrowed from the decrypting decorator (its inner source).
|
||||
///
|
||||
/// FALLIBLE, because the three outcomes of a crack are not two.
|
||||
/// [`crate::css::CrackOutcome`] exists precisely to separate "no scrambled
|
||||
/// sector was seen, so there is nothing to decrypt" from "scrambled sectors
|
||||
/// were seen and no key came out", and its doc says callers MUST surface
|
||||
/// the second as a hard error. This function used the `Option`-returning
|
||||
/// `crack_key`, which collapses both into `None`, and then fell back to the
|
||||
/// disc-wide key — descrambling this VTS with ANOTHER VTS's title key. The
|
||||
/// hazard is described in the ordering note below, which was written about
|
||||
/// the same fallback; ordering makes the crack far more likely to succeed,
|
||||
/// but it cannot make a failed crack safe.
|
||||
fn resolve_vts_key<S: SectorSource>(
|
||||
&self,
|
||||
vts: &str,
|
||||
planned: &[PlannedFile],
|
||||
dec: &mut DecryptingSectorSource<S>,
|
||||
base_keys: &DecryptKeys,
|
||||
) -> DecryptKeys {
|
||||
) -> Result<DecryptKeys> {
|
||||
// Gather the title VOB extents for this VTS (VTS_xx_1.VOB .. _9.VOB;
|
||||
// VTS_xx_0.VOB is the menu and is clear, so excluded from the crack).
|
||||
// Gather this VTS's title VOBs BY NAME, ascending.
|
||||
@@ -336,7 +347,7 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
if extents.is_empty() {
|
||||
return base_keys.clone();
|
||||
return Ok(base_keys.clone());
|
||||
}
|
||||
// PLAYBACK ORDER — do NOT sort. This is the 1.5.1 garbage bug, and it
|
||||
// grew back here in a new code path: the comment this replaced said it
|
||||
@@ -358,11 +369,20 @@ impl Disc {
|
||||
// in file order, which together is playback order for a DVD title set.
|
||||
// Crack against the raw (still-scrambled) inner reader, NOT the
|
||||
// decrypting view — `crack_key` runs the descrambler itself.
|
||||
match crate::css::crack_key(dec.inner_mut(), &extents, 64) {
|
||||
Some(state) => DecryptKeys::Css {
|
||||
match crate::css::crack_key_outcome(dec.inner_mut(), &extents, 64, None) {
|
||||
crate::css::CrackOutcome::Cracked(state) => Ok(DecryptKeys::Css {
|
||||
title_key: state.title_key,
|
||||
},
|
||||
None => base_keys.clone(),
|
||||
}),
|
||||
// No scrambled sector anywhere in this VTS: the content is clear,
|
||||
// and any key descrambles it as a no-op. The disc-wide key is the
|
||||
// right answer, and this is the ONLY case that ever was.
|
||||
crate::css::CrackOutcome::Unencrypted => Ok(base_keys.clone()),
|
||||
// Scrambled sectors WERE seen and no key came out. Reusing the
|
||||
// disc-wide key here writes corrupt PES behind an intact header and
|
||||
// reports a complete extract at exit 0. Skippable per title, which
|
||||
// is why this is the per-title code and not the disc-level one — a
|
||||
// sibling VTS may still crack.
|
||||
crate::css::CrackOutcome::ScrambledUncracked => Err(Error::CssKeyMissing),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1918,6 +1938,87 @@ mod tests {
|
||||
assert!(res.complete);
|
||||
}
|
||||
|
||||
/// A VTS that IS scrambled but whose key could not be recovered must
|
||||
/// FAIL, not borrow another VTS's key.
|
||||
///
|
||||
/// `crack_key` returns `Option`, which collapses "no scrambled sector was
|
||||
/// seen" with "scrambled sectors were seen and no key came out"; the
|
||||
/// fallback then descrambled this VTS under the disc-wide key and
|
||||
/// `extract_tree` reported `complete = true` at exit 0, because no read had
|
||||
/// failed. `CrackOutcome` exists to keep those two apart, and every sibling
|
||||
/// path in the crate already honours it.
|
||||
///
|
||||
/// The fixture: VTS_01 is crackable, VTS_02 is scrambled with NO periodic
|
||||
/// crib, so its crack genuinely fails.
|
||||
#[test]
|
||||
fn a_scrambled_vts_that_cannot_be_cracked_fails_instead_of_borrowing_a_key() {
|
||||
let key_1 = [0x10u8, 0x20, 0x30, 0x40, 0x50];
|
||||
let (_plain_1, scrambled_1) = {
|
||||
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x01];
|
||||
let mut plain = vec![0u8; 2048];
|
||||
plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||
plain[0x14] = 0x10;
|
||||
let pat: Vec<u8> = (0..8)
|
||||
.map(|k| (0xA0u8.wrapping_add(k as u8) ^ 0x01) ^ 0x5A)
|
||||
.collect();
|
||||
for (i, b) in plain.iter_mut().enumerate().skip(0x59) {
|
||||
*b = pat[i % 8];
|
||||
}
|
||||
plain[0x54..0x59].copy_from_slice(&seed);
|
||||
let mut scrambled = plain.clone();
|
||||
lfsr::scramble_sector(&key_1, &mut scrambled);
|
||||
(plain, scrambled)
|
||||
};
|
||||
// Scrambled — the pack header and the 0x14 flag are set, so the scan
|
||||
// SEES ciphertext — but the cleartext region carries no periodic run,
|
||||
// so the Stevenson crib never forms and no key is recoverable.
|
||||
let uncrackable = {
|
||||
let mut sect = vec![0u8; 2048];
|
||||
sect[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||
sect[0x14] = 0x10;
|
||||
for (i, b) in sect.iter_mut().enumerate().skip(0x59) {
|
||||
// Non-repeating, so no run of any period survives to 0x80.
|
||||
*b = (i as u8).wrapping_mul(37).wrapping_add(11);
|
||||
}
|
||||
sect
|
||||
};
|
||||
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![DirSpec {
|
||||
name: "VIDEO_TS".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: vec![
|
||||
file("VTS_01_1.VOB", 30, 5000, scrambled_1, false),
|
||||
file("VTS_02_1.VOB", 32, 6000, uncrackable, false),
|
||||
],
|
||||
subdirs: vec![],
|
||||
}],
|
||||
};
|
||||
let mut disc = build_disc(root);
|
||||
let out = TmpDir::new("css_uncrackable_vts");
|
||||
let mut d = clear_disc();
|
||||
d.content_format = crate::disc::ContentFormat::MpegPs;
|
||||
d.css = Some(crate::css::CssState {
|
||||
title_key: [0xFFu8; 5],
|
||||
crack_span: None,
|
||||
});
|
||||
let err = d
|
||||
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
|
||||
.expect_err(
|
||||
"a VTS whose key could not be recovered must be a hard error, \
|
||||
not a silent extract under another VTS's key",
|
||||
);
|
||||
assert!(
|
||||
matches!(err, Error::CssKeyMissing),
|
||||
"expected CssKeyMissing, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `Borrowed` is a thin forwarding wrapper the decrypting decorator uses
|
||||
/// to avoid taking ownership of the caller's reader -- every
|
||||
/// `SectorSource` method must forward to the wrapped `&mut dyn
|
||||
|
||||
+5
-5
@@ -5416,7 +5416,7 @@ mod tests {
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::crypto::AACS_IV;
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
|
||||
// Flag the unit encrypted (CPI bits on byte 0) before key derivation so
|
||||
// the recovered plaintext header matches and `decrypt_unit`'s CPI gate
|
||||
@@ -5424,14 +5424,14 @@ mod tests {
|
||||
unit[0] |= 0xC0;
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(uk));
|
||||
let mut blk = GenericArray::clone_from_slice(&header);
|
||||
let cipher = Aes128::new(&(*uk).into());
|
||||
let mut blk: Array<u8, _> = header.into();
|
||||
cipher.encrypt_block(&mut blk);
|
||||
let mut dk = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
dk[i] = blk[i] ^ header[i];
|
||||
}
|
||||
let bc = Aes128::new(GenericArray::from_slice(&dk));
|
||||
let bc = Aes128::new(&dk.into());
|
||||
let mut prev = AACS_IV;
|
||||
let mut i = 16;
|
||||
while i + 16 <= ALIGNED_UNIT_LEN {
|
||||
@@ -5439,7 +5439,7 @@ mod tests {
|
||||
for j in 0..16 {
|
||||
b[j] = unit[i + j] ^ prev[j];
|
||||
}
|
||||
let mut g = GenericArray::clone_from_slice(&b);
|
||||
let mut g: Array<u8, _> = b.into();
|
||||
bc.encrypt_block(&mut g);
|
||||
for j in 0..16 {
|
||||
unit[i + j] = g[j];
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//!
|
||||
//! So: drive sources get `freemkv_engine::copy`. Everything else gets this.
|
||||
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::halt::Halt;
|
||||
use crate::sector::SectorSource;
|
||||
@@ -33,9 +34,6 @@ use std::path::Path;
|
||||
/// a notable allocation and cancellation stays responsive.
|
||||
const BATCH_SECTORS: u32 = 2048;
|
||||
|
||||
/// Bytes per sector. Fixed for every medium this crate reads.
|
||||
const SECTOR_BYTES: usize = 2048;
|
||||
|
||||
/// Write `total_sectors` sectors from `reader` to `dest`.
|
||||
///
|
||||
/// Reads sequentially from LBA 0 and writes in order, so the output is a faithful
|
||||
|
||||
+13
-4
@@ -1418,11 +1418,20 @@ mod registry_tests {
|
||||
/// straight from the disc's UDF directory records, with attacker-chosen
|
||||
/// name lengths to inflate each comparison.
|
||||
///
|
||||
/// Proof is by deadline. With the linear scan this fixture measures well
|
||||
/// past the deadline; with a set it is milliseconds. Bounded so a
|
||||
/// regression fails fast instead of hanging CI.
|
||||
/// This is a HANG GUARD, and the name says so: a return to the linear scan
|
||||
/// makes this fixture run for minutes (120 000² / 2 comparisons over a
|
||||
/// 180-byte shared prefix), which without the deadline would wedge CI
|
||||
/// rather than fail it. It is not a complexity proof — no assertion here
|
||||
/// can distinguish `BTreeSet` from any other sub-quadratic dedup, and the
|
||||
/// clock-free half of the claim (dedup, sort, directory exclusion) belongs
|
||||
/// to `jar_inventory_dedups_sorts_and_skips_dirs` below.
|
||||
///
|
||||
/// The deadline is a real margin, unlike the 6x one that made
|
||||
/// `paramount.rs`'s wall-clock test flake under a loaded CI box: measured
|
||||
/// at 0.14 s debug / 0.07 s release against 10 s, so ~70x. A shared CPU
|
||||
/// does not close that; a quadratic dedup does not survive it.
|
||||
#[test]
|
||||
fn jar_inventory_dedup_is_not_quadratic() {
|
||||
fn jar_inventory_dedup_does_not_hang_on_a_hostile_directory() {
|
||||
const FILES: usize = 120_000;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
|
||||
+194
-60
@@ -98,6 +98,74 @@ enum ForcedSub {
|
||||
ForcedNarrative,
|
||||
}
|
||||
|
||||
/// The one number behind every cap in this parser: the highest CSV cell
|
||||
/// position that can ever be addressed.
|
||||
///
|
||||
/// The labelling loops number cells 1-based into a `u16` and `break` at
|
||||
/// `u16::try_from(i + 1)`, so cell `MAX_COM_INDICES` and everything past it is
|
||||
/// never visited. Two different things are measured against that, and they are
|
||||
/// not the same bound:
|
||||
///
|
||||
/// - **A VALUE at or beyond it cannot match any cell.** This is what caps the
|
||||
/// set: values are filtered before insertion, so at most `MAX_COM_INDICES`
|
||||
/// distinct entries can ever be stored, however long the attribute is. The
|
||||
/// `HashSet` that replaced a linear scan fixed the LOOKUP cost; this is what
|
||||
/// fixes the ALLOCATION, and a disc declaring half a billion indices no
|
||||
/// longer costs half a billion entries.
|
||||
/// - **A POSITION at or beyond it describes nothing new.** This caps the WORK,
|
||||
/// not the memory — the value filter already made the set small, but without
|
||||
/// it every one of those half a billion cells is still split and parsed. It
|
||||
/// is an early exit at the first position whose contents provably cannot
|
||||
/// matter, and it is why `forced_sub` — which holds no set at all, and so
|
||||
/// gets no protection from the value rule — is bounded too.
|
||||
///
|
||||
/// Real authoring is nowhere near either limit: the BD STN table admits at
|
||||
/// most 32 streams per playlist, so nothing legitimate is lost.
|
||||
const MAX_COM_INDICES: usize = u16::MAX as usize;
|
||||
|
||||
/// Parse a `*_com1_idx` attribute into the set the labelling loops query.
|
||||
///
|
||||
/// Extracted so the BOUND is observable. Asserting it through
|
||||
/// `labels_from_feature` is not possible: a `HashSet` collapses repeated
|
||||
/// values, and an out-of-range index changes no label either way, so such a
|
||||
/// test passes whether or not the cap exists — an assertion that cannot fail.
|
||||
/// Returning the set lets a test hand in tens of thousands of DISTINCT
|
||||
/// unaddressable indices and see them refused.
|
||||
fn com_indices(attr: Option<String>) -> HashSet<usize> {
|
||||
attr.map(|s| {
|
||||
s.split(',')
|
||||
.take(MAX_COM_INDICES)
|
||||
.filter_map(|i| i.trim().parse().ok())
|
||||
.filter(|&i| i < MAX_COM_INDICES)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Parse the `forced_sub` attribute into the cell list the subtitle loop
|
||||
/// queries — the third attacker-controlled CSV in this file, and the last one
|
||||
/// that was still unbounded.
|
||||
///
|
||||
/// Bounded by POSITION, and it has no other choice: a `*_com1_idx` list holds
|
||||
/// values that can be filtered, and that filter is what caps its set, but a
|
||||
/// `forced_sub` cell is a classification of the position it sits at, so there
|
||||
/// is nothing to filter and nothing else would ever cap this. The Vec is read
|
||||
/// only as `forced.get(i)` from a loop that stops at `MAX_COM_INDICES`, so
|
||||
/// every cell past that is unreachable by construction.
|
||||
///
|
||||
/// Extracted, like [`com_indices`], so the bound is OBSERVABLE. Through
|
||||
/// `labels_from_feature` it is not: the subtitle loop cannot reach those cells
|
||||
/// either, so a label-level assertion passes whether or not the cap exists.
|
||||
fn forced_subs(attr: Option<String>) -> Vec<ForcedSub> {
|
||||
attr.map(|s| {
|
||||
s.split(',')
|
||||
.take(MAX_COM_INDICES)
|
||||
.map(forced_sub_cell)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn forced_sub_cell(cell: &str) -> ForcedSub {
|
||||
match cell.trim() {
|
||||
"1" => ForcedSub::ContainsForcedSegments,
|
||||
@@ -123,9 +191,7 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
|
||||
// linearly once per stream, so `aud="..."` and `aud_com1_idx="..."`
|
||||
// both grown large make this quadratic in the size of one XML file.
|
||||
// Membership is the only operation performed on it.
|
||||
let com_indices: HashSet<usize> = xml::attr(feature, "aud_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
let com_indices = com_indices(xml::attr(feature, "aud_com1_idx"));
|
||||
|
||||
// The CSV *is* the STN list: one cell per stream, in stream order,
|
||||
// and `aud_com1_idx` is a 0-based index into those same cells. So
|
||||
@@ -172,15 +238,11 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
|
||||
|
||||
// Parse subtitle streams
|
||||
if let Some(sub) = xml::attr(feature, "sub") {
|
||||
let forced: Vec<ForcedSub> = xml::attr(feature, "forced_sub")
|
||||
.map(|s| s.split(',').map(forced_sub_cell).collect())
|
||||
.unwrap_or_default();
|
||||
let forced = forced_subs(xml::attr(feature, "forced_sub"));
|
||||
|
||||
// HashSet for the same reason as the audio side above: unbounded
|
||||
// parsed input, membership-only use, linear scan once per stream.
|
||||
let com_indices: HashSet<usize> = xml::attr(feature, "sub_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
let com_indices = com_indices(xml::attr(feature, "sub_com1_idx"));
|
||||
|
||||
// As with audio: the cell position IS the STN slot. `forced_sub` and
|
||||
// `sub_com1_idx` are indexed against those same cells, so an empty
|
||||
@@ -315,61 +377,133 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `sub_com1_idx` is an unbounded index list parsed straight out of the
|
||||
/// disc's `playlists.xml` and was membership-tested with a linear
|
||||
/// `Vec::contains` once per subtitle stream — quadratic in the size of a
|
||||
/// single attacker-supplied file.
|
||||
/// `sub_com1_idx` is parsed straight out of the disc's `playlists.xml`,
|
||||
/// which is attacker-controlled and has no length bound of its own.
|
||||
///
|
||||
/// Proof is by deadline rather than micro-benchmark. With the linear scan
|
||||
/// the original fixture (200 000 streams x 1 000 001 indices) measured
|
||||
/// 31 s in a release build and far longer in debug; with a set it measured
|
||||
/// 0.03 s release / 0.56 s debug. A 10 s deadline sits ~18x above the
|
||||
/// slowest passing measurement and ~3x below the fastest failing one, and
|
||||
/// makes a regression fail fast instead of hanging CI.
|
||||
/// This replaces a WALL-CLOCK test. That one built a 200 000 x 1 000 001
|
||||
/// fixture and failed if it took over 10 s, to prove the membership test
|
||||
/// was a set rather than a linear scan. Measured on the machine that
|
||||
/// wrote this: 1.62 s alone, and OVER 10 s — a real failure — when the
|
||||
/// suite's other 3 347 tests were running concurrently. A 6x margin
|
||||
/// against a shared CPU is not a margin; it is a CI failure that looks
|
||||
/// like a flake and gets re-run until it passes.
|
||||
///
|
||||
/// The CSV now stops at the end of the 1-based `u16` stream-numbering
|
||||
/// space, so only the first 65 535 cells are scanned. `INDICES` is raised
|
||||
/// to keep the linear-scan work product (`cells x indices`) at or above
|
||||
/// the original fixture's, preserving that deadline margin.
|
||||
/// It also measured the wrong thing. Making the lookup O(1) bounded the
|
||||
/// QUERY, not the PARSE: the set was still built from every entry the
|
||||
/// disc declared, so a hostile playlist could still force an unbounded
|
||||
/// allocation before any lookup happened. `MAX_COM_INDICES` bounds that.
|
||||
///
|
||||
/// Correctness is pinned on fixture-derived literals: indices 0, 2 and 4
|
||||
/// are the commentary tracks, 1 and 3 are not.
|
||||
/// What THIS test guards is that bounding did not change what a
|
||||
/// legitimate playlist MEANS: it goes red if the bound is set too LOW
|
||||
/// (verified at 2 — the real indices `0,2,4` stop resolving and the
|
||||
/// purposes change). It does NOT go red if the bound is deleted
|
||||
/// entirely, because the out-of-range filler is unobservable at the
|
||||
/// label level and a `HashSet` collapses the repeats. Enforcement is
|
||||
/// proven separately, by
|
||||
/// `distinct_unaddressable_indices_are_refused_not_stored`, which reads
|
||||
/// the set itself. Two tests, two properties; neither pretends to the
|
||||
/// other's job.
|
||||
#[test]
|
||||
fn commentary_index_lookup_is_not_quadratic() {
|
||||
/// Cells offered. Everything past `u16::MAX` is unnumberable and the
|
||||
/// parser stops there, so the scanned prefix is 65 535 cells.
|
||||
const STREAMS: usize = 200_000;
|
||||
const SCANNED: usize = u16::MAX as usize;
|
||||
const INDICES: usize = 3_100_000;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut feature = String::from(r#"<playlist name="Feature" sub=""#);
|
||||
feature.push_str(&"eng,".repeat(STREAMS));
|
||||
feature.pop();
|
||||
// Three real commentary indices, then a long run of one
|
||||
// out-of-range value: nothing here is bounded by the stream count.
|
||||
feature.push_str(r#"" sub_com1_idx="0,2,4,"#);
|
||||
feature.push_str(&"9999999,".repeat(INDICES));
|
||||
feature.pop();
|
||||
feature.push_str(r#"" />"#);
|
||||
let _ = tx.send(labels_from_feature(&feature));
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
|
||||
Ok(labels) => {
|
||||
worker.join().expect("worker panicked");
|
||||
assert_eq!(labels.len(), SCANNED);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(labels[1].purpose, LabelPurpose::Normal);
|
||||
assert_eq!(labels[2].purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(labels[3].purpose, LabelPurpose::Normal);
|
||||
assert_eq!(labels[4].purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
Err(_) => panic!(
|
||||
"labels_from_feature did not finish {STREAMS} streams x \
|
||||
{INDICES} commentary indices within 10s — the membership \
|
||||
test is still a linear scan"
|
||||
),
|
||||
}
|
||||
fn bounding_the_parse_does_not_change_a_legitimate_playlist() {
|
||||
// Three real indices, then far more entries than can address a cell.
|
||||
const OVERSIZED: usize = MAX_COM_INDICES + 10_000;
|
||||
let mut feature = String::from(r#"<playlist name="Feature" sub=""#);
|
||||
feature.push_str(&"eng,".repeat(8));
|
||||
feature.pop();
|
||||
feature.push_str(r#"" sub_com1_idx="0,2,4,"#);
|
||||
feature.push_str(&"9999999,".repeat(OVERSIZED));
|
||||
feature.pop();
|
||||
feature.push_str(r#"" />"#);
|
||||
|
||||
let labels = labels_from_feature(&feature);
|
||||
|
||||
// The fixture's real indices still decide the purposes: bounding the
|
||||
// parse must not change what a legitimate playlist means.
|
||||
assert_eq!(labels.len(), 8);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(labels[1].purpose, LabelPurpose::Normal);
|
||||
assert_eq!(labels[2].purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(labels[3].purpose, LabelPurpose::Normal);
|
||||
assert_eq!(labels[4].purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
/// The set REFUSES unaddressable indices, so a hostile playlist cannot
|
||||
/// inflate it. DISTINCT values on purpose: a `HashSet` collapses repeats,
|
||||
/// so a million copies of one index costs one entry and would prove
|
||||
/// nothing. Fifty thousand distinct out-of-range indices cost fifty
|
||||
/// thousand entries without the filter, and none with it — so this test
|
||||
/// goes red if the bound is removed, which the label-level assertions
|
||||
/// below cannot do.
|
||||
#[test]
|
||||
fn distinct_unaddressable_indices_are_refused_not_stored() {
|
||||
let hostile: String = (MAX_COM_INDICES..MAX_COM_INDICES + 50_000)
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let set = com_indices(Some(hostile));
|
||||
assert!(
|
||||
set.is_empty(),
|
||||
"kept {} unaddressable indices — the parse is still unbounded",
|
||||
set.len()
|
||||
);
|
||||
// The addressable ones are still kept.
|
||||
assert_eq!(com_indices(Some("0,2,4".to_string())).len(), 3);
|
||||
}
|
||||
|
||||
/// `forced_sub` is bounded too — the third CSV in the same function, and
|
||||
/// the one that had no value filter to hide behind.
|
||||
///
|
||||
/// Read through `forced_subs` rather than through the labels for the same
|
||||
/// reason the two tests above read the set: the subtitle loop stops at
|
||||
/// `MAX_COM_INDICES`, so a label-level assertion cannot tell a bounded
|
||||
/// parse from an unbounded one.
|
||||
#[test]
|
||||
fn forced_sub_cells_past_the_last_addressable_one_are_not_parsed() {
|
||||
let hostile = "0,".repeat(MAX_COM_INDICES + 50_000);
|
||||
let cells = forced_subs(Some(hostile));
|
||||
assert_eq!(
|
||||
cells.len(),
|
||||
MAX_COM_INDICES,
|
||||
"parsed {} cells — the forced_sub parse is still unbounded",
|
||||
cells.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Bounding it must not change what a legitimate playlist means: the
|
||||
/// cells that CAN address a stream still classify exactly as before.
|
||||
#[test]
|
||||
fn bounding_forced_sub_leaves_the_addressable_cells_alone() {
|
||||
let cells = forced_subs(Some("0,1,3,2".to_string()));
|
||||
assert_eq!(
|
||||
cells,
|
||||
vec![
|
||||
ForcedSub::None,
|
||||
ForcedSub::ContainsForcedSegments,
|
||||
ForcedSub::ForcedNarrative,
|
||||
ForcedSub::ForcedNarrative,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// An index that cannot address any cell is dropped rather than STORED.
|
||||
///
|
||||
/// Asserted through `com_indices`, not through the labels: the labelling
|
||||
/// loop never queries a cell position that high, so at the label level
|
||||
/// retaining the value is unobservable and the assertion could not fail.
|
||||
/// Reading the set is what makes the claim checkable.
|
||||
#[test]
|
||||
fn an_index_that_cannot_address_any_cell_is_not_retained() {
|
||||
let set = com_indices(Some(format!(
|
||||
"1,{},{}",
|
||||
MAX_COM_INDICES,
|
||||
MAX_COM_INDICES + 1
|
||||
)));
|
||||
assert_eq!(
|
||||
set.len(),
|
||||
1,
|
||||
"only the addressable index belongs in the set, got {set:?}"
|
||||
);
|
||||
assert!(set.contains(&1));
|
||||
}
|
||||
|
||||
/// Headroom: the BD STN_table admits at most 32 PG streams per playlist,
|
||||
|
||||
+58
-1
@@ -637,10 +637,18 @@ fn xml_escape(s: &str) -> String {
|
||||
}
|
||||
|
||||
/// Replace path-hostile characters in a filename component.
|
||||
///
|
||||
/// Control characters included, NUL above all. Every string this touches is
|
||||
/// disc bytes, and a language code is three raw STN bytes run through
|
||||
/// `from_utf8_lossy` with no validation — `00 00 00` is the ordinary
|
||||
/// "undefined" encoding on real Blu-rays. A NUL in a path aborts `File::create`
|
||||
/// with `InvalidInput`, which took the whole demux export down before a single
|
||||
/// track file was opened.
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
c if c.is_control() => '_',
|
||||
_ => c,
|
||||
})
|
||||
.collect()
|
||||
@@ -787,8 +795,11 @@ impl DemuxSink {
|
||||
Naming::Pid => format!("{} {:04x}", sanitize(&opts.base), pid),
|
||||
Naming::Friendly => {
|
||||
let mut parts = vec![sanitize(&opts.base), format!("t{idx:02}")];
|
||||
// The language is disc bytes too, and got none of the
|
||||
// treatment `opts.base` two lines up already had.
|
||||
let lang = sanitize(lang);
|
||||
if !lang.is_empty() {
|
||||
parts.push(lang.to_string());
|
||||
parts.push(lang);
|
||||
}
|
||||
parts.push(codec_label(codec).to_string());
|
||||
parts.join(" ")
|
||||
@@ -987,6 +998,52 @@ mod tests {
|
||||
Resolution, SampleRate, VideoStream,
|
||||
};
|
||||
|
||||
// ── The language component is disc bytes ──────────────────────────────
|
||||
//
|
||||
// `opts.base` was sanitised and the language beside it was not, though a
|
||||
// language code is three raw STN bytes run through `from_utf8_lossy` with
|
||||
// no validation. `00 00 00` is the ordinary "undefined" encoding on real
|
||||
// Blu-rays, and a NUL in a path fails `File::create` with `InvalidInput` —
|
||||
// which aborted the whole export before a single track file was opened.
|
||||
|
||||
/// The stem stays one usable filename component whatever the disc says.
|
||||
#[test]
|
||||
fn a_hostile_language_code_cannot_break_the_output_filename() {
|
||||
let opts = DemuxOptions {
|
||||
base: "Movie".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(opts.naming, Naming::Friendly), "default naming");
|
||||
|
||||
for lang in ["\u{0}\u{0}\u{0}", "a/b", "..", "a\nb", "e:s"] {
|
||||
let stem = DemuxSink::stem_for(&opts, 1, 0x1100, lang, Codec::Ac3);
|
||||
assert!(
|
||||
!stem.chars().any(|c| c.is_control()),
|
||||
"control character survived into the filename for {lang:?}: {stem:?}"
|
||||
);
|
||||
assert!(
|
||||
!stem.contains('/') && !stem.contains('\\') && !stem.contains(':'),
|
||||
"a path separator survived for {lang:?}: {stem:?}"
|
||||
);
|
||||
assert!(
|
||||
std::path::Path::new(&stem).components().count() == 1,
|
||||
"the stem must stay ONE component for {lang:?}: {stem:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A legitimate language is still carried through untouched — the
|
||||
/// sanitiser must not be so lossy that it stops naming the track.
|
||||
#[test]
|
||||
fn an_ordinary_language_code_survives_sanitising() {
|
||||
let opts = DemuxOptions {
|
||||
base: "Movie".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let stem = DemuxSink::stem_for(&opts, 1, 0x1100, "eng", Codec::Ac3);
|
||||
assert!(stem.contains("eng"), "got {stem:?}");
|
||||
}
|
||||
|
||||
fn video_stream(codec: Codec) -> DiscStream {
|
||||
DiscStream::Video(VideoStream {
|
||||
pid: 0x1011,
|
||||
|
||||
+3
-1
@@ -1448,7 +1448,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
// all tracks, A/V-sync-preserving) keeps the boundary from becoming a
|
||||
// band of non-monotonic block timestamps. Only the PRIMARY video track
|
||||
// drives the boundary decision; every other track (audio, subtitle, DV
|
||||
// EL) rides the current offset. No-op for single-clip titles.
|
||||
// EL) rides the current offset. A single-clip BD title still trims to
|
||||
// its one clip's [in, out] marks (dropping trailing audio the m2ts
|
||||
// carries past OUT); only a source with no marks at all is a true no-op.
|
||||
// `None` means the playlist does not include this frame — material
|
||||
// outside every clip's IN/OUT marks, which only a seam-plan-driven
|
||||
// title can report. Dropping it is the point: emitting it is what put
|
||||
|
||||
+65
-11
@@ -151,14 +151,25 @@ struct TrackPos {
|
||||
impl SeamPlan {
|
||||
/// Build a plan from a title's clips, or `None` when there is nothing to
|
||||
/// place: no clips (DVD, HD-DVD, `mkv://`/`m2ts://` sources — none of which
|
||||
/// carry PlayItem marks), a single clip (nothing to join), or marks that are
|
||||
/// not usable (a zero/inverted span means the playlist is not telling us
|
||||
/// anything we can act on, and guessing is what this type exists to avoid).
|
||||
/// carry PlayItem marks), or marks that are not usable (a zero/inverted span
|
||||
/// means the playlist is not telling us anything we can act on, and guessing
|
||||
/// is what this type exists to avoid).
|
||||
///
|
||||
/// A **single** clip still gets a plan. Joining is not the marks' only job —
|
||||
/// trimming to `[in, out]` is — and it matters whenever a clip's physical
|
||||
/// extent runs past its OUT mark. Real discs author trailing audio (a fade
|
||||
/// after the last video frame) beyond OUT in the m2ts; without a plan the
|
||||
/// inference path keeps it, leaving audio seconds past the declared duration.
|
||||
/// One clip needs no cross-clip placement, so the loop below reduces to the
|
||||
/// `[in, out]` drop filter plus the standard `offset = −in_ns` rebase — the
|
||||
/// same one clip 0 of a multi-clip title already gets, which the MKV muxer
|
||||
/// then re-anchors, so every KEPT frame is byte-identical to the no-plan
|
||||
/// path. The only change is that out-of-mark frames are now dropped.
|
||||
///
|
||||
/// Returning `None` leaves [`TimelineContinuity`] on its PTS-jump inference,
|
||||
/// which is what every non-BD path has always used.
|
||||
pub(crate) fn from_clips(clips: &[crate::disc::Clip]) -> Option<Self> {
|
||||
if clips.len() < 2 {
|
||||
if clips.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Trust the spans only if they tile the feed contiguously from 0.
|
||||
@@ -730,9 +741,10 @@ impl TimelineContinuity {
|
||||
|
||||
/// Corrector driven by a title's PlayItem marks where they exist.
|
||||
///
|
||||
/// Falls back to [`Self::new`]'s inference when the title has fewer than two
|
||||
/// clips or its marks are unusable — so DVD, HD-DVD, `mkv://` and `m2ts://`
|
||||
/// sources behave exactly as before.
|
||||
/// Falls back to [`Self::new`]'s inference when the title has no clips or
|
||||
/// its marks are unusable — so DVD, HD-DVD, `mkv://` and `m2ts://` sources
|
||||
/// behave exactly as before. A single BD clip DOES get a plan: its marks
|
||||
/// still trim trailing/pre-roll material outside `[in, out]`.
|
||||
pub(crate) fn with_clips(
|
||||
clips: &[crate::disc::Clip],
|
||||
content_format: crate::disc::ContentFormat,
|
||||
@@ -2226,10 +2238,6 @@ mod tests {
|
||||
#[test]
|
||||
fn no_seam_plan_without_usable_marks() {
|
||||
assert!(SeamPlan::from_clips(&[]).is_none(), "no clips");
|
||||
assert!(
|
||||
SeamPlan::from_clips(&seamless_branching_clips()[..1]).is_none(),
|
||||
"a single clip has nothing to join"
|
||||
);
|
||||
let mut bad = seamless_branching_clips();
|
||||
bad[3].out_time = bad[3].in_time; // zero-length span
|
||||
assert!(
|
||||
@@ -2238,6 +2246,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A SINGLE-clip title still gets a plan: joining is not the marks' only job,
|
||||
/// trimming to `[in, out]` is. Real discs author trailing audio (a fade after
|
||||
/// the last video frame) PAST the OUT mark in the m2ts; the no-plan inference
|
||||
/// path kept it, leaving audio seconds past the declared duration — the
|
||||
/// `The Bourne Supremacy` defect (audio +35.6 s past a single-clip title's
|
||||
/// end). The plan drops it, and every KEPT frame is placed exactly as the
|
||||
/// no-plan path would (raw rebased by `−in_ns`, which the MKV muxer already
|
||||
/// does), so a disc with no out-of-mark content is byte-identical.
|
||||
#[test]
|
||||
fn single_clip_trims_content_outside_its_marks() {
|
||||
let clips = seamless_branching_clips(); // clip 0: in 4199.0s, out 6033.04s
|
||||
let mut plan = SeamPlan::from_clips(&clips[..1]).expect("a single clip is planned");
|
||||
|
||||
let in_ns = mpls_ticks_to_ns(clips[0].in_time);
|
||||
let out_ns = mpls_ticks_to_ns(clips[0].out_time);
|
||||
|
||||
// In-mark frames are kept and rebased to 0 at IN — and drop nothing.
|
||||
assert_eq!(plan.place(in_ns, 0, true, None), Some(0), "IN rebases to 0");
|
||||
assert_eq!(
|
||||
plan.place(in_ns + 10 * S, 1, false, None),
|
||||
Some(10 * S),
|
||||
"a mid-clip audio frame keeps its offset from IN"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.place(out_ns, 2, false, None),
|
||||
Some(out_ns - in_ns),
|
||||
"a frame AT the OUT mark is inside and kept"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.dropped_total(),
|
||||
0,
|
||||
"no in-mark frame may be dropped (output is unchanged for a clean disc)"
|
||||
);
|
||||
|
||||
// Trailing audio past OUT is dropped AND counted, so the finish() gates
|
||||
// in the sinks can see the volume.
|
||||
assert!(
|
||||
plan.place(out_ns + 30 * S, 1, false, None).is_none(),
|
||||
"trailing audio 30s past OUT must be dropped, not emitted past the end"
|
||||
);
|
||||
assert!(
|
||||
plan.dropped_for(1) >= 1,
|
||||
"the dropped tail frame must be counted"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without a plan, `map` is exactly the old `adjust` and never drops.
|
||||
#[test]
|
||||
fn map_without_a_plan_is_the_old_behaviour() {
|
||||
|
||||
+18
-14
@@ -85,7 +85,7 @@ fn css_is_scrambled_detection() {
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_roundtrip() {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
let unit_key = [0xAAu8; 16];
|
||||
let aacs_iv: [u8; 16] = [
|
||||
@@ -113,8 +113,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
|
||||
// Step 1: AES-ECB encrypt header with unit key
|
||||
let cipher_header = Aes128::new(GenericArray::from_slice(&unit_key));
|
||||
let mut block = GenericArray::clone_from_slice(&header);
|
||||
let cipher_header = Aes128::new(&unit_key.into());
|
||||
let mut block: Array<u8, _> = header.into();
|
||||
cipher_header.encrypt_block(&mut block);
|
||||
let mut derived = [0u8; 16];
|
||||
derived.copy_from_slice(&block);
|
||||
@@ -126,7 +126,7 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
}
|
||||
|
||||
// Step 3: AES-CBC encrypt bytes 16..6144
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
||||
let cipher = Aes128::new(&encrypt_key.into());
|
||||
let mut prev = aacs_iv;
|
||||
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
@@ -134,7 +134,9 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
for j in 0..16 {
|
||||
plain[off + j] ^= prev[j];
|
||||
}
|
||||
let mut blk = GenericArray::clone_from_slice(&plain[off..off + 16]);
|
||||
let mut c_blk = [0u8; 16];
|
||||
c_blk.copy_from_slice(&plain[off..off + 16]);
|
||||
let mut blk: Array<u8, _> = c_blk.into();
|
||||
cipher.encrypt_block(&mut blk);
|
||||
plain[off..off + 16].copy_from_slice(&blk);
|
||||
prev.copy_from_slice(&plain[off..off + 16]);
|
||||
@@ -215,7 +217,7 @@ fn aacs_disc_hash_deterministic() {
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_key_roundtrip() {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
let vuk = [
|
||||
0x11u8, 0x14, 0x36, 0x0B, 0x10, 0xEE, 0x6E, 0xAC, 0x78, 0xAA, 0x4A, 0xC0, 0xB7, 0x52, 0xEA,
|
||||
@@ -227,8 +229,8 @@ fn aacs_decrypt_unit_key_roundtrip() {
|
||||
];
|
||||
|
||||
// Encrypt: AES-ECB encrypt the unit key with VUK
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&vuk));
|
||||
let mut block = GenericArray::clone_from_slice(&original_unit_key);
|
||||
let cipher = Aes128::new(&vuk.into());
|
||||
let mut block: Array<u8, _> = original_unit_key.into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut encrypted_uk = [0u8; 16];
|
||||
encrypted_uk.copy_from_slice(&block);
|
||||
@@ -340,9 +342,9 @@ fn aacs_clear_unit_reports_not_encrypted() {
|
||||
/// Independent AES-128-ECB encrypt (uses `aes` crate directly, NOT our library).
|
||||
fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
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);
|
||||
@@ -352,8 +354,8 @@ fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
/// Independent AES-128-CBC encrypt (uses `aes` crate directly, NOT our library).
|
||||
fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut prev = *iv;
|
||||
let num_blocks = data.len() / 16;
|
||||
for i in 0..num_blocks {
|
||||
@@ -361,7 +363,9 @@ fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
|
||||
for j in 0..16 {
|
||||
data[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&data[off..off + 16]);
|
||||
let mut c_block = [0u8; 16];
|
||||
c_block.copy_from_slice(&data[off..off + 16]);
|
||||
let mut block: Array<u8, _> = c_block.into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
data[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&data[off..off + 16]);
|
||||
|
||||
Reference in New Issue
Block a user