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 | ||
|
|
8d17904459 | ||
|
|
1bdb8add2f | ||
|
|
c9562c07a3 | ||
|
|
7f9dcf1870 | ||
|
|
3c5c681399 | ||
|
|
43d9fb583a | ||
|
|
60ed45c047 | ||
|
|
07cc9fa5f9 | ||
|
|
1e6bb8babd | ||
|
|
64f894c9f3 | ||
|
|
3820348d2f | ||
|
|
102ae82933 | ||
|
|
9d05f54ddb |
@@ -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
|
||||
+51
-21
@@ -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
|
||||
@@ -187,13 +213,17 @@ jobs:
|
||||
freemkv
|
||||
autorip
|
||||
bdemu
|
||||
- run: cargo check --all-targets
|
||||
# `cargo check` alone only proves the dependents still COMPILE against
|
||||
# this commit. It cannot see a behavioural change — the library keeps its
|
||||
# signatures and a dependent's tests start failing. That is the shape of
|
||||
# every defect worth catching here, so run their suites too.
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-keysources
|
||||
- run: cargo check --all-targets
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-engine
|
||||
- run: cargo check --all-targets
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv
|
||||
- run: cargo check --all-targets
|
||||
- run: cargo test --tests
|
||||
working-directory: autorip
|
||||
- run: cargo check --all-targets
|
||||
- run: cargo test --tests
|
||||
working-directory: bdemu
|
||||
|
||||
@@ -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,73 @@
|
||||
# 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
|
||||
|
||||
- **A stray moment of sound at the end of an HD-DVD title, and a click at every
|
||||
chapter break on a DVD.** Both came from the same thing. A disc's sound and
|
||||
picture do not arrive in lockstep, and where a title is stitched from
|
||||
segments, a few frames of sound can reach the muxer just before or just after
|
||||
the picture that marks the join. Those frames were timed against the wrong
|
||||
segment. On one HD-DVD title a single trailing sound frame was placed at
|
||||
3h33m in a 1h47m film. On a DVD, roughly half a second of sound was squeezed
|
||||
into an instant at each of eight chapter breaks — audible as a click, eight
|
||||
times in an eight-minute title. Sound is now timed against the segment it
|
||||
belongs to. Measured on both discs: the stray frame lands one frame after its
|
||||
neighbour, and the DVD's collapsed runs and jumps are gone, with picture
|
||||
timing unchanged to the millisecond. Blu-ray was never affected.
|
||||
|
||||
## [1.6.1] — 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
+6
-12
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.6.1"
|
||||
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.1" }
|
||||
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
|
||||
|
||||
@@ -695,6 +695,49 @@ mod tests {
|
||||
// ── `decrypt_sectors_in_content` (now a legacy alias of `decrypt_sectors`) ──
|
||||
|
||||
/// `DecryptKeys::None` is a no-op even with a content map + scrambled bytes.
|
||||
/// A forensic map's read plan is NOT the extents it was given — and that is
|
||||
/// the precondition the mux's provenance guard keys on.
|
||||
///
|
||||
/// A clip's feed span is measured over the FULL extents at scan time, while
|
||||
/// the mux reads this reduced plan, so the byte offsets stamped on frames
|
||||
/// and the offsets recorded in the spans describe different streams. The
|
||||
/// deficit accumulates, so every frame after the first segment resolves to
|
||||
/// an earlier clip than it came from. The spans still tile each other, so
|
||||
/// the tiling check cannot see it; the mux compares the plan against the
|
||||
/// full extents instead and stops trusting provenance when they differ.
|
||||
#[test]
|
||||
fn a_forensic_read_plan_drops_units_the_full_extents_include() {
|
||||
let full = vec![crate::disc::Extent {
|
||||
start_lba: 1000,
|
||||
sector_count: 60,
|
||||
}];
|
||||
|
||||
// No forensic segment: the plan IS the extents, byte for byte, so
|
||||
// provenance stays trustworthy on an ordinary disc.
|
||||
let plain = AacsKeyMap::from_ranges_phased(vec![(1000, 1060, 5, Phase::All)]);
|
||||
assert_eq!(
|
||||
plain.read_plan(&full, 3),
|
||||
full,
|
||||
"a non-forensic map must return the extents unchanged"
|
||||
);
|
||||
|
||||
// With alternate phases, units are omitted — fewer sectors are read
|
||||
// than the spans describe.
|
||||
let phased = AacsKeyMap::from_ranges_phased(vec![(1000, 1060, 5, Phase::Even)]);
|
||||
let plan = phased.read_plan(&full, 3);
|
||||
let planned: u32 = plan.iter().map(|e| e.sector_count).sum();
|
||||
let whole: u32 = full.iter().map(|e| e.sector_count).sum();
|
||||
assert!(
|
||||
planned < whole,
|
||||
"a forensic segment must drop units: planned {planned} of {whole}"
|
||||
);
|
||||
assert_ne!(
|
||||
plan, full,
|
||||
"the plan differs from the extents, which is exactly what the mux \
|
||||
detects before deciding whether a byte offset means anything"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_gate_none_keys_is_noop() {
|
||||
let mut keys = DecryptKeys::None;
|
||||
|
||||
@@ -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.
|
||||
|
||||
+46
-15
@@ -57,7 +57,24 @@ const MAX_CS0_NAME_BYTES: usize = 254;
|
||||
/// A directory File Entry's link count is `u16` and counts one per child
|
||||
/// directory plus one for its own entry in the parent, so the last usable
|
||||
/// value is `u16::MAX - 1`.
|
||||
/// Lowered under `cfg(test)` ONLY so the guard can actually be executed.
|
||||
/// Building a folder with 65534 subdirectories to reach the real cap is not a
|
||||
/// test anyone can run, so the previous test asserted arithmetic about the
|
||||
/// constant instead and would have passed with the guard deleted. The
|
||||
/// production value is unchanged.
|
||||
#[cfg(not(test))]
|
||||
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).
|
||||
///
|
||||
@@ -859,16 +876,6 @@ mod tests {
|
||||
assert!(!is_excluded("VTS_01_1.VOB"));
|
||||
}
|
||||
|
||||
/// A name too long for the FID's one-byte length field is refused by the
|
||||
/// PLANNER, on a real folder.
|
||||
///
|
||||
/// Audit finding: the length was narrowed with `as u8`, so a 255-byte ASCII
|
||||
/// name — POSIX NAME_MAX, legal on ext4/APFS/NTFS — encoded to 256 bytes
|
||||
/// with the CS0 compression byte and wrote a length of ZERO, making every
|
||||
/// later entry in that directory read from the wrong offset.
|
||||
///
|
||||
/// An earlier version of this test asserted arithmetic about the constants
|
||||
/// and never called `plan`, so it would have passed with the guard deleted.
|
||||
/// Two host names that the READER collapses into one must be refused by
|
||||
/// the planner, not written into the image.
|
||||
///
|
||||
@@ -912,6 +919,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A name too long for the FID's one-byte length field is refused by the
|
||||
/// PLANNER, on a real folder.
|
||||
///
|
||||
/// Audit finding: the length was narrowed with `as u8`, so a 255-byte ASCII
|
||||
/// name — POSIX NAME_MAX, legal on ext4/APFS/NTFS — encoded to 256 bytes
|
||||
/// with the CS0 compression byte and wrote a length of ZERO, making every
|
||||
/// later entry in that directory read from the wrong offset.
|
||||
///
|
||||
/// An earlier version of this test asserted arithmetic about the constants
|
||||
/// and never called `plan`, so it would have passed with the guard deleted.
|
||||
#[test]
|
||||
fn an_over_long_name_is_refused_by_the_planner() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
@@ -971,11 +988,25 @@ mod tests {
|
||||
/// so this pins the arithmetic relationship the guard relies on — and says
|
||||
/// plainly that it does NOT exercise `walk`.
|
||||
#[test]
|
||||
fn the_subdir_cap_keeps_the_link_count_representable() {
|
||||
assert_eq!(
|
||||
(MAX_SUBDIRS as u16).checked_add(1),
|
||||
Some(u16::MAX),
|
||||
"the largest permitted fan-out must still fit the link count"
|
||||
fn the_subdir_cap_refuses_a_folder_with_too_many_subdirectories() {
|
||||
// Executes the guard, rather than restating the constant. A directory's
|
||||
// File Entry records its link count in 16 bits — one per child
|
||||
// directory plus one for its own entry in the parent — so exceeding it
|
||||
// would wrap the count and produce an image whose directory structure
|
||||
// lies about itself.
|
||||
let dir = std::env::temp_dir().join(format!("fmkv-fanout-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for i in 0..=MAX_SUBDIRS {
|
||||
std::fs::create_dir_all(dir.join(format!("d{i}"))).unwrap();
|
||||
}
|
||||
|
||||
let err = plan(&dir).expect_err("more subdirectories than the link count can represent");
|
||||
assert!(
|
||||
matches!(err, Error::DirImageFanout { .. }),
|
||||
"expected DirImageFanout, got {err:?}"
|
||||
);
|
||||
|
||||
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.
|
||||
@@ -914,3 +991,111 @@ fn dump_title_sets_for_an_image() {
|
||||
println!(" vts={} titles={}", ts.vts_number, ts.titles.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an MPLS with N PlayItems, each naming its own clip.
|
||||
fn multi_item_mpls(clip_ids: &[&[u8; 5]]) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(b"MPLS0200");
|
||||
buf.extend_from_slice(&40u32.to_be_bytes());
|
||||
buf.extend_from_slice(&[0u8; 28]);
|
||||
|
||||
let pl = buf.len();
|
||||
buf.extend_from_slice(&[0u8; 4]);
|
||||
buf.extend_from_slice(&[0u8; 2]);
|
||||
buf.extend_from_slice(&(clip_ids.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(&[0u8; 2]);
|
||||
|
||||
for id in clip_ids {
|
||||
let mut item = Vec::new();
|
||||
item.extend_from_slice(*id);
|
||||
item.extend_from_slice(b"M2TS");
|
||||
item.push(0);
|
||||
item.extend_from_slice(&[0u8; 2]);
|
||||
item.extend_from_slice(&0u32.to_be_bytes());
|
||||
item.extend_from_slice(&(45_000u32 * 120).to_be_bytes());
|
||||
item.extend_from_slice(&[0u8; 8]);
|
||||
item.push(0);
|
||||
item.push(0);
|
||||
item.extend_from_slice(&[0u8; 2]);
|
||||
item.extend_from_slice(&16u16.to_be_bytes());
|
||||
item.extend_from_slice(&[0u8; 16]);
|
||||
buf.extend_from_slice(&(item.len() as u16).to_be_bytes());
|
||||
buf.extend_from_slice(&item);
|
||||
}
|
||||
|
||||
let pl_len = (buf.len() - pl - 4) as u32;
|
||||
buf[pl..pl + 4].copy_from_slice(&pl_len.to_be_bytes());
|
||||
let mark_start = buf.len() as u32;
|
||||
buf[12..16].copy_from_slice(&mark_start.to_be_bytes());
|
||||
buf.extend_from_slice(&2u32.to_be_bytes());
|
||||
buf.extend_from_slice(&0u16.to_be_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// `Clip::feed_span` is the INPUT to the whole provenance feature — it is what
|
||||
/// tells the muxer which clip a byte offset belongs to — and it is produced in
|
||||
/// exactly one place, `disc/bluray.rs`. Every `SeamPlan` test synthesizes spans
|
||||
/// by hand, so the consumer is thoroughly tested against fixtures written from
|
||||
/// the same assumptions as the producer, and nothing checks the producer at all.
|
||||
///
|
||||
/// That is the shape of the original defect: the placement logic was tested and
|
||||
/// correct, and the thing feeding it was wrong. `SeamPlan` only trusts spans
|
||||
/// that TILE the feed contiguously from zero, so this asserts exactly that,
|
||||
/// against the real scanner reading a real synthesized filesystem.
|
||||
#[test]
|
||||
fn a_multi_clip_playlist_produces_feed_spans_that_tile_the_feed() {
|
||||
let s = Scratch::new("spans");
|
||||
let packets = 4096u32;
|
||||
let ids: [&[u8; 5]; 3] = [b"00000", b"00001", b"00002"];
|
||||
let mut m2ts = vec![0x5Au8; packets as usize * 192];
|
||||
for p in m2ts.chunks_mut(192) {
|
||||
p[0] = 0x00;
|
||||
p[4] = 0x47;
|
||||
}
|
||||
s.file("BDMV/index.bdmv", &pattern(1, 64));
|
||||
s.file("BDMV/PLAYLIST/00000.mpls", &multi_item_mpls(&ids));
|
||||
for id in ids {
|
||||
let name = std::str::from_utf8(id).unwrap();
|
||||
s.file(&format!("BDMV/CLIPINF/{name}.clpi"), &minimal_clpi(packets));
|
||||
s.file(&format!("BDMV/STREAM/{name}.m2ts"), &m2ts);
|
||||
}
|
||||
|
||||
let (disc, _reader) =
|
||||
crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap();
|
||||
let title = disc.titles.first().expect("the playlist produces a title");
|
||||
assert_eq!(title.clips.len(), 3, "all three PlayItems are kept");
|
||||
|
||||
// Every clip must carry a span, or provenance is simply off for this title.
|
||||
let spans: Vec<(u64, u64)> = title
|
||||
.clips
|
||||
.iter()
|
||||
.map(|c| c.feed_span.expect("every clip carries a feed span"))
|
||||
.collect();
|
||||
|
||||
// They must tile from 0 with no gap and no overlap: `SeamPlan::from_clips`
|
||||
// refuses anything else, so a producer that drifts here silently disables
|
||||
// the feature rather than failing.
|
||||
let mut expect = 0u64;
|
||||
for (i, &(start, end)) in spans.iter().enumerate() {
|
||||
assert_eq!(
|
||||
start,
|
||||
expect,
|
||||
"clip {i} starts where clip {} ended",
|
||||
i.saturating_sub(1)
|
||||
);
|
||||
assert!(end > start, "clip {i} spans no bytes");
|
||||
expect = end;
|
||||
}
|
||||
|
||||
// And the tiling must describe the bytes the muxer will actually be fed:
|
||||
// the concatenation of every clip's extents.
|
||||
let feed_bytes: u64 = title
|
||||
.extents
|
||||
.iter()
|
||||
.map(|e| e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64)
|
||||
.sum();
|
||||
assert_eq!(
|
||||
expect, feed_bytes,
|
||||
"the spans must cover exactly the feed, or a byte offset lands in the wrong clip"
|
||||
);
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
+26
-9
@@ -479,6 +479,25 @@ pub struct Extent {
|
||||
/// Union a set of extents into sorted, merged, disjoint `(start_lba,
|
||||
/// sector_count)` ranges — the pure, testable core of
|
||||
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
|
||||
/// Is this disc structurally AACS-encrypted — i.e. does it carry an AACS
|
||||
/// directory?
|
||||
///
|
||||
/// THE ONE definition of that question. `Disc::identify` (the fast path, which
|
||||
/// only reads the filesystem) and `Disc::scan_with` (the full scan) both need
|
||||
/// it, and both used to spell the same two `find_dir` calls out by hand. They
|
||||
/// agreed today; nothing made them agree tomorrow. Adding a third AACS
|
||||
/// location, or excluding an empty placeholder directory, to one copy and not
|
||||
/// the other would silently desync the fast identify from the full scan — the
|
||||
/// same disc reported encrypted by one and clear by the other.
|
||||
///
|
||||
/// This is STRUCTURAL, not cryptographic: it says the tree looks like an
|
||||
/// encrypted disc, not that any sector actually is. A folder copied verbatim
|
||||
/// from a decrypted disc keeps its `AACS/` and answers true here (see
|
||||
/// `session::scan_dir`, which corrects for exactly that).
|
||||
pub(crate) fn aacs_dir_present(udf_fs: &crate::udf::UdfFs) -> bool {
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some()
|
||||
}
|
||||
|
||||
fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u32)> {
|
||||
let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect();
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
@@ -1628,8 +1647,7 @@ impl Disc {
|
||||
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
|
||||
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
|
||||
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
|
||||
let encrypted =
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
let encrypted = aacs_dir_present(&udf_fs);
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
|
||||
Ok(DiscId {
|
||||
@@ -2015,8 +2033,7 @@ impl Disc {
|
||||
let scan_with_t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
|
||||
// 2. Resolve encryption (AACS, CSS, or none)
|
||||
let encrypted =
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
let encrypted = aacs_dir_present(&udf_fs);
|
||||
|
||||
let (aacs, aacs_error) = if !encrypted {
|
||||
(None, None)
|
||||
@@ -5399,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
|
||||
@@ -5407,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 {
|
||||
@@ -5422,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];
|
||||
|
||||
@@ -62,6 +62,7 @@ pub const E_MAPFILE_INVALID: u16 = 6011;
|
||||
pub const E_SELECTION_PID_UNKNOWN: u16 = 6014;
|
||||
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
|
||||
pub const E_UDF_NOT_FILESYSTEM: u16 = 6013;
|
||||
pub const E_IMAGE_TRUNCATED: u16 = 6015;
|
||||
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
@@ -461,6 +462,14 @@ pub enum Error {
|
||||
MapfileInvalid {
|
||||
kind: &'static str,
|
||||
},
|
||||
/// The image a mapfile describes is shorter than the mapfile's own total
|
||||
/// size, so the two no longer agree about the same disc. Resuming against
|
||||
/// it would treat the absent tail as already recovered. `have` and `want`
|
||||
/// are byte lengths; a missing file reports `have == 0`.
|
||||
ImageTruncated {
|
||||
have: u64,
|
||||
want: u64,
|
||||
},
|
||||
|
||||
// AACS (7xxx)
|
||||
AacsNoKeys,
|
||||
@@ -859,6 +868,7 @@ impl Error {
|
||||
Error::NoStreams => E_NO_STREAMS,
|
||||
Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
|
||||
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
|
||||
Error::ImageTruncated { .. } => E_IMAGE_TRUNCATED,
|
||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
||||
@@ -1634,6 +1644,13 @@ mod tests {
|
||||
E_PLATFORM_NOT_IMPLEMENTED,
|
||||
),
|
||||
(Error::MapfileInvalid { kind: "hex" }, E_MAPFILE_INVALID),
|
||||
(
|
||||
Error::ImageTruncated {
|
||||
have: 0,
|
||||
want: 1024,
|
||||
},
|
||||
E_IMAGE_TRUNCATED,
|
||||
),
|
||||
(Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
|
||||
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
|
||||
// Both CSS no-key verdicts: numeric-only Display, no English.
|
||||
@@ -1820,6 +1837,7 @@ mod tests {
|
||||
E_NO_STREAMS,
|
||||
E_HALTED,
|
||||
E_MAPFILE_INVALID,
|
||||
E_IMAGE_TRUNCATED,
|
||||
E_UDF_BUFFER_TOO_SMALL,
|
||||
E_UDF_NOT_FILESYSTEM,
|
||||
E_AACS_NO_KEYS,
|
||||
@@ -1933,6 +1951,7 @@ mod tests {
|
||||
assert!((6000..7000).contains(&E_DISC_READ));
|
||||
assert!((6000..7000).contains(&E_HALTED));
|
||||
assert!((6000..7000).contains(&E_MAPFILE_INVALID));
|
||||
assert!((6000..7000).contains(&E_IMAGE_TRUNCATED));
|
||||
// AACS (7xxx)
|
||||
assert!((7000..8000).contains(&E_AACS_NO_KEYS));
|
||||
assert!((7000..8000).contains(&E_NO_DISC_KEY));
|
||||
|
||||
+128
@@ -2639,4 +2639,132 @@ mod tests {
|
||||
assert_eq!(map[&1], vec![(7u16, 1u8), (13, 2)]);
|
||||
assert_eq!(map[&2], vec![(0x0102u16, 1u8)]);
|
||||
}
|
||||
|
||||
// ── TT_SRPT: the title table (`parse_tt_srpt` / `parse_vmg`) ────────────
|
||||
//
|
||||
// This is the table that says which titles a DVD has, and it had no tests
|
||||
// at all until 1.6.2 — while being at the centre of both DVD stories in
|
||||
// 1.6.1. The CSS descramble bug destroyed the sector carrying it, so a disc
|
||||
// enumerating 38 titles produced an image enumerating 10, silently, at exit
|
||||
// 0. And `dir://` was pulled from the release on 2026-08-05 because a
|
||||
// folder produced the wrong title list — the same table, reached through a
|
||||
// synthesized image.
|
||||
//
|
||||
// Both were found on real discs. Nothing hermetic covered the table itself.
|
||||
|
||||
/// Build a VMG with a TT_SRPT at `tt_srpt_sector` declaring `entries`
|
||||
/// of `(num_chapters, vts_number, vts_title_num)`.
|
||||
fn vmg_with_tt_srpt(tt_srpt_sector: u32, entries: &[(u16, u8, u8)]) -> Vec<u8> {
|
||||
let off = tt_srpt_sector as usize * crate::consts::SECTOR_BYTES;
|
||||
let mut v = vec![0u8; off + 8 + entries.len() * 12 + 16];
|
||||
v[0..12].copy_from_slice(b"DVDVIDEO-VMG");
|
||||
v[0xC4..0xC8].copy_from_slice(&tt_srpt_sector.to_be_bytes());
|
||||
v[off..off + 2].copy_from_slice(&(entries.len() as u16).to_be_bytes());
|
||||
for (i, &(chapters, vts, title)) in entries.iter().enumerate() {
|
||||
let b = off + 8 + i * 12;
|
||||
v[b + 2..b + 4].copy_from_slice(&chapters.to_be_bytes());
|
||||
v[b + 6] = vts;
|
||||
v[b + 7] = title;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tt_srpt_groups_titles_by_their_title_set() {
|
||||
// Three titles: two in VTS 1, one in VTS 2 — the ordinary shape.
|
||||
let vmg = vmg_with_tt_srpt(1, &[(12, 1, 1), (5, 1, 2), (20, 2, 1)]);
|
||||
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
|
||||
assert_eq!(map.len(), 2, "two title sets");
|
||||
assert_eq!(
|
||||
map[&1],
|
||||
vec![(12, 1), (5, 2)],
|
||||
"VTS 1 keeps both titles in order"
|
||||
);
|
||||
assert_eq!(map[&2], vec![(20, 1)], "VTS 2 keeps its one");
|
||||
}
|
||||
|
||||
/// The pointer at 0xC4 is a SECTOR offset from the start of VIDEO_TS.IFO,
|
||||
/// not a byte offset. Reading it as bytes would find the table at 1/2048th
|
||||
/// of its real position — which is a zero-filled region on most discs, so
|
||||
/// it would report a disc with no titles rather than fail loudly.
|
||||
#[test]
|
||||
fn tt_srpt_pointer_is_a_sector_offset_not_a_byte_offset() {
|
||||
for sector in [1u32, 2, 5] {
|
||||
let vmg = vmg_with_tt_srpt(sector, &[(3, 1, 1)]);
|
||||
let at_sector = parse_tt_srpt(&vmg, sector as usize * crate::consts::SECTOR_BYTES)
|
||||
.expect("reading at the sector offset works");
|
||||
assert_eq!(at_sector[&1], vec![(3, 1)], "sector {sector}");
|
||||
// Reading the same pointer as a BYTE offset lands 1/2048th of the
|
||||
// way in — inside the header here — and yields a different table.
|
||||
// It does not error: it silently reports the wrong titles, which is
|
||||
// how 28 dropped titles looked like a scan difference rather than a
|
||||
// failure.
|
||||
let as_bytes = parse_tt_srpt(&vmg, sector as usize).expect("does not error");
|
||||
assert_ne!(
|
||||
as_bytes, at_sector,
|
||||
"sector {sector}: a byte-offset read must not coincidentally \
|
||||
agree with the correct one, or this test proves nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A VTS number of 0 is not a title set; entries carrying it are padding.
|
||||
#[test]
|
||||
fn tt_srpt_skips_entries_with_no_title_set() {
|
||||
let vmg = vmg_with_tt_srpt(1, &[(9, 0, 1), (4, 3, 1)]);
|
||||
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
|
||||
assert_eq!(map.len(), 1, "the vts=0 entry is dropped");
|
||||
assert_eq!(map[&3], vec![(4, 1)]);
|
||||
}
|
||||
|
||||
/// The same (title set, title) twice is one title, not two. Real discs
|
||||
/// carry duplicate TT_SRPT rows.
|
||||
#[test]
|
||||
fn tt_srpt_collapses_a_duplicate_title() {
|
||||
let vmg = vmg_with_tt_srpt(1, &[(7, 2, 1), (7, 2, 1), (8, 2, 2)]);
|
||||
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
|
||||
assert_eq!(
|
||||
map[&2],
|
||||
vec![(7, 1), (8, 2)],
|
||||
"the repeat is dropped, the distinct one kept"
|
||||
);
|
||||
}
|
||||
|
||||
/// A truncated table parses what is there rather than failing the whole
|
||||
/// disc — one unreadable row should not cost a user every other title.
|
||||
#[test]
|
||||
fn tt_srpt_truncated_keeps_the_entries_it_can_read() {
|
||||
let mut vmg = vmg_with_tt_srpt(1, &[(1, 1, 1), (2, 2, 1), (3, 3, 1)]);
|
||||
vmg.truncate(crate::consts::SECTOR_BYTES + 8 + 12 + 6); // 1 whole entry + a fragment
|
||||
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
|
||||
assert_eq!(map.len(), 1, "the complete entry survives");
|
||||
assert_eq!(map[&1], vec![(1, 1)]);
|
||||
}
|
||||
|
||||
/// The count is an untrusted u16 off the disc. DVD-Video caps a disc at 99
|
||||
/// titles, and each entry re-parses a PGC into a full DvdTitle — so an
|
||||
/// unclamped 65535 is a ~540 MB allocation from a ~800 KB crafted file.
|
||||
#[test]
|
||||
fn tt_srpt_clamps_an_absurd_declared_title_count() {
|
||||
// The fixture must actually CONTAIN more entries than the cap, or the
|
||||
// walk stops when the buffer runs out and the clamp is never what
|
||||
// bounded it. An earlier version of this test declared u16::MAX over a
|
||||
// two-kilobyte fixture: the loop broke on `base + 12 > len` after three
|
||||
// iterations and the assertion held with the clamp deleted entirely.
|
||||
const PRESENT: usize = MAX_TT_SRPT_TITLES + 40;
|
||||
let entries: Vec<(u16, u8, u8)> = (0..PRESENT)
|
||||
.map(|i| (1u16, 1u8, (i % 250 + 1) as u8))
|
||||
.collect();
|
||||
let mut vmg = vmg_with_tt_srpt(1, &entries);
|
||||
let off = crate::consts::SECTOR_BYTES;
|
||||
vmg[off..off + 2].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
|
||||
let map = parse_tt_srpt(&vmg, off).expect("parse");
|
||||
let total: usize = map.values().map(|v| v.len()).sum();
|
||||
assert!(
|
||||
total <= MAX_TT_SRPT_TITLES,
|
||||
"a declared count of u16::MAX over {PRESENT} real entries must be \
|
||||
clamped to {MAX_TT_SRPT_TITLES}, got {total}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-14
@@ -325,20 +325,21 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
///
|
||||
/// The thread is named `freemkv-pipeline-consumer` so it shows up
|
||||
/// distinctly in stack traces and `top -H`. Callers that want a
|
||||
/// more specific name (e.g. `freemkv-sweep-consumer`) should use
|
||||
/// [`Pipeline::spawn_named`] instead. Returns an `Error::IoError`
|
||||
/// if the OS refuses the thread spawn (resource exhaustion);
|
||||
/// callers already operate in fallible context, so this is
|
||||
/// propagated rather than panicked.
|
||||
/// more specific name should use [`Pipeline::spawn_named`] instead.
|
||||
/// Returns an `Error::IoError` if the OS refuses the thread spawn
|
||||
/// (resource exhaustion); callers already operate in fallible context, so
|
||||
/// this is propagated rather than panicked.
|
||||
///
|
||||
/// Sweep uses [`Pipeline::spawn_named`] directly so the consumer
|
||||
/// thread shows up as `freemkv-sweep-consumer`; mux uses
|
||||
/// `freemkv-mux-consumer`. `Pipeline::spawn` (this function, with
|
||||
/// the default name) is used only by the unit tests in this module.
|
||||
/// It used to name `disc::patch` as a caller; that module does not exist
|
||||
/// here any more — the sweep/patch recovery passes moved to freemkv-engine
|
||||
/// in 1.6.0, so the comment sent readers hunting a caller in this crate
|
||||
/// that had already left it.
|
||||
/// Inside this crate the only [`Pipeline::spawn_named`] caller is the mux
|
||||
/// driver, which names its thread `freemkv-mux-consumer`. `Pipeline::spawn`
|
||||
/// (this function, with the default name) is used only by the unit tests in
|
||||
/// this module.
|
||||
///
|
||||
/// This paragraph twice named a caller that had left the crate: first
|
||||
/// `disc::patch`, then Sweep and its `freemkv-sweep-consumer` thread. Both
|
||||
/// went to freemkv-engine with the recovery passes in 1.6.0, and each in
|
||||
/// turn sent readers hunting a component that is not here. Name callers
|
||||
/// that live in THIS crate, or none.
|
||||
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
|
||||
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
|
||||
}
|
||||
@@ -346,7 +347,7 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Like [`Pipeline::spawn`] but lets the caller supply the
|
||||
/// consumer thread's name. Useful when several pipelines run in
|
||||
/// the same process and stack traces / `top -H` need to tell them
|
||||
/// apart (e.g. `freemkv-sweep-consumer`, `freemkv-mux-consumer`).
|
||||
/// apart (e.g. `freemkv-mux-consumer`).
|
||||
pub fn spawn_named<S: Sink<I, Output = R>>(
|
||||
name: &str,
|
||||
depth: usize,
|
||||
@@ -631,6 +632,14 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// wedged inside an unkillable syscall, the producer can still
|
||||
/// observe `/api/stop` and unwind within
|
||||
/// [`SEND_HALT_CHECK_INTERVAL`].
|
||||
/// NOT a `foo_with_X` variant of [`Pipeline::send`], despite the name.
|
||||
/// The two encode OPPOSITE policies on the same event, each with its own
|
||||
/// test: after the consumer's `apply` has failed, `send` still succeeds
|
||||
/// (the consumer keeps draining, so the channel accepts the item), while
|
||||
/// this one hands the item straight back — so a producer does not read an
|
||||
/// hour of disc for a write that died on the first frame. Collapsing them
|
||||
/// into one Option-parameterised method deletes one of those behaviours;
|
||||
/// it was tried and `apply_error_drains_then_propagates` caught it.
|
||||
pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> {
|
||||
use crossbeam_channel::SendTimeoutError;
|
||||
let end = Instant::now() + deadline;
|
||||
@@ -742,6 +751,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Plain [`Pipeline::finish`] is preserved for callers without a
|
||||
/// halt-token plumbed through; that path still blocks indefinitely
|
||||
/// on `join()`, matching pre-0.20.8 behaviour.
|
||||
/// Also not a `foo_with_X` variant: [`Pipeline::finish`] joins and waits
|
||||
/// however long the consumer needs, while this one gives up after
|
||||
/// `JOIN_TIMEOUT_SECS` and reports halted. Which is right depends on
|
||||
/// whether the caller has a user waiting to cancel — the mux driver does
|
||||
/// and uses this; the unit tests do not and use the plain join. Merging
|
||||
/// them means picking one of those policies for both.
|
||||
pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result<R, Error> {
|
||||
let Pipeline {
|
||||
tx,
|
||||
|
||||
+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,
|
||||
|
||||
+84
-1
@@ -67,6 +67,13 @@ pub struct Ac3Parser {
|
||||
/// began in an earlier packet takes THAT packet's source offset, not the
|
||||
/// one that happened to complete it.
|
||||
acc: super::pesbuf::PesBuf,
|
||||
/// Reused working copy of `acc`, kept across calls so the per-PES scan does
|
||||
/// not allocate. `parse` runs once per PES packet on an audio track — of
|
||||
/// the order of 10^5 times per title — and previously built a fresh `Vec`
|
||||
/// each time. The copy itself is unavoidable without restructuring the
|
||||
/// borrow relationship (the scanner needs `&mut self.tally` while it reads
|
||||
/// these bytes); the ALLOCATION is not.
|
||||
scratch: Vec<u8>,
|
||||
/// PTS (ns) to stamp on the frame that begins the carry-over `buf` — i.e.
|
||||
/// the running per-frame PTS at the point the partial tail was retained.
|
||||
/// Used by `flush()` to time the final buffered frame at EOS.
|
||||
@@ -95,6 +102,7 @@ impl Default for Ac3Parser {
|
||||
impl Ac3Parser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
scratch: Vec::new(),
|
||||
acc: super::pesbuf::PesBuf::with_capacity(4096),
|
||||
flush_pts_ns: 0,
|
||||
tally: super::dropgate::DropTally::new("ac3"),
|
||||
@@ -500,7 +508,12 @@ impl CodecParser for Ac3Parser {
|
||||
// Copy the working bytes out so the scanner can borrow `self.tally`;
|
||||
// the buffer keeps its marks, so the unconsumed tail stays attributed
|
||||
// to the packet that carried it.
|
||||
let buf = self.acc.as_slice().to_vec();
|
||||
// Take the scratch OUT of `self` so the scanner can borrow `self.tally`
|
||||
// while reading it; it is put back at the end of the call, keeping its
|
||||
// capacity for the next PES. There is no early return after this point.
|
||||
let mut buf = std::mem::take(&mut self.scratch);
|
||||
buf.clear();
|
||||
buf.extend_from_slice(self.acc.as_slice());
|
||||
let marks = self.acc.marks_snapshot();
|
||||
let data = &buf;
|
||||
let (frames, keep_from, frame_pts_ns) =
|
||||
@@ -541,6 +554,9 @@ impl CodecParser for Ac3Parser {
|
||||
self.flush_pts_ns = frame_pts_ns;
|
||||
}
|
||||
|
||||
// Hand the working buffer back so the next PES reuses its capacity.
|
||||
// `data` borrowed it; that borrow ends here, at its last use.
|
||||
self.scratch = buf;
|
||||
frames
|
||||
}
|
||||
|
||||
@@ -807,6 +823,73 @@ mod tests {
|
||||
frame[n - 1] = (c & 0xFF) as u8;
|
||||
}
|
||||
|
||||
/// The per-PES working buffer must be REUSED, not reallocated.
|
||||
///
|
||||
/// `parse` runs once per PES packet — of the order of 10^5 times on a
|
||||
/// feature's audio track — and used to build a fresh `Vec` every call. The
|
||||
/// copy itself cannot go without restructuring the borrow relationship, but
|
||||
/// the allocation can: the buffer is taken out of `self`, filled, and put
|
||||
/// back with its capacity intact.
|
||||
///
|
||||
/// Asserting on capacity is deliberately white-box, because that is exactly
|
||||
/// what regresses if someone reverts to `to_vec()` — the behaviour would be
|
||||
/// identical and no other test would notice.
|
||||
#[test]
|
||||
fn the_working_buffer_is_reused_across_packets_not_reallocated() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame = make_ac3_frame(0, 0);
|
||||
|
||||
parser.parse(&PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: Some(90_000),
|
||||
dts: None,
|
||||
data: frame.clone(),
|
||||
discontinuity: false,
|
||||
});
|
||||
let cap_after_first = parser.scratch.capacity();
|
||||
assert!(
|
||||
cap_after_first > 0,
|
||||
"the buffer must be handed back to the parser, not dropped"
|
||||
);
|
||||
|
||||
// The discriminator: a LARGE packet, then a small one. A reused buffer
|
||||
// keeps the large capacity; a fresh `Vec` sized to each packet drops
|
||||
// back to the small one. Feeding equal-sized packets cannot tell those
|
||||
// apart — the first version of this test did exactly that and a
|
||||
// to_vec()-per-call implementation passed it.
|
||||
let big = [frame.clone(), frame.clone(), frame.clone(), frame.clone()].concat();
|
||||
parser.parse(&PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: big.clone(),
|
||||
discontinuity: false,
|
||||
});
|
||||
let cap_after_big = parser.scratch.capacity();
|
||||
assert!(
|
||||
cap_after_big >= big.len(),
|
||||
"the buffer must have grown to hold the large packet"
|
||||
);
|
||||
|
||||
parser.parse(&PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: frame.clone(),
|
||||
discontinuity: false,
|
||||
});
|
||||
assert_eq!(
|
||||
parser.scratch.capacity(),
|
||||
cap_after_big,
|
||||
"after a small packet the buffer must STILL hold the large \
|
||||
capacity — a fresh allocation per call would have shrunk to the \
|
||||
small packet's size"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
+54
-1
@@ -50,7 +50,15 @@ fn adts_verdict(data: &[u8]) -> AdtsVerdict {
|
||||
// aac_frame_length: 13 bits = byte3[1:0] | byte4 | byte5[7:5].
|
||||
let frame_length =
|
||||
((u32::from(data[3]) & 0x03) << 11) | (u32::from(data[4]) << 3) | (u32::from(data[5]) >> 5);
|
||||
if frame_length < 7 {
|
||||
// The floor is the header the frame SAYS it carries, not a constant.
|
||||
// protection_absent (byte1 bit0) clear means a 16-bit crc_check follows the
|
||||
// 7-byte fixed+variable header, so the frame cannot be shorter than 9 —
|
||||
// aac_frame_length counts the header and the CRC, not just the payload.
|
||||
// Comparing against a flat 7 let a CRC-present frame declaring 7 or 8
|
||||
// through as structurally Valid, and the muxer then carried a frame whose
|
||||
// own header says it is impossible.
|
||||
let header_bytes = if data[1] & 0x01 == 0 { 9 } else { 7 };
|
||||
if frame_length < header_bytes {
|
||||
return AdtsVerdict::Invalid;
|
||||
}
|
||||
AdtsVerdict::Valid
|
||||
@@ -152,6 +160,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A header that CLAIMS a CRC (protection_absent = 0) but declares a
|
||||
/// frame length too short to contain one.
|
||||
///
|
||||
/// `aac_frame_length` counts the header and the CRC, not just the payload,
|
||||
/// so with a CRC present the smallest structurally possible frame is 9
|
||||
/// bytes: the 7-byte fixed+variable header plus the 16-bit crc_check.
|
||||
/// The gate compared against a flat 7 and never read protection_absent at
|
||||
/// all, so a frame whose own header says it is impossible was classified
|
||||
/// Valid and forwarded to the muxer.
|
||||
#[test]
|
||||
fn a_crc_present_header_shorter_than_its_own_crc_is_invalid() {
|
||||
for declared in [7u32, 8] {
|
||||
let mut f = adts_frame(16);
|
||||
f[1] = 0xF0; // sync + MPEG-4, protection_absent = 0 => CRC present
|
||||
f[3] = (f[3] & 0xFC) | ((declared >> 11) & 0x03) as u8;
|
||||
f[4] = ((declared >> 3) & 0xFF) as u8;
|
||||
f[5] = (f[5] & 0x1F) | ((declared & 0x07) << 5) as u8;
|
||||
assert!(
|
||||
matches!(adts_verdict(&f), AdtsVerdict::Invalid),
|
||||
"protection_absent=0 declaring {declared} bytes cannot hold its \
|
||||
own 7-byte header plus a 2-byte CRC"
|
||||
);
|
||||
}
|
||||
|
||||
// 9 is the smallest length that CAN hold header + CRC, so it must pass
|
||||
// the structural gate — the floor moved, it did not become stricter
|
||||
// than the spec.
|
||||
let mut ok = adts_frame(16);
|
||||
ok[1] = 0xF0;
|
||||
let nine = 9u32;
|
||||
ok[3] = (ok[3] & 0xFC) | ((nine >> 11) & 0x03) as u8;
|
||||
ok[4] = ((nine >> 3) & 0xFF) as u8;
|
||||
ok[5] = (ok[5] & 0x1F) | ((nine & 0x07) << 5) as u8;
|
||||
assert!(matches!(adts_verdict(&ok), AdtsVerdict::Valid));
|
||||
|
||||
// And with NO CRC the floor is still 7, unchanged.
|
||||
let mut no_crc = adts_frame(16);
|
||||
no_crc[1] = 0xF1; // protection_absent = 1
|
||||
let seven = 7u32;
|
||||
no_crc[3] = (no_crc[3] & 0xFC) | ((seven >> 11) & 0x03) as u8;
|
||||
no_crc[4] = ((seven >> 3) & 0xFF) as u8;
|
||||
no_crc[5] = (no_crc[5] & 0x1F) | ((seven & 0x07) << 5) as u8;
|
||||
assert!(matches!(adts_verdict(&no_crc), AdtsVerdict::Valid));
|
||||
}
|
||||
|
||||
/// A valid ADTS header (AAC-LC, 44.1 kHz, stereo) + payload, with
|
||||
/// aac_frame_length set to the total size.
|
||||
fn adts_frame(payload: usize) -> Vec<u8> {
|
||||
|
||||
@@ -717,4 +717,22 @@ mod tests {
|
||||
let result = format_palette(&[[0x00, 16, 128, 128]], 0, 0);
|
||||
assert_eq!(String::from_utf8(result).unwrap(), "palette: 101010\n");
|
||||
}
|
||||
|
||||
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
||||
/// cannot see a parser that writes `source: facts.source` where the facts
|
||||
/// carry no offset. Only a runtime check proves an emitted frame really
|
||||
/// carries the byte it was read from, and without it a multi-clip title
|
||||
/// places this track by timestamp inference instead of by byte.
|
||||
#[test]
|
||||
fn an_emitted_frame_carries_the_packets_source_offset() {
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
let mut pes = make_pes(
|
||||
vec![0x00, 0x0A, 0x00, 0x08, 0x01, 0xFF, 0x02, 0x03, 0x04, 0x05],
|
||||
Some(90_000),
|
||||
);
|
||||
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(!frames.is_empty(), "the segment is emitted");
|
||||
assert_eq!(frames[0].source.map(|s| s.byte), Some(7_777));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,4 +271,19 @@ mod tests {
|
||||
);
|
||||
assert_eq!(emitted.len() + tail.len(), 2);
|
||||
}
|
||||
|
||||
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
||||
/// cannot see a parser that writes `source: facts.source` where the facts
|
||||
/// carry no offset. Only a runtime check proves an emitted frame really
|
||||
/// carries the byte it was read from, and without it a multi-clip title
|
||||
/// places this track by timestamp inference instead of by byte.
|
||||
#[test]
|
||||
fn an_emitted_frame_carries_the_packets_source_offset() {
|
||||
let mut p = FlacParser::new();
|
||||
let mut pes = make_pes(make_flac_frame(100), Some(90_000));
|
||||
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
||||
let f = p.parse(&pes);
|
||||
assert!(!f.is_empty(), "the frame is emitted");
|
||||
assert_eq!(f[0].source.map(|s| s.byte), Some(7_777));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,4 +279,21 @@ mod tests {
|
||||
let f = parser.parse(&make_pes(vec![0xAA, 0xBB], None));
|
||||
assert_eq!(f[0].pts_ns, 0);
|
||||
}
|
||||
|
||||
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
||||
/// cannot see a parser that writes `source: facts.source` where the facts
|
||||
/// carry no offset. Only a runtime check proves an emitted frame really
|
||||
/// carries the byte it was read from, and without it a multi-clip title
|
||||
/// places this track by timestamp inference instead of by byte.
|
||||
#[test]
|
||||
fn an_emitted_frame_carries_the_packets_source_offset() {
|
||||
let mut parser = LpcmParser::new();
|
||||
let mut data = vec![0x00, 0x01, 0x00, 0b1001_0001];
|
||||
data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE]);
|
||||
let mut pes = make_pes(data, Some(90_000));
|
||||
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(!frames.is_empty(), "the frame is emitted");
|
||||
assert_eq!(frames[0].source.map(|s| s.byte), Some(7_777));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -465,9 +465,23 @@ mod provenance_guard {
|
||||
// `PesFrame` in the tests of other modules is not ours; only
|
||||
// codec `Frame` literals are scanned, and a test fixture that
|
||||
// builds a PesPacket with `source: None` is legitimate.
|
||||
if blk.contains("source: None") {
|
||||
// Two spellings, not one. `source: None` is the obvious way to
|
||||
// lose provenance; OMITTING the field entirely is the quiet
|
||||
// one, because `Frame` derives Default, so
|
||||
// `Frame { pts_ns, .. Default::default() }` compiles and
|
||||
// yields `source: None` while containing no such text. A guard
|
||||
// that only knew the first spelling would have watched a
|
||||
// parser be rewritten into the second and stayed green.
|
||||
let explicit_none = blk.contains("source: None");
|
||||
let no_source_field = !blk.contains("source:");
|
||||
if explicit_none || no_source_field {
|
||||
let line = src[..src.find(blk).unwrap_or(0)].lines().count() + 1;
|
||||
offenders.push(format!("{name}:{line}"));
|
||||
let how = if explicit_none {
|
||||
"source: None"
|
||||
} else {
|
||||
"no source field (Default fills in None)"
|
||||
};
|
||||
offenders.push(format!("{name}:{line} ({how})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,4 +319,19 @@ mod tests {
|
||||
// a manufactured tail frame would break this even if it were non-empty.
|
||||
assert_eq!(emitted.len() + tail.len(), 2);
|
||||
}
|
||||
|
||||
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
||||
/// cannot see a parser that writes `source: facts.source` where the facts
|
||||
/// carry no offset. Only a runtime check proves an emitted frame really
|
||||
/// carries the byte it was read from, and without it a multi-clip title
|
||||
/// places this track by timestamp inference instead of by byte.
|
||||
#[test]
|
||||
fn an_emitted_frame_carries_the_packets_source_offset() {
|
||||
let mut p = MpegAudioParser::new();
|
||||
let mut pes = make_pes(mp3_frame(400), Some(90_000));
|
||||
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
||||
let f = p.parse(&pes);
|
||||
assert!(!f.is_empty(), "the frame is emitted");
|
||||
assert_eq!(f[0].source.map(|s| s.byte), Some(7_777));
|
||||
}
|
||||
}
|
||||
|
||||
+63
-6
@@ -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,
|
||||
@@ -1543,14 +1600,14 @@ mod tests {
|
||||
fn timeline_track0_drives_epoch_others_ride() {
|
||||
let mut tl = TimelineContinuity::new();
|
||||
// Clip 1: video 0..10s (track 0 drives the epoch).
|
||||
assert_eq!(tl.adjust(0, true), 0);
|
||||
assert_eq!(tl.adjust(0, false), 0); // audio rides the same offset
|
||||
assert_eq!(tl.adjust(10_000_000_000, true), 10_000_000_000);
|
||||
assert_eq!(tl.adjust(0, true, 0), 0);
|
||||
assert_eq!(tl.adjust(0, false, 1), 0); // audio rides the same offset
|
||||
assert_eq!(tl.adjust(10_000_000_000, true, 0), 10_000_000_000);
|
||||
// Clip 2 seam: video PTS jumps back to ~0 (> 3s back) → new epoch.
|
||||
let out = tl.adjust(0, true);
|
||||
let out = tl.adjust(0, true, 0);
|
||||
assert!(out >= 10_000_000_000, "epoch must advance past prev high");
|
||||
// Audio in clip 2 (non-epoch) gets the SAME offset (A/V sync preserved).
|
||||
let a = tl.adjust(0, false);
|
||||
let a = tl.adjust(0, false, 1);
|
||||
assert_eq!(a, out);
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -1368,6 +1368,14 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// Block and the dependent (right-eye) access unit rides as the
|
||||
/// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a
|
||||
/// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not
|
||||
/// TEST-ONLY. Every production path writes through [`MkvMuxer::write_frame_at`]
|
||||
/// and passes the byte offset the frame was read from; the deferred-mux
|
||||
/// replay of buffered frames was the last one that did not, and it now
|
||||
/// does. Keeping this compiled out of the library means there is no
|
||||
/// production API through which a frame can reach the file with its
|
||||
/// provenance discarded — the placement logic stopped relying on
|
||||
/// timestamps precisely so that could not happen silently.
|
||||
#[cfg(test)]
|
||||
/// a keyframe. `None` for every non-3D frame.
|
||||
pub fn write_frame(
|
||||
&mut self,
|
||||
@@ -1440,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
|
||||
|
||||
@@ -512,13 +512,21 @@ impl MkvStream {
|
||||
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
|
||||
}
|
||||
for (f, additional) in pending.buffered.drain(..) {
|
||||
muxer.write_frame(
|
||||
// Provenance must survive the replay. These frames were buffered
|
||||
// before the muxer existed, but each still carries the byte offset
|
||||
// it was read from — dropping it here sent them down the timestamp
|
||||
// heuristic instead, the one path the rest of this change set
|
||||
// exists to stop relying on. They sit at the head of the title, so
|
||||
// clip 0 is usually the right guess and the damage was bounded;
|
||||
// "usually right" is exactly what provenance replaced.
|
||||
muxer.write_frame_at(
|
||||
f.track,
|
||||
f.pts,
|
||||
f.keyframe,
|
||||
&f.data,
|
||||
f.duration_ns,
|
||||
additional.as_deref(),
|
||||
f.source.map(|s| s.byte),
|
||||
)?;
|
||||
}
|
||||
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
|
||||
|
||||
@@ -2180,10 +2180,25 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
// decrypted, or handed to the demux, so the demux sees one gapless our-variant
|
||||
// stream (no ciphertext to trip a concealed-gap resync). A non-forensic map
|
||||
// returns the extents unchanged, so the common disc reads exactly as before.
|
||||
let full_extents = extents.clone();
|
||||
let extents = match &key_map {
|
||||
Some(map) => map.read_plan(&extents, unit_align as u32),
|
||||
None => extents,
|
||||
};
|
||||
// The plan and the clips' feed spans must describe the SAME bytes.
|
||||
//
|
||||
// A clip's span was measured over the title's full extents at scan time,
|
||||
// and a frame is placed by the offset it was read from. When a forensic
|
||||
// segment makes the plan drop alternate-phase units, the mux feeds fewer
|
||||
// bytes than the spans describe and the two drift apart cumulatively —
|
||||
// every frame after the first segment looks earlier than it is, and near a
|
||||
// join it is placed in the wrong clip or dropped. The spans still tile each
|
||||
// other perfectly, so the trust check cannot see it.
|
||||
//
|
||||
// Provenance is only meaningful when the feed matches. When it does not,
|
||||
// say so and let placement fall back to timestamps, which is what the
|
||||
// untrusted path exists for.
|
||||
let feed_matches_spans = extents == full_extents;
|
||||
let mut decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||
if let Some(map) = key_map {
|
||||
@@ -2202,6 +2217,17 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
// correct `0x8x` sub-streams. No-op for non-DVD or an empty probe. Reset the
|
||||
// unit base afterward so the prefetcher's first batch starts clean.
|
||||
let mut title = title;
|
||||
if !feed_matches_spans {
|
||||
tracing::info!(
|
||||
target: "freemkv::mux",
|
||||
planned = extents.len(),
|
||||
full = full_extents.len(),
|
||||
"read plan omits units the clip spans include; placing by timestamps"
|
||||
);
|
||||
for c in &mut title.clips {
|
||||
c.feed_span = None;
|
||||
}
|
||||
}
|
||||
crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title);
|
||||
decrypting.set_unit_base(0);
|
||||
|
||||
|
||||
+482
-37
@@ -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.
|
||||
@@ -341,6 +352,27 @@ impl SeamPlan {
|
||||
Some(first)
|
||||
}
|
||||
|
||||
/// Pick the member of a shared-span run whose marks contain `raw_ns`.
|
||||
///
|
||||
/// `clip_at_byte` deliberately answers with the FIRST entry of a run so the
|
||||
/// lookup is stable. That is the right answer when the run is one clip; when
|
||||
/// several PlayItems reference one file it is only the right STARTING point,
|
||||
/// because they differ solely in their marks. Falls back to the first when
|
||||
/// none contains the timestamp, which keeps the existing drop behaviour for
|
||||
/// material genuinely outside every reference.
|
||||
fn clip_in_run_for(&self, first: usize, raw_ns: i64) -> usize {
|
||||
let span = self.clips[first].feed_span;
|
||||
let mut i = first;
|
||||
while i < self.clips.len() && self.clips[i].feed_span == span {
|
||||
let c = self.clips[i];
|
||||
if raw_ns >= c.in_ns && raw_ns <= c.out_ns {
|
||||
return i;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// `None` means DROP: the frame lies outside every clip's marks, so the
|
||||
/// playlist does not include it.
|
||||
fn place(
|
||||
@@ -400,6 +432,18 @@ impl SeamPlan {
|
||||
&& let Some(b) = src_byte
|
||||
&& let Some(found) = self.clip_at_byte(b)
|
||||
{
|
||||
// One FILE can be referenced by several PlayItems — a seamless
|
||||
// split, a looped segment, multi-angle — and `bluray.rs` counts its
|
||||
// extents once, so they all share one span. Provenance therefore
|
||||
// narrows the frame to the RUN, not to a member of it: every frame
|
||||
// of the file resolves to the same first index.
|
||||
//
|
||||
// Only the timestamp can say which reference a frame belongs to,
|
||||
// and each carries its own marks. Without this, everything past the
|
||||
// first PlayItem's OUT was judged against marks it was never inside
|
||||
// and dropped — half the file missing from the rip while the
|
||||
// timeline still charged for its duration.
|
||||
let found = self.clip_in_run_for(found, raw_ns);
|
||||
let c = self.clips[found];
|
||||
let placed = raw_ns >= c.in_ns && raw_ns <= c.out_ns;
|
||||
self.cursors[track] = TrackPos {
|
||||
@@ -649,11 +693,45 @@ pub(crate) struct TimelineContinuity {
|
||||
/// marks are known and are used verbatim; absent = fall back to inferring
|
||||
/// seams from PTS jumps, which is all any non-BD source has ever had.
|
||||
pub(crate) seams: Option<SeamPlan>,
|
||||
/// Every epoch already left behind, oldest first, as
|
||||
/// `(offset, frontier when it closed)`. A straggler carries a raw PTS from
|
||||
/// one of THESE, and a single `prev_offset_ns` cannot name the right one
|
||||
/// once a title has more than two epochs. The closing frontier is what makes
|
||||
/// the test meaningful: a straggler sits at the TAIL of its own epoch, which
|
||||
/// is only recognisable against that epoch's end — not against the current
|
||||
/// frontier, which by then may be a whole title away. Bounded: a source that
|
||||
/// rebases forever must not grow this.
|
||||
epoch_offsets: Vec<(i64, i64)>,
|
||||
/// Last raw PTS seen per track, for spotting a track's OWN discontinuity.
|
||||
/// Within an epoch a passive track's PTS only advances (audio and subtitles
|
||||
/// do not reorder, and a passive video track's B-frame dip is far under the
|
||||
/// backstep), so a large BACKWARD step is unambiguous. This is a different
|
||||
/// signal from the shared frontier, which is what the old false-positive
|
||||
/// ratchet keyed on.
|
||||
last_raw_ns: Vec<Option<i64>>,
|
||||
/// Per-track provisional offset for frames that arrive BEFORE the video
|
||||
/// frame opening their epoch, as `(epochs retired when it was taken,
|
||||
/// offset)`.
|
||||
///
|
||||
/// It is deliberately private to one track and never written to
|
||||
/// `offset_ns`, never advances `high_ns`, and never retires an epoch — so
|
||||
/// it cannot move the video timeline. Letting a passive track open a real
|
||||
/// epoch was tried and inflated a 476.776 s title to 656.216 s, because
|
||||
/// every track observes a boundary at its own pace and the video path
|
||||
/// rebased again on top of whatever they had done.
|
||||
provisional: Vec<Option<(usize, i64)>>,
|
||||
}
|
||||
|
||||
/// Most epochs retained for straggler resolution. A title has a handful; this
|
||||
/// only bounds a pathological source that rebases without end.
|
||||
const MAX_EPOCHS: usize = 64;
|
||||
|
||||
impl TimelineContinuity {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
epoch_offsets: Vec::new(),
|
||||
last_raw_ns: Vec::new(),
|
||||
provisional: Vec::new(),
|
||||
offset_ns: 0,
|
||||
prev_offset_ns: 0,
|
||||
high_ns: None,
|
||||
@@ -663,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,
|
||||
@@ -687,6 +766,9 @@ impl TimelineContinuity {
|
||||
crate::disc::ContentFormat::MpegPs => None,
|
||||
};
|
||||
Self {
|
||||
epoch_offsets: Vec::new(),
|
||||
last_raw_ns: Vec::new(),
|
||||
provisional: Vec::new(),
|
||||
offset_ns: 0,
|
||||
prev_offset_ns: 0,
|
||||
high_ns: None,
|
||||
@@ -734,7 +816,95 @@ impl TimelineContinuity {
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
Some(self.adjust(raw_pts_ns, drives_epoch))
|
||||
Some(self.adjust(raw_pts_ns, drives_epoch, track))
|
||||
}
|
||||
|
||||
/// The offset a passive frame should ride, and the bookkeeping around it.
|
||||
///
|
||||
/// Returns the effective offset for THIS frame. Normally that is the
|
||||
/// current epoch's. It differs only for a frame that arrived ahead of the
|
||||
/// video that opens its epoch: such a frame's own raw PTS has just jumped
|
||||
/// backwards AND its current-epoch mapping lands a whole epoch below the
|
||||
/// frontier, which no in-epoch frame ever does.
|
||||
///
|
||||
/// A provisional is dropped the moment the video actually retires an epoch,
|
||||
/// so the run rejoins the real offset with no seam — the two agree because
|
||||
/// both are `frontier - mapping + gap`.
|
||||
fn passive_offset(&mut self, track: usize, raw_pts_ns: i64) -> i64 {
|
||||
if self.last_raw_ns.len() <= track {
|
||||
self.last_raw_ns.resize(track + 1, None);
|
||||
self.provisional.resize(track + 1, None);
|
||||
}
|
||||
let prev_raw = self.last_raw_ns[track].replace(raw_pts_ns);
|
||||
let retired = self.epoch_offsets.len();
|
||||
|
||||
// A provisional only survives until the video opens the epoch for real.
|
||||
if let Some((taken_at, _)) = self.provisional[track]
|
||||
&& taken_at != retired
|
||||
{
|
||||
self.provisional[track] = None;
|
||||
}
|
||||
let effective = self.provisional[track].map_or(self.offset_ns, |(_, o)| o);
|
||||
|
||||
if let Some(high) = self.high_ns
|
||||
&& self.provisional[track].is_none()
|
||||
&& let Some(pr) = prev_raw
|
||||
&& raw_pts_ns < pr.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
|
||||
{
|
||||
let mapped = raw_pts_ns.saturating_add(effective);
|
||||
if mapped < high.saturating_sub(DISCONTINUITY_BACKSTEP_NS) {
|
||||
let off = high
|
||||
.saturating_sub(mapped)
|
||||
.saturating_add(DISCONTINUITY_GAP_NS);
|
||||
let off = effective.saturating_add(off);
|
||||
self.provisional[track] = Some((retired, off));
|
||||
return off;
|
||||
}
|
||||
}
|
||||
effective
|
||||
}
|
||||
|
||||
/// Retire the current epoch and open a new one continuing just after the
|
||||
/// frontier.
|
||||
///
|
||||
/// Retiring records the offset AND the frontier the epoch closed at. The
|
||||
/// closing frontier is what later makes a straggler recognisable: it says
|
||||
/// where that epoch's tail was, which the current frontier cannot, since by
|
||||
/// then it may be a whole title further on.
|
||||
fn open_epoch(&mut self, high: i64, mapped_now: i64) {
|
||||
self.prev_offset_ns = self.offset_ns;
|
||||
if self.epoch_offsets.len() == MAX_EPOCHS {
|
||||
self.epoch_offsets.remove(0);
|
||||
}
|
||||
self.epoch_offsets.push((self.offset_ns, high));
|
||||
let bump = high
|
||||
.saturating_sub(mapped_now)
|
||||
.saturating_add(DISCONTINUITY_GAP_NS);
|
||||
self.offset_ns = self.offset_ns.saturating_add(bump);
|
||||
}
|
||||
|
||||
/// The offset of the epoch a straggler actually belongs to.
|
||||
///
|
||||
/// A frame whose current-epoch mapping flies past the frontier carries a raw
|
||||
/// PTS from an epoch already left behind. Pick the retained epoch that lands
|
||||
/// it CLOSEST BELOW the frontier: that is where the just-ended epoch's tail
|
||||
/// was, and it can never forward-date a cluster. `None` when no retained
|
||||
/// epoch places it sanely, in which case the caller keeps the current
|
||||
/// mapping — which is exactly the pre-existing behaviour, so a title that
|
||||
/// never rebased is unaffected.
|
||||
fn straggler_offset(&self, raw_pts_ns: i64) -> Option<i64> {
|
||||
self.epoch_offsets
|
||||
.iter()
|
||||
.map(|(o, closing)| (raw_pts_ns.saturating_add(*o), *closing))
|
||||
// In the TAIL of that epoch: at or just below where it ended. A
|
||||
// frame landing far below its close is not a straggler at all — it
|
||||
// is a normal frame of a LATER epoch that merely leads a sparse
|
||||
// frontier, and demoting it would mis-time it by a whole clip.
|
||||
.filter(|(m, closing)| {
|
||||
*m <= *closing && *m >= closing.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
|
||||
})
|
||||
.map(|(m, _)| m)
|
||||
.max()
|
||||
}
|
||||
|
||||
/// Map a raw PES PTS (ns) onto the continuous output timeline.
|
||||
@@ -762,12 +932,13 @@ impl TimelineContinuity {
|
||||
/// - **Everything else** (normal progression + sub-threshold B-frame reorder
|
||||
/// dips) passes through with the current offset and advances the frontier,
|
||||
/// preserving PTS.
|
||||
pub(crate) fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool) -> i64 {
|
||||
pub(crate) fn adjust(&mut self, raw_pts_ns: i64, drives_epoch: bool, track: usize) -> i64 {
|
||||
// Passive track: ride the current epoch's offset. Never advance the
|
||||
// frontier and never open an epoch — these tracks each run on their own
|
||||
// (sparse/laggy/independent) timeline and would false-trigger the ratchet.
|
||||
if !drives_epoch {
|
||||
let mapped = raw_pts_ns.saturating_add(self.offset_ns);
|
||||
let effective = self.passive_offset(track, raw_pts_ns);
|
||||
let mapped = raw_pts_ns.saturating_add(effective);
|
||||
// Tail-straggler remap: at a REAL (base-video-driven) multi-clip
|
||||
// boundary the offset has just jumped forward by ~a whole clip, but a
|
||||
// lagging tail frame from the just-ended clip still carries an
|
||||
@@ -798,13 +969,9 @@ impl TimelineContinuity {
|
||||
// that fires the straggler clamp on essentially every passive frame.
|
||||
if let Some(high) = self.high_ns
|
||||
&& mapped > high.saturating_add(DISCONTINUITY_BACKSTEP_NS)
|
||||
&& let Some(placed) = self.straggler_offset(raw_pts_ns)
|
||||
{
|
||||
let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns);
|
||||
if prev_mapped <= high
|
||||
&& prev_mapped >= high.saturating_sub(DISCONTINUITY_BACKSTEP_NS)
|
||||
{
|
||||
return prev_mapped;
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
@@ -819,14 +986,10 @@ impl TimelineContinuity {
|
||||
// Clip-boundary reset (real multi-clip seam): continue just after the
|
||||
// frontier. Save the previous offset so a lagging non-video tail
|
||||
// frame can be recognised and remapped to the seam (see above).
|
||||
self.prev_offset_ns = self.offset_ns;
|
||||
// `high - adj` is a backward step, so positive — but both ends are
|
||||
// untrusted (`high` up to i64::MAX, `adj` down to i64::MIN), so
|
||||
// saturate rather than panic in a checked build.
|
||||
let bump = high
|
||||
.saturating_sub(adj)
|
||||
.saturating_add(DISCONTINUITY_GAP_NS);
|
||||
self.offset_ns = self.offset_ns.saturating_add(bump);
|
||||
// `open_epoch` saturates rather than panic in a checked build.
|
||||
self.open_epoch(high, adj);
|
||||
let adj2 = raw_pts_ns.saturating_add(self.offset_ns);
|
||||
self.high_ns = Some(high.max(adj2));
|
||||
adj2
|
||||
@@ -847,10 +1010,10 @@ mod tests {
|
||||
// Convenience: a video frame drives epoch decisions; non-video rides the
|
||||
// current offset. These wrappers make the test intent explicit.
|
||||
fn adj_video(tc: &mut TimelineContinuity, p: i64) -> i64 {
|
||||
tc.adjust(p, true)
|
||||
tc.adjust(p, true, 0)
|
||||
}
|
||||
fn adj_other(tc: &mut TimelineContinuity, p: i64) -> i64 {
|
||||
tc.adjust(p, false)
|
||||
tc.adjust(p, false, 1)
|
||||
}
|
||||
|
||||
/// Characterization of the BUG: a BD title's two clips concatenated with a
|
||||
@@ -1044,6 +1207,21 @@ mod tests {
|
||||
|
||||
/// Clips with byte spans, built from the real mark table so provenance and
|
||||
/// marks can be tested against each other.
|
||||
/// The output offset `from_clips` computes for a clip: the sum of every
|
||||
/// earlier clip's playable duration, minus its own IN.
|
||||
fn plan_offset_for(clips: &[crate::disc::Clip], want: &crate::disc::Clip) -> i64 {
|
||||
let mut cum = 0i64;
|
||||
for c in clips {
|
||||
let in_ns = mpls_ticks_to_ns(c.in_time);
|
||||
let out_ns = mpls_ticks_to_ns(c.out_time);
|
||||
if c.clip_id == want.clip_id && c.in_time == want.in_time {
|
||||
return cum;
|
||||
}
|
||||
cum += out_ns - in_ns;
|
||||
}
|
||||
cum
|
||||
}
|
||||
|
||||
fn clips_with_spans() -> Vec<crate::disc::Clip> {
|
||||
let mut clips = seamless_branching_clips();
|
||||
// Each clip's stream occupies a contiguous run of the feed. Sizes are
|
||||
@@ -1144,6 +1322,50 @@ mod tests {
|
||||
/// A playlist may reference the same clip twice (a looped segment). The
|
||||
/// bytes are read once, so both entries share one span — that must not be
|
||||
/// read as a broken map.
|
||||
/// A clip FILE referenced by two adjacent PlayItems — a seamless split, a
|
||||
/// looped segment, multi-angle — is one file with one set of bytes, so it
|
||||
/// has ONE feed span. But each PlayItem carries its OWN marks, and the two
|
||||
/// cover different halves of it.
|
||||
///
|
||||
/// Provenance alone cannot tell those halves apart: every frame of the file
|
||||
/// resolves to the same span. `clip_at_byte` answers with the FIRST of the
|
||||
/// run, so without the timestamp to disambiguate, every frame past the
|
||||
/// first PlayItem's OUT is judged against marks it was never inside and
|
||||
/// dropped — the second half of that clip silently missing from the rip,
|
||||
/// with the timeline still charged for its duration.
|
||||
#[test]
|
||||
fn a_clip_split_across_two_play_items_keeps_both_halves() {
|
||||
// One file, one span. Two PlayItems: [0s,10s) then [10s,20s).
|
||||
const SPAN: (u64, u64) = (0, 4_000_000);
|
||||
let mk = |in_t: u32, out_t: u32| crate::disc::Clip {
|
||||
feed_span: Some(SPAN),
|
||||
clip_id: "00001".into(),
|
||||
in_time: in_t,
|
||||
out_time: out_t,
|
||||
duration_secs: 10.0,
|
||||
source_packets: 0,
|
||||
};
|
||||
// 45 kHz ticks: 10 s = 450_000, 20 s = 900_000.
|
||||
let clips = vec![mk(0, 450_000), mk(450_000, 900_000)];
|
||||
let mut plan = SeamPlan::from_clips(&clips).expect("two PlayItems, one file");
|
||||
assert!(plan.spans_trusted, "one file legitimately has one span");
|
||||
|
||||
// A frame from the SECOND PlayItem's range, read from within the file.
|
||||
let raw = mpls_ticks_to_ns(600_000); // 13.33 s — inside [10s, 20s)
|
||||
let placed = plan.place(raw, 0, false, Some(2_000_000));
|
||||
assert!(
|
||||
placed.is_some(),
|
||||
"a frame in the second PlayItem's marks must be kept — it resolves \
|
||||
to the same span as the first, so only its timestamp can say which \
|
||||
half of the file it belongs to"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.dropped_total(),
|
||||
0,
|
||||
"nothing from a legitimately referenced half may be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeated_clip_shares_one_span_and_is_still_trusted() {
|
||||
let mut clips = clips_with_spans();
|
||||
@@ -1171,9 +1393,34 @@ mod tests {
|
||||
fn no_provenance_still_places_by_marks() {
|
||||
let clips = clips_with_spans();
|
||||
let mut plan = SeamPlan::from_clips(&clips).expect("plan");
|
||||
// `is_some()` alone was the whole assertion here, which passes for a
|
||||
// frame placed in the WRONG clip — on the one path taken whenever a
|
||||
// source stamps no offset.
|
||||
//
|
||||
// This timestamp sits in the OVERLAP of two clips in the real mark
|
||||
// table, which is precisely the ambiguity provenance exists to settle,
|
||||
// so pinning one specific clip would be asserting the coin-flip. The
|
||||
// invariant that holds either way: the frame is placed with the offset
|
||||
// of a clip whose marks actually contain it — never a clip it does not
|
||||
// belong to, and never at the head of the timeline.
|
||||
let raw = 7_900_000_000_000i64;
|
||||
let placed = plan
|
||||
.place(raw, 0, true, None)
|
||||
.expect("a frame with no source offset must still be placed");
|
||||
|
||||
let candidates: Vec<i64> = clips
|
||||
.iter()
|
||||
.filter(|c| raw >= mpls_ticks_to_ns(c.in_time) && raw <= mpls_ticks_to_ns(c.out_time))
|
||||
.map(|c| raw - mpls_ticks_to_ns(c.in_time) + plan_offset_for(&clips, c))
|
||||
.collect();
|
||||
assert!(
|
||||
plan.place(7_900_000_000_000, 0, true, None).is_some(),
|
||||
"a frame with no source offset must still be placed"
|
||||
!candidates.is_empty(),
|
||||
"fixture check: the probe timestamp must sit inside at least one clip"
|
||||
);
|
||||
assert!(
|
||||
candidates.contains(&placed),
|
||||
"placed at {placed}, but no clip containing this timestamp maps it there \
|
||||
(candidates {candidates:?}) — a frame was given a clip it does not belong to"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1353,13 +1600,13 @@ mod tests {
|
||||
feed_span: Some(SPAN),
|
||||
})
|
||||
.collect();
|
||||
let plan = SeamPlan::from_clips(&clips).expect("plan");
|
||||
let mut plan = SeamPlan::from_clips(&clips).expect("plan");
|
||||
assert!(
|
||||
plan.spans_trusted,
|
||||
"identical spans pass the tiling check -- this is the hazard"
|
||||
"identical spans pass the tiling check -- one file has one span"
|
||||
);
|
||||
// Every byte in the file resolves to the FIRST play item, so a byte
|
||||
// offset cannot say which of the 8 ranges a frame belongs to.
|
||||
// A byte offset alone cannot say which of the 8 ranges a frame belongs
|
||||
// to: every byte in the file resolves to the FIRST play item.
|
||||
for b in [0u64, 1_000_000, 20_000_000_000, 39_999_999_999] {
|
||||
assert_eq!(
|
||||
plan.clip_at_byte(b),
|
||||
@@ -1367,6 +1614,23 @@ mod tests {
|
||||
"byte {b} resolves to the first play item, always"
|
||||
);
|
||||
}
|
||||
|
||||
// ...which is why placement must finish the job with the timestamp.
|
||||
// This used to drop 7/8 of the title: every frame past the first
|
||||
// range's OUT was judged against marks it was never inside. Each
|
||||
// range's own frame must now be kept, and land in ITS range.
|
||||
for i in 0..N {
|
||||
let mid = mpls_ticks_to_ns(i * SEG + SEG / 2);
|
||||
let placed = plan
|
||||
.place(mid, 0, false, Some(1_000_000))
|
||||
.unwrap_or_else(|| panic!("a frame in play item {i}'s marks must be kept"));
|
||||
assert!(placed >= 0, "play item {i} placed at {placed}");
|
||||
}
|
||||
assert_eq!(
|
||||
plan.dropped_total(),
|
||||
0,
|
||||
"no range of a legitimately split file may be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
/// Marks that do not advance across the title are normal — each clip file
|
||||
@@ -1974,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!(
|
||||
@@ -1986,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() {
|
||||
@@ -2206,9 +2512,9 @@ mod tests {
|
||||
|
||||
// A NORMAL clip-2 audio frame at raw ~5s. Current-offset mapping is
|
||||
// ~605s, which IS more than a backstep (3s) past the 600s frontier — but
|
||||
// its previous-offset mapping (~5s) lands ~595s BELOW the frontier, far
|
||||
// outside the seam tail. It is a legitimate new-epoch frame, NOT a tail
|
||||
// straggler, and must ride the current offset.
|
||||
// it lands ~595s BELOW the end of the epoch it would be demoted into,
|
||||
// far outside that epoch's tail. It is a legitimate new-epoch frame, NOT
|
||||
// a straggler, and must ride the current offset.
|
||||
let raw = 5 * S;
|
||||
let out = adj_other(&mut tc, raw);
|
||||
assert_eq!(
|
||||
@@ -2224,6 +2530,145 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// MEASURED on a real DVD title with 8 cell boundaries. The demuxer hands
|
||||
/// the muxer ~18 audio frames of the NEXT cell before that cell's first
|
||||
/// video frame. Riding the just-ended epoch's offset put them ~21 s in the
|
||||
/// past, and the MKV writer's strictly-monotonic nudge then crushed the run
|
||||
/// onto one instant 0.1 ms apart — half a second of audio as a click,
|
||||
/// eight times in an 8-minute title.
|
||||
///
|
||||
/// They must instead continue after the frontier, and must rejoin the real
|
||||
/// offset seamlessly once the video opens the epoch.
|
||||
#[test]
|
||||
fn frames_arriving_before_their_epochs_video_ride_a_provisional_offset() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
for i in 0..=600 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
let frontier = tc.high_ns.expect("frontier");
|
||||
adj_other(&mut tc, 599 * S);
|
||||
|
||||
// The next cell's audio arrives first, raw restarted at 0.
|
||||
let step = 32_000_000; // 32 ms, the measured cadence
|
||||
let a0 = adj_other(&mut tc, 0);
|
||||
let a1 = adj_other(&mut tc, step);
|
||||
let a2 = adj_other(&mut tc, 2 * step);
|
||||
assert_eq!(
|
||||
a0,
|
||||
frontier + DISCONTINUITY_GAP_NS,
|
||||
"an early frame must continue after the frontier, not land an epoch \
|
||||
in the past where the monotonic nudge crushes it"
|
||||
);
|
||||
assert_eq!(a1 - a0, step, "the run keeps its cadence");
|
||||
assert_eq!(a2 - a1, step);
|
||||
|
||||
// Critically: the video timeline must be untouched. Letting a passive
|
||||
// track open a real epoch inflated a 476.776 s title to 656.216 s.
|
||||
assert_eq!(tc.offset_ns, 0, "a provisional must not move the offset");
|
||||
assert_eq!(tc.high_ns, Some(frontier), "nor the frontier");
|
||||
assert!(tc.epoch_offsets.is_empty(), "nor retire an epoch");
|
||||
|
||||
// Now the video for that cell arrives and opens the epoch for real.
|
||||
let v = adj_video(&mut tc, 0);
|
||||
assert_eq!(v, frontier + DISCONTINUITY_GAP_NS);
|
||||
assert_eq!(
|
||||
tc.epoch_offsets.len(),
|
||||
1,
|
||||
"exactly one epoch, opened by video"
|
||||
);
|
||||
|
||||
// The next audio frame rejoins the real offset with no seam: both are
|
||||
// `frontier - mapping + gap`, so they agree.
|
||||
let a3 = adj_other(&mut tc, 3 * step);
|
||||
assert_eq!(a3 - a2, step, "the run continues across the handover");
|
||||
}
|
||||
|
||||
/// A provisional must not outlive its epoch: once the video has retired an
|
||||
/// epoch, later frames of that track take the real offset. Otherwise a track
|
||||
/// would drift away from every other one for the rest of the title.
|
||||
#[test]
|
||||
fn a_provisional_is_dropped_once_the_epoch_is_real() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
for i in 0..=600 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
adj_other(&mut tc, 599 * S);
|
||||
adj_other(&mut tc, 0); // takes a provisional
|
||||
assert!(tc.provisional[1].is_some());
|
||||
|
||||
adj_video(&mut tc, 0); // video opens the epoch for real
|
||||
adj_other(&mut tc, S); // next frame of that track
|
||||
assert!(
|
||||
tc.provisional[1].is_none(),
|
||||
"the provisional must be dropped once the real epoch exists"
|
||||
);
|
||||
assert_eq!(
|
||||
adj_other(&mut tc, 2 * S),
|
||||
2 * S + tc.offset_ns,
|
||||
"the track now rides the real offset like every other"
|
||||
);
|
||||
}
|
||||
|
||||
/// MEASURED on a real HD-DVD title. Its second audio track's LAST frame
|
||||
/// carries a clip-1 raw PTS but arrives after clip 2's video opened the
|
||||
/// epoch, so it took clip 2's offset and landed at 12834.587 s in a
|
||||
/// 6434.100 s title — one packet, exactly double.
|
||||
///
|
||||
/// The old remap existed for this and refused it: the frame sits 23 s below
|
||||
/// the CURRENT frontier, outside the 3 s window. Judged against the frontier
|
||||
/// of the epoch it actually belongs to, it is 0.15 s from that epoch's end.
|
||||
#[test]
|
||||
fn a_straggler_is_judged_against_its_own_epochs_end_not_the_frontier() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
for i in 0..=600 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
adj_video(&mut tc, 0); // clip 2 opens; epoch 0 closed at 600 s
|
||||
for i in 0..=23 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
let frontier = tc.high_ns.expect("frontier");
|
||||
|
||||
// A clip-1 tail frame, 0.2 s before that clip ended, arriving late.
|
||||
let raw = 599 * S + 800_000_000;
|
||||
let out = adj_other(&mut tc, raw);
|
||||
assert_eq!(
|
||||
out, raw,
|
||||
"a tail straggler belongs to the epoch its raw PTS came from"
|
||||
);
|
||||
assert!(
|
||||
out < frontier,
|
||||
"it must never be flung past the frontier, got {out} vs {frontier}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Why the epoch HISTORY is needed and one `prev_offset_ns` is not enough:
|
||||
/// with three epochs, a straggler from the FIRST cannot be named by the
|
||||
/// immediately-previous offset at all.
|
||||
#[test]
|
||||
fn a_straggler_from_an_epoch_before_last_is_still_placed() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
for i in 0..=600 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
adj_video(&mut tc, 0); // epoch 0 closes at 600 s
|
||||
for i in 0..=300 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
adj_video(&mut tc, 0); // epoch 1 closes at ~900 s
|
||||
for i in 0..=50 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
|
||||
// Tail frame of epoch 0 — two epochs back.
|
||||
let raw = 599 * S + 500_000_000;
|
||||
assert_eq!(
|
||||
adj_other(&mut tc, raw),
|
||||
raw,
|
||||
"the straggler belongs to epoch 0, which `prev_offset_ns` no longer names"
|
||||
);
|
||||
}
|
||||
|
||||
/// A saturated frontier must not panic the muxer. An `mkv://` source's
|
||||
/// tick→ns multiply saturates at `i64::MAX` (mkvstream's `parse_block`), so a
|
||||
/// hostile TimestampScale/CLUSTER_TIMESTAMP puts `high_ns` AT `i64::MAX`.
|
||||
|
||||
+1
-1
@@ -450,7 +450,7 @@ const AACS_PROBE_UNITS: usize = 8;
|
||||
///
|
||||
/// The extra step over `scan_iso` is the encryption verdict.
|
||||
/// `Disc::scan_with` decides `encrypted` STRUCTURALLY, from the presence of an
|
||||
/// `/AACS` or `/BDMV/AACS` directory (`disc/mod.rs:1992-1993`). For the common
|
||||
/// `/AACS` or `/BDMV/AACS` directory (see `disc::aacs_dir_present`). For the common
|
||||
/// case — a MakeMKV-style backup, which strips `AACS/` — that already gives the
|
||||
/// right answer, and `DecryptKeys::None` is a pass-through. But a folder copied
|
||||
/// verbatim from a decrypted disc keeps `AACS/`, and the tree shape then claims
|
||||
|
||||
+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