Compare commits

...
18 Commits
Author SHA1 Message Date
Matthew Jackson ac46e2382f v1.6.4: bump version (freemkv-unlock git-pinned for the tag) 2026-08-15 09:13:26 -07:00
Matthew Jackson 862c1460c3 Trim single-clip titles to their playlist marks (1.6.4) 2026-08-15 08:14:31 -07:00
Matthew Jackson fea64d07e2 Stop an uncrackable VTS borrowing another VTS's title key
`resolve_vts_key` called the `Option`-returning `css::crack_key`, which
collapses "no scrambled sector was seen" with "scrambled sectors were
seen and no key came out", and then fell back to the disc-wide key. A
multi-VTS CSS DVD whose second title set resists the Stevenson scan was
descrambled under the FIRST set's key: corrupt PES behind an intact
header, written out with `complete = true` at exit 0.

`CrackOutcome` exists precisely to keep those two apart, and its doc says
callers must surface the second as a hard error. Every sibling path in
the crate already does — `Disc::decrypt_keys_for_title` and the mux path
both map `ScrambledUncracked` to `CssKeyMissing`. This was the one path
that did not. The ordering comment 15 lines above describes this exact
outcome as the bug it exists to prevent; ordering makes the crack far
more likely to succeed, but it cannot make a failed crack safe.

Also, three things nothing could catch:

- The demux output filename took the stream's language raw while the
  base beside it was sanitised. A language code is three raw STN bytes
  through `from_utf8_lossy`, and `00 00 00` is the ordinary "undefined"
  encoding on real discs — a NUL in a path fails `File::create` with
  InvalidInput, taking the whole export down before one track file
  opened. `sanitize` now maps control characters too; it did not.

- `css::crack_key_scan`'s short-read handling was dead code under test:
  every source in the module returned the full request, so reverting
  `advance` to the requested count, or dropping the `.max(1)`, left the
  suite green. The `.max(1)` is load-bearing — without it a source
  returning `Ok(0)` never moves the cursor and never increments the
  budget, so the scan spins forever. That mutation now HANGS the test
  rather than failing it, which is the honest demonstration.

- `MAX_SUBDIRS`'s const-assert carried `#[cfg(not(test))]` inside a
  `#[cfg(test)] mod tests`, so it compiled in no configuration and could
  never fire — the dead gate the test above it was written to replace.
  Moved to module scope, and verified it now rejects a wrong constant at
  compile time.
2026-08-11 17:47:56 -07:00
Matthew Jackson 6e8da15c34 Bound the third hostile CSV, and stop two docs overclaiming
`forced_sub` was the last unbounded attacker-controlled list in
paramount.rs. `MAX_COM_INDICES` capped the two `*_com1_idx` attributes;
this one had nothing capping it at all, and unlike them it has no value
to filter — a cell is a classification of the position it sits at, so
its bound has to be positional. Extracted as `forced_subs` for the same
reason `com_indices` was extracted: through `labels_from_feature` the
bound is unobservable, because the subtitle loop cannot reach those
cells either, so the assertion could not fail.

The `MAX_COM_INDICES` doc claimed the entry-allocation argument for the
whole constant. It is the VALUE filter that caps the set (values below
the bound, so at most that many distinct entries, however long the
attribute); the `take` caps the WORK. Both are real and they are not
the same bound; the doc now says which is which.

`jar_inventory_dedup_is_not_quadratic` called itself proof by deadline.
It is a hang guard — a return to the linear scan runs for minutes and
would wedge CI rather than fail it — and no assertion in it can tell a
BTreeSet from any other sub-quadratic dedup. Renamed and documented for
what it does. Its margin was measured before keeping it: 0.14s debug /
0.07s release against 10s, ~70x, unlike the 6x that made paramount.rs's
wall-clock test flake.
2026-08-11 16:59:33 -07:00
Matthew Jackson 8898351cb1 Round 2: stop two of round 1's tests claiming more than they prove
Both are mine, and both pass with the bound they "cover" deleted.

`a_hostile_commentary_index_list_is_bounded_not_merely_fast` asserted
only label purposes. The out-of-range filler it feeds is unobservable at
the label level and a HashSet collapses the repeats, so removing
MAX_COM_INDICES entirely leaves it green. What it DOES catch is a bound
set too low — verified at 2, where the real indices stop resolving. Named
and documented for that, and it no longer implies it guards enforcement.

`an_index_that_cannot_address_any_cell_is_not_retained` asserted through
the labels, where retention is by definition unobservable: the loop never
queries a cell that high. It now reads the set through `com_indices`,
where the claim is checkable.

Enforcement was and remains proven by
`distinct_unaddressable_indices_are_refused_not_stored`, which was red at
50,000 retained entries and green at zero. Two tests, two properties;
neither pretends to the other's job now.
2026-08-11 11:54:28 -07:00
Matthew Jackson 7a03be5496 Bound the commentary-index parse, and stop proving it with a clock
The gate caught `commentary_index_lookup_is_not_quadratic` failing, then
passing on a re-run. Measured both ways: 1.62s alone, OVER 10s against
its own 10s deadline while the suite's other 3,347 tests ran
concurrently. A 6x margin against a shared CPU is not a margin, and a
test that gets re-run until it passes is not a test.

It was also measuring the wrong thing. Replacing the linear scan with a
HashSet bounded the LOOKUP; the set was still built from every entry the
disc declared. `playlists.xml` is attacker-controlled and has no length
of its own, so a hostile disc could still force an unbounded allocation
before any lookup happened — the parse, not the query, was the exposure.
The code's own comment said "unbounded parsed input" and only fixed half
of it.

`MAX_COM_INDICES` bounds both halves, at the one value that cannot
change behaviour: an index at or beyond `u16::MAX` can never match a
cell, because the labelling loops break at `u16::try_from(i + 1)`. Real
authoring is nowhere near it — the BD STN table admits 32 streams.

The parse moved into a `com_indices` helper so the bound is OBSERVABLE.
Through `labels_from_feature` it is not: a HashSet collapses repeated
values, and an out-of-range index changes no label, so the obvious test
passes with or without the cap — an assertion that cannot fail, which is
what the first draft of this fix shipped. The test now hands in 50,000
DISTINCT unaddressable indices and asserts they are refused. Proven red
with the bound removed (50,000 kept), green with it.

Applies to both `sub_com1_idx` and `aud_com1_idx`.

