Compare commits

...
31 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
Matthew Jackson 8d17904459 v1.6.2: bump version (freemkv-unlock git-pinned for the tag) 2026-08-08 22:18:28 -07:00
Matthew Jackson 1bdb8add2f Stop trusting a byte offset when the feed is not what was measured
A clip's span is measured over the title's full extents when the disc is
scanned, and a frame is placed by the offset it was read from. Those only
agree while the mux reads every byte the scan counted.

On a disc carrying a forensic segment it does not: the read plan omits the
units belonging to another device group, so fewer bytes are fed than the
spans describe, and the shortfall grows through the title. Every frame past
the first segment then looks earlier than it is — placed in a clip it did
not come from, or dropped at a join for failing marks it was never inside.
The spans still tile one another perfectly, so the check that asks whether
they can be trusted cannot see any of it.

The plan is now compared against the extents it was built from. When they
differ the spans are dropped and placement falls back to timestamps, which
is what that path is for and what the surrounding comment already promised
would happen when an offset stops meaning anything. Ordinary discs are
untouched: with no forensic segment the plan IS the extents.
2026-08-08 19:42:53 -07:00
Matthew Jackson c9562c07a3 Make four guards testable, and one guard see both spellings
The provenance guard knew one way to lose a frame's source offset —
writing it as absent. Omitting the field entirely does the same thing,
because the frame type fills it in by default, and the guard read straight
past that. A parser rewritten into the second spelling would have kept its
green light while its track silently went back to being placed by guesswork.
It now reports which spelling it found and where.

The title-count clamp was asserted over a fixture too small to hold more
titles than the cap allows, so the walk stopped when the buffer ran out and
the clamp was never what bounded it — the assertion held with the clamp
deleted. The fixture now carries more entries than the cap.

The subdirectory limit was checked by restating the constant's own
definition; the guard itself had never run, and deleting it changed
nothing. The limit is lowered under test so a real folder can exceed it,
and the test now walks one and requires the refusal.

One test also carried two unrelated grounding notes while the test they
described had none, so reading the note above a test told you about a
different one.
2026-08-08 19:06:37 -07:00
Matthew Jackson 7f9dcf1870 Keep both halves of a clip file that two play items share
A playlist may point at the same .m2ts from several play items — a
seamless split, a looped segment, multiple angles. The file has one set of
bytes, so it has one span, and the byte a frame was read from therefore
identifies the FILE, not which play item's range it falls in. Placement
took the first of them and judged every frame against its marks, so
everything past that range's end was treated as material the playlist
excludes and dropped: half the clip missing from the rip, with the
timeline still charged for its full duration.

The offset narrows a frame to the run; only its timestamp can finish the
job, and each play item carries its own marks. A frame is now matched
against the marks of the entry it actually falls in, falling back to the
first when it falls in none, which leaves genuinely-excluded material
dropped as before.

The test for this construction asserted only that such a playlist is
trusted, and never placed a frame from the second range — so the loss it
described in prose was invisible to it. It now places one from every range
and requires them all to survive.

The deferred-mux replay of buffered frames wrote them without their source
offset, quietly sending the head of every such title down the timestamp
heuristic the rest of this work exists to retire. It passes the offset
through now, and the frame writer that omits it is compiled out of the
library entirely: no production path can discard provenance any more.

Also: the AC-3 buffer-reuse test could not see the regression it named.
Feeding equal-sized packets, a fresh allocation per call yields the same
capacity as a reused one. It now feeds a large packet then a small one,
where only a reused buffer keeps the larger capacity.
2026-08-08 17:18:35 -07:00
Matthew Jackson 3c5c681399 Audit round: an ADTS frame that cannot hold its own CRC, and three drifts
An ADTS header that declares a CRC follows must be at least nine bytes —
seven of header plus the two the CRC occupies — because the declared frame
length counts them. The structural gate compared against a flat seven and
never read the bit that says whether a CRC is there at all, so a frame
whose own header describes something impossible was accepted and handed to
the muxer as decodable.

