mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6658a80819 | ||
|
|
3db11a192c | ||
|
|
a425a71cd5 | ||
|
|
f79745d815 | ||
|
|
5d8f77e049 | ||
|
|
79a16a9279 | ||
|
|
219a77b581 | ||
|
|
be378cbc34 | ||
|
|
63200a1d1f | ||
|
|
526dd3552b | ||
|
|
8d17904459 | ||
|
|
1bdb8add2f | ||
|
|
c9562c07a3 | ||
|
|
7f9dcf1870 | ||
|
|
3c5c681399 | ||
|
|
43d9fb583a | ||
|
|
60ed45c047 | ||
|
|
07cc9fa5f9 | ||
|
|
1e6bb8babd | ||
|
|
64f894c9f3 | ||
|
|
3820348d2f | ||
|
|
102ae82933 | ||
|
|
9d05f54ddb |
@@ -0,0 +1,48 @@
|
||||
version: 2
|
||||
|
||||
# Dependency updates land on `dev`, never on `main`.
|
||||
#
|
||||
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
|
||||
# commit on it would put work there that no tag contains, which is exactly the
|
||||
# state that aborted the 1.6.2 cascade at the last step -- so pointing
|
||||
# Dependabot at main would recreate that failure on a schedule.
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
# One PR per week for the routine bumps instead of one per crate. Eight
|
||||
# repos times a handful of crates is a volume nobody reads, and an
|
||||
# unread PR queue is indistinguishable from no updates at all.
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The freemkv crates depend on each other by GIT TAG, re-pinned by
|
||||
# release.sh as part of the release commit. Dependabot cannot see that
|
||||
# cascade, so a PR bumping one of these would fight the release process
|
||||
# and could pin a version whose tag does not exist yet.
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
|
||||
# The workflows are now real infrastructure -- the release cascade, the
|
||||
# cross-platform hash matrix, the disc gate -- so their actions need the same
|
||||
# attention as the crates.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
+51
-21
@@ -14,7 +14,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -77,9 +77,22 @@ jobs:
|
||||
working-directory: libfreemkv
|
||||
|
||||
check-macos:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
@@ -91,7 +104,7 @@ jobs:
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -104,9 +117,22 @@ jobs:
|
||||
working-directory: libfreemkv
|
||||
|
||||
check-windows:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
# libfreemkv path-deps ../freemkv-unlock on the BRANCH tip (release.sh
|
||||
@@ -118,7 +144,7 @@ jobs:
|
||||
# Both repos go into subdirectories because actions/checkout refuses a
|
||||
# `path:` outside $GITHUB_WORKSPACE, and `../freemkv-unlock` is outside.
|
||||
# With this layout the path dep resolves exactly as it does locally.
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
@@ -149,21 +175,21 @@ jobs:
|
||||
consumers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { path: libfreemkv }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-unlock, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-unlock }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-keysources, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-keysources }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-engine, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-engine }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv-i18n, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv-i18n }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/freemkv, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: freemkv }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/autorip, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: autorip }
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with: { repository: freemkv/bdemu, ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}", path: bdemu }
|
||||
- name: Point every dependent at THIS libfreemkv commit
|
||||
shell: bash
|
||||
@@ -187,13 +213,17 @@ jobs:
|
||||
freemkv
|
||||
autorip
|
||||
bdemu
|
||||
- run: cargo check --all-targets
|
||||
# `cargo check` alone only proves the dependents still COMPILE against
|
||||
# this commit. It cannot see a behavioural change — the library keeps its
|
||||
# signatures and a dependent's tests start failing. That is the shape of
|
||||
# every defect worth catching here, so run their suites too.
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-keysources
|
||||
- run: cargo check --all-targets
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-engine
|
||||
- run: cargo check --all-targets
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv
|
||||
- run: cargo check --all-targets
|
||||
- run: cargo test --tests
|
||||
working-directory: autorip
|
||||
- run: cargo check --all-targets
|
||||
- run: cargo test --tests
|
||||
working-directory: bdemu
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
@@ -92,10 +92,10 @@ jobs:
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
@@ -117,10 +117,10 @@ jobs:
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# libfreemkv is a library — Cargo.lock isn't tracked, so --locked
|
||||
@@ -56,8 +56,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Update README version
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
|
||||
|
||||
- name: Update version in README
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
FILE="README.md"
|
||||
|
||||
# Update cargo dependency version (e.g. "0.2" -> "0.3")
|
||||
MAJOR_MINOR="${VERSION#v}"
|
||||
MAJOR_MINOR="${MAJOR_MINOR%.*}"
|
||||
sed -i "s|libfreemkv = \"[0-9]*\.[0-9]*\"|libfreemkv = \"${MAJOR_MINOR}\"|" "$FILE"
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add README.md
|
||||
git diff --cached --quiet || git commit -m "Update to libfreemkv ${VERSION}"
|
||||
git push
|
||||
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## [1.6.3] — 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **Six crates that were never used have been removed, and nothing about reading
|
||||
a disc has changed.** They were declared as dependencies but referenced
|
||||
nowhere, so they were compiled into every build for nothing. The rest were
|
||||
aligned with the versions the other freemkv crates use.
|
||||
- **Two complete AES implementations were being built into the product.** Two
|
||||
different releases of the cipher crates had been pulled in by different parts
|
||||
of the tree and both were compiled. They are now one.
|
||||
|
||||
## [1.6.2] — 2026-08-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A stray moment of sound at the end of an HD-DVD title, and a click at every
|
||||
chapter break on a DVD.** Both came from the same thing. A disc's sound and
|
||||
picture do not arrive in lockstep, and where a title is stitched from
|
||||
segments, a few frames of sound can reach the muxer just before or just after
|
||||
the picture that marks the join. Those frames were timed against the wrong
|
||||
segment. On one HD-DVD title a single trailing sound frame was placed at
|
||||
3h33m in a 1h47m film. On a DVD, roughly half a second of sound was squeezed
|
||||
into an instant at each of eight chapter breaks — audible as a click, eight
|
||||
times in an eight-minute title. Sound is now timed against the segment it
|
||||
belongs to. Measured on both discs: the stray frame lands one frame after its
|
||||
neighbour, and the DVD's collapsed runs and jumps are gone, with picture
|
||||
timing unchanged to the millisecond. Blu-ray was never affected.
|
||||
|
||||
## [1.6.1] — 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
+6
-12
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.6.1"
|
||||
version = "1.6.3"
|
||||
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.3" }
|
||||
rand = "0.10"
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.23"
|
||||
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
|
||||
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
|
||||
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.6.x | Yes |
|
||||
| < 1.6 | No |
|
||||
|
||||
Only the current 1.6.x line receives security fixes.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report vulnerabilities privately through GitHub Security Advisories:
|
||||
https://github.com/freemkv/libfreemkv/security/advisories/new
|
||||
|
||||
Do not open a public issue for a security report. Include the affected
|
||||
version, steps to reproduce, and the impact you believe the issue has.
|
||||
|
||||
## Response time
|
||||
|
||||
You will get an initial response within 7 days.
|
||||
+10
-6
@@ -4,7 +4,7 @@
|
||||
#[cfg(test)]
|
||||
use aes::Aes128;
|
||||
#[cfg(test)]
|
||||
use aes::cipher::{KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, KeyInit};
|
||||
|
||||
use super::crypto::{aes_cbc_decrypt, aes_cbc_encrypt, aes_ecb_encrypt};
|
||||
// Only this module's test fixtures build CBC ciphertext by hand now — the
|
||||
@@ -418,7 +418,7 @@ pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||
mod tests {
|
||||
use super::super::crypto::aes_ecb_decrypt;
|
||||
use super::*;
|
||||
use aes::cipher::BlockEncrypt; // test fixtures build ciphertext directly
|
||||
use aes::cipher::BlockCipherEncrypt; // test fixtures build ciphertext directly
|
||||
|
||||
/// [`encrypt_unit`] is the exact inverse of [`decrypt_unit`]: whatever an
|
||||
/// authoring caller encrypts, the read path must recover byte-for-byte.
|
||||
@@ -725,7 +725,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// CBC encrypt bytes 16..6143
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
||||
let cipher = Aes128::new(&encrypt_key.into());
|
||||
let mut prev = AACS_IV;
|
||||
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
@@ -733,7 +733,9 @@ mod tests {
|
||||
for j in 0..16 {
|
||||
plain[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]);
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&plain[off..off + 16]);
|
||||
let mut block: Array<u8, _> = chunk.into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
plain[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&plain[off..off + 16]);
|
||||
@@ -1496,7 +1498,7 @@ mod tests {
|
||||
let plain = unit.clone();
|
||||
|
||||
// Forward: CBC-encrypt unit[s+16 .. s+2048] per sector under AACS IV.
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&rdk));
|
||||
let cipher = Aes128::new(&rdk.into());
|
||||
for s in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_BYTES) {
|
||||
let mut prev = AACS_IV;
|
||||
let body = s + 16;
|
||||
@@ -1507,7 +1509,9 @@ mod tests {
|
||||
for j in 0..16 {
|
||||
unit[off + j] ^= prev[j];
|
||||
}
|
||||
let mut blk = GenericArray::clone_from_slice(&unit[off..off + 16]);
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&unit[off..off + 16]);
|
||||
let mut blk: Array<u8, _> = chunk.into();
|
||||
cipher.encrypt_block(&mut blk);
|
||||
unit[off..off + 16].copy_from_slice(&blk);
|
||||
prev.copy_from_slice(&unit[off..off + 16]);
|
||||
|
||||
+10
-8
@@ -8,7 +8,7 @@
|
||||
//! content / keys / variant modules.
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, BlockCipherDecrypt, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
/// Fixed IV used by AACS for all AES-CBC operations. [C] §2.1.2 (default CBC IV, `iv0`).
|
||||
pub(crate) const AACS_IV: [u8; 16] = [
|
||||
@@ -39,13 +39,13 @@ pub(crate) fn new_cipher_for(key: &[u8; 16]) -> Aes128 {
|
||||
fn new_cipher(key: &[u8; 16]) -> Aes128 {
|
||||
#[cfg(test)]
|
||||
KEY_EXPANSIONS.with(|c| c.set(c.get() + 1));
|
||||
Aes128::new(GenericArray::from_slice(key))
|
||||
Aes128::new(&(*key).into())
|
||||
}
|
||||
|
||||
/// AES-128-ECB encrypt a single 16-byte block. [C] §2.1.1 (`AES-128E`).
|
||||
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
@@ -54,8 +54,8 @@ pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block. [C] §2.1.1 (`AES-128D`).
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
@@ -88,7 +88,7 @@ pub(crate) fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
for j in 0..16 {
|
||||
block[j] = data[offset + j] ^ prev[j];
|
||||
}
|
||||
let mut ga = GenericArray::clone_from_slice(&block);
|
||||
let mut ga: Array<u8, _> = block.into();
|
||||
cipher.encrypt_block(&mut ga);
|
||||
data[offset..offset + 16].copy_from_slice(&ga);
|
||||
prev.copy_from_slice(&ga);
|
||||
@@ -140,7 +140,9 @@ pub(crate) fn cbc_decrypt_blocks(cipher: &Aes128, data: &mut [u8]) {
|
||||
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||
p
|
||||
};
|
||||
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||
let mut chunk = [0u8; 16];
|
||||
chunk.copy_from_slice(&data[offset..offset + 16]);
|
||||
let mut block: Array<u8, _> = chunk.into();
|
||||
cipher.decrypt_block(&mut block);
|
||||
for j in 0..16 {
|
||||
data[offset + j] = block[j] ^ prev[j];
|
||||
|
||||
@@ -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;
|
||||
|
||||
+42
-15
@@ -57,7 +57,15 @@ 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;
|
||||
|
||||
/// Largest image this planner will synthesize, in sectors (128 GiB).
|
||||
///
|
||||
@@ -859,16 +867,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 +910,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 +979,30 @@ 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:?}"
|
||||
);
|
||||
|
||||
// And the real production value is what ships: one per child plus the
|
||||
// parent's own entry must still fit in the 16-bit field.
|
||||
#[cfg(not(test))]
|
||||
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,3 +914,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"
|
||||
);
|
||||
}
|
||||
|
||||
+26
-9
@@ -479,6 +479,25 @@ pub struct Extent {
|
||||
/// Union a set of extents into sorted, merged, disjoint `(start_lba,
|
||||
/// sector_count)` ranges — the pure, testable core of
|
||||
/// [`Disc::encrypted_content_ranges`]. Reuses [`crate::udf::merge_ranges`].
|
||||
/// Is this disc structurally AACS-encrypted — i.e. does it carry an AACS
|
||||
/// directory?
|
||||
///
|
||||
/// THE ONE definition of that question. `Disc::identify` (the fast path, which
|
||||
/// only reads the filesystem) and `Disc::scan_with` (the full scan) both need
|
||||
/// it, and both used to spell the same two `find_dir` calls out by hand. They
|
||||
/// agreed today; nothing made them agree tomorrow. Adding a third AACS
|
||||
/// location, or excluding an empty placeholder directory, to one copy and not
|
||||
/// the other would silently desync the fast identify from the full scan — the
|
||||
/// same disc reported encrypted by one and clear by the other.
|
||||
///
|
||||
/// This is STRUCTURAL, not cryptographic: it says the tree looks like an
|
||||
/// encrypted disc, not that any sector actually is. A folder copied verbatim
|
||||
/// from a decrypted disc keeps its `AACS/` and answers true here (see
|
||||
/// `session::scan_dir`, which corrects for exactly that).
|
||||
pub(crate) fn aacs_dir_present(udf_fs: &crate::udf::UdfFs) -> bool {
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some()
|
||||
}
|
||||
|
||||
fn merged_extents<'a>(extents: impl Iterator<Item = &'a Extent>) -> Vec<(u32, u32)> {
|
||||
let mut ranges: Vec<(u32, u32)> = extents.map(|e| (e.start_lba, e.sector_count)).collect();
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
@@ -1628,8 +1647,7 @@ impl Disc {
|
||||
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
|
||||
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
|
||||
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
|
||||
let encrypted =
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
let encrypted = aacs_dir_present(&udf_fs);
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
|
||||
Ok(DiscId {
|
||||
@@ -2015,8 +2033,7 @@ impl Disc {
|
||||
let scan_with_t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
|
||||
// 2. Resolve encryption (AACS, CSS, or none)
|
||||
let encrypted =
|
||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
let encrypted = aacs_dir_present(&udf_fs);
|
||||
|
||||
let (aacs, aacs_error) = if !encrypted {
|
||||
(None, None)
|
||||
@@ -5399,7 +5416,7 @@ mod tests {
|
||||
use crate::aacs::content::ALIGNED_UNIT_LEN;
|
||||
use crate::aacs::crypto::AACS_IV;
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
let mut unit = clear[..ALIGNED_UNIT_LEN].to_vec();
|
||||
// Flag the unit encrypted (CPI bits on byte 0) before key derivation so
|
||||
// the recovered plaintext header matches and `decrypt_unit`'s CPI gate
|
||||
@@ -5407,14 +5424,14 @@ mod tests {
|
||||
unit[0] |= 0xC0;
|
||||
let mut header = [0u8; 16];
|
||||
header.copy_from_slice(&unit[..16]);
|
||||
let cipher = Aes128::new(GenericArray::from_slice(uk));
|
||||
let mut blk = GenericArray::clone_from_slice(&header);
|
||||
let cipher = Aes128::new(&(*uk).into());
|
||||
let mut blk: Array<u8, _> = header.into();
|
||||
cipher.encrypt_block(&mut blk);
|
||||
let mut dk = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
dk[i] = blk[i] ^ header[i];
|
||||
}
|
||||
let bc = Aes128::new(GenericArray::from_slice(&dk));
|
||||
let bc = Aes128::new(&dk.into());
|
||||
let mut prev = AACS_IV;
|
||||
let mut i = 16;
|
||||
while i + 16 <= ALIGNED_UNIT_LEN {
|
||||
@@ -5422,7 +5439,7 @@ mod tests {
|
||||
for j in 0..16 {
|
||||
b[j] = unit[i + j] ^ prev[j];
|
||||
}
|
||||
let mut g = GenericArray::clone_from_slice(&b);
|
||||
let mut g: Array<u8, _> = b.into();
|
||||
bc.encrypt_block(&mut g);
|
||||
for j in 0..16 {
|
||||
unit[i + j] = g[j];
|
||||
|
||||
@@ -62,6 +62,7 @@ pub const E_MAPFILE_INVALID: u16 = 6011;
|
||||
pub const E_SELECTION_PID_UNKNOWN: u16 = 6014;
|
||||
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
|
||||
pub const E_UDF_NOT_FILESYSTEM: u16 = 6013;
|
||||
pub const E_IMAGE_TRUNCATED: u16 = 6015;
|
||||
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
@@ -461,6 +462,14 @@ pub enum Error {
|
||||
MapfileInvalid {
|
||||
kind: &'static str,
|
||||
},
|
||||
/// The image a mapfile describes is shorter than the mapfile's own total
|
||||
/// size, so the two no longer agree about the same disc. Resuming against
|
||||
/// it would treat the absent tail as already recovered. `have` and `want`
|
||||
/// are byte lengths; a missing file reports `have == 0`.
|
||||
ImageTruncated {
|
||||
have: u64,
|
||||
want: u64,
|
||||
},
|
||||
|
||||
// AACS (7xxx)
|
||||
AacsNoKeys,
|
||||
@@ -859,6 +868,7 @@ impl Error {
|
||||
Error::NoStreams => E_NO_STREAMS,
|
||||
Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
|
||||
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
|
||||
Error::ImageTruncated { .. } => E_IMAGE_TRUNCATED,
|
||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
||||
@@ -1634,6 +1644,13 @@ mod tests {
|
||||
E_PLATFORM_NOT_IMPLEMENTED,
|
||||
),
|
||||
(Error::MapfileInvalid { kind: "hex" }, E_MAPFILE_INVALID),
|
||||
(
|
||||
Error::ImageTruncated {
|
||||
have: 0,
|
||||
want: 1024,
|
||||
},
|
||||
E_IMAGE_TRUNCATED,
|
||||
),
|
||||
(Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
|
||||
(Error::ExtentNotUnitAligned, E_EXTENT_NOT_UNIT_ALIGNED),
|
||||
// Both CSS no-key verdicts: numeric-only Display, no English.
|
||||
@@ -1820,6 +1837,7 @@ mod tests {
|
||||
E_NO_STREAMS,
|
||||
E_HALTED,
|
||||
E_MAPFILE_INVALID,
|
||||
E_IMAGE_TRUNCATED,
|
||||
E_UDF_BUFFER_TOO_SMALL,
|
||||
E_UDF_NOT_FILESYSTEM,
|
||||
E_AACS_NO_KEYS,
|
||||
@@ -1933,6 +1951,7 @@ mod tests {
|
||||
assert!((6000..7000).contains(&E_DISC_READ));
|
||||
assert!((6000..7000).contains(&E_HALTED));
|
||||
assert!((6000..7000).contains(&E_MAPFILE_INVALID));
|
||||
assert!((6000..7000).contains(&E_IMAGE_TRUNCATED));
|
||||
// AACS (7xxx)
|
||||
assert!((7000..8000).contains(&E_AACS_NO_KEYS));
|
||||
assert!((7000..8000).contains(&E_NO_DISC_KEY));
|
||||
|
||||
+128
@@ -2639,4 +2639,132 @@ mod tests {
|
||||
assert_eq!(map[&1], vec![(7u16, 1u8), (13, 2)]);
|
||||
assert_eq!(map[&2], vec![(0x0102u16, 1u8)]);
|
||||
}
|
||||
|
||||
// ── TT_SRPT: the title table (`parse_tt_srpt` / `parse_vmg`) ────────────
|
||||
//
|
||||
// This is the table that says which titles a DVD has, and it had no tests
|
||||
// at all until 1.6.2 — while being at the centre of both DVD stories in
|
||||
// 1.6.1. The CSS descramble bug destroyed the sector carrying it, so a disc
|
||||
// enumerating 38 titles produced an image enumerating 10, silently, at exit
|
||||
// 0. And `dir://` was pulled from the release on 2026-08-05 because a
|
||||
// folder produced the wrong title list — the same table, reached through a
|
||||
// synthesized image.
|
||||
//
|
||||
// Both were found on real discs. Nothing hermetic covered the table itself.
|
||||
|
||||
/// Build a VMG with a TT_SRPT at `tt_srpt_sector` declaring `entries`
|
||||
/// of `(num_chapters, vts_number, vts_title_num)`.
|
||||
fn vmg_with_tt_srpt(tt_srpt_sector: u32, entries: &[(u16, u8, u8)]) -> Vec<u8> {
|
||||
let off = tt_srpt_sector as usize * crate::consts::SECTOR_BYTES;
|
||||
let mut v = vec![0u8; off + 8 + entries.len() * 12 + 16];
|
||||
v[0..12].copy_from_slice(b"DVDVIDEO-VMG");
|
||||
v[0xC4..0xC8].copy_from_slice(&tt_srpt_sector.to_be_bytes());
|
||||
v[off..off + 2].copy_from_slice(&(entries.len() as u16).to_be_bytes());
|
||||
for (i, &(chapters, vts, title)) in entries.iter().enumerate() {
|
||||
let b = off + 8 + i * 12;
|
||||
v[b + 2..b + 4].copy_from_slice(&chapters.to_be_bytes());
|
||||
v[b + 6] = vts;
|
||||
v[b + 7] = title;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tt_srpt_groups_titles_by_their_title_set() {
|
||||
// Three titles: two in VTS 1, one in VTS 2 — the ordinary shape.
|
||||
let vmg = vmg_with_tt_srpt(1, &[(12, 1, 1), (5, 1, 2), (20, 2, 1)]);
|
||||
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
|
||||
assert_eq!(map.len(), 2, "two title sets");
|
||||
assert_eq!(
|
||||
map[&1],
|
||||
vec![(12, 1), (5, 2)],
|
||||
"VTS 1 keeps both titles in order"
|
||||
);
|
||||
assert_eq!(map[&2], vec![(20, 1)], "VTS 2 keeps its one");
|
||||
}
|
||||
|
||||
/// The pointer at 0xC4 is a SECTOR offset from the start of VIDEO_TS.IFO,
|
||||
/// not a byte offset. Reading it as bytes would find the table at 1/2048th
|
||||
/// of its real position — which is a zero-filled region on most discs, so
|
||||
/// it would report a disc with no titles rather than fail loudly.
|
||||
#[test]
|
||||
fn tt_srpt_pointer_is_a_sector_offset_not_a_byte_offset() {
|
||||
for sector in [1u32, 2, 5] {
|
||||
let vmg = vmg_with_tt_srpt(sector, &[(3, 1, 1)]);
|
||||
let at_sector = parse_tt_srpt(&vmg, sector as usize * crate::consts::SECTOR_BYTES)
|
||||
.expect("reading at the sector offset works");
|
||||
assert_eq!(at_sector[&1], vec![(3, 1)], "sector {sector}");
|
||||
// Reading the same pointer as a BYTE offset lands 1/2048th of the
|
||||
// way in — inside the header here — and yields a different table.
|
||||
// It does not error: it silently reports the wrong titles, which is
|
||||
// how 28 dropped titles looked like a scan difference rather than a
|
||||
// failure.
|
||||
let as_bytes = parse_tt_srpt(&vmg, sector as usize).expect("does not error");
|
||||
assert_ne!(
|
||||
as_bytes, at_sector,
|
||||
"sector {sector}: a byte-offset read must not coincidentally \
|
||||
agree with the correct one, or this test proves nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A VTS number of 0 is not a title set; entries carrying it are padding.
|
||||
#[test]
|
||||
fn tt_srpt_skips_entries_with_no_title_set() {
|
||||
let vmg = vmg_with_tt_srpt(1, &[(9, 0, 1), (4, 3, 1)]);
|
||||
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
|
||||
assert_eq!(map.len(), 1, "the vts=0 entry is dropped");
|
||||
assert_eq!(map[&3], vec![(4, 1)]);
|
||||
}
|
||||
|
||||
/// The same (title set, title) twice is one title, not two. Real discs
|
||||
/// carry duplicate TT_SRPT rows.
|
||||
#[test]
|
||||
fn tt_srpt_collapses_a_duplicate_title() {
|
||||
let vmg = vmg_with_tt_srpt(1, &[(7, 2, 1), (7, 2, 1), (8, 2, 2)]);
|
||||
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
|
||||
assert_eq!(
|
||||
map[&2],
|
||||
vec![(7, 1), (8, 2)],
|
||||
"the repeat is dropped, the distinct one kept"
|
||||
);
|
||||
}
|
||||
|
||||
/// A truncated table parses what is there rather than failing the whole
|
||||
/// disc — one unreadable row should not cost a user every other title.
|
||||
#[test]
|
||||
fn tt_srpt_truncated_keeps_the_entries_it_can_read() {
|
||||
let mut vmg = vmg_with_tt_srpt(1, &[(1, 1, 1), (2, 2, 1), (3, 3, 1)]);
|
||||
vmg.truncate(crate::consts::SECTOR_BYTES + 8 + 12 + 6); // 1 whole entry + a fragment
|
||||
let map = parse_tt_srpt(&vmg, crate::consts::SECTOR_BYTES).expect("parse");
|
||||
assert_eq!(map.len(), 1, "the complete entry survives");
|
||||
assert_eq!(map[&1], vec![(1, 1)]);
|
||||
}
|
||||
|
||||
/// The count is an untrusted u16 off the disc. DVD-Video caps a disc at 99
|
||||
/// titles, and each entry re-parses a PGC into a full DvdTitle — so an
|
||||
/// unclamped 65535 is a ~540 MB allocation from a ~800 KB crafted file.
|
||||
#[test]
|
||||
fn tt_srpt_clamps_an_absurd_declared_title_count() {
|
||||
// The fixture must actually CONTAIN more entries than the cap, or the
|
||||
// walk stops when the buffer runs out and the clamp is never what
|
||||
// bounded it. An earlier version of this test declared u16::MAX over a
|
||||
// two-kilobyte fixture: the loop broke on `base + 12 > len` after three
|
||||
// iterations and the assertion held with the clamp deleted entirely.
|
||||
const PRESENT: usize = MAX_TT_SRPT_TITLES + 40;
|
||||
let entries: Vec<(u16, u8, u8)> = (0..PRESENT)
|
||||
.map(|i| (1u16, 1u8, (i % 250 + 1) as u8))
|
||||
.collect();
|
||||
let mut vmg = vmg_with_tt_srpt(1, &entries);
|
||||
let off = crate::consts::SECTOR_BYTES;
|
||||
vmg[off..off + 2].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
|
||||
let map = parse_tt_srpt(&vmg, off).expect("parse");
|
||||
let total: usize = map.values().map(|v| v.len()).sum();
|
||||
assert!(
|
||||
total <= MAX_TT_SRPT_TITLES,
|
||||
"a declared count of u16::MAX over {PRESENT} real entries must be \
|
||||
clamped to {MAX_TT_SRPT_TITLES}, got {total}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-14
@@ -325,20 +325,21 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
///
|
||||
/// The thread is named `freemkv-pipeline-consumer` so it shows up
|
||||
/// distinctly in stack traces and `top -H`. Callers that want a
|
||||
/// more specific name (e.g. `freemkv-sweep-consumer`) should use
|
||||
/// [`Pipeline::spawn_named`] instead. Returns an `Error::IoError`
|
||||
/// if the OS refuses the thread spawn (resource exhaustion);
|
||||
/// callers already operate in fallible context, so this is
|
||||
/// propagated rather than panicked.
|
||||
/// more specific name should use [`Pipeline::spawn_named`] instead.
|
||||
/// Returns an `Error::IoError` if the OS refuses the thread spawn
|
||||
/// (resource exhaustion); callers already operate in fallible context, so
|
||||
/// this is propagated rather than panicked.
|
||||
///
|
||||
/// Sweep uses [`Pipeline::spawn_named`] directly so the consumer
|
||||
/// thread shows up as `freemkv-sweep-consumer`; mux uses
|
||||
/// `freemkv-mux-consumer`. `Pipeline::spawn` (this function, with
|
||||
/// the default name) is used only by the unit tests in this module.
|
||||
/// It used to name `disc::patch` as a caller; that module does not exist
|
||||
/// here any more — the sweep/patch recovery passes moved to freemkv-engine
|
||||
/// in 1.6.0, so the comment sent readers hunting a caller in this crate
|
||||
/// that had already left it.
|
||||
/// Inside this crate the only [`Pipeline::spawn_named`] caller is the mux
|
||||
/// driver, which names its thread `freemkv-mux-consumer`. `Pipeline::spawn`
|
||||
/// (this function, with the default name) is used only by the unit tests in
|
||||
/// this module.
|
||||
///
|
||||
/// This paragraph twice named a caller that had left the crate: first
|
||||
/// `disc::patch`, then Sweep and its `freemkv-sweep-consumer` thread. Both
|
||||
/// went to freemkv-engine with the recovery passes in 1.6.0, and each in
|
||||
/// turn sent readers hunting a component that is not here. Name callers
|
||||
/// that live in THIS crate, or none.
|
||||
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
|
||||
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
|
||||
}
|
||||
@@ -346,7 +347,7 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Like [`Pipeline::spawn`] but lets the caller supply the
|
||||
/// consumer thread's name. Useful when several pipelines run in
|
||||
/// the same process and stack traces / `top -H` need to tell them
|
||||
/// apart (e.g. `freemkv-sweep-consumer`, `freemkv-mux-consumer`).
|
||||
/// apart (e.g. `freemkv-mux-consumer`).
|
||||
pub fn spawn_named<S: Sink<I, Output = R>>(
|
||||
name: &str,
|
||||
depth: usize,
|
||||
@@ -631,6 +632,14 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// wedged inside an unkillable syscall, the producer can still
|
||||
/// observe `/api/stop` and unwind within
|
||||
/// [`SEND_HALT_CHECK_INTERVAL`].
|
||||
/// NOT a `foo_with_X` variant of [`Pipeline::send`], despite the name.
|
||||
/// The two encode OPPOSITE policies on the same event, each with its own
|
||||
/// test: after the consumer's `apply` has failed, `send` still succeeds
|
||||
/// (the consumer keeps draining, so the channel accepts the item), while
|
||||
/// this one hands the item straight back — so a producer does not read an
|
||||
/// hour of disc for a write that died on the first frame. Collapsing them
|
||||
/// into one Option-parameterised method deletes one of those behaviours;
|
||||
/// it was tried and `apply_error_drains_then_propagates` caught it.
|
||||
pub fn send_with_halt(&self, item: I, halt: &Halt, deadline: Duration) -> Result<(), I> {
|
||||
use crossbeam_channel::SendTimeoutError;
|
||||
let end = Instant::now() + deadline;
|
||||
@@ -742,6 +751,12 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Plain [`Pipeline::finish`] is preserved for callers without a
|
||||
/// halt-token plumbed through; that path still blocks indefinitely
|
||||
/// on `join()`, matching pre-0.20.8 behaviour.
|
||||
/// Also not a `foo_with_X` variant: [`Pipeline::finish`] joins and waits
|
||||
/// however long the consumer needs, while this one gives up after
|
||||
/// `JOIN_TIMEOUT_SECS` and reports halted. Which is right depends on
|
||||
/// whether the caller has a user waiting to cancel — the mux driver does
|
||||
/// and uses this; the unit tests do not and use the plain join. Merging
|
||||
/// them means picking one of those policies for both.
|
||||
pub fn finish_with_halt(self, halt: Option<&Halt>) -> Result<R, Error> {
|
||||
let Pipeline {
|
||||
tx,
|
||||
|
||||
+84
-1
@@ -67,6 +67,13 @@ pub struct Ac3Parser {
|
||||
/// began in an earlier packet takes THAT packet's source offset, not the
|
||||
/// one that happened to complete it.
|
||||
acc: super::pesbuf::PesBuf,
|
||||
/// Reused working copy of `acc`, kept across calls so the per-PES scan does
|
||||
/// not allocate. `parse` runs once per PES packet on an audio track — of
|
||||
/// the order of 10^5 times per title — and previously built a fresh `Vec`
|
||||
/// each time. The copy itself is unavoidable without restructuring the
|
||||
/// borrow relationship (the scanner needs `&mut self.tally` while it reads
|
||||
/// these bytes); the ALLOCATION is not.
|
||||
scratch: Vec<u8>,
|
||||
/// PTS (ns) to stamp on the frame that begins the carry-over `buf` — i.e.
|
||||
/// the running per-frame PTS at the point the partial tail was retained.
|
||||
/// Used by `flush()` to time the final buffered frame at EOS.
|
||||
@@ -95,6 +102,7 @@ impl Default for Ac3Parser {
|
||||
impl Ac3Parser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
scratch: Vec::new(),
|
||||
acc: super::pesbuf::PesBuf::with_capacity(4096),
|
||||
flush_pts_ns: 0,
|
||||
tally: super::dropgate::DropTally::new("ac3"),
|
||||
@@ -500,7 +508,12 @@ impl CodecParser for Ac3Parser {
|
||||
// Copy the working bytes out so the scanner can borrow `self.tally`;
|
||||
// the buffer keeps its marks, so the unconsumed tail stays attributed
|
||||
// to the packet that carried it.
|
||||
let buf = self.acc.as_slice().to_vec();
|
||||
// Take the scratch OUT of `self` so the scanner can borrow `self.tally`
|
||||
// while reading it; it is put back at the end of the call, keeping its
|
||||
// capacity for the next PES. There is no early return after this point.
|
||||
let mut buf = std::mem::take(&mut self.scratch);
|
||||
buf.clear();
|
||||
buf.extend_from_slice(self.acc.as_slice());
|
||||
let marks = self.acc.marks_snapshot();
|
||||
let data = &buf;
|
||||
let (frames, keep_from, frame_pts_ns) =
|
||||
@@ -541,6 +554,9 @@ impl CodecParser for Ac3Parser {
|
||||
self.flush_pts_ns = frame_pts_ns;
|
||||
}
|
||||
|
||||
// Hand the working buffer back so the next PES reuses its capacity.
|
||||
// `data` borrowed it; that borrow ends here, at its last use.
|
||||
self.scratch = buf;
|
||||
frames
|
||||
}
|
||||
|
||||
@@ -807,6 +823,73 @@ mod tests {
|
||||
frame[n - 1] = (c & 0xFF) as u8;
|
||||
}
|
||||
|
||||
/// The per-PES working buffer must be REUSED, not reallocated.
|
||||
///
|
||||
/// `parse` runs once per PES packet — of the order of 10^5 times on a
|
||||
/// feature's audio track — and used to build a fresh `Vec` every call. The
|
||||
/// copy itself cannot go without restructuring the borrow relationship, but
|
||||
/// the allocation can: the buffer is taken out of `self`, filled, and put
|
||||
/// back with its capacity intact.
|
||||
///
|
||||
/// Asserting on capacity is deliberately white-box, because that is exactly
|
||||
/// what regresses if someone reverts to `to_vec()` — the behaviour would be
|
||||
/// identical and no other test would notice.
|
||||
#[test]
|
||||
fn the_working_buffer_is_reused_across_packets_not_reallocated() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame = make_ac3_frame(0, 0);
|
||||
|
||||
parser.parse(&PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: Some(90_000),
|
||||
dts: None,
|
||||
data: frame.clone(),
|
||||
discontinuity: false,
|
||||
});
|
||||
let cap_after_first = parser.scratch.capacity();
|
||||
assert!(
|
||||
cap_after_first > 0,
|
||||
"the buffer must be handed back to the parser, not dropped"
|
||||
);
|
||||
|
||||
// The discriminator: a LARGE packet, then a small one. A reused buffer
|
||||
// keeps the large capacity; a fresh `Vec` sized to each packet drops
|
||||
// back to the small one. Feeding equal-sized packets cannot tell those
|
||||
// apart — the first version of this test did exactly that and a
|
||||
// to_vec()-per-call implementation passed it.
|
||||
let big = [frame.clone(), frame.clone(), frame.clone(), frame.clone()].concat();
|
||||
parser.parse(&PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: big.clone(),
|
||||
discontinuity: false,
|
||||
});
|
||||
let cap_after_big = parser.scratch.capacity();
|
||||
assert!(
|
||||
cap_after_big >= big.len(),
|
||||
"the buffer must have grown to hold the large packet"
|
||||
);
|
||||
|
||||
parser.parse(&PesPacket {
|
||||
source: None,
|
||||
pid: 0x1100,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data: frame.clone(),
|
||||
discontinuity: false,
|
||||
});
|
||||
assert_eq!(
|
||||
parser.scratch.capacity(),
|
||||
cap_after_big,
|
||||
"after a small packet the buffer must STILL hold the large \
|
||||
capacity — a fresh allocation per call would have shrunk to the \
|
||||
small packet's size"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_pes() {
|
||||
let mut parser = Ac3Parser::new();
|
||||
|
||||
+54
-1
@@ -50,7 +50,15 @@ fn adts_verdict(data: &[u8]) -> AdtsVerdict {
|
||||
// aac_frame_length: 13 bits = byte3[1:0] | byte4 | byte5[7:5].
|
||||
let frame_length =
|
||||
((u32::from(data[3]) & 0x03) << 11) | (u32::from(data[4]) << 3) | (u32::from(data[5]) >> 5);
|
||||
if frame_length < 7 {
|
||||
// The floor is the header the frame SAYS it carries, not a constant.
|
||||
// protection_absent (byte1 bit0) clear means a 16-bit crc_check follows the
|
||||
// 7-byte fixed+variable header, so the frame cannot be shorter than 9 —
|
||||
// aac_frame_length counts the header and the CRC, not just the payload.
|
||||
// Comparing against a flat 7 let a CRC-present frame declaring 7 or 8
|
||||
// through as structurally Valid, and the muxer then carried a frame whose
|
||||
// own header says it is impossible.
|
||||
let header_bytes = if data[1] & 0x01 == 0 { 9 } else { 7 };
|
||||
if frame_length < header_bytes {
|
||||
return AdtsVerdict::Invalid;
|
||||
}
|
||||
AdtsVerdict::Valid
|
||||
@@ -152,6 +160,51 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A header that CLAIMS a CRC (protection_absent = 0) but declares a
|
||||
/// frame length too short to contain one.
|
||||
///
|
||||
/// `aac_frame_length` counts the header and the CRC, not just the payload,
|
||||
/// so with a CRC present the smallest structurally possible frame is 9
|
||||
/// bytes: the 7-byte fixed+variable header plus the 16-bit crc_check.
|
||||
/// The gate compared against a flat 7 and never read protection_absent at
|
||||
/// all, so a frame whose own header says it is impossible was classified
|
||||
/// Valid and forwarded to the muxer.
|
||||
#[test]
|
||||
fn a_crc_present_header_shorter_than_its_own_crc_is_invalid() {
|
||||
for declared in [7u32, 8] {
|
||||
let mut f = adts_frame(16);
|
||||
f[1] = 0xF0; // sync + MPEG-4, protection_absent = 0 => CRC present
|
||||
f[3] = (f[3] & 0xFC) | ((declared >> 11) & 0x03) as u8;
|
||||
f[4] = ((declared >> 3) & 0xFF) as u8;
|
||||
f[5] = (f[5] & 0x1F) | ((declared & 0x07) << 5) as u8;
|
||||
assert!(
|
||||
matches!(adts_verdict(&f), AdtsVerdict::Invalid),
|
||||
"protection_absent=0 declaring {declared} bytes cannot hold its \
|
||||
own 7-byte header plus a 2-byte CRC"
|
||||
);
|
||||
}
|
||||
|
||||
// 9 is the smallest length that CAN hold header + CRC, so it must pass
|
||||
// the structural gate — the floor moved, it did not become stricter
|
||||
// than the spec.
|
||||
let mut ok = adts_frame(16);
|
||||
ok[1] = 0xF0;
|
||||
let nine = 9u32;
|
||||
ok[3] = (ok[3] & 0xFC) | ((nine >> 11) & 0x03) as u8;
|
||||
ok[4] = ((nine >> 3) & 0xFF) as u8;
|
||||
ok[5] = (ok[5] & 0x1F) | ((nine & 0x07) << 5) as u8;
|
||||
assert!(matches!(adts_verdict(&ok), AdtsVerdict::Valid));
|
||||
|
||||
// And with NO CRC the floor is still 7, unchanged.
|
||||
let mut no_crc = adts_frame(16);
|
||||
no_crc[1] = 0xF1; // protection_absent = 1
|
||||
let seven = 7u32;
|
||||
no_crc[3] = (no_crc[3] & 0xFC) | ((seven >> 11) & 0x03) as u8;
|
||||
no_crc[4] = ((seven >> 3) & 0xFF) as u8;
|
||||
no_crc[5] = (no_crc[5] & 0x1F) | ((seven & 0x07) << 5) as u8;
|
||||
assert!(matches!(adts_verdict(&no_crc), AdtsVerdict::Valid));
|
||||
}
|
||||
|
||||
/// A valid ADTS header (AAC-LC, 44.1 kHz, stereo) + payload, with
|
||||
/// aac_frame_length set to the total size.
|
||||
fn adts_frame(payload: usize) -> Vec<u8> {
|
||||
|
||||
@@ -717,4 +717,22 @@ mod tests {
|
||||
let result = format_palette(&[[0x00, 16, 128, 128]], 0, 0);
|
||||
assert_eq!(String::from_utf8(result).unwrap(), "palette: 101010\n");
|
||||
}
|
||||
|
||||
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
||||
/// cannot see a parser that writes `source: facts.source` where the facts
|
||||
/// carry no offset. Only a runtime check proves an emitted frame really
|
||||
/// carries the byte it was read from, and without it a multi-clip title
|
||||
/// places this track by timestamp inference instead of by byte.
|
||||
#[test]
|
||||
fn an_emitted_frame_carries_the_packets_source_offset() {
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
let mut pes = make_pes(
|
||||
vec![0x00, 0x0A, 0x00, 0x08, 0x01, 0xFF, 0x02, 0x03, 0x04, 0x05],
|
||||
Some(90_000),
|
||||
);
|
||||
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(!frames.is_empty(), "the segment is emitted");
|
||||
assert_eq!(frames[0].source.map(|s| s.byte), Some(7_777));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,4 +271,19 @@ mod tests {
|
||||
);
|
||||
assert_eq!(emitted.len() + tail.len(), 2);
|
||||
}
|
||||
|
||||
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
||||
/// cannot see a parser that writes `source: facts.source` where the facts
|
||||
/// carry no offset. Only a runtime check proves an emitted frame really
|
||||
/// carries the byte it was read from, and without it a multi-clip title
|
||||
/// places this track by timestamp inference instead of by byte.
|
||||
#[test]
|
||||
fn an_emitted_frame_carries_the_packets_source_offset() {
|
||||
let mut p = FlacParser::new();
|
||||
let mut pes = make_pes(make_flac_frame(100), Some(90_000));
|
||||
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
||||
let f = p.parse(&pes);
|
||||
assert!(!f.is_empty(), "the frame is emitted");
|
||||
assert_eq!(f[0].source.map(|s| s.byte), Some(7_777));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,4 +279,21 @@ mod tests {
|
||||
let f = parser.parse(&make_pes(vec![0xAA, 0xBB], None));
|
||||
assert_eq!(f[0].pts_ns, 0);
|
||||
}
|
||||
|
||||
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
||||
/// cannot see a parser that writes `source: facts.source` where the facts
|
||||
/// carry no offset. Only a runtime check proves an emitted frame really
|
||||
/// carries the byte it was read from, and without it a multi-clip title
|
||||
/// places this track by timestamp inference instead of by byte.
|
||||
#[test]
|
||||
fn an_emitted_frame_carries_the_packets_source_offset() {
|
||||
let mut parser = LpcmParser::new();
|
||||
let mut data = vec![0x00, 0x01, 0x00, 0b1001_0001];
|
||||
data.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE]);
|
||||
let mut pes = make_pes(data, Some(90_000));
|
||||
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(!frames.is_empty(), "the frame is emitted");
|
||||
assert_eq!(frames[0].source.map(|s| s.byte), Some(7_777));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-2
@@ -465,9 +465,23 @@ mod provenance_guard {
|
||||
// `PesFrame` in the tests of other modules is not ours; only
|
||||
// codec `Frame` literals are scanned, and a test fixture that
|
||||
// builds a PesPacket with `source: None` is legitimate.
|
||||
if blk.contains("source: None") {
|
||||
// Two spellings, not one. `source: None` is the obvious way to
|
||||
// lose provenance; OMITTING the field entirely is the quiet
|
||||
// one, because `Frame` derives Default, so
|
||||
// `Frame { pts_ns, .. Default::default() }` compiles and
|
||||
// yields `source: None` while containing no such text. A guard
|
||||
// that only knew the first spelling would have watched a
|
||||
// parser be rewritten into the second and stayed green.
|
||||
let explicit_none = blk.contains("source: None");
|
||||
let no_source_field = !blk.contains("source:");
|
||||
if explicit_none || no_source_field {
|
||||
let line = src[..src.find(blk).unwrap_or(0)].lines().count() + 1;
|
||||
offenders.push(format!("{name}:{line}"));
|
||||
let how = if explicit_none {
|
||||
"source: None"
|
||||
} else {
|
||||
"no source field (Default fills in None)"
|
||||
};
|
||||
offenders.push(format!("{name}:{line} ({how})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,4 +319,19 @@ mod tests {
|
||||
// a manufactured tail frame would break this even if it were non-empty.
|
||||
assert_eq!(emitted.len() + tail.len(), 2);
|
||||
}
|
||||
|
||||
/// The text guard in `codec/mod.rs` scans for a literal `source: None` and
|
||||
/// cannot see a parser that writes `source: facts.source` where the facts
|
||||
/// carry no offset. Only a runtime check proves an emitted frame really
|
||||
/// carries the byte it was read from, and without it a multi-clip title
|
||||
/// places this track by timestamp inference instead of by byte.
|
||||
#[test]
|
||||
fn an_emitted_frame_carries_the_packets_source_offset() {
|
||||
let mut p = MpegAudioParser::new();
|
||||
let mut pes = make_pes(mp3_frame(400), Some(90_000));
|
||||
pes.source = Some(crate::pes::SourcePos::at_byte(7_777));
|
||||
let f = p.parse(&pes);
|
||||
assert!(!f.is_empty(), "the frame is emitted");
|
||||
assert_eq!(f[0].source.map(|s| s.byte), Some(7_777));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1543,14 +1543,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -512,13 +512,21 @@ impl MkvStream {
|
||||
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
|
||||
}
|
||||
for (f, additional) in pending.buffered.drain(..) {
|
||||
muxer.write_frame(
|
||||
// Provenance must survive the replay. These frames were buffered
|
||||
// before the muxer existed, but each still carries the byte offset
|
||||
// it was read from — dropping it here sent them down the timestamp
|
||||
// heuristic instead, the one path the rest of this change set
|
||||
// exists to stop relying on. They sit at the head of the title, so
|
||||
// clip 0 is usually the right guess and the damage was bounded;
|
||||
// "usually right" is exactly what provenance replaced.
|
||||
muxer.write_frame_at(
|
||||
f.track,
|
||||
f.pts,
|
||||
f.keyframe,
|
||||
&f.data,
|
||||
f.duration_ns,
|
||||
additional.as_deref(),
|
||||
f.source.map(|s| s.byte),
|
||||
)?;
|
||||
}
|
||||
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
|
||||
|
||||
@@ -2180,10 +2180,25 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
// decrypted, or handed to the demux, so the demux sees one gapless our-variant
|
||||
// stream (no ciphertext to trip a concealed-gap resync). A non-forensic map
|
||||
// returns the extents unchanged, so the common disc reads exactly as before.
|
||||
let full_extents = extents.clone();
|
||||
let extents = match &key_map {
|
||||
Some(map) => map.read_plan(&extents, unit_align as u32),
|
||||
None => extents,
|
||||
};
|
||||
// The plan and the clips' feed spans must describe the SAME bytes.
|
||||
//
|
||||
// A clip's span was measured over the title's full extents at scan time,
|
||||
// and a frame is placed by the offset it was read from. When a forensic
|
||||
// segment makes the plan drop alternate-phase units, the mux feeds fewer
|
||||
// bytes than the spans describe and the two drift apart cumulatively —
|
||||
// every frame after the first segment looks earlier than it is, and near a
|
||||
// join it is placed in the wrong clip or dropped. The spans still tile each
|
||||
// other perfectly, so the trust check cannot see it.
|
||||
//
|
||||
// Provenance is only meaningful when the feed matches. When it does not,
|
||||
// say so and let placement fall back to timestamps, which is what the
|
||||
// untrusted path exists for.
|
||||
let feed_matches_spans = extents == full_extents;
|
||||
let mut decrypting =
|
||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||
if let Some(map) = key_map {
|
||||
@@ -2202,6 +2217,17 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
||||
// correct `0x8x` sub-streams. No-op for non-DVD or an empty probe. Reset the
|
||||
// unit base afterward so the prefetcher's first batch starts clean.
|
||||
let mut title = title;
|
||||
if !feed_matches_spans {
|
||||
tracing::info!(
|
||||
target: "freemkv::mux",
|
||||
planned = extents.len(),
|
||||
full = full_extents.len(),
|
||||
"read plan omits units the clip spans include; placing by timestamps"
|
||||
);
|
||||
for c in &mut title.clips {
|
||||
c.feed_span = None;
|
||||
}
|
||||
}
|
||||
crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title);
|
||||
decrypting.set_unit_base(0);
|
||||
|
||||
|
||||
+417
-26
@@ -341,6 +341,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 +421,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 +682,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,
|
||||
@@ -687,6 +754,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 +804,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 +920,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 +957,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 +974,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 +998,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 +1195,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 +1310,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 +1381,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 +1588,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 +1602,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
|
||||
@@ -2206,9 +2458,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 +2476,145 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// MEASURED on a real DVD title with 8 cell boundaries. The demuxer hands
|
||||
/// the muxer ~18 audio frames of the NEXT cell before that cell's first
|
||||
/// video frame. Riding the just-ended epoch's offset put them ~21 s in the
|
||||
/// past, and the MKV writer's strictly-monotonic nudge then crushed the run
|
||||
/// onto one instant 0.1 ms apart — half a second of audio as a click,
|
||||
/// eight times in an 8-minute title.
|
||||
///
|
||||
/// They must instead continue after the frontier, and must rejoin the real
|
||||
/// offset seamlessly once the video opens the epoch.
|
||||
#[test]
|
||||
fn frames_arriving_before_their_epochs_video_ride_a_provisional_offset() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
for i in 0..=600 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
let frontier = tc.high_ns.expect("frontier");
|
||||
adj_other(&mut tc, 599 * S);
|
||||
|
||||
// The next cell's audio arrives first, raw restarted at 0.
|
||||
let step = 32_000_000; // 32 ms, the measured cadence
|
||||
let a0 = adj_other(&mut tc, 0);
|
||||
let a1 = adj_other(&mut tc, step);
|
||||
let a2 = adj_other(&mut tc, 2 * step);
|
||||
assert_eq!(
|
||||
a0,
|
||||
frontier + DISCONTINUITY_GAP_NS,
|
||||
"an early frame must continue after the frontier, not land an epoch \
|
||||
in the past where the monotonic nudge crushes it"
|
||||
);
|
||||
assert_eq!(a1 - a0, step, "the run keeps its cadence");
|
||||
assert_eq!(a2 - a1, step);
|
||||
|
||||
// Critically: the video timeline must be untouched. Letting a passive
|
||||
// track open a real epoch inflated a 476.776 s title to 656.216 s.
|
||||
assert_eq!(tc.offset_ns, 0, "a provisional must not move the offset");
|
||||
assert_eq!(tc.high_ns, Some(frontier), "nor the frontier");
|
||||
assert!(tc.epoch_offsets.is_empty(), "nor retire an epoch");
|
||||
|
||||
// Now the video for that cell arrives and opens the epoch for real.
|
||||
let v = adj_video(&mut tc, 0);
|
||||
assert_eq!(v, frontier + DISCONTINUITY_GAP_NS);
|
||||
assert_eq!(
|
||||
tc.epoch_offsets.len(),
|
||||
1,
|
||||
"exactly one epoch, opened by video"
|
||||
);
|
||||
|
||||
// The next audio frame rejoins the real offset with no seam: both are
|
||||
// `frontier - mapping + gap`, so they agree.
|
||||
let a3 = adj_other(&mut tc, 3 * step);
|
||||
assert_eq!(a3 - a2, step, "the run continues across the handover");
|
||||
}
|
||||
|
||||
/// A provisional must not outlive its epoch: once the video has retired an
|
||||
/// epoch, later frames of that track take the real offset. Otherwise a track
|
||||
/// would drift away from every other one for the rest of the title.
|
||||
#[test]
|
||||
fn a_provisional_is_dropped_once_the_epoch_is_real() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
for i in 0..=600 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
adj_other(&mut tc, 599 * S);
|
||||
adj_other(&mut tc, 0); // takes a provisional
|
||||
assert!(tc.provisional[1].is_some());
|
||||
|
||||
adj_video(&mut tc, 0); // video opens the epoch for real
|
||||
adj_other(&mut tc, S); // next frame of that track
|
||||
assert!(
|
||||
tc.provisional[1].is_none(),
|
||||
"the provisional must be dropped once the real epoch exists"
|
||||
);
|
||||
assert_eq!(
|
||||
adj_other(&mut tc, 2 * S),
|
||||
2 * S + tc.offset_ns,
|
||||
"the track now rides the real offset like every other"
|
||||
);
|
||||
}
|
||||
|
||||
/// MEASURED on a real HD-DVD title. Its second audio track's LAST frame
|
||||
/// carries a clip-1 raw PTS but arrives after clip 2's video opened the
|
||||
/// epoch, so it took clip 2's offset and landed at 12834.587 s in a
|
||||
/// 6434.100 s title — one packet, exactly double.
|
||||
///
|
||||
/// The old remap existed for this and refused it: the frame sits 23 s below
|
||||
/// the CURRENT frontier, outside the 3 s window. Judged against the frontier
|
||||
/// of the epoch it actually belongs to, it is 0.15 s from that epoch's end.
|
||||
#[test]
|
||||
fn a_straggler_is_judged_against_its_own_epochs_end_not_the_frontier() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
for i in 0..=600 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
adj_video(&mut tc, 0); // clip 2 opens; epoch 0 closed at 600 s
|
||||
for i in 0..=23 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
let frontier = tc.high_ns.expect("frontier");
|
||||
|
||||
// A clip-1 tail frame, 0.2 s before that clip ended, arriving late.
|
||||
let raw = 599 * S + 800_000_000;
|
||||
let out = adj_other(&mut tc, raw);
|
||||
assert_eq!(
|
||||
out, raw,
|
||||
"a tail straggler belongs to the epoch its raw PTS came from"
|
||||
);
|
||||
assert!(
|
||||
out < frontier,
|
||||
"it must never be flung past the frontier, got {out} vs {frontier}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Why the epoch HISTORY is needed and one `prev_offset_ns` is not enough:
|
||||
/// with three epochs, a straggler from the FIRST cannot be named by the
|
||||
/// immediately-previous offset at all.
|
||||
#[test]
|
||||
fn a_straggler_from_an_epoch_before_last_is_still_placed() {
|
||||
let mut tc = TimelineContinuity::new();
|
||||
for i in 0..=600 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
adj_video(&mut tc, 0); // epoch 0 closes at 600 s
|
||||
for i in 0..=300 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
adj_video(&mut tc, 0); // epoch 1 closes at ~900 s
|
||||
for i in 0..=50 {
|
||||
adj_video(&mut tc, i * S);
|
||||
}
|
||||
|
||||
// Tail frame of epoch 0 — two epochs back.
|
||||
let raw = 599 * S + 500_000_000;
|
||||
assert_eq!(
|
||||
adj_other(&mut tc, raw),
|
||||
raw,
|
||||
"the straggler belongs to epoch 0, which `prev_offset_ns` no longer names"
|
||||
);
|
||||
}
|
||||
|
||||
/// A saturated frontier must not panic the muxer. An `mkv://` source's
|
||||
/// tick→ns multiply saturates at `i64::MAX` (mkvstream's `parse_block`), so a
|
||||
/// hostile TimestampScale/CLUSTER_TIMESTAMP puts `high_ns` AT `i64::MAX`.
|
||||
|
||||
+1
-1
@@ -450,7 +450,7 @@ const AACS_PROBE_UNITS: usize = 8;
|
||||
///
|
||||
/// The extra step over `scan_iso` is the encryption verdict.
|
||||
/// `Disc::scan_with` decides `encrypted` STRUCTURALLY, from the presence of an
|
||||
/// `/AACS` or `/BDMV/AACS` directory (`disc/mod.rs:1992-1993`). For the common
|
||||
/// `/AACS` or `/BDMV/AACS` directory (see `disc::aacs_dir_present`). For the common
|
||||
/// case — a MakeMKV-style backup, which strips `AACS/` — that already gives the
|
||||
/// right answer, and `DecryptKeys::None` is a pass-through. But a folder copied
|
||||
/// verbatim from a decrypted disc keeps `AACS/`, and the tree shape then claims
|
||||
|
||||
+18
-14
@@ -85,7 +85,7 @@ fn css_is_scrambled_detection() {
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_roundtrip() {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
let unit_key = [0xAAu8; 16];
|
||||
let aacs_iv: [u8; 16] = [
|
||||
@@ -113,8 +113,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
|
||||
// Step 1: AES-ECB encrypt header with unit key
|
||||
let cipher_header = Aes128::new(GenericArray::from_slice(&unit_key));
|
||||
let mut block = GenericArray::clone_from_slice(&header);
|
||||
let cipher_header = Aes128::new(&unit_key.into());
|
||||
let mut block: Array<u8, _> = header.into();
|
||||
cipher_header.encrypt_block(&mut block);
|
||||
let mut derived = [0u8; 16];
|
||||
derived.copy_from_slice(&block);
|
||||
@@ -126,7 +126,7 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
}
|
||||
|
||||
// Step 3: AES-CBC encrypt bytes 16..6144
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
||||
let cipher = Aes128::new(&encrypt_key.into());
|
||||
let mut prev = aacs_iv;
|
||||
let num_blocks = (aacs::content::ALIGNED_UNIT_LEN - 16) / 16;
|
||||
for i in 0..num_blocks {
|
||||
@@ -134,7 +134,9 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
for j in 0..16 {
|
||||
plain[off + j] ^= prev[j];
|
||||
}
|
||||
let mut blk = GenericArray::clone_from_slice(&plain[off..off + 16]);
|
||||
let mut c_blk = [0u8; 16];
|
||||
c_blk.copy_from_slice(&plain[off..off + 16]);
|
||||
let mut blk: Array<u8, _> = c_blk.into();
|
||||
cipher.encrypt_block(&mut blk);
|
||||
plain[off..off + 16].copy_from_slice(&blk);
|
||||
prev.copy_from_slice(&plain[off..off + 16]);
|
||||
@@ -215,7 +217,7 @@ fn aacs_disc_hash_deterministic() {
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_key_roundtrip() {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
|
||||
let vuk = [
|
||||
0x11u8, 0x14, 0x36, 0x0B, 0x10, 0xEE, 0x6E, 0xAC, 0x78, 0xAA, 0x4A, 0xC0, 0xB7, 0x52, 0xEA,
|
||||
@@ -227,8 +229,8 @@ fn aacs_decrypt_unit_key_roundtrip() {
|
||||
];
|
||||
|
||||
// Encrypt: AES-ECB encrypt the unit key with VUK
|
||||
let cipher = Aes128::new(GenericArray::from_slice(&vuk));
|
||||
let mut block = GenericArray::clone_from_slice(&original_unit_key);
|
||||
let cipher = Aes128::new(&vuk.into());
|
||||
let mut block: Array<u8, _> = original_unit_key.into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut encrypted_uk = [0u8; 16];
|
||||
encrypted_uk.copy_from_slice(&block);
|
||||
@@ -340,9 +342,9 @@ fn aacs_clear_unit_reports_not_encrypted() {
|
||||
/// Independent AES-128-ECB encrypt (uses `aes` crate directly, NOT our library).
|
||||
fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
@@ -352,8 +354,8 @@ fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
/// Independent AES-128-CBC encrypt (uses `aes` crate directly, NOT our library).
|
||||
fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut prev = *iv;
|
||||
let num_blocks = data.len() / 16;
|
||||
for i in 0..num_blocks {
|
||||
@@ -361,7 +363,9 @@ fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
|
||||
for j in 0..16 {
|
||||
data[off + j] ^= prev[j];
|
||||
}
|
||||
let mut block = GenericArray::clone_from_slice(&data[off..off + 16]);
|
||||
let mut c_block = [0u8; 16];
|
||||
c_block.copy_from_slice(&data[off..off + 16]);
|
||||
let mut block: Array<u8, _> = c_block.into();
|
||||
cipher.encrypt_block(&mut block);
|
||||
data[off..off + 16].copy_from_slice(&block);
|
||||
prev.copy_from_slice(&data[off..off + 16]);
|
||||
|
||||
Reference in New Issue
Block a user