NOT done here: `labels/mod.rs:1421`'s `jar_inventory_dedup_is_not_quadratic`
is the same wall-clock shape and has the same flakiness. Named so it is
not lost.
2026-08-11 11:30:53 -07:00
Matthew Jackson de94e12864 Cover the dir:// input door, and correct the CSS scramble-gate docs
Three round-1 audit findings on v1.6.0..HEAD.

Tests: `mux::resolve::input("dir://…")` — the door the CLI rips a folder
through — was never driven with a real folder. The only dir:// input test
uses a missing path, which fails in DirImage::open long before
`image_input`, so dropping the `is_folder` argument (and with it
`session::apply_folder_encryption_verdict`) passed the whole suite. Two
tests now close it, stated as a DIFFERENTIAL between the two doors,
because that is the invariant the shared function exists to hold: a
clear folder that kept its AACS/ directory must scan the same and select
the same extents through `scan_dir` and through `dir://`, and a
scrambled one must be refused through both with E9063. Proven red
against a mutated guard (both fail with E7022, the tree-shape verdict
the probe overrides) and green with it restored.

Docs: css/mod.rs documented `is_scrambled`, a function this line renamed
to `has_scramble_flag_bits`. Eleven stale sites, two of them broken
rustdoc links. Two were not stale names but false statements — they said
the descramble loop keeps the looser raw-flag test, when
`descramble_sector` and `descramble_region` both gate on
`is_scrambled_pack`; that parenthetical is rewritten to say what the
code does and why (the measured VIDEO_TS.IFO case: 38 titles became 10,
silently, at exit 0). The one mention that must stay is the sentence
explaining why the name was rejected.

Constants: io::image_writer and dirimage::encode each re-declared the
2048-byte sector that consts::SECTOR_BYTES already exports; both now
alias it. Note `BATCH_SECTORS = 2048` in image_writer is a different
quantity (sectors per batch, not bytes) and is deliberately left alone.
Bare 2048 literals elsewhere in the crate are out of scope here.
2026-08-11 10:23:37 -07:00
Matthew Jackson 21ec1d3f80 restore freemkv-unlock path dep for local dev (post-v1.6.3) 2026-08-10 09:04:40 -07:00
Matthew Jackson 6658a80819 v1.6.3: bump version (freemkv-unlock git-pinned for the tag) 2026-08-10 09:04:35 -07:00
Matthew Jackson 3db11a192c Carry the same licence and community files as the other repos
These are eight public repos that ship one product on one version, and
they had drifted: freemkv-engine carried no LICENSE at all (GitHub
reported its licence as none), two crates had no code of conduct, four
had no contributing guide, and none had a security policy — so there was
no private route to report a vulnerability in a disc-decryption tool.

SECURITY.md names GitHub Security Advisories on each repo rather than an
address, so there is nothing to keep in sync and no inbox to go stale.
2026-08-09 22:08:22 -07:00
Matthew Jackson a425a71cd5 Keep the cross-platform jobs for the release candidate
dev is where work lands and is meant to be pushed to often, so what runs
there should be the cheap answer to "did I break it": lint, tests and the
Linux build. The macOS and Windows jobs now run on qa and main instead of
on every push to dev.

Nothing is deleted and no platform goes unchecked before a release. qa.yml
already covers macOS and Windows independently, and the jobs that live only
here -- the Intel macOS build, the Windows release build -- still run, on
the branches where a cross-platform break is worth blocking on.

They are SKIPPED on dev via `if`, not left unscheduled. A queued job would
be worse than a slow one: release.sh's CI gate refuses while any run for
the commit is still in progress, so a job that never gets a runner blocks
releases silently, with no error anywhere. That is the failure the
real-media note in qa.yml describes, and it is why this is an `if` on the
job rather than a narrower set of trigger branches.
2026-08-09 20:24:07 -07:00
Matthew Jackson f79745d815 Release 1.6.3
Version bump and changelog for the 1.6.3 sync. All eight crates ship the
same version, so the crates with no functional change this cycle say so
rather than carrying an empty section.
2026-08-09 20:01:33 -07:00
Matthew Jackson 5d8f77e049 Drop six unused crates, and align the rest with the workspace
Two problems, both invisible until the whole graph is looked at together.

DEAD: num-bigint, sha2, num-traits, num-integer, cmac and cbc are
declared here and referenced nowhere -- not in src, tests or benches.
They were being compiled, audited and offered version bumps forever for
no reason. Removing beats bumping.

cbc nearly survived the sweep: a substring search for "cbc" matches 44
occurrences of ycbcr_to_rgb in the DVD subtitle decoder, so it looked
used. Only a word-boundary search exposed it.

SKEW: this crate was the outlier on every shared dependency -- aes 0.8,
rand 0.8, base64 0.22.1 and zip 2 against 0.9 / 0.10 / 0.23 / 8
elsewhere. Cargo cannot unify across a major version, so it compiled
BOTH: 32 duplicated crates in the freemkv binary's graph, including two
complete AES implementations (aes 0.8 + 0.9, cipher 0.4 + 0.5), two
digest stacks and two getrandom. Two crypto stacks in one product is
worth removing on its own.

The aes bump is an API rename -- BlockCipher-prefixed traits, Array for
GenericArray -- and the obvious translation uses Array::from_slice,
which the new version deprecates and clippy's -D warnings would reject.
These use the From<[T; N]> conversion the crate points at instead.