The pipeline's spawn doc named Sweep, and the thread Sweep would have
created, as callers to look for. Neither has been in this crate since the
recovery passes moved out. The same paragraph already records fixing this
once, for a different departed caller — it simply drifted again a sentence
later, so it now says to name callers that live here or name none.

Whether a disc is structurally AACS-encrypted was spelled out by hand in
both the fast identify and the full scan. They agreed today; nothing made
them agree tomorrow, and disagreeing would mean the same disc reported
encrypted by one path and clear by the other. There is one definition now,
and the comment that pointed at it by line number points at its name.

The AC-3 parser built a fresh buffer on every packet — of the order of a
hundred thousand times per title — to work around a borrow it cannot
avoid. The copy stays; the allocation does not. The buffer is now lent out
and handed back, and a test pins that, because reverting it would be
invisible in behaviour.

Left alone deliberately: send/send_with_halt and finish/finish_with_halt
look like one action under two names, and are not. After the consumer
fails, one must still accept items and the other must refuse them; that
difference is what stops a producer reading an entire disc for a write
that died on its first frame. Collapsing them was tried here and the
existing test caught it. Both now say so where the choice is made.
2026-08-08 16:55:55 -07:00
Matthew Jackson 43d9fb583a Add an error for an image shorter than its recovery data
A recovery resumed against an image that has been truncated since the
previous pass — a full disk, an interrupted transfer, a remount — cannot
repair it: the pass only revisits the ranges the recovery data calls bad,
so everything past the cut stays a hole while the counts still describe a
whole disc. There was no way to say that. Reusing the recovery-data error
would have been wrong, because the recovery data is intact; the image is
not.

Registered in the uniqueness list, the code table and the range check, so
a future variant cannot silently reuse 6015 or map to the wrong code.
2026-08-08 13:35:44 -07:00
Matthew Jackson 60ed45c047 changelog: 1.6.2 2026-08-08 08:53:13 -07:00
Matthew Jackson 07cc9fa5f9 ci: run the dependents' test suites, not just a type-check
The `consumers` job checked out all five dependents against this commit and
ran `cargo check --all-targets`. That proves they still COMPILE, which
catches a changed signature and nothing else. The failures worth catching
here keep every signature intact and change behaviour: the library still
builds, the dependent still builds, and the dependent's tests are what go
red. Those never ran.

Run their suites instead. Same checkouts, same patched paths — only the
verb changes.
2026-08-08 08:46:17 -07:00
Matthew Jackson 1e6bb8babd Give a frame that outruns its epoch's video a provisional offset
The demuxer interleaves, so at a boundary the streams do not reset on the
same frame: audio for the next segment can reach the muxer before the video
frame that opens the epoch it belongs to. Only the primary video track may
open one, so those frames rode the just-ended offset and landed a whole
segment in the past. Downstream the strictly-monotonic block nudge then
crushed the entire run onto one instant a tick apart, which is audible.

Such a frame is recognised on its OWN raw PTS, which within an epoch only
advances, so a large backward step is unambiguous — a different signal from
the shared frontier, which is what the old false-positive ratchet keyed on.
It then rides a provisional offset private to its track, computed the same
way the video path computes a real one, and drops it the moment the video
retires an epoch, so the run rejoins with no seam.

The offset is deliberately private: it never writes offset_ns, never
advances the frontier and never retires an epoch, so it cannot move the
video timeline. Letting a passive track open a REAL epoch was tried first
and inflated a 476.776 s title to 656.216 s, because every track meets a
boundary at its own pace and the video path rebased again on top.

Measured on a real DVD title with 8 cell boundaries: audio frames stamped
inside one cadence 120 -> 0, outsized gaps 7 -> 0, subtitle 1 -> 0, and the
video span byte-identical at 476.484. On the HD-DVD title the remaining
boundary gap on its first audio track falls from 0.999 s to 0.275 s.
Acceptance: 76 pass, 0 fail, 0 skip.
2026-08-08 00:48:10 -07:00
Matthew Jackson 64f894c9f3 Place a straggler by its own epoch's end, not the current frontier
A non-video frame can reach the muxer after the video that opened the next
epoch has already been processed. It carries an old-epoch PTS, so adding
the new offset flings it forward by a whole clip. The remap that exists to
catch this compared the frame against the CURRENT frontier and only
accepted it within one backstep, which is the wrong yardstick: by the time
a straggler is seen the frontier has moved on into a new epoch, and how far
below it the frame lands says nothing about where it belongs.

Retiring an epoch now records the frontier it closed at alongside its
offset, and a straggler is judged against the end of the epoch its PTS came
from. Keeping the whole history rather than one previous offset also fixes
the case a single `prev_offset_ns` cannot express at all: a frame from two
or more epochs back.

Measured on a real HD-DVD title whose second audio track put its final
packet at 12834.587 s in a 6434.100 s file. That frame sits 23 s below the
current frontier — outside the old window, so it was refused — but 0.15 s
below the end of the clip it actually came from. It now lands at 6417.216,
one 32 ms cadence step after its neighbour, and the track spans 6416.160
with no outsized gaps. The other two streams are unchanged.

A title that never rebases retires no epoch, so the lookup finds nothing
and the mapping is bit-identical to before.
2026-08-08 00:02:57 -07:00
Matthew Jackson 3820348d2f test: cover the 1.6.1 provenance work where it was assumed, not asserted
Four parsers (dvdsub, flac, lpcm, mpegaudio) stamp a source offset on
every frame but had no test that read one back, so a regression to
`source: None` would have been caught only by the brace-balanced audit
in codec/mod.rs — a lint, not a behavioural check. Each now asserts the
emitted frame carries the offset of the packet that supplied its first
byte.

The Blu-ray feed spans had no direct test at all. Add one that walks a
multi-item playlist and requires the spans to tile the feed with no gap
or overlap; it catches a one-sector-per-clip drift, which is exactly the
error class that would misattribute frames near a seam.

`no_provenance_still_places_by_marks` asserted only that placement
returned something, which passes for a frame placed in the wrong clip.
Its probe timestamp lands in an overlap between two clips in the real
mark table, so pinning one clip would assert a coin-flip; instead
require the offset to be one that a clip actually containing that
timestamp would produce.
2026-08-07 22:16:02 -07:00
Matthew Jackson 102ae82933 Test the DVD title table's sector-offset arithmetic
The TT_SRPT pointer at 0xC4 is a SECTOR offset from the start of
VIDEO_TS.IFO. Reading it as a byte offset lands 1/2048th of the way in and
does not error — it silently returns a different title list, which is how
a disc enumerating 38 titles and an image enumerating 10 looked like a
scan difference rather than a failure.

The table already had tests for its entry layout, dedup and the 99-title
clamp. What it did not have was anything pinning the offset unit itself,
or the grouping of titles by title set, or a truncated table keeping the
rows it can read rather than failing a whole disc.
2026-08-07 22:01:37 -07:00
Matthew Jackson 9d05f54ddb restore freemkv-unlock path dep for local dev (post-v1.6.1) 2026-08-07 19:08:46 -07:00
38 changed files with 1943 additions and 294 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
+51 -21
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
@@ -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
+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
+68
View File
@@ -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
View File
@@ -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
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
+43
View File
@@ -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;
+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.
+46 -15
View File
@@ -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);
}
}
+185
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.
@@ -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
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
+26 -9
View File
@@ -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];
+19
View File
@@ -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
View File
@@ -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}"
);
}
}
+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
+29 -14
View File
@@ -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
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,
+84 -1
View File
@@ -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
View File
@@ -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> {
+18
View File
@@ -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));
}
}
+15
View File
@@ -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));
}
}
+17
View File
@@ -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
View File
@@ -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})"));
}
}
}
+15
View File
@@ -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
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,
@@ -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
View File
@@ -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
+9 -1
View File
@@ -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)));
+26
View File
@@ -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
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.
@@ -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
View File
@@ -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
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]);