3441 tests pass in debug and release. The AACS crypto here is covered by
known-answer tests, so a byte-order or sizing mistake in that rename
could not have passed.
2026-08-09 18:37:24 -07:00
dependabot[bot] 79a16a9279 Bump softprops/action-gh-release from 2 to 3 (#4)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 17:14:30 -07:00
dependabot[bot] 219a77b581 Bump actions/checkout from 5 to 7 (#5)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:27 -07:00
Matthew Jackson be378cbc34 Have Dependabot watch the dependencies, and land its PRs on dev
Version updates were never configured here -- only security alerts, which
report but never open a pull request. So the absence of Dependabot PRs was
not "nothing to update", it was "nothing was checking".

Updates target `dev`, never `main`. main is a release pointer that
release.sh moves to each tag, so a bot commit on it would put work there
that no tag contains -- the exact state that aborted the 1.6.2 cascade at
its last step, and pointing a scheduled bot at main would recreate it
weekly.

Minor and patch bumps are grouped into one PR rather than one per crate:
eight repos times a handful of dependencies is a volume nobody reads, and
an unread PR queue is indistinguishable from no updates at all.

The freemkv crates are excluded. They depend on each other by git tag,
re-pinned by release.sh inside the release commit, and Dependabot cannot
see that cascade -- a PR bumping one could pin a version whose tag does
not exist yet.
2026-08-09 16:48:31 -07:00
Matthew Jackson 63200a1d1f Retire the README version bot
It fired on `release: published` and committed to main, the branch the
release points at its tag, so it could only ever leave main ahead of the
tag. release.sh now makes the same edit inside the release commit.

In this repo it was a no-op regardless: the pattern it rewrote does not
appear in this README, so it has been running and changing nothing.
2026-08-09 07:20:44 -07:00
Matthew Jackson 526dd3552b restore freemkv-unlock path dep for local dev (post-v1.6.2) 2026-08-08 22:18:33 -07:00
24 changed files with 847 additions and 224 deletions
+48
View File
@@ -0,0 +1,48 @@
version: 2
# Dependency updates land on `dev`, never on `main`.
#
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
# commit on it would put work there that no tag contains, which is exactly the
# state that aborted the 1.6.2 cascade at the last step -- so pointing
# Dependabot at main would recreate that failure on a schedule.
updates:
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate. Eight
# repos times a handful of crates is a volume nobody reads, and an
# unread PR queue is indistinguishable from no updates at all.
groups:
minor-and-patch:
update-types:
- minor
- patch
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release.sh as part of the release commit. Dependabot cannot see that
# cascade, so a PR bumping one of these would fight the release process
# and could pin a version whose tag does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
# The workflows are now real infrastructure -- the release cascade, the
# cross-platform hash matrix, the disc gate -- so their actions need the same
# attention as the crates.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
actions:
update-types:
- minor
- patch
+42 -16
View File
@@ -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
+1 -1
View File
@@ -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
+7 -7
View File
@@ -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
+4 -4
View File
@@ -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
-36
View File
@@ -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
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## [1.6.4] — 2026-08-15
### Fixed
- **On a few Blu-ray/UHD titles the sound ran on for half a minute after the
picture had ended.** A disc stores each part of a film as a clip, and the
playlist marks exactly where that clip's content begins and ends. Where a
title is a single clip, freemkv trimmed the picture to those marks but not the
sound — and some discs leave extra audio in the file past the end mark (a
quiet fade authored after the last frame of picture). That trailing audio was
copied through, so the file claimed one running time while carrying up to ~36
seconds more sound than picture. Measured on `The Bourne Supremacy`: the
picture ends at 1:48:26 as declared, but every sound track ran to 1:49:02.
Single-clip titles are now trimmed to their playlist marks the same way
multi-clip titles already were, so sound and picture end together at the
declared duration. A title that had no extra material past its marks is
byte-for-byte unchanged. Multi-clip titles were never affected.
- **A multi-title-set CSS DVD could descramble one title set under another
set's key.** When a DVD's second title set resisted the keyless title-key
recovery, the decrypt fell back to the disc-wide key instead of failing —
writing a corrupt title behind an intact header and reporting success at exit
0. A failed recovery is now a hard error, the same as every other path in the
crate already does; ordering makes the recovery more likely to succeed but
cannot make a failed one safe.
- **A disc whose stream language field was all-zero could abort the whole track
export.** An all-zero language code (the ordinary "undefined" value on real
discs) put a NUL byte into a demux output filename and failed file creation
before a single track opened. Control bytes in that field are now sanitised
the same way the rest of the name already was.
### Security
- **Bounded the last unbounded attacker-controlled list in the DVD label
parser.** A crafted IFO could grow the forced-subtitle index list without
limit; it now carries the same positional cap as the neighbouring command
lists. No effect on a well-formed disc.
## [1.6.3] — 2026-08-10
### Changed
- **Six crates that were never used have been removed, and nothing about reading
a disc has changed.** They were declared as dependencies but referenced
nowhere, so they were compiled into every build for nothing. The rest were
aligned with the versions the other freemkv crates use.
- **Two complete AES implementations were being built into the product.** Two
different releases of the cipher crates had been pulled in by different parts
of the tree and both were compiled. They are now one.
## [1.6.2] — 2026-08-08
### Fixed
+6 -12
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.6.2"
version = "1.6.4"
edition = "2024"
rust-version = "1.97"
license = "MIT"
@@ -22,20 +22,14 @@ codegen-units = 1
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10"
sha2 = "0.10"
aes = "0.8"
cbc = "0.1"
aes = "0.9"
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.2" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
rand = "0.8"
cmac = "0.7"
zip = { version = "2", default-features = false, features = ["deflate"] }
base64 = "0.22.1"
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.6.4" }
rand = "0.10"
zip = { version = "8", default-features = false, features = ["deflate"] }
base64 = "0.23"
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -2
View File
@@ -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.
+9 -5
View File
@@ -67,6 +67,15 @@ const MAX_SUBDIRS: usize = (u16::MAX - 1) as usize;
#[cfg(test)]
const MAX_SUBDIRS: usize = 4;
/// One entry per child plus the parent's own must still fit the 16-bit field.
///
/// At MODULE scope, and it has to be. This assertion previously sat inside
/// `mod tests`, which is `#[cfg(test)]`, while carrying `#[cfg(not(test))]`
/// itself — so it was compiled in NO configuration and could never fire, which
/// made it exactly the dead gate the test above it was written to replace.
#[cfg(not(test))]
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
/// Largest image this planner will synthesize, in sectors (128 GiB).
///
/// A DVD title set records where its VOBS begins as an offset in its own IFO,
@@ -998,11 +1007,6 @@ mod tests {
"expected DirImageFanout, got {err:?}"
);
// And the real production value is what ships: one per child plus the
// parent's own entry must still fit in the 16-bit field.
#[cfg(not(test))]
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
let _ = std::fs::remove_dir_all(&dir);
}
}
+77
View File
@@ -650,6 +650,83 @@ fn an_aacs_folder_with_scrambled_content_is_rejected() {
assert_eq!(err.code(), crate::error::E_DIR_IMAGE_ENCRYPTED);
}
// ── The OTHER door: `dir://` through the PES input path ─────────────────────
//
// `session::scan_dir` (covered above) and `mux::resolve::input("dir://…")` are
// two doors into the same folder, and they once disagreed: a folder that ripped
// fine through one failed through the other, because only `scan_dir` re-judged
// the tree-shape encryption verdict from CONTENT. The fix was to share
// `session::apply_folder_encryption_verdict` between them — see its doc
// comment, which names this exact failure. Nothing tested the second door, so
// dropping the `is_folder` argument at `mux::resolve`'s call site restored the
// bug silently.
/// The two doors must AGREE. Same folder, same verdict, same extents.
///
/// Stated as a differential rather than a bare `is_ok()` so it cannot go
/// vacuous: if the fixture ever stops producing an AACS state, a one-sided
/// `Ok` assertion would still pass while guarding nothing, whereas "both doors
/// see the same title" is the invariant the shared function actually exists to
/// hold.
#[test]
fn both_doors_agree_on_a_clear_folder_that_kept_its_aacs_directory() {
// `s` MUST outlive `stream`: the pipeline's producer thread is still
// reading the folder until the stream is dropped, and `Scratch::drop`
// removes the directory out from under it.
let s = playable_bdmv("dirdoor", false);
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
// Door 1 — the scan path.
let (disc, _reader) =
crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap();
assert!(
!disc.encrypted,
"door 1 must judge the clear folder decrypted"
);
let scanned_extents = disc.titles[0].extents.clone();
assert!(
!scanned_extents.is_empty(),
"fixture must have real extents"
);
// Door 2 — the PES input path the CLI actually rips through.
let url = format!("dir://{}", s.path().display());
let stream = crate::input(&url, &crate::InputOptions::default())
.expect("dir:// input must open a clear folder, exactly as scan_dir does");
assert_eq!(
stream.info().extents,
scanned_extents,
"the two doors selected different titles from the same folder"
);
drop(stream);
}
/// The other verdict, through the same door: a folder whose content units are
/// really scrambled is refused with the TYPED code, not muxed into garbage.
///
/// This is the load-bearing half. `E_DIR_IMAGE_ENCRYPTED` is produced at
/// exactly one site in the crate (`session::apply_folder_encryption_verdict`),
/// reachable from here only through the `is_folder` argument — so this test
/// cannot pass if that argument is dropped, whatever else changes.
#[test]
fn a_scrambled_folder_is_refused_through_the_dir_url_door_too() {
let s = playable_bdmv("dirdoorenc", true);
s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]);
s.file("AACS/MKB_RO.inf", &[0u8; 64]);
let url = format!("dir://{}", s.path().display());
let err = match crate::input(&url, &crate::InputOptions::default()) {
Ok(_) => panic!("a scrambled folder must not open as a PES source"),
Err(e) => e,
};
assert_eq!(
crate::error::error_code(&err),
Some(crate::error::E_DIR_IMAGE_ENCRYPTED),
"expected the typed dir-source-encrypted code, got: {err}"
);
}
// ── External oracle ─────────────────────────────────────────────────────────
/// Write a synthesized image to a real file and ask the OS to mount it.
+108 -7
View File
@@ -254,7 +254,7 @@ impl Disc {
let key = match vts_keys.get(&vts) {
Some(k) => k.clone(),
None => {
let k = self.resolve_vts_key(&vts, &planned, &mut dec, &base_keys);
let k = self.resolve_vts_key(&vts, &planned, &mut dec, &base_keys)?;
vts_keys.insert(vts.clone(), k.clone());
k
}
@@ -291,13 +291,24 @@ impl Disc {
/// VOB extents (keyless Stevenson attack). The disc-wide key is reused when
/// it already covers this VTS (single-VTS discs, or this VTS's span). The
/// reader is borrowed from the decrypting decorator (its inner source).
///
/// FALLIBLE, because the three outcomes of a crack are not two.
/// [`crate::css::CrackOutcome`] exists precisely to separate "no scrambled
/// sector was seen, so there is nothing to decrypt" from "scrambled sectors
/// were seen and no key came out", and its doc says callers MUST surface
/// the second as a hard error. This function used the `Option`-returning
/// `crack_key`, which collapses both into `None`, and then fell back to the
/// disc-wide key — descrambling this VTS with ANOTHER VTS's title key. The
/// hazard is described in the ordering note below, which was written about
/// the same fallback; ordering makes the crack far more likely to succeed,
/// but it cannot make a failed crack safe.
fn resolve_vts_key<S: SectorSource>(
&self,
vts: &str,
planned: &[PlannedFile],
dec: &mut DecryptingSectorSource<S>,
base_keys: &DecryptKeys,
) -> DecryptKeys {
) -> Result<DecryptKeys> {
// Gather the title VOB extents for this VTS (VTS_xx_1.VOB .. _9.VOB;
// VTS_xx_0.VOB is the menu and is clear, so excluded from the crack).
// Gather this VTS's title VOBs BY NAME, ascending.
@@ -336,7 +347,7 @@ impl Disc {
}
}
if extents.is_empty() {
return base_keys.clone();
return Ok(base_keys.clone());
}
// PLAYBACK ORDER — do NOT sort. This is the 1.5.1 garbage bug, and it
// grew back here in a new code path: the comment this replaced said it
@@ -358,11 +369,20 @@ impl Disc {
// in file order, which together is playback order for a DVD title set.
// Crack against the raw (still-scrambled) inner reader, NOT the
// decrypting view — `crack_key` runs the descrambler itself.
match crate::css::crack_key(dec.inner_mut(), &extents, 64) {
Some(state) => DecryptKeys::Css {
match crate::css::crack_key_outcome(dec.inner_mut(), &extents, 64, None) {
crate::css::CrackOutcome::Cracked(state) => Ok(DecryptKeys::Css {
title_key: state.title_key,
},
None => base_keys.clone(),
}),
// No scrambled sector anywhere in this VTS: the content is clear,
// and any key descrambles it as a no-op. The disc-wide key is the
// right answer, and this is the ONLY case that ever was.
crate::css::CrackOutcome::Unencrypted => Ok(base_keys.clone()),
// Scrambled sectors WERE seen and no key came out. Reusing the
// disc-wide key here writes corrupt PES behind an intact header and
// reports a complete extract at exit 0. Skippable per title, which
// is why this is the per-title code and not the disc-level one — a
// sibling VTS may still crack.
crate::css::CrackOutcome::ScrambledUncracked => Err(Error::CssKeyMissing),
}
}
}
@@ -1918,6 +1938,87 @@ mod tests {
assert!(res.complete);
}
/// A VTS that IS scrambled but whose key could not be recovered must
/// FAIL, not borrow another VTS's key.
///
/// `crack_key` returns `Option`, which collapses "no scrambled sector was
/// seen" with "scrambled sectors were seen and no key came out"; the
/// fallback then descrambled this VTS under the disc-wide key and
/// `extract_tree` reported `complete = true` at exit 0, because no read had
/// failed. `CrackOutcome` exists to keep those two apart, and every sibling
/// path in the crate already honours it.
///
/// The fixture: VTS_01 is crackable, VTS_02 is scrambled with NO periodic
/// crib, so its crack genuinely fails.
#[test]
fn a_scrambled_vts_that_cannot_be_cracked_fails_instead_of_borrowing_a_key() {
let key_1 = [0x10u8, 0x20, 0x30, 0x40, 0x50];
let (_plain_1, scrambled_1) = {
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x01];
let mut plain = vec![0u8; 2048];
plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
plain[0x14] = 0x10;
let pat: Vec<u8> = (0..8)
.map(|k| (0xA0u8.wrapping_add(k as u8) ^ 0x01) ^ 0x5A)
.collect();
for (i, b) in plain.iter_mut().enumerate().skip(0x59) {
*b = pat[i % 8];
}
plain[0x54..0x59].copy_from_slice(&seed);
let mut scrambled = plain.clone();
lfsr::scramble_sector(&key_1, &mut scrambled);
(plain, scrambled)
};
// Scrambled — the pack header and the 0x14 flag are set, so the scan
// SEES ciphertext — but the cleartext region carries no periodic run,
// so the Stevenson crib never forms and no key is recoverable.
let uncrackable = {
let mut sect = vec![0u8; 2048];
sect[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
sect[0x14] = 0x10;
for (i, b) in sect.iter_mut().enumerate().skip(0x59) {
// Non-repeating, so no run of any period survives to 0x80.
*b = (i as u8).wrapping_mul(37).wrapping_add(11);
}
sect
};
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "VIDEO_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![
file("VTS_01_1.VOB", 30, 5000, scrambled_1, false),
file("VTS_02_1.VOB", 32, 6000, uncrackable, false),
],
subdirs: vec![],
}],
};
let mut disc = build_disc(root);
let out = TmpDir::new("css_uncrackable_vts");
let mut d = clear_disc();
d.content_format = crate::disc::ContentFormat::MpegPs;
d.css = Some(crate::css::CssState {
title_key: [0xFFu8; 5],
crack_span: None,
});
let err = d
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect_err(
"a VTS whose key could not be recovered must be a hard error, \
not a silent extract under another VTS's key",
);
assert!(
matches!(err, Error::CssKeyMissing),
"expected CssKeyMissing, got {err:?}"
);
}
/// `Borrowed` is a thin forwarding wrapper the decrypting decorator uses
/// to avoid taking ownership of the caller's reader -- every
/// `SectorSource` method must forward to the wrapped `&mut dyn
+5 -5
View File
@@ -5416,7 +5416,7 @@ mod tests {
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::crypto::AACS_IV;
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
// Flag the unit encrypted (CPI bits on byte 0) before key derivation so
// the recovered plaintext header matches and `decrypt_unit`'s CPI gate
@@ -5424,14 +5424,14 @@ mod tests {
unit[0] |= 0xC0;
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let cipher = Aes128::new(GenericArray::from_slice(uk));
let mut blk = GenericArray::clone_from_slice(&header);
let cipher = Aes128::new(&(*uk).into());
let mut blk: Array<u8, _> = header.into();
cipher.encrypt_block(&mut blk);
let mut dk = [0u8; 16];
for i in 0..16 {
dk[i] = blk[i] ^ header[i];
}
let bc = Aes128::new(GenericArray::from_slice(&dk));
let bc = Aes128::new(&dk.into());
let mut prev = AACS_IV;
let mut i = 16;
while i + 16 <= ALIGNED_UNIT_LEN {
@@ -5439,7 +5439,7 @@ mod tests {
for j in 0..16 {
b[j] = unit[i + j] ^ prev[j];
}
let mut g = GenericArray::clone_from_slice(&b);
let mut g: Array<u8, _> = b.into();
bc.encrypt_block(&mut g);
for j in 0..16 {
unit[i + j] = g[j];
+1 -3
View File
@@ -21,6 +21,7 @@
//!
//! So: drive sources get `freemkv_engine::copy`. Everything else gets this.
use crate::consts::SECTOR_BYTES;
use crate::error::{Error, Result};
use crate::halt::Halt;
use crate::sector::SectorSource;
@@ -33,9 +34,6 @@ use std::path::Path;
/// a notable allocation and cancellation stays responsive.
const BATCH_SECTORS: u32 = 2048;
/// Bytes per sector. Fixed for every medium this crate reads.
const SECTOR_BYTES: usize = 2048;
/// Write `total_sectors` sectors from `reader` to `dest`.
///
/// Reads sequentially from LBA 0 and writes in order, so the output is a faithful
+13 -4
View File
@@ -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
View File
@@ -98,6 +98,74 @@ enum ForcedSub {
ForcedNarrative,
}
/// The one number behind every cap in this parser: the highest CSV cell
/// position that can ever be addressed.
///
/// The labelling loops number cells 1-based into a `u16` and `break` at
/// `u16::try_from(i + 1)`, so cell `MAX_COM_INDICES` and everything past it is
/// never visited. Two different things are measured against that, and they are
/// not the same bound:
///
/// - **A VALUE at or beyond it cannot match any cell.** This is what caps the
/// set: values are filtered before insertion, so at most `MAX_COM_INDICES`
/// distinct entries can ever be stored, however long the attribute is. The
/// `HashSet` that replaced a linear scan fixed the LOOKUP cost; this is what
/// fixes the ALLOCATION, and a disc declaring half a billion indices no
/// longer costs half a billion entries.
/// - **A POSITION at or beyond it describes nothing new.** This caps the WORK,
/// not the memory — the value filter already made the set small, but without
/// it every one of those half a billion cells is still split and parsed. It
/// is an early exit at the first position whose contents provably cannot
/// matter, and it is why `forced_sub` — which holds no set at all, and so
/// gets no protection from the value rule — is bounded too.
///
/// Real authoring is nowhere near either limit: the BD STN table admits at
/// most 32 streams per playlist, so nothing legitimate is lost.
const MAX_COM_INDICES: usize = u16::MAX as usize;
/// Parse a `*_com1_idx` attribute into the set the labelling loops query.
///
/// Extracted so the BOUND is observable. Asserting it through
/// `labels_from_feature` is not possible: a `HashSet` collapses repeated
/// values, and an out-of-range index changes no label either way, so such a
/// test passes whether or not the cap exists — an assertion that cannot fail.
/// Returning the set lets a test hand in tens of thousands of DISTINCT
/// unaddressable indices and see them refused.
fn com_indices(attr: Option<String>) -> HashSet<usize> {
attr.map(|s| {
s.split(',')
.take(MAX_COM_INDICES)
.filter_map(|i| i.trim().parse().ok())
.filter(|&i| i < MAX_COM_INDICES)
.collect()
})
.unwrap_or_default()
}
/// Parse the `forced_sub` attribute into the cell list the subtitle loop
/// queries — the third attacker-controlled CSV in this file, and the last one
/// that was still unbounded.
///
/// Bounded by POSITION, and it has no other choice: a `*_com1_idx` list holds
/// values that can be filtered, and that filter is what caps its set, but a
/// `forced_sub` cell is a classification of the position it sits at, so there
/// is nothing to filter and nothing else would ever cap this. The Vec is read
/// only as `forced.get(i)` from a loop that stops at `MAX_COM_INDICES`, so
/// every cell past that is unreachable by construction.
///
/// Extracted, like [`com_indices`], so the bound is OBSERVABLE. Through
/// `labels_from_feature` it is not: the subtitle loop cannot reach those cells
/// either, so a label-level assertion passes whether or not the cap exists.
fn forced_subs(attr: Option<String>) -> Vec<ForcedSub> {
attr.map(|s| {
s.split(',')
.take(MAX_COM_INDICES)
.map(forced_sub_cell)
.collect()
})
.unwrap_or_default()
}
fn forced_sub_cell(cell: &str) -> ForcedSub {
match cell.trim() {
"1" => ForcedSub::ContainsForcedSegments,
@@ -123,9 +191,7 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
// linearly once per stream, so `aud="..."` and `aud_com1_idx="..."`
// both grown large make this quadratic in the size of one XML file.
// Membership is the only operation performed on it.
let com_indices: HashSet<usize> = xml::attr(feature, "aud_com1_idx")
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
.unwrap_or_default();
let com_indices = com_indices(xml::attr(feature, "aud_com1_idx"));
// The CSV *is* the STN list: one cell per stream, in stream order,
// and `aud_com1_idx` is a 0-based index into those same cells. So
@@ -172,15 +238,11 @@ fn labels_from_feature(feature: &str) -> Vec<StreamLabel> {
// Parse subtitle streams
if let Some(sub) = xml::attr(feature, "sub") {
let forced: Vec<ForcedSub> = xml::attr(feature, "forced_sub")
.map(|s| s.split(',').map(forced_sub_cell).collect())
.unwrap_or_default();
let forced = forced_subs(xml::attr(feature, "forced_sub"));
// HashSet for the same reason as the audio side above: unbounded
// parsed input, membership-only use, linear scan once per stream.
let com_indices: HashSet<usize> = xml::attr(feature, "sub_com1_idx")
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
.unwrap_or_default();
let com_indices = com_indices(xml::attr(feature, "sub_com1_idx"));
// As with audio: the cell position IS the STN slot. `forced_sub` and
// `sub_com1_idx` are indexed against those same cells, so an empty
@@ -315,61 +377,133 @@ mod tests {
);
}
/// `sub_com1_idx` is an unbounded index list parsed straight out of the
/// disc's `playlists.xml` and was membership-tested with a linear
/// `Vec::contains` once per subtitle stream — quadratic in the size of a
/// single attacker-supplied file.
/// `sub_com1_idx` is parsed straight out of the disc's `playlists.xml`,
/// which is attacker-controlled and has no length bound of its own.
///
/// Proof is by deadline rather than micro-benchmark. With the linear scan
/// the original fixture (200 000 streams x 1 000 001 indices) measured
/// 31 s in a release build and far longer in debug; with a set it measured
/// 0.03 s release / 0.56 s debug. A 10 s deadline sits ~18x above the
/// slowest passing measurement and ~3x below the fastest failing one, and
/// makes a regression fail fast instead of hanging CI.
/// This replaces a WALL-CLOCK test. That one built a 200 000 x 1 000 001
/// fixture and failed if it took over 10 s, to prove the membership test
/// was a set rather than a linear scan. Measured on the machine that
/// wrote this: 1.62 s alone, and OVER 10 s — a real failure — when the
/// suite's other 3 347 tests were running concurrently. A 6x margin
/// against a shared CPU is not a margin; it is a CI failure that looks
/// like a flake and gets re-run until it passes.
///
/// The CSV now stops at the end of the 1-based `u16` stream-numbering
/// space, so only the first 65 535 cells are scanned. `INDICES` is raised
/// to keep the linear-scan work product (`cells x indices`) at or above
/// the original fixture's, preserving that deadline margin.
/// It also measured the wrong thing. Making the lookup O(1) bounded the
/// QUERY, not the PARSE: the set was still built from every entry the
/// disc declared, so a hostile playlist could still force an unbounded
/// allocation before any lookup happened. `MAX_COM_INDICES` bounds that.
///
/// Correctness is pinned on fixture-derived literals: indices 0, 2 and 4
/// are the commentary tracks, 1 and 3 are not.
/// What THIS test guards is that bounding did not change what a
/// legitimate playlist MEANS: it goes red if the bound is set too LOW
/// (verified at 2 — the real indices `0,2,4` stop resolving and the
/// purposes change). It does NOT go red if the bound is deleted
/// entirely, because the out-of-range filler is unobservable at the
/// label level and a `HashSet` collapses the repeats. Enforcement is
/// proven separately, by
/// `distinct_unaddressable_indices_are_refused_not_stored`, which reads
/// the set itself. Two tests, two properties; neither pretends to the
/// other's job.
#[test]
fn commentary_index_lookup_is_not_quadratic() {
/// Cells offered. Everything past `u16::MAX` is unnumberable and the
/// parser stops there, so the scanned prefix is 65 535 cells.
const STREAMS: usize = 200_000;
const SCANNED: usize = u16::MAX as usize;
const INDICES: usize = 3_100_000;
let (tx, rx) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
let mut feature = String::from(r#"<playlist name="Feature" sub=""#);
feature.push_str(&"eng,".repeat(STREAMS));
feature.pop();
// Three real commentary indices, then a long run of one
// out-of-range value: nothing here is bounded by the stream count.
feature.push_str(r#"" sub_com1_idx="0,2,4,"#);
feature.push_str(&"9999999,".repeat(INDICES));
feature.pop();
feature.push_str(r#"" />"#);
let _ = tx.send(labels_from_feature(&feature));
});
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
Ok(labels) => {
worker.join().expect("worker panicked");
assert_eq!(labels.len(), SCANNED);
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
assert_eq!(labels[1].purpose, LabelPurpose::Normal);
assert_eq!(labels[2].purpose, LabelPurpose::Commentary);
assert_eq!(labels[3].purpose, LabelPurpose::Normal);
assert_eq!(labels[4].purpose, LabelPurpose::Commentary);
}
Err(_) => panic!(
"labels_from_feature did not finish {STREAMS} streams x \
{INDICES} commentary indices within 10s — the membership \
test is still a linear scan"
),
}
fn bounding_the_parse_does_not_change_a_legitimate_playlist() {
// Three real indices, then far more entries than can address a cell.
const OVERSIZED: usize = MAX_COM_INDICES + 10_000;
let mut feature = String::from(r#"<playlist name="Feature" sub=""#);
feature.push_str(&"eng,".repeat(8));
feature.pop();
feature.push_str(r#"" sub_com1_idx="0,2,4,"#);
feature.push_str(&"9999999,".repeat(OVERSIZED));
feature.pop();
feature.push_str(r#"" />"#);
let labels = labels_from_feature(&feature);
// The fixture's real indices still decide the purposes: bounding the
// parse must not change what a legitimate playlist means.
assert_eq!(labels.len(), 8);
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
assert_eq!(labels[1].purpose, LabelPurpose::Normal);
assert_eq!(labels[2].purpose, LabelPurpose::Commentary);
assert_eq!(labels[3].purpose, LabelPurpose::Normal);
assert_eq!(labels[4].purpose, LabelPurpose::Commentary);
}
/// The set REFUSES unaddressable indices, so a hostile playlist cannot
/// inflate it. DISTINCT values on purpose: a `HashSet` collapses repeats,
/// so a million copies of one index costs one entry and would prove
/// nothing. Fifty thousand distinct out-of-range indices cost fifty
/// thousand entries without the filter, and none with it — so this test
/// goes red if the bound is removed, which the label-level assertions
/// below cannot do.
#[test]
fn distinct_unaddressable_indices_are_refused_not_stored() {
let hostile: String = (MAX_COM_INDICES..MAX_COM_INDICES + 50_000)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let set = com_indices(Some(hostile));
assert!(
set.is_empty(),
"kept {} unaddressable indices — the parse is still unbounded",
set.len()
);
// The addressable ones are still kept.
assert_eq!(com_indices(Some("0,2,4".to_string())).len(), 3);
}
/// `forced_sub` is bounded too — the third CSV in the same function, and
/// the one that had no value filter to hide behind.
///
/// Read through `forced_subs` rather than through the labels for the same
/// reason the two tests above read the set: the subtitle loop stops at
/// `MAX_COM_INDICES`, so a label-level assertion cannot tell a bounded
/// parse from an unbounded one.
#[test]
fn forced_sub_cells_past_the_last_addressable_one_are_not_parsed() {
let hostile = "0,".repeat(MAX_COM_INDICES + 50_000);
let cells = forced_subs(Some(hostile));
assert_eq!(
cells.len(),
MAX_COM_INDICES,
"parsed {} cells — the forced_sub parse is still unbounded",
cells.len()
);
}
/// Bounding it must not change what a legitimate playlist means: the
/// cells that CAN address a stream still classify exactly as before.
#[test]
fn bounding_forced_sub_leaves_the_addressable_cells_alone() {
let cells = forced_subs(Some("0,1,3,2".to_string()));
assert_eq!(
cells,
vec![
ForcedSub::None,
ForcedSub::ContainsForcedSegments,
ForcedSub::ForcedNarrative,
ForcedSub::ForcedNarrative,
]
);
}
/// An index that cannot address any cell is dropped rather than STORED.
///
/// Asserted through `com_indices`, not through the labels: the labelling
/// loop never queries a cell position that high, so at the label level
/// retaining the value is unobservable and the assertion could not fail.
/// Reading the set is what makes the claim checkable.
#[test]
fn an_index_that_cannot_address_any_cell_is_not_retained() {
let set = com_indices(Some(format!(
"1,{},{}",
MAX_COM_INDICES,
MAX_COM_INDICES + 1
)));
assert_eq!(
set.len(),
1,
"only the addressable index belongs in the set, got {set:?}"
);
assert!(set.contains(&1));
}
/// Headroom: the BD STN_table admits at most 32 PG streams per playlist,
+58 -1
View File
@@ -637,10 +637,18 @@ fn xml_escape(s: &str) -> String {
}
/// Replace path-hostile characters in a filename component.
///
/// Control characters included, NUL above all. Every string this touches is
/// disc bytes, and a language code is three raw STN bytes run through
/// `from_utf8_lossy` with no validation — `00 00 00` is the ordinary
/// "undefined" encoding on real Blu-rays. A NUL in a path aborts `File::create`
/// with `InvalidInput`, which took the whole demux export down before a single
/// track file was opened.
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
_ => c,
})
.collect()
@@ -787,8 +795,11 @@ impl DemuxSink {
Naming::Pid => format!("{} {:04x}", sanitize(&opts.base), pid),
Naming::Friendly => {
let mut parts = vec![sanitize(&opts.base), format!("t{idx:02}")];
// The language is disc bytes too, and got none of the
// treatment `opts.base` two lines up already had.
let lang = sanitize(lang);
if !lang.is_empty() {
parts.push(lang.to_string());
parts.push(lang);
}
parts.push(codec_label(codec).to_string());
parts.join(" ")
@@ -987,6 +998,52 @@ mod tests {
Resolution, SampleRate, VideoStream,
};
// ── The language component is disc bytes ──────────────────────────────
//
// `opts.base` was sanitised and the language beside it was not, though a
// language code is three raw STN bytes run through `from_utf8_lossy` with
// no validation. `00 00 00` is the ordinary "undefined" encoding on real
// Blu-rays, and a NUL in a path fails `File::create` with `InvalidInput` —
// which aborted the whole export before a single track file was opened.
/// The stem stays one usable filename component whatever the disc says.
#[test]
fn a_hostile_language_code_cannot_break_the_output_filename() {
let opts = DemuxOptions {
base: "Movie".to_string(),
..Default::default()
};
assert!(matches!(opts.naming, Naming::Friendly), "default naming");
for lang in ["\u{0}\u{0}\u{0}", "a/b", "..", "a\nb", "e:s"] {
let stem = DemuxSink::stem_for(&opts, 1, 0x1100, lang, Codec::Ac3);
assert!(
!stem.chars().any(|c| c.is_control()),
"control character survived into the filename for {lang:?}: {stem:?}"
);
assert!(
!stem.contains('/') && !stem.contains('\\') && !stem.contains(':'),
"a path separator survived for {lang:?}: {stem:?}"
);
assert!(
std::path::Path::new(&stem).components().count() == 1,
"the stem must stay ONE component for {lang:?}: {stem:?}"
);
}
}
/// A legitimate language is still carried through untouched — the
/// sanitiser must not be so lossy that it stops naming the track.
#[test]
fn an_ordinary_language_code_survives_sanitising() {
let opts = DemuxOptions {
base: "Movie".to_string(),
..Default::default()
};
let stem = DemuxSink::stem_for(&opts, 1, 0x1100, "eng", Codec::Ac3);
assert!(stem.contains("eng"), "got {stem:?}");
}
fn video_stream(codec: Codec) -> DiscStream {
DiscStream::Video(VideoStream {
pid: 0x1011,
+3 -1
View File
@@ -1448,7 +1448,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
// all tracks, A/V-sync-preserving) keeps the boundary from becoming a
// band of non-monotonic block timestamps. Only the PRIMARY video track
// drives the boundary decision; every other track (audio, subtitle, DV
// EL) rides the current offset. No-op for single-clip titles.
// EL) rides the current offset. A single-clip BD title still trims to
// its one clip's [in, out] marks (dropping trailing audio the m2ts
// carries past OUT); only a source with no marks at all is a true no-op.
// `None` means the playlist does not include this frame — material
// outside every clip's IN/OUT marks, which only a seam-plan-driven
// title can report. Dropping it is the point: emitting it is what put
+65 -11
View File
@@ -151,14 +151,25 @@ struct TrackPos {
impl SeamPlan {
/// Build a plan from a title's clips, or `None` when there is nothing to
/// place: no clips (DVD, HD-DVD, `mkv://`/`m2ts://` sources — none of which
/// carry PlayItem marks), a single clip (nothing to join), or marks that are
/// not usable (a zero/inverted span means the playlist is not telling us
/// anything we can act on, and guessing is what this type exists to avoid).
/// carry PlayItem marks), or marks that are not usable (a zero/inverted span
/// means the playlist is not telling us anything we can act on, and guessing
/// is what this type exists to avoid).
///
/// A **single** clip still gets a plan. Joining is not the marks' only job —
/// trimming to `[in, out]` is — and it matters whenever a clip's physical
/// extent runs past its OUT mark. Real discs author trailing audio (a fade
/// after the last video frame) beyond OUT in the m2ts; without a plan the
/// inference path keeps it, leaving audio seconds past the declared duration.
/// One clip needs no cross-clip placement, so the loop below reduces to the
/// `[in, out]` drop filter plus the standard `offset = −in_ns` rebase — the
/// same one clip 0 of a multi-clip title already gets, which the MKV muxer
/// then re-anchors, so every KEPT frame is byte-identical to the no-plan
/// path. The only change is that out-of-mark frames are now dropped.
///
/// Returning `None` leaves [`TimelineContinuity`] on its PTS-jump inference,
/// which is what every non-BD path has always used.
pub(crate) fn from_clips(clips: &[crate::disc::Clip]) -> Option<Self> {
if clips.len() < 2 {
if clips.is_empty() {
return None;
}
// Trust the spans only if they tile the feed contiguously from 0.
@@ -730,9 +741,10 @@ impl TimelineContinuity {
/// Corrector driven by a title's PlayItem marks where they exist.
///
/// Falls back to [`Self::new`]'s inference when the title has fewer than two
/// clips or its marks are unusable — so DVD, HD-DVD, `mkv://` and `m2ts://`
/// sources behave exactly as before.
/// Falls back to [`Self::new`]'s inference when the title has no clips or
/// its marks are unusable — so DVD, HD-DVD, `mkv://` and `m2ts://` sources
/// behave exactly as before. A single BD clip DOES get a plan: its marks
/// still trim trailing/pre-roll material outside `[in, out]`.
pub(crate) fn with_clips(
clips: &[crate::disc::Clip],
content_format: crate::disc::ContentFormat,
@@ -2226,10 +2238,6 @@ mod tests {
#[test]
fn no_seam_plan_without_usable_marks() {
assert!(SeamPlan::from_clips(&[]).is_none(), "no clips");
assert!(
SeamPlan::from_clips(&seamless_branching_clips()[..1]).is_none(),
"a single clip has nothing to join"
);
let mut bad = seamless_branching_clips();
bad[3].out_time = bad[3].in_time; // zero-length span
assert!(
@@ -2238,6 +2246,52 @@ mod tests {
);
}
/// A SINGLE-clip title still gets a plan: joining is not the marks' only job,
/// trimming to `[in, out]` is. Real discs author trailing audio (a fade after
/// the last video frame) PAST the OUT mark in the m2ts; the no-plan inference
/// path kept it, leaving audio seconds past the declared duration — the
/// `The Bourne Supremacy` defect (audio +35.6 s past a single-clip title's
/// end). The plan drops it, and every KEPT frame is placed exactly as the
/// no-plan path would (raw rebased by `−in_ns`, which the MKV muxer already
/// does), so a disc with no out-of-mark content is byte-identical.
#[test]
fn single_clip_trims_content_outside_its_marks() {
let clips = seamless_branching_clips(); // clip 0: in 4199.0s, out 6033.04s
let mut plan = SeamPlan::from_clips(&clips[..1]).expect("a single clip is planned");
let in_ns = mpls_ticks_to_ns(clips[0].in_time);
let out_ns = mpls_ticks_to_ns(clips[0].out_time);
// In-mark frames are kept and rebased to 0 at IN — and drop nothing.
assert_eq!(plan.place(in_ns, 0, true, None), Some(0), "IN rebases to 0");
assert_eq!(
plan.place(in_ns + 10 * S, 1, false, None),
Some(10 * S),
"a mid-clip audio frame keeps its offset from IN"
);
assert_eq!(
plan.place(out_ns, 2, false, None),
Some(out_ns - in_ns),
"a frame AT the OUT mark is inside and kept"
);
assert_eq!(
plan.dropped_total(),
0,
"no in-mark frame may be dropped (output is unchanged for a clean disc)"
);
// Trailing audio past OUT is dropped AND counted, so the finish() gates
// in the sinks can see the volume.
assert!(
plan.place(out_ns + 30 * S, 1, false, None).is_none(),
"trailing audio 30s past OUT must be dropped, not emitted past the end"
);
assert!(
plan.dropped_for(1) >= 1,
"the dropped tail frame must be counted"
);
}
/// Without a plan, `map` is exactly the old `adjust` and never drops.
#[test]
fn map_without_a_plan_is_the_old_behaviour() {
+18 -14
View File
@@ -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]);