mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd07927292 | ||
|
|
49348eab2d | ||
|
|
36e9d93525 | ||
|
|
d2f6bbd272 | ||
|
|
8c2d9be19a | ||
|
|
473f629d13 | ||
|
|
1662345b7e | ||
|
|
0ec14fc338 | ||
|
|
520df9ebe3 | ||
|
|
5bf4afce0b | ||
|
|
d0a3ab852b | ||
|
|
91b906fa61 | ||
|
|
e987506d96 | ||
|
|
60cff0ead6 | ||
|
|
c333c6000b | ||
|
|
adf2401c06 | ||
|
|
b89b7c9660 | ||
|
|
1121522534 | ||
|
|
146f53873d | ||
|
|
b925a4fa50 | ||
|
|
4f169a4881 | ||
|
|
e251e0bf6f | ||
|
|
6704c1e05b | ||
|
|
a34980970b | ||
|
|
948511853e | ||
|
|
373cdc4cf2 | ||
|
|
a60b021c35 | ||
|
|
fc8b6e3a05 | ||
|
|
bdbc805ba5 | ||
|
|
fa75d28441 | ||
|
|
5d2e11a4dd | ||
|
|
50bd0e4be2 | ||
|
|
148419c2bb | ||
|
|
1b97e6186d | ||
|
|
23988922eb | ||
|
|
e4ecc3cf83 | ||
|
|
55ef44e74e | ||
|
|
173a532a6e | ||
|
|
6879df55eb | ||
|
|
0a735498fb | ||
|
|
531e3a40bf | ||
|
|
ae8e5ade3c | ||
|
|
8b914e7121 | ||
|
|
62ebd61ff1 | ||
|
|
30c16bc415 | ||
|
|
8c3afbee67 | ||
|
|
6e4f05dec4 | ||
|
|
5bf6c4875b | ||
|
|
1c29377780 | ||
|
|
7c544e3780 | ||
|
|
4a241e04bc | ||
|
|
d19481ae59 | ||
|
|
707c8bdc57 | ||
|
|
599422ef22 | ||
|
|
ed5066af48 | ||
|
|
9b28a7c89d | ||
|
|
8a8397965b | ||
|
|
a52698b6bf | ||
|
|
89e58d716b | ||
|
|
bf18ec0cec | ||
|
|
7e9849ccbb | ||
|
|
94f082a43b | ||
|
|
1fbf3f6c63 | ||
|
|
b75599a5cb | ||
|
|
e83b7a3489 | ||
|
|
4ef4d5dfd6 | ||
|
|
febf4d0504 | ||
|
|
5e96e3e9d8 | ||
|
|
2415a58d5d | ||
|
|
edb14d3aea | ||
|
|
29d67e803d | ||
|
|
ce96676389 | ||
|
|
df92b830bd | ||
|
|
b31cdc2519 | ||
|
|
397a8a2960 | ||
|
|
1306901662 | ||
|
|
d41e200e56 | ||
|
|
44f963cf1b | ||
|
|
acbf5929fc | ||
|
|
38ccf5eaba | ||
|
|
73b1de1de0 | ||
|
|
eefd40ad12 | ||
|
|
df73285813 | ||
|
|
cf72927527 | ||
|
|
484f024801 | ||
|
|
e08a4e5fd3 | ||
|
|
3cdf0201ae | ||
|
|
dd9a5dc79b | ||
|
|
c7484d5b95 | ||
|
|
5f751e4a45 | ||
|
|
ebe39c0b74 | ||
|
|
864e572c4f | ||
|
|
a6cd50bc3f | ||
|
|
f3560032d7 | ||
|
|
fb202a06e5 | ||
|
|
c820b5ab3f | ||
|
|
f61a3c3372 | ||
|
|
caf5d2a3b0 | ||
|
|
8ccdd9988f | ||
|
|
0a66749f60 | ||
|
|
a00035b06d | ||
|
|
98a51184c9 | ||
|
|
3c9963426b | ||
|
|
93bbe789fa | ||
|
|
a5077167ec | ||
|
|
9f99745912 | ||
|
|
e543bc4513 | ||
|
|
f13f0a58fe | ||
|
|
8945934ca9 | ||
|
|
99df6f7cb0 | ||
|
|
6ffc839397 | ||
|
|
28a07754e7 | ||
|
|
d6d701e199 | ||
|
|
aeec3d58d7 | ||
|
|
b5a96aa0f7 | ||
|
|
f76356c7ab | ||
|
|
c4259249e1 | ||
|
|
ca60c8fe1c | ||
|
|
2131c2c4e4 | ||
|
|
796da099a8 | ||
|
|
8efc287eb3 | ||
|
|
e963104dcf | ||
|
|
7408a916bf | ||
|
|
efaaab6ab8 | ||
|
|
ca7a0e41c2 | ||
|
|
102895330d | ||
|
|
acc0144870 | ||
|
|
d5261cc097 | ||
|
|
311aef939c | ||
|
|
ad16d745a4 | ||
|
|
1a586239bc | ||
|
|
a89e688866 | ||
|
|
03e42c1f5a | ||
|
|
3175b71fce | ||
|
|
ee6e5efa4e | ||
|
|
65e08c6dc2 | ||
|
|
10c6838506 | ||
|
|
1911e362a8 | ||
|
|
74160f00e9 | ||
|
|
22c37f1b50 | ||
|
|
00dd77d49b | ||
|
|
fe78d7445f | ||
|
|
36fb6f0c61 | ||
|
|
1a9a3befe3 | ||
|
|
d7152e0f73 | ||
|
|
6ceb92ade9 | ||
|
|
32b24c0ba7 | ||
|
|
974c1dc664 | ||
|
|
7766527e49 | ||
|
|
a0e5d0e405 | ||
|
|
26cdab9080 | ||
|
|
05420176d1 | ||
|
|
b90d64fe31 | ||
|
|
f6a1e08593 | ||
|
|
f208d6cf70 | ||
|
|
b577f7fa17 | ||
|
|
87e18c64cd | ||
|
|
c7db7c7f9c | ||
|
|
f18a5b3edf | ||
|
|
3f162511d3 | ||
|
|
9b7a707947 | ||
|
|
a6f73b624b | ||
|
|
cdadd3dc70 | ||
|
|
177ee8c08e | ||
|
|
c81fb8bbd4 | ||
|
|
ec86a22e7c | ||
|
|
5f550be7cf | ||
|
|
5ce5e424d3 | ||
|
|
086e763ab7 | ||
|
|
049a11c6d4 | ||
|
|
15e41136cf | ||
|
|
14f0f8c675 | ||
|
|
56fb0d53c9 | ||
|
|
7b6531069b | ||
|
|
7d935f2002 | ||
|
|
a546b050ec | ||
|
|
9726ba5980 | ||
|
|
dbe7bd257a | ||
|
|
251c08e6e3 | ||
|
|
02931638d8 | ||
|
|
8d4d8cb576 | ||
|
|
a774b483b4 | ||
|
|
dcc1438ae4 | ||
|
|
20e4b9d1c1 | ||
|
|
67f4eeb20e | ||
|
|
9a24177163 | ||
|
|
8fbf5676a0 | ||
|
|
8ad4e5788c | ||
|
|
e5939245be | ||
|
|
140af1d29b | ||
|
|
611901404c | ||
|
|
5b30c5d849 | ||
|
|
2b4014dd24 | ||
|
|
e27e876f49 | ||
|
|
d45a22a878 | ||
|
|
9b7df43b83 | ||
|
|
fe2e1a8a77 | ||
|
|
e58ea7c998 | ||
|
|
4e9c52e686 | ||
|
|
0a351bb873 | ||
|
|
0139ba6f1b | ||
|
|
3957ac70c1 | ||
|
|
a4e8fbe06c | ||
|
|
d23fcbd094 |
@@ -0,0 +1,33 @@
|
||||
# Version updates are monthly and grouped into one PR per ecosystem.
|
||||
# Security updates are separate and run whenever an advisory is published.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
- dependency-name: freemkv/.github*
|
||||
@@ -0,0 +1,262 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
|
||||
# to often: these are the FAST checks, so a mistake surfaces in minutes.
|
||||
# `qa` is the release candidate — it runs these too, plus the expensive
|
||||
# suite in qa.yml. `main` only ever moves at release time, to a tagged
|
||||
# commit that was already green on qa.
|
||||
branches: [main, dev, qa]
|
||||
pull_request:
|
||||
|
||||
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# WHY THE SIBLING CHECKOUTS — this is NOT a monorepo.
|
||||
#
|
||||
# The committed Cargo.toml resolves libfreemkv from a git TAG (it is off
|
||||
# crates.io). That means CI was building this branch against the LAST RELEASE
|
||||
# of libfreemkv, never against libfreemkv's dev tip — so a coordinated change
|
||||
# across the two repos could not be verified until release-orchestrate.yml cut the tags.
|
||||
# Green here meant "compiles against last month's library".
|
||||
#
|
||||
# The fix mirrors the developer's local layout via the shared composite
|
||||
# freemkv/.github//actions/checkout-siblings: every sibling repo is checked out
|
||||
# into a subdirectory of $GITHUB_WORKSPACE, reproducing
|
||||
# ~/Developer/freemkv/{freemkv-keysources,libfreemkv,freemkv-unlock}, and a
|
||||
# generated .cargo/config.toml redirects the git-tag patch to those paths.
|
||||
# Config-level [patch.crates-io] wins over the manifest one, which is the same
|
||||
# mechanism local development already uses (see the note in Cargo.toml).
|
||||
#
|
||||
# Subdirectories rather than `../libfreemkv` because actions/checkout refuses
|
||||
# any `path:` outside $GITHUB_WORKSPACE, and the workspace IS the repo dir.
|
||||
# Repos stay separate, tags stay separate, the release cascade is untouched.
|
||||
#
|
||||
# libfreemkv path-deps ../freemkv-unlock, which resolves to the workspace root
|
||||
# under this layout — hence the third checkout.
|
||||
#
|
||||
# `--locked` is deliberately absent: Cargo.lock is now committed (issue #15) and
|
||||
# the `deny` job audits the resolved graph, but CI's sibling path-patch layout
|
||||
# resolves the freemkv crates as path packages, which `--locked` can't satisfy —
|
||||
# the same reason the binary crates omit it. The lock that has to hold is the one
|
||||
# release-orchestrate.yml regenerates in each consuming binary.
|
||||
|
||||
jobs:
|
||||
# Single source of truth for the MSRV — read rust-version from Cargo.toml once
|
||||
# and export it; MSRV-bound jobs consume needs.resolve-msrv.outputs.v. clippy
|
||||
# runs on stable instead (see CI.md's Toolchain policy).
|
||||
resolve-msrv:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# CI owns the changelog: main may never carry a released version without a
|
||||
# CHANGELOG.md entry for it. The release orchestrator dates an existing
|
||||
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
|
||||
# forgot its changelog entry must fail here rather than ship a blank release.
|
||||
# Hard-fails on main (and PRs targeting main); warns elsewhere so an
|
||||
# in-progress dev/qa branch whose entry isn't written yet is not blocked.
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if grep -qE "^## \[${version}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md has an entry for ${version}."
|
||||
exit 0
|
||||
fi
|
||||
msg="CHANGELOG.md has no '## [${version}]' entry"
|
||||
if [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_BASE_REF:-}" = "main" ]; then
|
||||
echo "::error::${msg} — main requires a changelog entry for the released version."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
|
||||
|
||||
lint:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
# fmt runs on the MSRV pin; clippy runs on stable so new-lint debt is a
|
||||
# blocking gate the pinned toolchain would hide. Stable is installed first
|
||||
# for clippy; the MSRV pin goes second so it stays fmt's default toolchain.
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo fmt --check
|
||||
working-directory: freemkv-keysources
|
||||
# --all-targets so the test code is linted too, not just the library.
|
||||
- run: cargo +stable clippy --all-targets -- -D warnings
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
test:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
# Dependency-audit gate. Mirrors the `test` job's sibling-checkout layout and
|
||||
# generated .cargo/config.toml so `cargo metadata` resolves the git-tag patch
|
||||
# to the local sibling paths; cargo-deny then audits advisories, bans,
|
||||
# licenses, and sources against the committed deny.toml.
|
||||
deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: taiki-e/install-action@cargo-deny
|
||||
# Run from the repo dir so the generated .cargo/config.toml path patches apply.
|
||||
- run: cargo deny --all-features check advisories bans licenses sources
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
# This crate ships wherever freemkv and autorip ship, and it drags in all of
|
||||
# libfreemkv's platform code (SCSI transports included). Build the tests, not
|
||||
# `cargo check` — check skips codegen and never compiles test-only code.
|
||||
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: a never-scheduled job leaves the run pending forever, so
|
||||
# the branch's required checks never report complete.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo build --tests
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
# Additive, NON-REQUIRED coverage. Mirrors the `test` job's sibling-checkout
|
||||
# layout and pinned toolchain (nothing in the release cascade depends on it,
|
||||
# and codecov upload is tokenless with fail_ci_if_error:false, so a flaky
|
||||
# upload never reddens the run). `--locked` is deliberately absent here for
|
||||
# the same reason it is in `test`: CI's sibling path-patch layout resolves the
|
||||
# freemkv crates as path packages, which `--locked` can't satisfy.
|
||||
coverage:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
|
||||
working-directory: freemkv-keysources
|
||||
- uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: freemkv-keysources/lcov.info
|
||||
slug: freemkv/freemkv-keysources
|
||||
fail_ci_if_error: false
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
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: a never-scheduled job leaves the run pending forever, so
|
||||
# the branch's required checks never report complete.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo build --tests
|
||||
working-directory: freemkv-keysources
|
||||
@@ -0,0 +1,12 @@
|
||||
name: comment-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
|
||||
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
|
||||
on: [push, pull_request]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
comment-guard:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-comment.yml@v2
|
||||
@@ -0,0 +1,14 @@
|
||||
name: identity-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on:
|
||||
push:
|
||||
branches: [dev, qa, main]
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
identity:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
|
||||
@@ -0,0 +1,11 @@
|
||||
name: leak-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on: [push, pull_request]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
leak-guard:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
|
||||
@@ -0,0 +1,157 @@
|
||||
name: qa
|
||||
|
||||
# ── The qa gate: "is this production worth?" ────────────────────────────────
|
||||
#
|
||||
# dev -> qa -> main.
|
||||
#
|
||||
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
|
||||
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
|
||||
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
|
||||
# that a commit is production worth: everything expensive that can run without
|
||||
# physical media. `main` only ever receives a qa that went green here.
|
||||
#
|
||||
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
|
||||
# dependencies from dev tips would be validating a combination that is not the
|
||||
# one being released, which is the exact failure this branch exists to prevent.
|
||||
# The shared checkout-siblings composite resolves qa->qa from the caller branch.
|
||||
#
|
||||
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
|
||||
# and no hosted runner has an optical drive or the image hoard. That coverage
|
||||
# lives in freemkv's qa.yml (the real-media EC2 matrix), not in this file.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
# Supersede an in-progress qa run when a newer candidate lands on the branch.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
|
||||
# the MSRV read needs.resolve-msrv.outputs.v from Cargo.toml's rust-version;
|
||||
# clippy (cross-lint) runs on stable instead.
|
||||
resolve-msrv:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
|
||||
# qa right now, and is it the one I tested?" — a question that otherwise gets
|
||||
# answered from memory.
|
||||
#
|
||||
# This runs FIRST and does not depend on the gates, deliberately. A red
|
||||
# candidate needs a name more than a green one does: "rc3 failed
|
||||
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
|
||||
# Red on qa is a working gate, not an incident — it is the branch saying this
|
||||
# is not production worth yet. Fix on dev, get dev green, push qa again.
|
||||
#
|
||||
# release.yml excludes v*-rc* so a candidate never publishes a release.
|
||||
rc-tag:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
|
||||
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
|
||||
# lexical sort gets that backwards from the tenth candidate on.
|
||||
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
|
||||
tag="v$v-rc$(( ${n:-0} + 1 ))"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The debug suite runs on every dev push. Release is a DIFFERENT build:
|
||||
# overflow checks are off, debug_assert! is compiled out, and inlining
|
||||
# changes what the optimiser can prove. A test that only passes in debug is
|
||||
# a test that never guarded the binary anyone actually ships.
|
||||
release-tests:
|
||||
needs: resolve-msrv
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo test --release --tests
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
# clippy's output is target-dependent: cfg-gated code only gets linted on
|
||||
# the target it compiles for. Linting solely on the dev machine's host
|
||||
# target is how a lint that CI rejects reaches a push.
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: rustup target add x86_64-unknown-linux-gnu
|
||||
- run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
# Windows compiles the tests but does not run them, matching the policy
|
||||
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
|
||||
# of the SCSI transport and platform layers compile on no other runner, so
|
||||
# without this they are first built at release time.
|
||||
windows-build:
|
||||
needs: resolve-msrv
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo build --release --tests
|
||||
working-directory: freemkv-keysources
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
# NOT the release-candidate tags. Every push to `qa` stamps a
|
||||
# v<version>-rc<N> so a run can be named, and 'v*' matches those too —
|
||||
# which would have this workflow build and PUBLISH a GitHub release for
|
||||
# every candidate, including the red ones.
|
||||
- '!v*-rc*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
if [ "$CARGO_VER" != "${{ github.ref_name }}" ]; then
|
||||
echo "::error::Cargo.toml says $CARGO_VER but tag is ${{ github.ref_name }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version match: $CARGO_VER"
|
||||
|
||||
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
|
||||
# release job does NOT `needs:` this job. The tag decision was already gated by
|
||||
# CI (ci.yml on dev, then a green qa.yml on the same commit).
|
||||
#
|
||||
# Cargo.lock is now committed (issue #15), but --locked is still omitted: on
|
||||
# the runner the libfreemkv dep resolves from crates.io, not the local path
|
||||
# patch, which --locked can't reconcile. Its
|
||||
# libfreemkv dep resolves from crates.io (the dev .cargo/config.toml patch is
|
||||
# gitignored and absent on the runner), so libfreemkv must already be published
|
||||
# at this version — release-orchestrate.yml enforces that ordering before tagging.
|
||||
test:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ steps.msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test
|
||||
|
||||
# NOTE: there is no crates.io publish — keysources is `publish = false` and
|
||||
# git-tag-only. Consumers git-tag-pin it via a committed [patch.crates-io], so
|
||||
# the git tag itself is the release artifact. CI here only verifies the
|
||||
# version/tag match, runs tests (tripwire), and cuts the GitHub Release.
|
||||
|
||||
release:
|
||||
# Only needs `verify`; the GitHub Release is cut as soon as the version
|
||||
# check passes, in parallel with the test tripwire.
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
+8
-1
@@ -1,2 +1,9 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
|
||||
# session scratch — never track (may contain RE breadcrumbs)
|
||||
scratch/
|
||||
|
||||
# internal agent context — never publish (leak-guard blocks both)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.cargo/config.toml
|
||||
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
# Changelog
|
||||
|
||||
## [1.8.1] — 2026-10-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Unit tests live in `*_tests.rs` side files. No behaviour change.
|
||||
|
||||
## [1.8.0] — 2026-10-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- The online key service ignores `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`: with one set, every key lookup failed (the pinned resolver also answered the proxy's address, so the proxy request went to the key service).
|
||||
|
||||
### Changed
|
||||
|
||||
- `KeydbSource` hands out host certs best first, with no disc input: certs with no `Revoked in MKBv<N>` note in keydb order, then revoked ones by latest revocation. The inherent `host_certs()` and the `KeySource::host_certs` trait method both return this order; the trait ignores its `mkb` argument.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `KeyDb::host_certs(mkb)`: use `KeyDb::host_certs_ranked`.
|
||||
|
||||
### Added
|
||||
|
||||
- `KeyDb::host_certs_ranked`.
|
||||
- `check_keyserver_url` with `KeyserverUrlRejection`/`KeyserverUrlFault`: a typed Permanent vs Temporary verdict, so callers need not match `validate_keyserver_url`'s text.
|
||||
- `test-hooks` feature exposing `set_last_decode_reachability`, to test decode-verdict handling without a network.
|
||||
|
||||
## [1.7.7] — 2026-09-26
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Replace comment-overflow documentation with concise source contracts and README instructions; enforce the shared comment policy in CI.
|
||||
|
||||
## [1.7.6] — 2026-09-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.6 for the unified release. No functional changes to this crate; the release is driven by the freemkv 1.7.6 Linux desktop shell (GTK4 + libadwaita) and the rip-finished desktop notification (issue #56).
|
||||
|
||||
## [1.7.5] — 2026-09-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.5 for the unified release. No functional changes to this crate; the release is driven by freemkv-unlock mirroring the freemkv-firmware 0.9.0 ABI (the drive's `Ake` and `Bus` levers retired into a single `Encryption` lever).
|
||||
|
||||
## [1.7.4] — 2026-09-21
|
||||
### Fixed
|
||||
|
||||
- keydb lookups now report match state and entry shape, so a "no entry" result tells you whether the disc was truly absent or matched-but-underivable (no usable VID) (#46).
|
||||
- ureq 3.4.2 with a bounded, rolling-idle reply body, so a slow-but-progressing download is no longer killed by an absolute deadline.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- CI moved to the central reusable workflows.
|
||||
|
||||
## [1.7.3] — 2026-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified release with freemkv 1.7.3 (`info --share` disc-structure capture for bug reports + the multi-angle UHD main-title fix in libfreemkv; see the libfreemkv/freemkv 1.7.3 notes). No functional changes to this crate.
|
||||
|
||||
## [1.7.2] — UNRELEASED
|
||||
|
||||
### Added
|
||||
|
||||
- `OnlineSource` now records the reachability outcome of its actual `/decode` POST (an HTTP `Status(code)` for any answer, including 200/404/422/429/5xx, or `Transport` for a connect/timeout/DNS/TLS failure) in a per-thread slot, readable via the new `take_last_decode_reachability()` / `DecodeReachability`. This lets a caller (autorip) classify a no-key result from the REAL decode's outcome instead of firing a second, redundant probe against the POST-only endpoint. No behavioural change to key resolution itself.
|
||||
|
||||
## [1.7.1] — UNRELEASED
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.1 for the unified release (freemkv-unlock 1.7.1 LibreDrive unlock fix); no functional changes.
|
||||
|
||||
## [1.7.0] — 2026-09-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.0 for the unified release. Internal CI/lint hardening only (stable-clippy MSRV split, cargo-deny dependency-audit gate, audience-based comment-guard); no functional changes.
|
||||
|
||||
## [1.6.14] — 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.14 for the unified release.
|
||||
|
||||
## [1.6.13] — 2026-08-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.13 for the unified release.
|
||||
|
||||
## [1.6.12] — 2026-08-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.12 for the unified release; no functional changes.
|
||||
|
||||
## [1.6.11] — 2026-08-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.11 for the unified release. No functional changes to
|
||||
this crate; the release was driven by the libfreemkv main-feature selection
|
||||
improvements and the autorip mux-quarantine fix (see the libfreemkv and
|
||||
autorip 1.6.11 notes).
|
||||
|
||||
### Added
|
||||
|
||||
- Codecov coverage reporting and badge.
|
||||
- Substantially expanded unit-test coverage.
|
||||
|
||||
## [1.6.10] — 2026-08-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.10 for the unified release. No functional changes to
|
||||
this crate; the release was driven by libfreemkv (TrueHD/MLP audio now resyncs
|
||||
to the next major-sync access unit after a source transport-stream
|
||||
discontinuity, instead of splicing post-gap audio mid-stream — fixing
|
||||
decoder-choking seams on discs whose stream carries a continuity-counter gap;
|
||||
see the libfreemkv 1.6.10 notes).
|
||||
|
||||
## [1.6.9] — 2026-08-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.9 for the unified release. No functional changes to
|
||||
this crate; the release was driven by autorip (automatic per-episode TV
|
||||
ripping — each episode named `S{NN}E{MM}`, with TMDB runtime-aligned episode
|
||||
numbering across multi-disc seasons — a Manual Rename option, and a unified
|
||||
per-disc staging state file — see the autorip 1.6.9 notes).
|
||||
|
||||
## [1.6.8] — 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.8 for the unified release. No functional changes to
|
||||
this crate; the release was driven by autorip (webhooks now fire per pipeline
|
||||
stage — Rip / Mux / Move — with the Rip hook firing the moment the drive is
|
||||
free again, plus a Ripper-tab activity-banner fix so it also shows during
|
||||
moves — see the autorip 1.6.8 notes).
|
||||
|
||||
## [1.6.7] — 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.7 for the unified release. No functional changes to
|
||||
this crate; the release was driven by autorip (per-webhook event selection,
|
||||
a progress bar per moved artifact, and move-queue / webhook-error fixes —
|
||||
see the autorip 1.6.7 notes).
|
||||
|
||||
## [1.6.6] — 2026-08-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.6 for the unified release. No functional changes
|
||||
to this crate; the release was driven by autorip (webhooks may now target
|
||||
private/LAN addresses — see the autorip 1.6.6 notes).
|
||||
|
||||
## [1.6.5] — 2026-08-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A media, disc-ID, or VUK key at the end of a keydb row is no longer
|
||||
lost to the comment beside it.** Many `keydb.cfg` rows end with a
|
||||
`; MKBv…` note. When that note sat directly after the row's final
|
||||
field and that field was the media key (`M`), disc ID (`I`), or VUK
|
||||
(`V`) — rather than a unit key (`U`) — the note was read as part of the
|
||||
value, the hex parse failed, and the key was silently dropped, so the
|
||||
disc reported no key even though the file held one. The note is now
|
||||
stripped from those three fields exactly as it already was from the
|
||||
unit-key field (a hex value never contains `;`, so the split is safe).
|
||||
A disc that stopped resolving for this reason resolves again.
|
||||
|
||||
- **A misconfigured key service is no longer quieter than an outage.** A
|
||||
mistyped port, an `http://` URL, or a key-service address the SSRF guard
|
||||
refuses all mean the same thing: the service was never asked, so nothing is
|
||||
known about the disc. They were reported as an empty result — the same value a
|
||||
service returns when it genuinely holds no key — so a standing
|
||||
misconfiguration surfaced to the operator as "no key for this disc" and never
|
||||
self-corrected, while a transient DNS failure was correctly reported as a
|
||||
failure. All three are now failures, logged at error level and separated in
|
||||
the log text from an outage: one says fix the URL, the other says wait. A
|
||||
later key source's real key still wins, so nothing that used to rip stops
|
||||
ripping.
|
||||
- **A replaced `keydb.cfg` can no longer be missed.** The parsed keydb is cached
|
||||
behind the file's identity, and that identity was size plus modification time
|
||||
— which are byte-identical when a file is replaced within the same second by
|
||||
one of the same length, the common shape of a daily refresh on a filesystem
|
||||
that records whole seconds. The old, superseded keys were then served for the
|
||||
rest of the process's life while every lookup reported success. The identity
|
||||
now includes the file's inode, and a cache entry is not trusted until the file
|
||||
has been unmodified long enough for its timestamp to be meaningful.
|
||||
- **A corrupt keydb keeps saying so.** The summary warning that counts rejected
|
||||
and duplicated rows is produced while parsing, and the cache skips parsing —
|
||||
so a damaged keydb warned once, at whatever moment it was first read, and was
|
||||
then served in silence to every later disc. The summary is now re-emitted on
|
||||
every lookup that serves that file. An intact keydb stays silent.
|
||||
- **A keydb's identity and its contents are read through one file handle.** The
|
||||
cache used to stat the path and then separately open it, so a replacement
|
||||
landing between the two stored one file's identity beside another file's keys.
|
||||
|
||||
### Security
|
||||
|
||||
- **`{:?}` on a keydb no longer spells out its keys.** `KeyDb` and
|
||||
`DiscEntry` derived their `Debug`, so a single `{:?}` in a log line, a
|
||||
tracing field, or a panic message wrote out every processing key and
|
||||
every disc's media key, disc ID, VUK, and unit keys in full — on the
|
||||
published file, all 184,860 discs' worth. Both are public API, so any
|
||||
caller could trip it. Both types now format through a redacting `Debug`
|
||||
that names each field without its bytes, matching the AACS types this
|
||||
parser was split from, and a test fails if the plain derive ever returns.
|
||||
|
||||
- **The host certificate's redaction is now pinned by a test.** `{:?}` on a
|
||||
keydb host certificate does not print the AACS host private key, and never
|
||||
did — but that depended entirely on libfreemkv's own redacting formatter,
|
||||
with nothing in this crate to notice if it changed. The whole-database
|
||||
formatter's test did not cover it. It does now, along with device keys.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Breaking (API):** `KeyDb::disc_entries`' key and `DiscEntry::disc_hash` are
|
||||
`Arc<str>` rather than `String` (a ~13 MB saving on the published keydb, which
|
||||
is now held in memory). Code that requires an owned `String` from either needs
|
||||
`.to_string()`. Unreleased in 1.6.4; recorded here because it shipped to git
|
||||
consumers without a note.
|
||||
|
||||
## [1.6.4] — 2026-08-15
|
||||
|
||||
### Changed
|
||||
|
||||
- **No functional change.** This crate ships alongside the rest of freemkv at a
|
||||
matching version; its behaviour is untouched.
|
||||
|
||||
## [1.6.3] — 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **The key service is reached over a newer HTTP client, with the same
|
||||
protections.** Before contacting a key service this crate resolves the host,
|
||||
rejects any private or link-local address, and then pins the connection to the
|
||||
addresses it just checked, so a hostname cannot be re-pointed at an internal
|
||||
machine in the moment between the check and the request. That behaviour is
|
||||
unchanged; only the client underneath it moved.
|
||||
|
||||
### Security
|
||||
|
||||
- **The address pinning is now proven by a test rather than assumed.** The checks
|
||||
that reject a bad address were well covered, but nothing verified that the
|
||||
approved addresses were the ones actually connected to — and every existing
|
||||
test still passed with that wiring removed entirely, because none of them
|
||||
opened a connection. A connection made to an unreachable name can only
|
||||
succeed if the pinned address is honoured, and that is now asserted. No
|
||||
defect is being fixed here: the pinning was correct. It simply had no way of
|
||||
telling anyone if it stopped being correct.
|
||||
|
||||
## [1.6.2] — 2026-08-08
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
## [1.6.1] — 2026-08-07
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
## [1.6.0] — 2026-08-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Key-service failures are no longer indistinguishable from "no key for this
|
||||
disc".** `query` returned an empty vector for a 502, a 401, a 429, a
|
||||
transport error and an unparseable body alike — the same value a successful
|
||||
lookup with no match returns. It now returns a typed error, and the HTTP
|
||||
status is classified into the operator action it implies: 401/403 the token,
|
||||
429 back off, any other non-2xx the service. A genuine miss is a 200 with no
|
||||
key, so every non-2xx is a failure by definition. A DNS failure or timeout
|
||||
now counts as unreachable rather than as a bad URL.
|
||||
|
||||
Version sync with the workspace (freemkv-engine split release). No source change
|
||||
in this crate; it remains a pluggable AACS key-source provider consumed by the
|
||||
`freemkv` CLI and libfreemkv's key resolver.
|
||||
|
||||
## [1.5.2] — 2026-07-22
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.5.2 (CSS DVD descramble
|
||||
fix). No source change in this crate.
|
||||
|
||||
## [1.4.5] — 2026-07-18
|
||||
|
||||
Version sync; inherits libfreemkv 1.4.5. `KeySource` split into `get_unit_keys` +
|
||||
`get_fmts_indexes`, and a keydb device-key parse bug on an uppercase `0X` hex
|
||||
prefix was fixed (case-insensitive hex parsing across the toolchain).
|
||||
|
||||
## [1.4.4] — 2026-07-17
|
||||
|
||||
Version sync; inherits libfreemkv 1.4.4. The online `/decode` request is built from
|
||||
a `DecodeSampleSet` proven sufficient by type rather than a runtime length check.
|
||||
|
||||
## [1.4.3] — 2026-07-17
|
||||
|
||||
Version sync; inherits libfreemkv 1.4.3. The online unit-key reply is parsed as a
|
||||
list (one key for an ordinary disc, the ordered set for a forensic-variant disc),
|
||||
and `MIN_SAMPLE_UNITS` is re-exported from libfreemkv.
|
||||
|
||||
## [1.4.2] — 2026-07-15
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.2. The keydb test that
|
||||
feeds real key material into the AACS crypto was adapted to the segregated
|
||||
`decrypt_unit` + `is_clean` primitives (behaviour unchanged).
|
||||
|
||||
## [1.4.1] — 2026-07-14
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.1.
|
||||
|
||||
## [1.4.0] — 2026-07-13
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.0.
|
||||
|
||||
## [1.3.2] — 2026-07-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Unit keys carry libfreemkv's new `UnitKey.variant_number`; every source
|
||||
(keydb, online, VUK-derived) emits `0` — ordinary, non-forensic content —
|
||||
via the `UnitKey::new` constructor. No behaviour change. Inherits
|
||||
**libfreemkv 1.3.2**.
|
||||
|
||||
## [1.3.1] — 2026-07-10
|
||||
|
||||
### Licensing
|
||||
|
||||
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
|
||||
including 1.3.0 remain under AGPL-3.0).
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.3.1.
|
||||
|
||||
## [1.3.0] — 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.0 host certs round-trip through `keydb.cfg`.** `to_keydb_cfg` now
|
||||
emits the sibling `| HC2 |` line — the inverse of the v2 host-cert parser — so
|
||||
writing a keydb back out no longer silently drops AACS 2.0 host certs.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Resolve runs directly on `libfreemkv::aacs` primitives.** After libfreemkv
|
||||
dropped its `aacs::boil` veneer, the resolve path now calls
|
||||
`derive_media_key_from_{pk,dk}`, `derive_vuk`, and `decrypt_unit_key` from
|
||||
`aacs::derive` with the `aacs::types` newtypes. No behaviour change.
|
||||
- Inherits **libfreemkv 1.3.0**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **keydb save-validation matches the parser exactly.** A `0x` line counts as a
|
||||
disc entry only when it also contains ` = `, so validating and persisting
|
||||
content that parses to zero usable entries (e.g. a stray `0xDEADBEEF` line) can
|
||||
no longer succeed.
|
||||
- **Disc-entry titles round-trip verbatim** (parentheses and all) — the parse
|
||||
path now keeps the title exactly as the emit path writes it.
|
||||
|
||||
## [1.2.0] — 2026-06-29
|
||||
|
||||
### Changed
|
||||
|
||||
- **One hex parser across the toolchain.** Online and keydb hex inputs now parse
|
||||
through `libfreemkv::hex`, the same parser the library uses — no separate
|
||||
decoder with its own length/nibble rules.
|
||||
- **`DiscInputs` carries the disc's AACS version**, and the tests derive the
|
||||
`Unit_Key_RO` stride from `inputs.version` instead of hardcoding it, so an
|
||||
AACS-1.0 (V10, 48-byte) and AACS-2.x (V20/V21, 64-byte) disc are each handled
|
||||
at their own stride.
|
||||
- **Online MKB read cap aligned with libfreemkv (64 MiB)**, and an over-cap MKB
|
||||
is logged rather than silently truncated.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the project maintainer via GitHub at https://github.com/MattJackson. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,18 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest!
|
||||
|
||||
- **Report a bug** — open an issue
|
||||
- **Fix a bug** — fork, branch, PR
|
||||
- **Suggest a feature** — open an issue first
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Generated
+1311
File diff suppressed because it is too large
Load Diff
+45
-6
@@ -1,17 +1,56 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "0.30.0"
|
||||
version = "1.8.1"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Pluggable AACS key sources (keydb, online key service, mapfile) for libfreemkv. Each source looks a disc up and hands libfreemkv a Key; the library does all derivation."
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
description = "Pluggable AACS key sources (keydb, online key service) for libfreemkv. Each source looks a disc up and hands libfreemkv its terminal Unit Keys via get_unit_keys; the library does all derivation."
|
||||
repository = "https://github.com/freemkv/freemkv-keysources"
|
||||
keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"]
|
||||
categories = ["multimedia"]
|
||||
# OFF crates.io: depends on libfreemkv, which is now git-only (it git-deps the
|
||||
# firmware crate). A crates.io crate can't carry a git dep, so keysources is
|
||||
# consumed by git tag too. Clients git-tag-pin it.
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
||||
libfreemkv = "0.30"
|
||||
# libfreemkv is git-only now; the committed [patch.crates-io] below redirects this
|
||||
# bare version req to the libfreemkv git tag (local dev overrides it to a path).
|
||||
libfreemkv = "1.8"
|
||||
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
# KeydbSource::save: decompress a downloaded keydb (.zip / .gz). Same versions
|
||||
# already in the resolved graph via libfreemkv — no new tree, no new HTTP stack.
|
||||
flate2 = "1"
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Structural begin/end logging around the keyserver round-trip (never logs
|
||||
# key material).
|
||||
tracing = "0.1"
|
||||
|
||||
[features]
|
||||
# `set_last_decode_reachability`, for consumers' tests only (enable in dev-dependencies).
|
||||
test-hooks = []
|
||||
|
||||
[dev-dependencies]
|
||||
# `test_util` fixtures (`decrypt_unit`, …) for this crate's tests (keys-upfront
|
||||
# KU-P1). CI resolves it through the sibling path patch.
|
||||
libfreemkv = { version = "1.8", features = ["test-util"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.6"`
|
||||
# req above to the git tag — committed and CI-visible. The release script
|
||||
# rewrites this tag to the new version before regenerating Cargo.lock. Local dev
|
||||
# overrides it with a path patch via the gitignored .cargo/config.toml (a
|
||||
# config-level [patch.crates-io] wins over this manifest one for the same crate).
|
||||
[patch.crates-io]
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.8.1" }
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.8.1" }
|
||||
|
||||
[lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/freemkv/freemkv-keysources/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/freemkv/freemkv-keysources)
|
||||
|
||||
# freemkv-keysources
|
||||
|
||||
Pluggable AACS key sources (keydb, online key service) for
|
||||
[libfreemkv](https://github.com/freemkv/libfreemkv). Each source looks a disc up
|
||||
and hands libfreemkv its terminal Unit Keys via `get_unit_keys`; the library
|
||||
does all derivation.
|
||||
|
||||
## Development
|
||||
|
||||
Build API documentation with `cargo doc --no-deps --open` for source construction,
|
||||
keydb paths and online-query contracts. Run `cargo test --tests` to validate
|
||||
parsing, caching and request guards. Tests use synthetic keys and local fixtures;
|
||||
never commit real key material or credentials.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
+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/freemkv-keysources/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.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Coverage is informational only: it never blocks a merge or a release. The
|
||||
# release cascade is gated by ci.yml / qa.yml and the local precommit, not by
|
||||
# a coverage threshold.
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
|
||||
# No PR comment noise; the badge and the Codecov UI are enough.
|
||||
comment: false
|
||||
|
||||
# Exclude non-product code from the coverage denominator.
|
||||
ignore:
|
||||
- "tests/**"
|
||||
- "ci/**"
|
||||
@@ -0,0 +1,42 @@
|
||||
# cargo-deny — gates the RESOLVED dependency graph (issue #15).
|
||||
# Runs in CI against the committed Cargo.lock: RUSTSEC advisories, a permissive
|
||||
# licence allow-list, and source/ban hygiene. Update the lock (cargo update)
|
||||
# to clear an advisory rather than adding an `ignore` entry without cause.
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
# ignore = ["RUSTSEC-0000-0000"] # only with a written justification
|
||||
|
||||
[licenses]
|
||||
# The whole tree is permissive. r-efi's LGPL arm is satisfied by Apache-2.0
|
||||
# (its expression is "Apache-2.0 OR LGPL-2.1-or-later"), so no copyleft is
|
||||
# allowed here. Add a licence only when a real dependency introduces it.
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"0BSD",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"Zlib",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
confidence-threshold = 0.9
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn" # transitive dupes (digest 0.10/0.11 etc.) are noise, not a gate
|
||||
wildcards = "deny"
|
||||
# The freemkv siblings resolve as path deps under CI's path-patch layout, which
|
||||
# reads as a wildcard version req. That is internal, not a supply-chain wildcard.
|
||||
allow-wildcard-paths = true
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
# The freemkv siblings are first-party git-tag deps; allow git sources from
|
||||
# our own org only (CI resolves them as git, not the local path override).
|
||||
[sources.allow-org]
|
||||
github = ["freemkv"]
|
||||
+616
-197
@@ -1,30 +1,138 @@
|
||||
//! `keydb.cfg` key source (source #1).
|
||||
//!
|
||||
//! Parses a local `keydb.cfg` and enumerates the material it holds for a disc
|
||||
//! as candidate [`Key`]s, most-specific first. It does NO derivation — picking
|
||||
//! which device key applies, or which media key verifies, is the MKB walk, and
|
||||
//! that lives in libfreemkv (`Disc::decrypt_with`). The candidate order lets
|
||||
//! the library try each path the keydb could satisfy:
|
||||
//!
|
||||
//! 1. per-disc VUK (hash hit) → `Key::Volume`
|
||||
//! 2. per-disc unit keys (hash hit) → `Key::Unit`
|
||||
//! 3. per-disc media key (hash hit) → `Key::Media`
|
||||
//! 4. device-key pool (universal) → `Key::Device` (lib walks the MKB)
|
||||
//! 5. processing-key pool → `Key::Processing`
|
||||
//! 6. media-key pool (all entries) → `Key::Media` (lib brutes vs the MKB)
|
||||
//! Parses a local `keydb.cfg`, looks the disc up by hash, and derives the disc's terminal
|
||||
//! **Unit Keys** by composing libfreemkv's raw `aacs::derive` primitives — never
|
||||
//! re-implementing AES. The path mirrors the OLD candidate order EXACTLY, cheapest-first:
|
||||
//! per-disc Unit Keys, then VUK, then a Media Key (stored / PK pool / DK pool) via
|
||||
//! `derive_vuk`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use libfreemkv::aacs::{HostCert, KeyDb};
|
||||
use libfreemkv::{DiscInputs, Key, KeySource};
|
||||
use crate::uks_from_vuk;
|
||||
use libfreemkv::aacs::derive::{derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk};
|
||||
use libfreemkv::aacs::trace::{KeyNode, MatchedEntry};
|
||||
use libfreemkv::aacs::types::{HostCert, MediaKey, UnitKey, Vid};
|
||||
use libfreemkv::keysource::{ResolveCtx, UnitKeyResolution};
|
||||
use libfreemkv::{Error, KeySource};
|
||||
|
||||
use crate::keydb_format::KeyDb;
|
||||
// Decompression-bomb cap on decompressed keydb size: a tiny zip/gz could
|
||||
// inflate to GiB and OOM the refresh thread. Same constant `KeyDb::load`
|
||||
// uses on-disk, defined once in keydb_format so the two can't drift.
|
||||
use crate::keydb_format::MAX_KEYDB_BYTES;
|
||||
|
||||
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateResult {
|
||||
pub path: PathBuf,
|
||||
pub entries: usize,
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
// Widest observed granularity of a filesystem's stored mtime (HFS+, many container/network
|
||||
// filesystems record whole seconds); 2 s leaves rounding room.
|
||||
const MTIME_GRANULARITY: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
// The identity of the keydb file a cache entry was parsed from. `(len, modified)` alone is not
|
||||
// an identity; `dev`+`ino` plus CacheEntry::is_settled close the gap.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct FileStamp {
|
||||
/// Filesystem device id. `0` where unavailable.
|
||||
dev: u64,
|
||||
/// Inode number — the discriminator an atomic rename always changes. `0`
|
||||
/// where unavailable.
|
||||
ino: u64,
|
||||
len: u64,
|
||||
modified: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
impl FileStamp {
|
||||
/// Stamp from a metadata block. Take it from `File::metadata` on an OPEN
|
||||
/// handle (an `fstat`), never from a second `std::fs::metadata` on the path:
|
||||
/// only the handle guarantees the stamp and the bytes describe one file.
|
||||
fn of(meta: &std::fs::Metadata) -> Self {
|
||||
// dev/ino are a Unix concept; Windows's rough equivalent is documented
|
||||
// unreliable on some filesystems, so the cross-platform fallback is
|
||||
// simply "no inode discriminator" (rule 2 alone is correct there).
|
||||
#[cfg(unix)]
|
||||
let (dev, ino) = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(meta.dev(), meta.ino())
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let (dev, ino) = (0u64, 0u64);
|
||||
Self {
|
||||
dev,
|
||||
ino,
|
||||
len: meta.len(),
|
||||
modified: meta.modified().ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cached parse: the file identity it came from, WHEN that identity was
|
||||
/// observed, the parsed database, and the parser's rejection counts.
|
||||
struct CacheEntry {
|
||||
stamp: FileStamp,
|
||||
/// Wall-clock time at which `stamp` was read off the open handle. The whole
|
||||
/// point of storing it is [`Self::is_settled`].
|
||||
stamped_at: std::time::SystemTime,
|
||||
db: Arc<KeyDb>,
|
||||
/// Retained, not just logged: [`KeydbSource::cached_db`] re-emits the
|
||||
/// summary on every hit, because a cache hit skips `KeyDb::parse` and with
|
||||
/// it the one warning that a corrupt keydb is being served.
|
||||
stats: crate::keydb_format::ParseStats,
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
// Require a settled identity before reusing a cached parse: coarse mtimes can hide
|
||||
// in-place edits within one timestamp tick. Missing/future mtimes use the age of
|
||||
// the observation; inode identity detects atomic replacement.
|
||||
fn is_settled(&self, granularity: std::time::Duration) -> bool {
|
||||
match self
|
||||
.stamp
|
||||
.modified
|
||||
.and_then(|m| self.stamped_at.duration_since(m).ok())
|
||||
{
|
||||
// The normal case: a real mtime that was already `granularity` in
|
||||
// the past when we stamped it, so any later write bumps the mtime
|
||||
// past our stamp and is detected. Trust it.
|
||||
Some(age) => age >= granularity,
|
||||
// No mtime (mtime-less FS) or a future mtime (clock skew): `duration_since`
|
||||
// yields None, so the mtime can't discriminate change — settle via inode
|
||||
// identity once this observation has itself aged past the granularity.
|
||||
None => std::time::SystemTime::now()
|
||||
.duration_since(self.stamped_at)
|
||||
.is_ok_and(|age| age >= granularity),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`KeySource`] backed by a local `keydb.cfg` file.
|
||||
///
|
||||
/// The parsed database is CACHED behind the file's identity stamp: a single AACS-cert rip calls
|
||||
/// `host_certs()`, the trait `host_certs(mkb)`, and `get_unit_keys`, and each used to re-read +
|
||||
/// re-parse the whole ~62 MiB file. A replaced keydb is picked up on the next call, with one
|
||||
/// narrow exception.
|
||||
pub struct KeydbSource {
|
||||
path: PathBuf,
|
||||
/// Lazily-built candidate list (UK ▸ VK ▸ MK ▸ DK ▸ …) plus its cursor —
|
||||
/// the keydb owns the order and hands one candidate per `next_key`. `None`
|
||||
/// until the first `next_key` parses the file.
|
||||
cursor: Option<std::vec::IntoIter<Key>>,
|
||||
// Mutex, not RwLock: the guarded section is a stamp compare + Arc clone,
|
||||
// so there's nothing for concurrent readers to win. Poisoning recovers
|
||||
// rather than propagates — a panic elsewhere must not poison every lookup.
|
||||
cache: Mutex<Option<CacheEntry>>,
|
||||
// Cache MISSES (real reads+parses). The only honest way to assert the
|
||||
// cache from a test, since timing is flaky and the parsed value is
|
||||
// identical either way.
|
||||
parses: AtomicUsize,
|
||||
// The `G` of CacheEntry::is_settled, as a field so tests can isolate the two staleness
|
||||
// discriminators one at a time (0 vs. a huge duration).
|
||||
mtime_granularity: std::time::Duration,
|
||||
// Corruption summaries emitted (see emit_parse_stats). Same rationale as
|
||||
// `parses`: avoids pulling a `tracing` subscriber into dev-dependencies.
|
||||
warnings: AtomicUsize,
|
||||
}
|
||||
|
||||
impl KeydbSource {
|
||||
@@ -32,207 +140,518 @@ impl KeydbSource {
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
cursor: None,
|
||||
cache: Mutex::new(None),
|
||||
parses: AtomicUsize::new(0),
|
||||
mtime_granularity: MTIME_GRANULARITY,
|
||||
warnings: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
// Log the parser's rejection summary and count the emission — the count
|
||||
// is the only way to assert, without a `tracing` dev-dependency, that the
|
||||
// corruption warning is NOT swallowed by the cache. See `cached_db`.
|
||||
fn emit_parse_stats(&self, stats: &crate::keydb_format::ParseStats) {
|
||||
if stats.log() {
|
||||
self.warnings.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Corruption summaries emitted so far. Test-only observability, paired with
|
||||
/// [`Self::parse_count`]: a corrupt keydb must warn once per LOOKUP, while
|
||||
/// still parsing once.
|
||||
#[cfg(test)]
|
||||
fn warning_count(&self) -> usize {
|
||||
self.warnings.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Test-only: override the settle window (see the field's doc).
|
||||
#[cfg(test)]
|
||||
fn with_mtime_granularity(mut self, g: std::time::Duration) -> Self {
|
||||
self.mtime_granularity = g;
|
||||
self
|
||||
}
|
||||
|
||||
// The parsed keydb, from cache when unchanged (errors mirror KeyDb::load). ONE OPEN, ONE
|
||||
// IDENTITY: stamp (fstat) and bytes share one handle.
|
||||
fn cached_db(&self) -> std::io::Result<Arc<KeyDb>> {
|
||||
let file = std::fs::File::open(&self.path)?;
|
||||
let stamp = FileStamp::of(&file.metadata()?);
|
||||
let stamped_at = std::time::SystemTime::now();
|
||||
// Fast path: a settled cache hit under a brief lock (stamp compare +
|
||||
// Arc clone). Re-emit the rejection summary on EVERY hit — a hit skips
|
||||
// `KeyDb::parse`, else a corrupt keydb warns once then serves silently.
|
||||
{
|
||||
let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = guard.as_ref()
|
||||
&& entry.stamp == stamp
|
||||
&& entry.is_settled(self.mtime_granularity)
|
||||
{
|
||||
self.emit_parse_stats(&entry.stats);
|
||||
return Ok(entry.db.clone());
|
||||
}
|
||||
}
|
||||
// Miss: parse the ~62 MiB file OUTSIDE the lock so a reparse can't stall
|
||||
// every other worker. Stamp (fstat) and bytes still come from the ONE
|
||||
// open handle, preserving the one-open-one-identity invariant.
|
||||
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
|
||||
let db = Arc::new(db);
|
||||
// Re-acquire and double-check: a peer may have installed the same
|
||||
// settled stamp while we parsed — adopt theirs and drop our redundant
|
||||
// parse rather than racing a lost update.
|
||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = guard.as_ref()
|
||||
&& entry.stamp == stamp
|
||||
&& entry.is_settled(self.mtime_granularity)
|
||||
{
|
||||
self.emit_parse_stats(&entry.stats);
|
||||
return Ok(entry.db.clone());
|
||||
}
|
||||
self.emit_parse_stats(&stats);
|
||||
self.parses.fetch_add(1, Ordering::Relaxed);
|
||||
*guard = Some(CacheEntry {
|
||||
stamp,
|
||||
stamped_at,
|
||||
db: db.clone(),
|
||||
stats,
|
||||
});
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Drop the cached parse. Called after this source WRITES the file, so the
|
||||
/// next lookup re-reads it without depending on mtime resolution.
|
||||
fn invalidate_cache(&self) {
|
||||
*self.cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Cache misses so far — the number of real file reads + parses. Test-only
|
||||
/// observability for the cache (see [`Self::cached_db`]).
|
||||
#[cfg(test)]
|
||||
fn parse_count(&self) -> usize {
|
||||
self.parses.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
// Turn a KeyDb::load failure into this source's verdict: MISSING is the documented benign
|
||||
// case (Ok/None); everything else is logged and surfaced as an error, not silently reported
|
||||
// as "no key".
|
||||
fn load_failure(&self, e: &std::io::Error) -> Option<Error> {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => None,
|
||||
std::io::ErrorKind::InvalidData => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
path = %self.path.display(),
|
||||
"keydb.cfg is unusable (over the size cap or not valid UTF-8) — this is a corrupt or truncated keydb, NOT a disc without a key"
|
||||
);
|
||||
Some(Error::KeydbInvalid)
|
||||
}
|
||||
kind => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
path = %self.path.display(),
|
||||
io_kind = ?kind,
|
||||
"keydb.cfg could not be read — NOT a disc without a key"
|
||||
);
|
||||
Some(Error::KeydbLoad {
|
||||
path: self.path.display().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate, decompress, and crash-safely persist raw keydb bytes (plain
|
||||
/// text, `.zip`, or `.gz`) to THIS source's own [`path`](Self::path).
|
||||
///
|
||||
/// The bytes are decompressed (zip / gz / plain), checked for at least
|
||||
/// one recognisable keydb entry, then atomically written to the source's
|
||||
/// path (sibling-temp + fsync + rename + parent-dir fsync). Decompressed
|
||||
/// size is capped at [`MAX_KEYDB_BYTES`] (decompression-bomb guard).
|
||||
/// Writes to the source's own path, not a hardcoded default, so the
|
||||
/// caller decides the destination (CLI `--keydb`, the autorip service path).
|
||||
pub fn save(&self, bytes: &[u8]) -> Result<UpdateResult, Error> {
|
||||
let text = if bytes.starts_with(b"PK\x03\x04") {
|
||||
extract_zip(bytes)?
|
||||
} else if bytes.starts_with(&[0x1f, 0x8b]) {
|
||||
read_capped_to_string(flate2::read::GzDecoder::new(bytes))?
|
||||
} else {
|
||||
// Plain-text body: route through the same capped reader as the
|
||||
// gz/zip branches so an oversized uncompressed upload can't bypass
|
||||
// MAX_KEYDB_BYTES.
|
||||
read_capped_to_string(std::io::Cursor::new(bytes))?
|
||||
};
|
||||
|
||||
let entries = text
|
||||
.lines()
|
||||
// Mirror KeyDb::parse by running the same parsers, not a prefix check: a
|
||||
// right-prefix row with bad hex / short cert parses to nothing, so a prefix count
|
||||
// let junk past `entries > 0`.
|
||||
.filter(|l| crate::keydb_format::is_parseable_entry_line(l))
|
||||
.count();
|
||||
|
||||
if entries == 0 {
|
||||
return Err(Error::KeydbInvalid);
|
||||
}
|
||||
|
||||
write_atomic(&self.path, &text)?;
|
||||
// The file this source reads was just replaced; drop the parsed copy.
|
||||
self.invalidate_cache();
|
||||
|
||||
Ok(UpdateResult {
|
||||
path: self.path.clone(),
|
||||
entries,
|
||||
bytes: text.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch keydb bytes from `url` via the caller-supplied `fetch` transport,
|
||||
/// then validate + save them to this source's path.
|
||||
///
|
||||
/// The transport is INJECTED: this crate stays transport-agnostic on the
|
||||
/// update path so the application supplies its own TLS / SSRF-guarded fetch
|
||||
/// (the `freemkv` CLI passes its `keydb_fetch::fetch`). `fetch` returns the
|
||||
/// raw response body (plain text, `.zip`, or `.gz`); [`save`](Self::save)
|
||||
/// does the verify + atomic write.
|
||||
pub fn update(
|
||||
&self,
|
||||
fetch: impl Fn(&str) -> Result<Vec<u8>, Error>,
|
||||
url: &str,
|
||||
) -> Result<UpdateResult, Error> {
|
||||
let bytes = fetch(url)?;
|
||||
self.save(&bytes)
|
||||
}
|
||||
|
||||
/// The host certificate(s) in this keydb — the second kind of data the one
|
||||
/// keydb file holds (alongside decryption keys). The app passes these to the
|
||||
/// live-drive scan as `DriveCredentials` for the AACS handshake. Empty if
|
||||
/// the keydb is missing/unreadable or carries no host cert.
|
||||
///
|
||||
/// Best first ([`KeyDb::host_certs_ranked`]): this is what the scan-options
|
||||
/// builder hands the live-drive handshake as `DriveCredentials`.
|
||||
pub fn host_certs(&self) -> Vec<HostCert> {
|
||||
match KeyDb::load(&self.path) {
|
||||
Ok(db) => db.host_certs,
|
||||
Err(_) => Vec::new(),
|
||||
match self.cached_db() {
|
||||
Ok(db) => db.host_certs_ranked(),
|
||||
// No error channel here (the scan-options builder wants a list), so
|
||||
// the failure can only be LOGGED — but it must not be invisible.
|
||||
Err(e) => {
|
||||
let _ = self.load_failure(&e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the ordered candidate list from a parsed keydb. Pure (no I/O), so
|
||||
/// it is unit-testable without a file on disk.
|
||||
///
|
||||
/// Order = cheapest + most authoritative first: **UK ▸ VK ▸ MK ▸ DK**. The
|
||||
/// UK is the final per-CPS-unit content key — zero derivation, directly
|
||||
/// usable — so it is tried first; the VUK needs one derivation step, an MK
|
||||
/// two, and the device-key pool the full MKB walk (AACS-1.0-only, slowest),
|
||||
/// so it is the last-resort fallback. Trying the UK first is also what lets a
|
||||
/// stale/wrong per-disc VUK be skipped in favour of a good UK in the SAME
|
||||
/// entry (`decrypt_with` rejects the VUK; the loop falls through to the UK).
|
||||
fn candidates_from(db: &KeyDb, inputs: &DiscInputs) -> Vec<Key> {
|
||||
let mut out = Vec::new();
|
||||
// Return terminal Unit Keys from a parsed keydb without I/O.
|
||||
// CPS unit numbers are one-based; the returned vector uses zero-based indexes.
|
||||
fn unit_keys_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
|
||||
Self::resolve_from(db, ctx).keys
|
||||
}
|
||||
|
||||
// Per-disc hit (most specific). find_disc normalizes the hash form.
|
||||
if let Some(entry) = db.find_disc(&inputs.disc_hash) {
|
||||
// UK first — terminal content key, no derivation.
|
||||
if !entry.unit_keys.is_empty() {
|
||||
out.push(Key::Unit(entry.unit_keys.clone()));
|
||||
// De-conflated resolution: the keys PLUS whether the disc matched and — on a
|
||||
// keyless match — WHY nothing derived, so a matched-but-underivable disc is
|
||||
// never reported as a flat "no entry" (issue #46). Pure (no I/O).
|
||||
fn resolve_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> KeydbResolution {
|
||||
// Per-disc hit (most specific); find_disc normalizes the hash form. Without a match
|
||||
// there is no per-disc anchor, so the global PK/DK pools are never consulted.
|
||||
let entries_loaded = db.disc_entries.len();
|
||||
let Some(entry) = db.find_disc(ctx.disc_hash()) else {
|
||||
return KeydbResolution::miss(entries_loaded);
|
||||
};
|
||||
|
||||
// UNION every source of terminal keys, then dedup — never first-hit, since a stored
|
||||
// `unit_keys` list can be PARTIAL while the VUK boils every declared unit.
|
||||
let mut keys: Vec<UnitKey> = Vec::new();
|
||||
|
||||
// 1. Terminal Unit Keys stored in the entry — directly usable, no
|
||||
// derivation. Preserve the keydb's CPS numbering (idx = num - 1).
|
||||
for (num, key) in &entry.unit_keys {
|
||||
// A valid CPS unit number is >= 1; num 0 would collide with unit 1
|
||||
// at idx 0 (num - 1), so skip it rather than mis-map two units.
|
||||
if *num == 0 {
|
||||
continue;
|
||||
}
|
||||
// VK next — one step (decrypt Unit_Key_RO.inf).
|
||||
if let Some(vuk) = entry.vuk {
|
||||
out.push(Key::Volume(vuk));
|
||||
}
|
||||
// MK — two steps (derive the VUK, then the unit keys).
|
||||
if let Some(mk) = entry.media_key {
|
||||
out.push(Key::Media(vec![mk]));
|
||||
keys.push(UnitKey::new(num - 1, *key));
|
||||
}
|
||||
|
||||
// The disc's encrypted title keys (from Unit_Key_RO.inf). Empty when
|
||||
// the scan captured none, in which case only the stored list (1)
|
||||
// contributes.
|
||||
let enc_title_keys = match ctx.enc_title_keys() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
// Mirror online.rs: surface the read failure, then fall back to
|
||||
// empty (only the stored unit-key list can contribute).
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
error = %e,
|
||||
"keydb: disc encrypted title keys unreadable; deriving without them"
|
||||
);
|
||||
&[]
|
||||
}
|
||||
};
|
||||
|
||||
// Why the keyless derivation could not finish, for the trace's miss
|
||||
// path. `None` once any key lands (or when there was no material to try).
|
||||
let mut miss_reason: Option<KeyNode> = None;
|
||||
|
||||
if !enc_title_keys.is_empty() {
|
||||
// VUK path, else MK path (stored/PK/DK) → VUK.
|
||||
let derived = if let Some(vuk) = entry.vuk {
|
||||
uks_from_vuk(&vuk, enc_title_keys)
|
||||
} else {
|
||||
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
|
||||
let mkb = match ctx.mkb() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
// Mirror online.rs: log the failure, then fall back to
|
||||
// empty (PK/DK derivation may then find no Media Key).
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
error = %e,
|
||||
"keydb: disc MKB unreadable; media-key derivation may fail"
|
||||
);
|
||||
&[]
|
||||
}
|
||||
};
|
||||
let mk: Option<MediaKey> = entry
|
||||
.media_key
|
||||
.map(MediaKey)
|
||||
.or_else(|| derive_media_key_from_pk(mkb, &db.processing_keys).map(MediaKey))
|
||||
// DK pool: the real Subset-Difference MKB walk. No VID at the MK
|
||||
// step (it enters at the VUK step below); the VID guard follows.
|
||||
.or_else(|| derive_media_key_from_dk(mkb, &db.device_keys).map(MediaKey));
|
||||
match (mk, vid) {
|
||||
// VUK = derive_vuk(MK, VID), then boil the disc's encrypted
|
||||
// title keys to the terminal Unit Keys.
|
||||
(Some(mk), Some(vid)) => {
|
||||
uks_from_vuk(&derive_vuk(&mk.0, &vid.0), enc_title_keys)
|
||||
}
|
||||
// Locked VID-per-path rule: an MK with no VID cannot derive.
|
||||
// De-conflate the two reasons so the trace can say WHICH.
|
||||
(Some(_), None) => {
|
||||
miss_reason = Some(KeyNode::NoVid);
|
||||
Vec::new()
|
||||
}
|
||||
(None, _) => {
|
||||
miss_reason = Some(KeyNode::NoDerivableKey);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
keys.extend(derived);
|
||||
}
|
||||
|
||||
// Universal material — the library walks/brutes it against this disc's
|
||||
// MKB and VID.
|
||||
if !db.device_keys.is_empty() {
|
||||
out.push(Key::Device(db.device_keys.clone()));
|
||||
}
|
||||
if !db.processing_keys.is_empty() {
|
||||
out.push(Key::Processing(db.processing_keys.clone()));
|
||||
}
|
||||
// Unique by key value, first occurrence wins (stored numbering kept).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
keys.retain(|u| seen.insert(u.key));
|
||||
|
||||
// Media-key pool across every entry: an MK is MKB-scoped, so a sibling
|
||||
// disc's MK may verify against this disc (the path-2.5 brute). Hand the
|
||||
// whole pool; the library picks the one that verifies.
|
||||
let mk_pool: Vec<[u8; 16]> = db.iter_disc_entries().filter_map(|e| e.media_key).collect();
|
||||
if !mk_pool.is_empty() {
|
||||
out.push(Key::Media(mk_pool));
|
||||
}
|
||||
// Booleans-and-lengths shape of the matched entry, for the app to log —
|
||||
// answers "what did the matched entry actually carry?" (issue #46).
|
||||
let shape = MatchedEntry {
|
||||
has_vuk: entry.vuk.is_some(),
|
||||
has_unit_keys: !entry.unit_keys.is_empty(),
|
||||
unit_keys_len: entry.unit_keys.len(),
|
||||
has_media_key: entry.media_key.is_some(),
|
||||
has_keydb_vid: entry.vid.is_some(),
|
||||
enc_title_keys_len: enc_title_keys.len(),
|
||||
vid_available: ctx.vid().is_some() || entry.vid.is_some(),
|
||||
};
|
||||
|
||||
out
|
||||
// On a match with no key: carry the specific reason if we have one, else
|
||||
// leave it empty for the library to render a bare `NoDerivableKey`.
|
||||
// KU J23: `NoVid` stays beside partial keys (the VID would derive the rest).
|
||||
let miss_path = match miss_reason {
|
||||
Some(n) if keys.is_empty() || n == KeyNode::NoVid => vec![n],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
KeydbResolution {
|
||||
keys,
|
||||
matched: true,
|
||||
shape: Some(shape),
|
||||
miss_path,
|
||||
entries_loaded,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeySource for KeydbSource {
|
||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
||||
// On the first ask, parse the keydb once and build the ordered candidate
|
||||
// list; later asks just advance the cursor. A missing/unreadable keydb
|
||||
// is not an error — it simply yields no candidates (another source may
|
||||
// have the key), the same as the library's own loader.
|
||||
if self.cursor.is_none() {
|
||||
let cands = match KeyDb::load(&self.path) {
|
||||
Ok(db) => Self::candidates_from(&db, inputs),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
self.cursor = Some(cands.into_iter());
|
||||
// The de-conflated result of a keydb lookup: keys plus enough context that a
|
||||
// MATCHED-but-underivable disc is reported distinctly from a true miss. See
|
||||
// [`KeydbSource::resolve_from`] and issue #46.
|
||||
struct KeydbResolution {
|
||||
keys: Vec<UnitKey>,
|
||||
matched: bool,
|
||||
shape: Option<MatchedEntry>,
|
||||
miss_path: Vec<KeyNode>,
|
||||
// Per-disc entries loaded in the keydb — named in a true-miss verdict so a
|
||||
// reporter can confirm a wrong-pressing (`… not in keydb (N entries loaded)`).
|
||||
entries_loaded: usize,
|
||||
}
|
||||
|
||||
impl KeydbResolution {
|
||||
// A true miss: the disc hash was not in the keydb at all.
|
||||
fn miss(entries_loaded: usize) -> Self {
|
||||
Self {
|
||||
keys: Vec::new(),
|
||||
matched: false,
|
||||
shape: None,
|
||||
miss_path: Vec::new(),
|
||||
entries_loaded,
|
||||
}
|
||||
self.cursor.as_mut().and_then(Iterator::next)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a decompressed stream into a `String` with a hard size ceiling.
|
||||
/// Returns [`Error::KeydbInvalid`] if the input exceeds the cap, or
|
||||
/// [`Error::KeydbParse`] if the bytes are not valid UTF-8.
|
||||
fn read_capped_to_string<R: Read>(reader: R) -> Result<String, Error> {
|
||||
let mut buf = Vec::new();
|
||||
// Read one byte past the cap so an exactly-at-cap stream is accepted but
|
||||
// anything larger is rejected.
|
||||
reader
|
||||
.take(MAX_KEYDB_BYTES + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.map_err(|_| Error::KeydbParse)?;
|
||||
if buf.len() as u64 > MAX_KEYDB_BYTES {
|
||||
return Err(Error::KeydbInvalid);
|
||||
}
|
||||
String::from_utf8(buf).map_err(|_| Error::KeydbParse)
|
||||
}
|
||||
|
||||
/// Extract the first `*.cfg` member of a zip archive as a capped `String`.
|
||||
fn extract_zip(data: &[u8]) -> Result<String, Error> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
|
||||
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
|
||||
return read_capped_to_string(file);
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::KeydbInvalid)
|
||||
}
|
||||
|
||||
// Write `text` to `path` crash-safely (temp file, fsync, atomic rename) so an interrupted
|
||||
// update never leaves a half-written keydb.
|
||||
fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
|
||||
let werr = || Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| {
|
||||
tracing::warn!(error = %e, path = %path.display(), "keydb dir create failed");
|
||||
werr()
|
||||
})?;
|
||||
}
|
||||
let tmp = {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
path.with_extension(format!(
|
||||
"tmp.{}.{}",
|
||||
std::process::id(),
|
||||
TMP_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
))
|
||||
};
|
||||
let write_result = (|| -> std::io::Result<()> {
|
||||
// The temp file is renamed onto keydb.cfg, which holds AACS key
|
||||
// material and the host private key/cert — create it 0600 on Unix so
|
||||
// umask can't leave the keys world-readable. Non-unix keeps create().
|
||||
#[cfg(unix)]
|
||||
let mut f = {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)?
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = write_result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
tracing::warn!(error = %e, path = %path.display(), "keydb write/fsync failed; keydb unchanged");
|
||||
return Err(werr());
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
tracing::warn!(error = %e, path = %path.display(), "keydb rename failed; keydb unchanged");
|
||||
return Err(werr());
|
||||
}
|
||||
// Durably commit the new dirent: on POSIX filesystems (ext2, some NFS) a
|
||||
// crash right after the rename can lose the directory entry even though the
|
||||
// rename returned. Best-effort (swallowed on failure); no-op on Windows.
|
||||
if let Some(dir) = path.parent() {
|
||||
libfreemkv::io::fsync::dir(dir);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl KeySource for KeydbSource {
|
||||
// Resolve this disc's base per-CPS-unit Unit Keys from the keydb. A MISSING keydb yields no
|
||||
// keys (Ok(empty)); an UNUSABLE one is a source failure (Err) — never conflated.
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
match self.cached_db() {
|
||||
Ok(db) => Ok(Self::unit_keys_from(&db, ctx)),
|
||||
Err(e) => match self.load_failure(&e) {
|
||||
// Missing keydb: the documented benign miss.
|
||||
None => Ok(Vec::new()),
|
||||
// Corrupt / unreadable keydb: a SOURCE FAILURE, not a miss.
|
||||
Some(err) => Err(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// De-conflated resolution (issue #46): besides the keys, report whether the
|
||||
// disc MATCHED, why nothing derived on a keyless match, and the matched
|
||||
// entry's shape to log — so a matched-no-key disc is not a mute "no entry".
|
||||
fn resolve_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<UnitKeyResolution, Error> {
|
||||
match self.cached_db() {
|
||||
Ok(db) => {
|
||||
let r = Self::resolve_from(&db, ctx);
|
||||
Ok(UnitKeyResolution {
|
||||
keys: r.keys,
|
||||
matched: r.matched,
|
||||
miss_path: r.miss_path,
|
||||
matched_entry: r.shape,
|
||||
store_entries: Some(r.entries_loaded),
|
||||
})
|
||||
}
|
||||
Err(e) => match self.load_failure(&e) {
|
||||
// Missing keydb: the documented benign miss (nothing matched).
|
||||
None => Ok(UnitKeyResolution::default()),
|
||||
// Corrupt / unreadable keydb: a SOURCE FAILURE, not a miss.
|
||||
Some(err) => Err(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The keydb's host certs, best first. `mkb` is ignored: cert selection takes no disc input.
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> {
|
||||
match self.cached_db() {
|
||||
Ok(db) => db.host_certs_ranked(),
|
||||
// Vec-returning trait method: log the failure, return nothing.
|
||||
Err(e) => {
|
||||
let _ = self.load_failure(&e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyed by disc hash, not by samples: asked once per resolve, not per
|
||||
// piece (KU §2.3 step 8, N-KU10; KSK1).
|
||||
fn answer_depends_on_samples(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"keydb"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use libfreemkv::aacs::{DeviceKey, DiscEntry};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn inputs(hash: &str) -> DiscInputs {
|
||||
DiscInputs {
|
||||
disc_hash: hash.into(),
|
||||
volume_id: [0u8; 16],
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
volume_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dk() -> DeviceKey {
|
||||
DeviceKey {
|
||||
key: [0x22u8; 16],
|
||||
node: 1,
|
||||
uv: 2,
|
||||
u_mask_shift: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_with_vuk(hash: &str, vuk: [u8; 16]) -> DiscEntry {
|
||||
DiscEntry {
|
||||
disc_hash: hash.into(),
|
||||
title: String::new(),
|
||||
media_key: None,
|
||||
disc_id: None,
|
||||
vuk: Some(vuk),
|
||||
unit_keys: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_disc_vuk_ranks_before_device_pool() {
|
||||
let mut entries = HashMap::new();
|
||||
entries.insert("0xaabb".into(), entry_with_vuk("0xaabb", [0x11u8; 16]));
|
||||
let db = KeyDb {
|
||||
device_keys: vec![dk()],
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: entries,
|
||||
};
|
||||
|
||||
let cands = KeydbSource::candidates_from(&db, &inputs("0xaabb"));
|
||||
assert!(
|
||||
matches!(cands.first(), Some(Key::Volume(v)) if *v == [0x11u8; 16]),
|
||||
"the disc's own VUK must be the first (most specific) candidate"
|
||||
);
|
||||
assert!(
|
||||
cands.iter().any(|k| matches!(k, Key::Device(_))),
|
||||
"the universal device-key pool is still offered as a fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_disc_uk_ranks_before_vuk() {
|
||||
// An entry with BOTH a UK and a VUK (the Being There shape) must hand the
|
||||
// terminal UK out first, so a stale/wrong VUK never pre-empts a good UK.
|
||||
let mut entries = HashMap::new();
|
||||
let mut e = entry_with_vuk("0xaabb", [0x11u8; 16]);
|
||||
e.unit_keys = vec![(1, [0x22u8; 16])];
|
||||
entries.insert("0xaabb".into(), e);
|
||||
let db = KeyDb {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: entries,
|
||||
};
|
||||
|
||||
let cands = KeydbSource::candidates_from(&db, &inputs("0xaabb"));
|
||||
assert!(
|
||||
matches!(cands.first(), Some(Key::Unit(_))),
|
||||
"the terminal UK must be the first candidate"
|
||||
);
|
||||
assert!(
|
||||
matches!(cands.get(1), Some(Key::Volume(v)) if *v == [0x11u8; 16]),
|
||||
"the VUK follows the UK"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_disc_hit_offers_only_universal_material() {
|
||||
let db = KeyDb {
|
||||
device_keys: vec![dk()],
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: HashMap::new(),
|
||||
};
|
||||
// A disc with no per-disc entry: no Volume/Unit candidate, just the pool.
|
||||
let cands = KeydbSource::candidates_from(&db, &inputs("0xdeadbeef"));
|
||||
assert!(cands.iter().all(|k| matches!(k, Key::Device(_))));
|
||||
assert_eq!(cands.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_keydb_offers_nothing() {
|
||||
let db = KeyDb {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: HashMap::new(),
|
||||
};
|
||||
assert!(KeydbSource::candidates_from(&db, &inputs("0xaabb")).is_empty());
|
||||
}
|
||||
|
||||
/// No keydb (or a LibreDrive deployment) → no host credentials, not an
|
||||
/// error. (The positive parse is NOT tested here — it would require host
|
||||
/// key material, which must never appear in code.)
|
||||
#[test]
|
||||
fn host_certs_empty_when_keydb_missing() {
|
||||
assert!(
|
||||
KeydbSource::new("/nonexistent/path/keydb.cfg")
|
||||
.host_certs()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "keydb_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,869 @@
|
||||
//! AACS Key Database parsing — KEYDB.cfg format.
|
||||
//!
|
||||
//! Byte-faithful copy of libfreemkv's `aacs::keydb` parser, relocated so the
|
||||
//! keydb.cfg format lives with the key sources that consume it. The parsing
|
||||
//! logic is identical; the only deviation is [`KeyDb::load`], which returns a
|
||||
//! standalone [`std::io::Result`] here instead of `libfreemkv::error::Result`
|
||||
//! (so the format crate carries no dependency on libfreemkv's error type).
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use libfreemkv::aacs::types::{DeviceKey, HostCert};
|
||||
|
||||
/// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
|
||||
pub type NumberedUnitKey = (u32, [u8; 16]);
|
||||
|
||||
/// Upper bound on the keydb.cfg byte size (the public UHD keydb is ~62 MiB;
|
||||
/// 128 MiB bounds a hostile/corrupt file). The SINGLE definition for the crate:
|
||||
/// [`KeyDb::load`] uses it as the on-disk load cap and [`crate::KeydbSource`]
|
||||
/// re-uses it as the decompression-bomb cap on the save/update path, so the two
|
||||
/// can never drift out of lockstep.
|
||||
pub(crate) const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Parsed AACS key database.
|
||||
///
|
||||
/// NO `#[derive(Debug)]`: see the hand-written redacting impl below. The
|
||||
/// derive dumped every processing key and all ~181k discs' key material into
|
||||
/// any `{:?}`.
|
||||
pub struct KeyDb {
|
||||
/// Device keys for MKB processing
|
||||
pub device_keys: Vec<DeviceKey>,
|
||||
/// Processing keys (pre-computed media keys for specific MKB versions)
|
||||
pub processing_keys: Vec<[u8; 16]>,
|
||||
/// Host certificate + private key for SCSI authentication, paired with the
|
||||
/// keydb's revocation metadata (libfreemkv's `HostCert` stays pure; the
|
||||
/// `Revoked in MKBv<N>` annotation is tracked in this crate).
|
||||
pub host_certs: Vec<KeydbHostCert>,
|
||||
/// Per-disc VUK entries indexed by disc hash (hex lowercase).
|
||||
///
|
||||
/// The key is `Arc<str>` and is the SAME allocation as the entry's own
|
||||
/// [`DiscEntry::disc_hash`] — the map used to own a second `String` clone
|
||||
/// of every hash, ~13 MB of pure duplication across the real keydb's 181k
|
||||
/// entries (and now that [`crate::KeydbSource`] caches the parsed db for
|
||||
/// the process lifetime, that duplication is resident, not transient).
|
||||
/// `Arc<str>: Borrow<str>`, so lookups still take a plain `&str`.
|
||||
pub disc_entries: HashMap<Arc<str>, DiscEntry>,
|
||||
}
|
||||
|
||||
// Key material must never leak via `{:?}`; both types below carry raw AACS key bytes.
|
||||
|
||||
impl std::fmt::Debug for KeyDb {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("KeyDb")
|
||||
.field("device_keys_len", &self.device_keys.len())
|
||||
.field("processing_keys_len", &self.processing_keys.len())
|
||||
.field("host_certs_len", &self.host_certs.len())
|
||||
.field("disc_entries_len", &self.disc_entries.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DiscEntry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DiscEntry")
|
||||
.field("disc_hash", &self.disc_hash)
|
||||
.field("title", &self.title)
|
||||
.field("media_key", &self.media_key.map(|_| "<redacted>"))
|
||||
.field("vid", &self.vid.map(|_| "<redacted>"))
|
||||
.field("vuk", &self.vuk.map(|_| "<redacted>"))
|
||||
.field("unit_keys_len", &self.unit_keys.len())
|
||||
.field("mkb_version", &self.mkb_version)
|
||||
.field("volume_size", &self.volume_size)
|
||||
.field("is_uhd", &self.is_uhd)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A keydb host certificate together with its revocation generation.
|
||||
///
|
||||
/// libfreemkv's [`HostCert`] is intentionally crypto-pure and carries no
|
||||
/// revocation state; the keydb's `; Revoked in MKBv<N>` comment is parsed
|
||||
/// here and stored alongside the cert so callers can filter by MKB generation
|
||||
/// without modifying the library type.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeydbHostCert {
|
||||
/// The pure libfreemkv host certificate + private key(s).
|
||||
pub cert: HostCert,
|
||||
/// The MKB generation at which this host cert was revoked, parsed from a
|
||||
/// `; Revoked in MKBv<N>` comment. `None` when the cert carries no such
|
||||
/// annotation (treated as never-revoked).
|
||||
pub revoked_at_mkb: Option<u32>,
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
///
|
||||
/// NO `#[derive(Debug)]`: see the hand-written redacting impl above.
|
||||
#[derive(Clone)]
|
||||
pub struct DiscEntry {
|
||||
/// Disc hash (20 bytes, hex). `Arc<str>` so the entry and the
|
||||
/// [`KeyDb::disc_entries`] key that points at it share ONE allocation
|
||||
/// instead of two.
|
||||
pub disc_hash: Arc<str>,
|
||||
/// Disc title
|
||||
pub title: String,
|
||||
/// Media Key (16 bytes) — from MKB processing
|
||||
pub media_key: Option<[u8; 16]>,
|
||||
/// Volume ID — the AACS VID (the keydb `I` token), 16 bytes. NOT the disc's
|
||||
/// identity (that's `disc_hash`); this is the per-disc Volume ID used to
|
||||
/// derive the VUK.
|
||||
pub vid: Option<[u8; 16]>,
|
||||
/// Volume Unique Key (16 bytes) — decrypts title keys
|
||||
pub vuk: Option<[u8; 16]>,
|
||||
/// Unit keys (title keys) indexed by CPS unit number
|
||||
pub unit_keys: Vec<NumberedUnitKey>,
|
||||
/// MKB version parsed from the trailing `; MKBv<N>` comment, if present.
|
||||
pub mkb_version: Option<u32>,
|
||||
/// Volume size in bytes parsed from `VolumeSize: <N>` in the comment.
|
||||
pub volume_size: Option<u64>,
|
||||
/// True if the comment contains the literal `(UHD)` flag.
|
||||
pub is_uhd: bool,
|
||||
}
|
||||
|
||||
// Parse a hex string like "0xABCD..." into bytes. Operates on bytes, not
|
||||
// `&str` char boundaries: the keydb is third-party content, so a non-ASCII
|
||||
// scalar must not panic on a mid-codepoint slice. Non-hex byte -> `None`.
|
||||
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
|
||||
// The one workspace hex parser (strips an optional 0x/0X, byte-based).
|
||||
libfreemkv::hex::parse_hex_bytes(s)
|
||||
}
|
||||
|
||||
// Read digits right after the first `marker` in `text`. Byte-based so
|
||||
// untrusted comment text never panics on a char boundary; skips whitespace
|
||||
// before the digits, serving both `MKBv<N>` and `VolumeSize: <N>`.
|
||||
fn parse_digits_after<T: std::str::FromStr>(text: &str, marker: &str) -> Option<T> {
|
||||
let bytes = text.as_bytes();
|
||||
let start = text.find(marker)? + marker.len();
|
||||
let mut i = start;
|
||||
// Skip any whitespace between the marker and the digits.
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
let digit_start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
if i == digit_start {
|
||||
return None;
|
||||
}
|
||||
// The digit run is pure ASCII, so this slice is a valid str.
|
||||
std::str::from_utf8(&bytes[digit_start..i])
|
||||
.ok()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Parse the host-cert revocation generation from a `Revoked in MKBv<N>`
|
||||
/// comment on a `| HC |`/`| HC2 |` line. `None` when absent.
|
||||
fn parse_revoked_at_mkb(line: &str) -> Option<u32> {
|
||||
parse_digits_after(line, "Revoked in MKBv")
|
||||
}
|
||||
|
||||
/// Parse hex into a fixed-size array.
|
||||
pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> {
|
||||
libfreemkv::hex::parse_hex_fixed::<16>(s)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
|
||||
libfreemkv::hex::parse_hex_fixed::<20>(s)
|
||||
}
|
||||
|
||||
// What `KeyDb::parse` threw away, by reason.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct ParseStats {
|
||||
/// `| DK` rows that parsed as neither a positioned nor an orphan device key.
|
||||
pub dk_rejected: usize,
|
||||
/// `| PK` rows whose key field did not yield 16 bytes.
|
||||
pub pk_rejected: usize,
|
||||
/// `| HC` rows rejected (bad hex, short cert, missing field).
|
||||
pub hc_rejected: usize,
|
||||
/// `| HC2` rows rejected (bad hex, wrong priv length, short cert).
|
||||
pub hc2_rejected: usize,
|
||||
/// `0x… = …` disc rows the field parser refused outright.
|
||||
pub disc_rejected: usize,
|
||||
/// Disc rows that REPLACED an earlier row with the same hash (last wins).
|
||||
/// Not necessarily corruption, but never something to discover silently:
|
||||
/// a duplicated hash means one of the two rows' keys is now unreachable.
|
||||
pub disc_duplicate: usize,
|
||||
}
|
||||
|
||||
impl ParseStats {
|
||||
/// Total rows the parser refused. Excludes duplicates, which were parsed
|
||||
/// successfully and merely overwrote a sibling.
|
||||
fn rejected(&self) -> usize {
|
||||
self.dk_rejected
|
||||
+ self.pk_rejected
|
||||
+ self.hc_rejected
|
||||
+ self.hc2_rejected
|
||||
+ self.disc_rejected
|
||||
}
|
||||
|
||||
// Emit the one summary line, if there is anything to say; returns whether
|
||||
// it fired (tests use this). `pub(crate)` so `KeydbSource` can re-emit it
|
||||
// on a cache hit too, keeping the warning from going silent forever.
|
||||
pub(crate) fn log(&self) -> bool {
|
||||
if self.rejected() == 0 && self.disc_duplicate == 0 {
|
||||
return false;
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
dk_rejected = self.dk_rejected,
|
||||
pk_rejected = self.pk_rejected,
|
||||
hc_rejected = self.hc_rejected,
|
||||
hc2_rejected = self.hc2_rejected,
|
||||
disc_rejected = self.disc_rejected,
|
||||
disc_duplicate = self.disc_duplicate,
|
||||
"keydb.cfg lines were rejected while parsing; the file may be truncated or corrupt (keys past the damage will not resolve)"
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// True when `line` opens a per-disc row. Matches `0x`/`0X` case-insensitively
|
||||
// (like `parse_hex`) so an uppercase row isn't silently dropped. Shared with
|
||||
// `KeydbSource::save`, whose entry count must match what this parser accepts.
|
||||
pub(crate) fn is_disc_entry_line(line: &str) -> bool {
|
||||
(line.starts_with("0x") || line.starts_with("0X")) && line.contains(" = ")
|
||||
}
|
||||
|
||||
/// True when `line` is a row `KeyDb::parse` would actually ACCEPT into the db — a disc row OR a
|
||||
/// DK/PK/HC/HC2 row that its real parser accepts, not merely one carrying the right `| XX`
|
||||
/// prefix. `KeydbSource::save`'s "won't persist unparseable content" guard counts entries with
|
||||
/// THIS, so a syntactically prefixed but malformed DK/PK/HC row (right prefix, bad hex/short
|
||||
/// cert) can no longer inflate the entry count past the `entries > 0` check. Mirrors the
|
||||
/// dispatch in [`KeyDb::parse_counted`] exactly.
|
||||
pub(crate) fn is_parseable_entry_line(line: &str) -> bool {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||||
return false;
|
||||
}
|
||||
// Order matches `parse_counted`: HC2 before HC (both share the `| HC`
|
||||
// prefix); an orphan DK (no position fields) still counts as a DK.
|
||||
if line.starts_with("| DK") {
|
||||
return KeyDb::parse_device_key(line).is_some() || KeyDb::parse_orphan_dk(line).is_some();
|
||||
}
|
||||
if line.starts_with("| PK") {
|
||||
return KeyDb::parse_processing_key(line).is_some();
|
||||
}
|
||||
if line.starts_with("| HC2") {
|
||||
return KeyDb::parse_host_cert_v2(line).is_some();
|
||||
}
|
||||
if line.starts_with("| HC") {
|
||||
return KeyDb::parse_host_cert(line).is_some();
|
||||
}
|
||||
is_disc_entry_line(line) && KeyDb::parse_disc_entry(line).is_some()
|
||||
}
|
||||
|
||||
impl KeyDb {
|
||||
/// Construct an empty KeyDb. Used by unit tests; production code
|
||||
/// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`].
|
||||
pub fn empty() -> Self {
|
||||
KeyDb {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a KEYDB.cfg file from a string.
|
||||
///
|
||||
/// Lines the parser cannot use are counted and summarised in ONE
|
||||
/// `tracing::warn!` (see [`ParseStats`]) — they are never dropped silently.
|
||||
pub fn parse(data: &str) -> Self {
|
||||
let (db, stats) = Self::parse_counted(data);
|
||||
stats.log();
|
||||
db
|
||||
}
|
||||
|
||||
/// [`Self::parse`] without the log — the seam the rejection-counting tests
|
||||
/// assert on (a `tracing` subscriber would be the only other way to observe
|
||||
/// a count, and the count is the thing under test, not its formatting).
|
||||
pub(crate) fn parse_counted(data: &str) -> (Self, ParseStats) {
|
||||
let mut stats = ParseStats::default();
|
||||
let mut db = KeyDb {
|
||||
device_keys: Vec::new(),
|
||||
processing_keys: Vec::new(),
|
||||
host_certs: Vec::new(),
|
||||
disc_entries: HashMap::new(),
|
||||
};
|
||||
|
||||
// Strip a leading UTF-8 BOM (U+FEFF): it is NOT trim()-able whitespace, so
|
||||
// it would cling to line 1's `0x…` hash and silently drop the first disc
|
||||
// row (a keydb saved by a Windows editor).
|
||||
let data = data.strip_prefix('\u{feff}').unwrap_or(data);
|
||||
|
||||
for line in data.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Device Key: positioned rows -> `device_keys` (tree walk); orphan
|
||||
// rows (no position fields) -> `processing_keys` (brute walker).
|
||||
// Per AACS a "PK" IS a DK at terminal position, so both are DKs here.
|
||||
if line.starts_with("| DK") {
|
||||
if let Some(dk) = Self::parse_device_key(line) {
|
||||
db.device_keys.push(dk);
|
||||
} else if let Some(key) = Self::parse_orphan_dk(line) {
|
||||
db.processing_keys.push(key);
|
||||
} else {
|
||||
stats.dk_rejected += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Processing Key
|
||||
if line.starts_with("| PK") {
|
||||
if let Some(pk) = Self::parse_processing_key(line) {
|
||||
db.processing_keys.push(pk);
|
||||
} else {
|
||||
stats.pk_rejected += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host Certificate (AACS 2.0). Normally augments the preceding HC
|
||||
// (AACS 1.0) row, but an HC2 row may appear first (third-party line
|
||||
// ordering); then it's carried on a fresh HostCert with an empty v1 cert.
|
||||
if line.starts_with("| HC2") {
|
||||
if let Some((pk, cert, revoked_at_mkb)) = Self::parse_host_cert_v2(line) {
|
||||
if let Some(hc) = db.host_certs.last_mut() {
|
||||
hc.cert.private_key_v2 = Some(pk);
|
||||
hc.cert.certificate_v2 = Some(cert);
|
||||
// The revocation annotation can live on the HC2 line
|
||||
// instead of the HC line; carry it onto the combined
|
||||
// cert if the HC line had none, so it isn't lost.
|
||||
if hc.revoked_at_mkb.is_none() {
|
||||
hc.revoked_at_mkb = revoked_at_mkb;
|
||||
}
|
||||
} else {
|
||||
db.host_certs.push(KeydbHostCert {
|
||||
cert: HostCert {
|
||||
private_key: [0u8; 20],
|
||||
certificate: Vec::new(),
|
||||
private_key_v2: Some(pk),
|
||||
certificate_v2: Some(cert),
|
||||
},
|
||||
revoked_at_mkb,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
stats.hc2_rejected += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host Certificate (AACS 1.0)
|
||||
if line.starts_with("| HC") {
|
||||
if let Some(hc) = Self::parse_host_cert(line) {
|
||||
db.host_certs.push(hc);
|
||||
} else {
|
||||
stats.hc_rejected += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gate on the `0x` PREFIX alone (not `is_disc_entry_line`, which also
|
||||
// needs " = "): a malformed `0x…` row is COUNTED as rejected, never
|
||||
// silently dropped. No entry cap — a keydb must hold every row.
|
||||
if line.starts_with("0x") || line.starts_with("0X") {
|
||||
match Self::parse_disc_entry(line) {
|
||||
// The map key IS the entry's own `disc_hash` allocation
|
||||
// (`Arc` clone, no second copy of the string).
|
||||
Some(entry) => {
|
||||
if db
|
||||
.disc_entries
|
||||
.insert(entry.disc_hash.clone(), entry)
|
||||
.is_some()
|
||||
{
|
||||
stats.disc_duplicate += 1;
|
||||
}
|
||||
}
|
||||
None => stats.disc_rejected += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(db, stats)
|
||||
}
|
||||
|
||||
/// Load a KEYDB.cfg from disk.
|
||||
///
|
||||
/// A read failure (missing/unreadable file, non-UTF-8 content) surfaces
|
||||
/// as an [`std::io::Error`] (the cap-exceeded case as
|
||||
/// [`std::io::ErrorKind::InvalidData`]). Note that [`Self::parse`] itself
|
||||
/// is lenient: a syntactically valid but key-less file parses to an empty
|
||||
/// [`KeyDb`] rather than an error — callers needing a non-empty db must
|
||||
/// check the parsed contents.
|
||||
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||||
let (db, stats) = Self::load_counted(std::fs::File::open(path)?, path)?;
|
||||
stats.log();
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
// `Self::load` from an ALREADY-OPEN file, without the log.
|
||||
pub(crate) fn load_counted(
|
||||
f: std::fs::File,
|
||||
path: &std::path::Path,
|
||||
) -> std::io::Result<(Self, ParseStats)> {
|
||||
// Read through a hard `take` cap rather than trusting a stat: a
|
||||
// `metadata` pre-check reports len 0 for a FIFO/char device (e.g. would
|
||||
// read `/dev/zero` forever). One byte past cap matches `read_capped_to_string`.
|
||||
use std::io::Read;
|
||||
let mut buf = Vec::new();
|
||||
f.take(MAX_KEYDB_BYTES + 1).read_to_end(&mut buf)?;
|
||||
if buf.len() as u64 > MAX_KEYDB_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"keydb.cfg exceeds {MAX_KEYDB_BYTES} byte cap: {}",
|
||||
path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
let data = String::from_utf8(buf).map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("keydb.cfg is not valid UTF-8: {}", path.display()),
|
||||
)
|
||||
})?;
|
||||
Ok(Self::parse_counted(&data))
|
||||
}
|
||||
|
||||
/// Look up a disc by its hash. Returns the VUK if found.
|
||||
pub fn find_vuk(&self, disc_hash: &str) -> Option<[u8; 16]> {
|
||||
let hash = disc_hash
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.trim_start_matches("0x")
|
||||
.to_string();
|
||||
// Try with 0x prefix and without; every stored key currently carries
|
||||
// the prefix, so the no-prefix fallback is a defensive match kept for
|
||||
// the prefix-agnostic lookup contract.
|
||||
self.disc_entries
|
||||
.get(format!("0x{hash}").as_str())
|
||||
.or_else(|| self.disc_entries.get(hash.as_str()))
|
||||
.and_then(|e| e.vuk)
|
||||
}
|
||||
|
||||
/// Look up a disc by its hash. Returns the full entry.
|
||||
pub fn find_disc(&self, disc_hash: &str) -> Option<&DiscEntry> {
|
||||
let hash = disc_hash
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.trim_start_matches("0x")
|
||||
.to_string();
|
||||
// No-prefix fallback below (every stored key carries "0x", see
|
||||
// find_vuk); kept as a defensive match for the lookup contract.
|
||||
self.disc_entries
|
||||
.get(format!("0x{hash}").as_str())
|
||||
.or_else(|| self.disc_entries.get(hash.as_str()))
|
||||
}
|
||||
|
||||
/// Iterate every disc entry. Used by Path 3 (scan for matching VID).
|
||||
pub fn iter_disc_entries(&self) -> impl Iterator<Item = &DiscEntry> {
|
||||
self.disc_entries.values()
|
||||
}
|
||||
|
||||
/// The host certs usable at MKB generation `mkb`.
|
||||
///
|
||||
/// A cert annotated `Revoked in MKBv<R>` is unusable once the disc's MKB
|
||||
/// generation reaches `R` (an AACS MKB revokes a cert from its own
|
||||
/// generation onward), so it is included only while `gen < R`. When `mkb`
|
||||
/// is `None` the disc's generation is unknown and cannot be filtered, so
|
||||
/// every cert is returned; certs with no revocation annotation are always
|
||||
/// returned.
|
||||
#[deprecated(note = "use host_certs_ranked; host cert selection takes no disc input")]
|
||||
pub fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
|
||||
self.host_certs
|
||||
.iter()
|
||||
.filter(|hc| match (hc.revoked_at_mkb, mkb) {
|
||||
(None, _) => true,
|
||||
(Some(_), None) => true,
|
||||
(Some(revoked), Some(disc_gen)) => disc_gen < revoked,
|
||||
})
|
||||
.map(|hc| hc.cert.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every host cert, best first, with no disc input: certs with no `Revoked in
|
||||
/// MKBv<N>` note in file order, then revoked ones by latest revocation (a later
|
||||
/// revocation is the better bet without knowing the disc's generation).
|
||||
pub fn host_certs_ranked(&self) -> Vec<HostCert> {
|
||||
let mut ranked: Vec<&KeydbHostCert> = self.host_certs.iter().collect();
|
||||
// Stable sort: ties (every unannotated cert, equal revocations) keep file order.
|
||||
ranked.sort_by_key(|hc| {
|
||||
let r = hc.revoked_at_mkb;
|
||||
(r.is_some(), std::cmp::Reverse(r.unwrap_or(0)))
|
||||
});
|
||||
ranked.into_iter().map(|hc| hc.cert.clone()).collect()
|
||||
}
|
||||
|
||||
/// Standalone keydb accessor: the disc's Volume ID (the keydb `I` token),
|
||||
/// looked up by the same disc-hash form [`Self::find_disc`] accepts. Pure
|
||||
/// file lookup; no crypto/derivation.
|
||||
pub fn get_vid(&self, disc_hash: &str) -> Option<[u8; 16]> {
|
||||
self.find_disc(disc_hash).and_then(|e| e.vid)
|
||||
}
|
||||
|
||||
/// Standalone keydb accessor: the disc's stored unit (title) keys, cloned.
|
||||
/// Empty when the disc is absent or carries no unit keys. Pure file lookup.
|
||||
pub fn get_uk(&self, disc_hash: &str) -> Vec<NumberedUnitKey> {
|
||||
self.find_disc(disc_hash)
|
||||
.map(|e| e.unit_keys.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Standalone keydb accessor: `(disc_hash, unit_keys)` for every disc entry
|
||||
/// that carries at least one unit key. Pure file lookup.
|
||||
pub fn get_uks(&self) -> Vec<(String, Vec<NumberedUnitKey>)> {
|
||||
self.disc_entries
|
||||
.values()
|
||||
.filter(|e| !e.unit_keys.is_empty())
|
||||
.map(|e| (e.disc_hash.to_string(), e.unit_keys.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Serialize back to keydb.cfg text — the INVERSE of [`Self::parse`], so the
|
||||
/// keydb wire format lives in ONE place (parse + emit together). Emits, in a
|
||||
/// deterministic order: host certs, device keys, processing keys, then one
|
||||
/// line per disc entry (sorted by hash). `parse(to_keydb_cfg(kd))` reproduces
|
||||
/// every field (see `round_trips_through_parse`). Used by the key-import tool
|
||||
/// to export a complete keydb.cfg (keys + host certs + VIDs).
|
||||
pub fn to_keydb_cfg(&self) -> String {
|
||||
fn hx(b: &[u8]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut s = String::with_capacity(b.len() * 2);
|
||||
for x in b {
|
||||
let _ = write!(s, "{x:02x}");
|
||||
}
|
||||
s
|
||||
}
|
||||
let mut out = String::new();
|
||||
|
||||
// Host certs (AACS 1.0): | HC | HOST_PRIV_KEY 0x.. | HOST_CERT 0x.. ; Revoked in MKBv<N>
|
||||
// AACS 2.0 credentials ride a sibling `| HC2 |` line; emit it too so a
|
||||
// round-trip through `to_keydb_cfg` never silently drops v2 host certs.
|
||||
for hc in &self.host_certs {
|
||||
out.push_str("| HC | HOST_PRIV_KEY 0x");
|
||||
out.push_str(&hx(&hc.cert.private_key));
|
||||
out.push_str(" | HOST_CERT 0x");
|
||||
out.push_str(&hx(&hc.cert.certificate));
|
||||
if let Some(n) = hc.revoked_at_mkb {
|
||||
out.push_str(" ; Revoked in MKBv");
|
||||
out.push_str(&n.to_string());
|
||||
}
|
||||
out.push('\n');
|
||||
// AACS 2.0 (HC2): inverse of `parse_host_cert_v2`.
|
||||
if let (Some(pk2), Some(cert2)) = (
|
||||
hc.cert.private_key_v2.as_ref(),
|
||||
hc.cert.certificate_v2.as_ref(),
|
||||
) {
|
||||
out.push_str("| HC2 | HOST_PRIV_KEY 0x");
|
||||
out.push_str(&hx(pk2));
|
||||
out.push_str(" | HOST_CERT 0x");
|
||||
out.push_str(&hx(cert2));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Device keys: | DK | DEVICE_KEY 0x.. | DEVICE_NODE 0x.. | KEY_UV 0x.. | KEY_U_MASK_SHIFT 0x..
|
||||
for dk in &self.device_keys {
|
||||
out.push_str("| DK | DEVICE_KEY 0x");
|
||||
out.push_str(&hx(&dk.key));
|
||||
out.push_str(&format!(
|
||||
" | DEVICE_NODE 0x{:04x} | KEY_UV 0x{:08x} | KEY_U_MASK_SHIFT 0x{:02x}\n",
|
||||
dk.node, dk.uv, dk.u_mask_shift
|
||||
));
|
||||
}
|
||||
|
||||
// Processing keys: | PK | 0x..
|
||||
for pk in &self.processing_keys {
|
||||
out.push_str("| PK | 0x");
|
||||
out.push_str(&hx(pk));
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// Per-disc entries, sorted by hash for a deterministic, diff-friendly file.
|
||||
let mut hashes: Vec<&Arc<str>> = self.disc_entries.keys().collect();
|
||||
hashes.sort();
|
||||
for h in hashes {
|
||||
let d = &self.disc_entries[&**h];
|
||||
// `parse` keeps the `hash_part` verbatim, so the stored `disc_hash`
|
||||
// already carries its `0x` prefix — emit it as-is (prefixing another
|
||||
// `0x` would double it on re-parse).
|
||||
out.push_str(h);
|
||||
out.push_str(" = ");
|
||||
// Parse stores the title VERBATIM (parens and all), so emitting it
|
||||
// bare round-trips through parse. Empty → "Unknown".
|
||||
if d.title.is_empty() {
|
||||
out.push_str("Unknown");
|
||||
} else {
|
||||
out.push_str(&d.title);
|
||||
}
|
||||
if let Some(mk) = d.media_key {
|
||||
out.push_str(" | M | 0x");
|
||||
out.push_str(&hx(&mk));
|
||||
}
|
||||
if let Some(id) = d.vid {
|
||||
out.push_str(" | I | 0x");
|
||||
out.push_str(&hx(&id));
|
||||
}
|
||||
if let Some(vuk) = d.vuk {
|
||||
out.push_str(" | V | 0x");
|
||||
out.push_str(&hx(&vuk));
|
||||
}
|
||||
if !d.unit_keys.is_empty() {
|
||||
out.push_str(" | U |");
|
||||
for (n, k) in &d.unit_keys {
|
||||
out.push_str(&format!(" {}-0x{}", n, hx(k)));
|
||||
}
|
||||
// Comment only after U (the one ;-split field) so it can't corrupt
|
||||
// a preceding hex value on re-parse.
|
||||
if d.mkb_version.is_some() || d.volume_size.is_some() || d.is_uhd {
|
||||
out.push_str(" ;");
|
||||
if let Some(v) = d.mkb_version {
|
||||
out.push_str(&format!(" MKBv{v}"));
|
||||
}
|
||||
if let Some(sz) = d.volume_size {
|
||||
out.push_str(&format!(" VolumeSize: {sz}"));
|
||||
}
|
||||
if d.is_uhd {
|
||||
out.push_str(" (UHD)");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private parsers (re-open the inherent impl) ─────────────────────────────
|
||||
|
||||
impl KeyDb {
|
||||
fn parse_device_key(line: &str) -> Option<DeviceKey> {
|
||||
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||||
let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
|
||||
let node_str = line.split("DEVICE_NODE").nth(1)?.split('|').next()?.trim();
|
||||
let uv_str = line.split("KEY_UV").nth(1)?.split('|').next()?.trim();
|
||||
let shift_str = line
|
||||
.split("KEY_U_MASK_SHIFT")
|
||||
.nth(1)?
|
||||
.split(';')
|
||||
.next()?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
|
||||
Some(DeviceKey {
|
||||
key: parse_hex16(key_str)?,
|
||||
// Canonical hex parsers (one prefix/case rule for the whole workspace)
|
||||
// — NOT an ad-hoc `from_str_radix(trim_start_matches("0x"))`, whose
|
||||
// case-sensitive strip silently dropped an uppercase-`0X` value.
|
||||
node: libfreemkv::hex::parse_hex_u16(node_str)?,
|
||||
uv: libfreemkv::hex::parse_hex_u32(uv_str)?,
|
||||
u_mask_shift: libfreemkv::hex::parse_hex_u8(shift_str)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_processing_key(line: &str) -> Option<[u8; 16]> {
|
||||
// | PK | 0x...
|
||||
let parts: Vec<&str> = line.split('|').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key_str = parts[2].split(';').next()?.trim();
|
||||
return parse_hex16(key_str);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// Parse an orphan DK row: `| DK |` with only `DEVICE_KEY` (no position
|
||||
// metadata), treated as terminal/unpositioned by the brute walker. `None`
|
||||
// if a position field is present — those are positioned DKs (`parse_device_key`).
|
||||
fn parse_orphan_dk(line: &str) -> Option<[u8; 16]> {
|
||||
if line.contains("DEVICE_NODE")
|
||||
|| line.contains("KEY_UV")
|
||||
|| line.contains("KEY_U_MASK_SHIFT")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let key_str = line
|
||||
.split("DEVICE_KEY")
|
||||
.nth(1)?
|
||||
.split('|')
|
||||
.next()?
|
||||
.split(';')
|
||||
.next()?
|
||||
.trim();
|
||||
parse_hex16(key_str)
|
||||
}
|
||||
|
||||
fn parse_host_cert(line: &str) -> Option<KeydbHostCert> {
|
||||
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x... ; Revoked in MKBv<N>
|
||||
let priv_str = line
|
||||
.split("HOST_PRIV_KEY")
|
||||
.nth(1)?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
let cert_str = line
|
||||
.split("HOST_CERT")
|
||||
.nth(1)?
|
||||
.split(';')
|
||||
.next()?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
|
||||
let certificate = parse_hex(cert_str)?;
|
||||
// AACS 1.0 host certs are 92 bytes; drop malformed/short rows at
|
||||
// parse time so the handshake never attempts junk (mirrors the v2
|
||||
// path, which enforces >= 132).
|
||||
if certificate.len() < 92 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(KeydbHostCert {
|
||||
cert: HostCert {
|
||||
private_key: parse_hex20(priv_str)?,
|
||||
certificate,
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
},
|
||||
revoked_at_mkb: parse_revoked_at_mkb(line),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...`
|
||||
/// Returns the private key, the cert bytes, and the `Revoked in MKBv<N>`
|
||||
/// generation (if the line carries that comment).
|
||||
fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec<u8>, Option<u32>)> {
|
||||
let priv_str = line
|
||||
.split("HOST_PRIV_KEY")
|
||||
.nth(1)?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
let cert_str = line
|
||||
.split("HOST_CERT")
|
||||
.nth(1)?
|
||||
.split(';')
|
||||
.next()?
|
||||
.split('|')
|
||||
.next()?
|
||||
.trim();
|
||||
|
||||
let priv_bytes = parse_hex(priv_str)?;
|
||||
if priv_bytes.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
let mut pk = [0u8; 32];
|
||||
pk.copy_from_slice(&priv_bytes);
|
||||
|
||||
let cert = parse_hex(cert_str)?;
|
||||
if cert.len() < 132 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((pk, cert, parse_revoked_at_mkb(line)))
|
||||
}
|
||||
|
||||
fn parse_disc_entry(line: &str) -> Option<DiscEntry> {
|
||||
// 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys> ; <comment>
|
||||
let (hash_part, rest) = line.split_once(" = ")?;
|
||||
let disc_hash: Arc<str> = Arc::from(hash_part.trim().to_lowercase());
|
||||
|
||||
// The trailing `;` comment (e.g. "; MKBv76/BEE/FindVUK 1.74 -
|
||||
// VolumeSize: 81309007872 (UHD)") carries metadata the key fields
|
||||
// don't; capture everything after the FIRST ';' on the line.
|
||||
let comment = line.split_once(';').map(|(_, c)| c).unwrap_or("");
|
||||
let mkb_version: Option<u32> = parse_digits_after(comment, "MKBv");
|
||||
let volume_size: Option<u64> = parse_digits_after(comment, "VolumeSize:");
|
||||
let is_uhd = comment.contains("(UHD)");
|
||||
|
||||
// Title kept VERBATIM (trimmed), a faithful copy for exact round-trip.
|
||||
let before_fields = rest.split(" | ").next().unwrap_or("");
|
||||
// A title-only entry (no key fields) carries its `;` comment on the same
|
||||
// chunk — strip it so the comment doesn't leak into the title.
|
||||
let title = before_fields
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Parse fields by tag
|
||||
let mut media_key = None;
|
||||
let mut vid = None;
|
||||
let mut vuk = None;
|
||||
let mut unit_keys = Vec::new();
|
||||
|
||||
let parts: Vec<&str> = rest.split(" | ").collect();
|
||||
// Field scan starts at index 1: `parts[0]` is ALWAYS the title chunk;
|
||||
// excluding it stops a title matching a field-tag letter ("M"/"I"/"V"/
|
||||
// "U"/"D") from being eaten as a tag and shadowing the real field.
|
||||
let mut i = 1;
|
||||
while i < parts.len() {
|
||||
match parts[i].trim() {
|
||||
// M/I/V strip a trailing `; comment` before hex-parsing (like the
|
||||
// U arm below): when one of these is the row's LAST field, the
|
||||
// comment gets glued on and parse_hex16 would otherwise fail.
|
||||
"M" => {
|
||||
if i + 1 < parts.len() {
|
||||
media_key =
|
||||
parse_hex16(parts[i + 1].split(';').next().unwrap_or("").trim());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"I" => {
|
||||
if i + 1 < parts.len() {
|
||||
vid = parse_hex16(parts[i + 1].split(';').next().unwrap_or("").trim());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"V" => {
|
||||
if i + 1 < parts.len() {
|
||||
vuk = parse_hex16(parts[i + 1].split(';').next().unwrap_or("").trim());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
"U" if i + 1 < parts.len() => {
|
||||
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
|
||||
let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
|
||||
for uk in uk_str.split(' ') {
|
||||
let uk = uk.trim();
|
||||
if let Some((num, key)) = uk.split_once('-')
|
||||
&& let Ok(n) = num.parse::<u32>()
|
||||
&& let Some(k) = parse_hex16(key)
|
||||
{
|
||||
unit_keys.push((n, k));
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
Some(DiscEntry {
|
||||
disc_hash,
|
||||
title,
|
||||
media_key,
|
||||
vid,
|
||||
vuk,
|
||||
unit_keys,
|
||||
mkb_version,
|
||||
volume_size,
|
||||
is_uhd,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "keydb_format_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
+1311
File diff suppressed because it is too large
Load Diff
+143
-122
@@ -1,153 +1,174 @@
|
||||
//! Pluggable AACS key sources for libfreemkv.
|
||||
//!
|
||||
//! libfreemkv performs no key lookup — it is handed a [`Key`] and derives down
|
||||
//! the AACS chain to decrypt. This crate provides the published [`KeySource`]
|
||||
//! implementations that do the lookup:
|
||||
//!
|
||||
//! - [`KeydbSource`] — a local `keydb.cfg` (source #1).
|
||||
//! - [`OnlineSource`] — a remote key service (source #2).
|
||||
//! - [`MapfileSource`] — the persisted unit key from a rip mapfile (source #3).
|
||||
//!
|
||||
//! Applications (autorip, the `freemkv` CLI) choose and order the sources from
|
||||
//! their own config — the local-vs-online policy is just which impls they plug
|
||||
//! in — then resolve and hand the resulting key to `Disc::decrypt_with`.
|
||||
//!
|
||||
//! Sources are dumb and stateful: each hands its candidate keys out one at a
|
||||
//! time via [`KeySource::next_key`], in its own best order, and reports
|
||||
//! exhaustion. Compose several with [`MultiSource`] in the caller's chosen
|
||||
//! order; [`resolve_and_apply`] drives the loop — handing each key to
|
||||
//! `Disc::decrypt_with` (which validates against the disc's content samples) and
|
||||
//! stopping at the first that decrypts, or reporting a genuine "no key" when
|
||||
//! every source is spent.
|
||||
//! libfreemkv owns the AACS crypto; this crate provides [`KeySource`] impls that look a disc up
|
||||
//! and drive the boil-down primitives to terminal Unit Keys: [`KeydbSource`] (local
|
||||
//! `keydb.cfg`) and [`OnlineSource`] (remote key service). Applications choose and order
|
||||
//! sources, then resolve and hand the key to `Disc::decrypt_with`; compose several with
|
||||
//! [`MultiSource`].
|
||||
|
||||
mod keydb;
|
||||
mod mapfile;
|
||||
/// The `keydb.cfg` parser (`KeyDb`, `DiscEntry`, …). Public: parsing the keydb
|
||||
/// is not secret — freemkv uses it, and so do tools that build a disc registry
|
||||
/// from it (e.g. a per-disc Volume-ID index).
|
||||
pub mod keydb_format;
|
||||
mod online;
|
||||
mod paths;
|
||||
|
||||
pub use keydb::KeydbSource;
|
||||
pub use mapfile::MapfileSource;
|
||||
pub use online::OnlineSource;
|
||||
pub use keydb::{KeydbSource, UpdateResult};
|
||||
pub use keydb_format::{DiscEntry, KeyDb};
|
||||
#[cfg(feature = "test-hooks")]
|
||||
pub use online::set_last_decode_reachability;
|
||||
pub use online::{
|
||||
DecodeReachability, KeyserverUrlFault, KeyserverUrlRejection, MIN_SAMPLE_UNITS, OnlineSource,
|
||||
check_keyserver_url, check_keyserver_url_static, take_last_decode_reachability,
|
||||
validate_keyserver_url,
|
||||
};
|
||||
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
|
||||
|
||||
// Re-exported for downstream convenience so apps need only depend on this crate
|
||||
// for the source-side types.
|
||||
pub use libfreemkv::{DiscInputs, Key, KeySource};
|
||||
pub use libfreemkv::aacs::types::UnitKey;
|
||||
pub use libfreemkv::keysource::ResolveCtx;
|
||||
pub use libfreemkv::{DiscInputs, KeySource};
|
||||
|
||||
use libfreemkv::{Disc, DiscTitle, SectorSource};
|
||||
// VUK -> the disc's terminal Unit Keys (positional index), one AES-ECB-decrypt
|
||||
// per encrypted title key, via `aacs::derive::decrypt_unit_key`. Replaces the
|
||||
// removed libfreemkv `aacs::boil::uk_from_vuk` wrapper.
|
||||
pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
|
||||
enc_title_keys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| UnitKey::new(i as u32, libfreemkv::aacs::derive::decrypt_unit_key(vuk, e)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An ordered composition of key sources, driven as one. `next_key` exhausts
|
||||
/// the first source (one candidate per call), then the next, … then `None`.
|
||||
/// **The caller supplies the list AND the order** — local-first `[Keydb,
|
||||
/// Online]`, online-first `[Online, Keydb]`, resume `[Mapfile, Keydb]`, etc. —
|
||||
/// so the "which sources, in what order" policy lives entirely with the
|
||||
/// application, not the library. `MultiSource` is itself a [`KeySource`], so it
|
||||
/// nests and composes.
|
||||
/// An ordered composition of key sources, driven as one.
|
||||
///
|
||||
/// [`MultiSource::get_unit_keys`] tries each inner source in order and returns the first
|
||||
/// non-empty Unit Key set (and [`MultiSource::get_fmts_indexes`] does the same for the forensic
|
||||
/// set). The caller supplies the list AND the order — local-first `[Keydb, Online]`,
|
||||
/// online-first `[Online, Keydb]`, etc. `MultiSource` is itself a [`KeySource`], so it nests
|
||||
/// and composes.
|
||||
pub struct MultiSource {
|
||||
sources: Vec<Box<dyn KeySource>>,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
impl MultiSource {
|
||||
/// Compose the given sources, tried in the order supplied.
|
||||
pub fn new(sources: Vec<Box<dyn KeySource>>) -> Self {
|
||||
Self { sources, idx: 0 }
|
||||
Self { sources }
|
||||
}
|
||||
}
|
||||
|
||||
// Drive `sources` in order, returning the first non-empty result and preserving Ok/Err when
|
||||
// nothing resolves. `get` selects the trait method so the base and forensic paths share one
|
||||
// implementation.
|
||||
fn first_non_empty(
|
||||
sources: &[Box<dyn KeySource>],
|
||||
get: impl Fn(&dyn KeySource, &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error>,
|
||||
ctx: &dyn ResolveCtx,
|
||||
) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
let mut first_failure: Option<libfreemkv::Error> = None;
|
||||
for s in sources {
|
||||
match get(s.as_ref(), ctx) {
|
||||
Ok(uks) if !uks.is_empty() => return Ok(uks),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
if first_failure.is_none() {
|
||||
first_failure = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match first_failure {
|
||||
// At least one source could not answer, and nothing else had a key: the
|
||||
// composition does NOT know that this disc has no key.
|
||||
Some(e) => Err(e),
|
||||
// Every source answered; none holds a key. The genuine miss.
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
impl KeySource for MultiSource {
|
||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
||||
while self.idx < self.sources.len() {
|
||||
if let Some(key) = self.sources[self.idx].next_key(inputs) {
|
||||
return Some(key);
|
||||
}
|
||||
self.idx += 1; // this source is spent — advance to the next
|
||||
}
|
||||
None
|
||||
// First inner source to return a non-empty base Unit Key set wins. All
|
||||
// exhausted -> `Ok(empty)` if all answered, else the first failure (see
|
||||
// [`MultiSource`]).
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
first_non_empty(&self.sources, |s, c| s.get_unit_keys(c), ctx)
|
||||
}
|
||||
|
||||
fn needs_samples(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.needs_samples())
|
||||
}
|
||||
|
||||
fn errored(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.errored())
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive `sources` until one key decrypts `disc`. Loops `next_key` and hands
|
||||
/// each candidate to [`Disc::decrypt_with`] (which validates it against
|
||||
/// `inputs.samples` and only mutates the disc on success), returning `true` at
|
||||
/// the first key that decrypts and `false` once every source is exhausted — the
|
||||
/// genuine "no key for this disc". THE shared key-resolution loop: every
|
||||
/// application (the `freemkv` CLI, autorip) uses it instead of re-rolling the
|
||||
/// candidate/retry logic, so the "no key" verdict is identical everywhere.
|
||||
pub fn resolve_and_apply(
|
||||
sources: &mut dyn KeySource,
|
||||
inputs: &DiscInputs,
|
||||
disc: &mut Disc,
|
||||
) -> bool {
|
||||
while let Some(key) = sources.next_key(inputs) {
|
||||
if disc.decrypt_with(key, &inputs.samples).is_ok() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Read up to `n` ENCRYPTED 6144-byte aligned units from `title`'s body, raw (no
|
||||
/// decrypt) — the content samples a caller hands to [`resolve_and_apply`] (for
|
||||
/// `Disc::decrypt_with` to validate a key against) and that a sample-needing
|
||||
/// source (an online key service) byte-validates against.
|
||||
///
|
||||
/// "Encrypted" is decided by `libfreemkv::aacs::is_aacs_scrambled` — the SAME
|
||||
/// predicate the library's decrypt gate and a key service use — so all sides
|
||||
/// agree. A clip opens with clear navigation units (PAT/PMT, menus); only the
|
||||
/// feature body is scrambled, and a clear unit proves nothing, so this collects
|
||||
/// only scrambled ones, sampling the largest extent at its midpoint forward.
|
||||
pub fn read_sample_units(
|
||||
reader: &mut dyn SectorSource,
|
||||
title: &DiscTitle,
|
||||
n: usize,
|
||||
) -> Vec<Vec<u8>> {
|
||||
const UNIT_LEN: usize = 6144;
|
||||
const UNIT_SECTORS: u32 = 3; // 6144 / 2048
|
||||
const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap
|
||||
const MAX_CHUNKS_PER_EXTENT: u32 = 4; // ~60 units scanned at each extent's midpoint
|
||||
|
||||
let mut out: Vec<Vec<u8>> = Vec::new();
|
||||
for ext in &title.extents {
|
||||
let total_units = ext.sector_count / UNIT_SECTORS;
|
||||
if total_units == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut unit = total_units / 2; // midpoint (past the clear nav at the head)
|
||||
for _ in 0..MAX_CHUNKS_PER_EXTENT {
|
||||
if unit >= total_units {
|
||||
break;
|
||||
}
|
||||
let units_this = CHUNK_UNITS.min(total_units - unit);
|
||||
let lba = ext.start_lba + unit * UNIT_SECTORS;
|
||||
let count = (units_this * UNIT_SECTORS) as u16;
|
||||
let mut buf = vec![0u8; count as usize * 2048];
|
||||
// `false` = no recovery retries; the reader is the raw drive/file
|
||||
// (no decrypt decorator), so these are the on-disc encrypted bytes.
|
||||
if reader.read_sectors(lba, count, &mut buf, false).is_err() {
|
||||
break;
|
||||
}
|
||||
for i in 0..units_this as usize {
|
||||
let o = i * UNIT_LEN;
|
||||
if o + UNIT_LEN > buf.len() {
|
||||
break;
|
||||
// De-conflated counterpart: first inner source with keys wins; else the
|
||||
// richest miss survives composition (a MATCHED inner source beats a plain
|
||||
// miss). Same Err/Ok failure contract as `get_unit_keys`.
|
||||
fn resolve_unit_keys(
|
||||
&self,
|
||||
ctx: &dyn ResolveCtx,
|
||||
) -> Result<libfreemkv::keysource::UnitKeyResolution, libfreemkv::Error> {
|
||||
let mut first_failure: Option<libfreemkv::Error> = None;
|
||||
let mut matched: Option<libfreemkv::keysource::UnitKeyResolution> = None;
|
||||
for s in &self.sources {
|
||||
match s.resolve_unit_keys(ctx) {
|
||||
Ok(r) if !r.keys.is_empty() => return Ok(r),
|
||||
Ok(r) => {
|
||||
if r.matched && matched.is_none() {
|
||||
matched = Some(r);
|
||||
}
|
||||
}
|
||||
let u = &buf[o..o + UNIT_LEN];
|
||||
if libfreemkv::aacs::is_aacs_scrambled(u) {
|
||||
out.push(u.to_vec());
|
||||
if out.len() >= n {
|
||||
return out;
|
||||
Err(e) => {
|
||||
if first_failure.is_none() {
|
||||
first_failure = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
unit += units_this;
|
||||
}
|
||||
if let Some(r) = matched {
|
||||
return Ok(r);
|
||||
}
|
||||
match first_failure {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(libfreemkv::keysource::UnitKeyResolution::default()),
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
// Forensic-index counterpart to `get_unit_keys`, same failure-preserving
|
||||
// rule. A source with no forensic material (keydb, via the trait default)
|
||||
// contributes empty and is skipped; today the online source answers.
|
||||
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
first_non_empty(&self.sources, |s, c| s.get_fmts_indexes(c), ctx)
|
||||
}
|
||||
|
||||
/// UNION every inner source's host certs, in source order (`mkb` is passed
|
||||
/// through unchanged). Without this a composed source would hide an inner
|
||||
/// source's cert from the OEM cert-auth route — the gap this fixes.
|
||||
fn host_certs(&self, mkb: Option<u32>) -> Vec<libfreemkv::aacs::types::HostCert> {
|
||||
self.sources
|
||||
.iter()
|
||||
.flat_map(|s| s.host_certs(mkb))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
"multi"
|
||||
}
|
||||
|
||||
// KU3-6: depends on samples if ANY inner source does, so a nested
|
||||
// sample-dependent source keeps getting the per-piece ask.
|
||||
fn answer_depends_on_samples(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.answer_depends_on_samples())
|
||||
}
|
||||
|
||||
// Same `any(inner)` rule (KU-K1 review): `resolve` retries the composition
|
||||
// whenever any inner source's last failure was transport-class.
|
||||
fn last_failure_was_transport(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.last_failure_was_transport())
|
||||
}
|
||||
|
||||
// KU J23: any inner source that consumes the VID makes the composition one.
|
||||
fn uses_vid(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.uses_vid())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lib_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
use super::*;
|
||||
use libfreemkv::aacs::types::HostCert;
|
||||
|
||||
// A minimal KeySource that only supplies host certs — the sole MultiSource
|
||||
// behaviour under test. It never resolves a unit key.
|
||||
struct CertOnly {
|
||||
certs: Vec<HostCert>,
|
||||
}
|
||||
impl KeySource for CertOnly {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> {
|
||||
self.certs.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// A host cert tagged with a distinct marker byte so a returned set can be
|
||||
// identified exactly. Synthetic bytes only; no real key material.
|
||||
fn cert(marker: u8) -> HostCert {
|
||||
HostCert {
|
||||
private_key: [marker; 20],
|
||||
certificate: vec![marker; 92],
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed(certs: Vec<HostCert>) -> Box<dyn KeySource> {
|
||||
Box::new(CertOnly { certs }) as Box<dyn KeySource>
|
||||
}
|
||||
|
||||
/// `MultiSource::host_certs` must UNION every inner source's certs. Without
|
||||
/// this a composed source hides an inner source's cert from the OEM
|
||||
/// cert-auth route — the gap the union closes.
|
||||
#[test]
|
||||
fn multi_source_host_certs_unions_every_inner_source() {
|
||||
let multi = MultiSource::new(vec![
|
||||
boxed(vec![cert(0x01)]),
|
||||
boxed(vec![cert(0x02), cert(0x03)]),
|
||||
]);
|
||||
let mut markers: Vec<u8> = multi
|
||||
.host_certs(Some(50))
|
||||
.iter()
|
||||
.map(|c| c.certificate[0])
|
||||
.collect();
|
||||
markers.sort_unstable();
|
||||
assert_eq!(
|
||||
markers,
|
||||
vec![0x01, 0x02, 0x03],
|
||||
"certs from every inner source must be unioned"
|
||||
);
|
||||
}
|
||||
|
||||
/// With no inner source holding a cert, the union is empty (not an error).
|
||||
#[test]
|
||||
fn multi_source_host_certs_empty_when_no_source_has_one() {
|
||||
let multi = MultiSource::new(vec![boxed(vec![]), boxed(vec![])]);
|
||||
assert!(multi.host_certs(None).is_empty());
|
||||
}
|
||||
|
||||
// A source whose `answer_depends_on_samples` is fixed to the given value —
|
||||
// the only behaviour KSK2 exercises.
|
||||
struct FixedDependency(bool);
|
||||
impl KeySource for FixedDependency {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn answer_depends_on_samples(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// KSK2: `MultiSource::answer_depends_on_samples` is `any(inner)`, so nesting
|
||||
// a sample-dependent online source under a sample-independent keydb still
|
||||
// gets the per-piece ask (`resolve` must not treat the pair as keydb-only).
|
||||
#[test]
|
||||
fn multi_source_answer_depends_on_samples_is_any_inner() {
|
||||
let all_independent = MultiSource::new(vec![
|
||||
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(!all_independent.answer_depends_on_samples());
|
||||
|
||||
let keydb_then_online = MultiSource::new(vec![
|
||||
Box::new(FixedDependency(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedDependency(true)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(keydb_then_online.answer_depends_on_samples());
|
||||
}
|
||||
|
||||
// A source whose `uses_vid` is fixed to the given value.
|
||||
struct FixedVid(bool);
|
||||
impl KeySource for FixedVid {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn uses_vid(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// KU J23: like `answer_depends_on_samples`, a composed source uses the VID if any
|
||||
// inner one does, so a nested online source still makes a Missing piece VID-derivable.
|
||||
#[test]
|
||||
fn multi_source_uses_vid_is_any_inner() {
|
||||
let neither = MultiSource::new(vec![
|
||||
Box::new(FixedVid(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedVid(false)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(!neither.uses_vid());
|
||||
let keydb_then_online = MultiSource::new(vec![
|
||||
Box::new(FixedVid(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedVid(true)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(keydb_then_online.uses_vid());
|
||||
}
|
||||
|
||||
// A source whose `last_failure_was_transport` is fixed to the given value.
|
||||
struct FixedTransport(bool);
|
||||
impl KeySource for FixedTransport {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn last_failure_was_transport(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
// For consistency with `answer_depends_on_samples` (KU3-6): a composed
|
||||
// source's last-failure verdict is `any(inner)` too, so `resolve` retries
|
||||
// the composition whenever ANY inner source's last failure was transport.
|
||||
#[test]
|
||||
fn multi_source_last_failure_was_transport_is_any_inner() {
|
||||
let none_transport = MultiSource::new(vec![
|
||||
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(!none_transport.last_failure_was_transport());
|
||||
|
||||
let one_transport = MultiSource::new(vec![
|
||||
Box::new(FixedTransport(false)) as Box<dyn KeySource>,
|
||||
Box::new(FixedTransport(true)) as Box<dyn KeySource>,
|
||||
]);
|
||||
assert!(one_transport.last_failure_was_transport());
|
||||
}
|
||||
|
||||
/// The `mkb` generation must reach each inner source so its OWN revocation
|
||||
/// filter can act; a source that drops everything at a given generation is
|
||||
/// honoured by the union.
|
||||
#[test]
|
||||
fn multi_source_host_certs_passes_mkb_through_to_inner_sources() {
|
||||
struct GenAware;
|
||||
impl KeySource for GenAware {
|
||||
fn get_unit_keys(&self, _c: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
|
||||
match mkb {
|
||||
// Modelled on a cert revoked in MKBv72.
|
||||
Some(g) if g >= 72 => Vec::new(),
|
||||
_ => vec![cert(0xaa)],
|
||||
}
|
||||
}
|
||||
}
|
||||
let multi = MultiSource::new(vec![Box::new(GenAware) as Box<dyn KeySource>]);
|
||||
assert_eq!(multi.host_certs(Some(50)).len(), 1, "usable below gen 72");
|
||||
assert!(
|
||||
multi.host_certs(Some(72)).is_empty(),
|
||||
"the gen must reach the inner source's filter"
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Mapfile cache source (source #3).
|
||||
//!
|
||||
//! A rip's ddrescue-style mapfile persists the resolved unit keys in its
|
||||
//! `# freemkv-uk:` header (written at sweep time when the disc was keyed). On
|
||||
//! resume / deferred mux, that mapfile is the fastest source — the keys are
|
||||
//! already resolved, no keydb parse and no network round-trip. This source
|
||||
//! reads them back as a terminal [`Key::Unit`] candidate.
|
||||
//!
|
||||
//! It is keyed by the mapfile path (the disc identity is implicit in which
|
||||
//! mapfile belongs to which rip), so it ignores [`DiscInputs`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use libfreemkv::disc::mapfile::Mapfile;
|
||||
use libfreemkv::{DiscInputs, Key, KeySource};
|
||||
|
||||
/// A [`KeySource`] backed by a rip mapfile's persisted unit keys.
|
||||
pub struct MapfileSource {
|
||||
path: PathBuf,
|
||||
/// The mapfile holds exactly one (terminal) UK set, so it is read once —
|
||||
/// this flips true after the first `next_key`.
|
||||
asked: bool,
|
||||
}
|
||||
|
||||
impl MapfileSource {
|
||||
/// A mapfile source reading the given `*.mapfile` path.
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
asked: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeySource for MapfileSource {
|
||||
fn next_key(&mut self, _inputs: &DiscInputs) -> Option<Key> {
|
||||
if self.asked {
|
||||
return None;
|
||||
}
|
||||
self.asked = true;
|
||||
// A missing/unreadable/keyless mapfile simply offers nothing.
|
||||
let map = Mapfile::load(&self.path).ok()?;
|
||||
let uks = map.unit_keys();
|
||||
(!uks.is_empty()).then(|| Key::Unit(uks.to_vec()))
|
||||
}
|
||||
}
|
||||
+1114
-76
File diff suppressed because it is too large
Load Diff
+1936
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
//! Where the `keydb.cfg` lives: local to the executable, local ONLY.
|
||||
//!
|
||||
//! Key-path policy belongs here (key sources), not in libfreemkv, which
|
||||
//! just reads a handed-in path. This module gives the *search* list (first
|
||||
//! existing wins) and the single *default* write location.
|
||||
//!
|
||||
//! freemkv is portable: `keydb.cfg` lives next to the exe — `<dir of current exe>/keydb.cfg` —
|
||||
//! with no OS config-dir fallback. `--keydb PATH` bypasses this module.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The single `keydb.cfg` location to search: next to the current executable.
|
||||
///
|
||||
/// Returns exactly one path — `<dir of current exe>/keydb.cfg` — on success.
|
||||
/// Returns an empty list if the executable's own directory can't be determined
|
||||
/// (`std::env::current_exe()` fails or has no parent); there is deliberately no
|
||||
/// OS config-dir fallback (portable / local-only).
|
||||
///
|
||||
/// The caller picks the first path that exists on disk (see
|
||||
/// [`existing_keydb_path`]); for writing a freshly-downloaded keydb, use
|
||||
/// [`default_keydb_path`].
|
||||
pub fn keydb_search_paths() -> Vec<PathBuf> {
|
||||
match std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
|
||||
{
|
||||
Some(path) => vec![path],
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first search path that exists on disk, if any.
|
||||
///
|
||||
/// Use this to LOCATE an existing keydb for reading. Falls back to `None` when
|
||||
/// no candidate file exists (the caller then surfaces "no KEYDB.cfg found").
|
||||
pub fn existing_keydb_path() -> Option<PathBuf> {
|
||||
keydb_search_paths().into_iter().find(|p| p.exists())
|
||||
}
|
||||
|
||||
/// The canonical default location to WRITE the keydb to (e.g. after a download).
|
||||
///
|
||||
/// This is the sole entry of [`keydb_search_paths`]: `<dir of current exe>/keydb.cfg`.
|
||||
/// Returns `None` only when the executable's own directory can't be determined.
|
||||
pub fn default_keydb_path() -> Option<PathBuf> {
|
||||
keydb_search_paths().into_iter().next()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "paths_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,40 @@
|
||||
use super::*;
|
||||
|
||||
/// The expected exe-local keydb path, computed the same way the code does.
|
||||
/// Under `cargo test`, `current_exe()` is the test binary under `target/…`.
|
||||
fn expected_local() -> Option<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_paths_is_exactly_exe_local_keydb() {
|
||||
let paths = keydb_search_paths();
|
||||
match expected_local() {
|
||||
Some(expected) => {
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![expected],
|
||||
"search list must be exactly [<exe dir>/keydb.cfg]"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
// No exe dir available → empty, no OS fallback (local only).
|
||||
assert!(
|
||||
paths.is_empty(),
|
||||
"no exe dir means an empty search list, never an OS fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_path_matches_search_head() {
|
||||
// The write default is the single search entry, or None if unavailable.
|
||||
assert_eq!(default_keydb_path(), expected_local());
|
||||
assert_eq!(
|
||||
default_keydb_path(),
|
||||
keydb_search_paths().into_iter().next()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
//! Fixture-based integration tests for the published key sources.
|
||||
//!
|
||||
//! These exercise the *public* surface of `freemkv-keysources` end-to-end —
|
||||
//! real files on disk, the real `KeyDb` parser from libfreemkv, and the
|
||||
//! `KeySource` trait (`get_unit_keys` over a `ResolveCtx`) the applications
|
||||
//! drive. Covered: `KeydbSource` terminal unit-key lookup by disc hash
|
||||
//! (plus exe-local path helpers and MKB-aware host-cert serving);
|
||||
//! `OnlineSource` SSRF/scheme validation and the unconfigured no-op; and
|
||||
//! `MultiSource` ordering/precedence, host-cert UNION, and nesting.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use freemkv_keysources::{
|
||||
DiscInputs, KeySource, KeydbSource, MultiSource, OnlineSource, UnitKey, default_keydb_path,
|
||||
existing_keydb_path, keydb_search_paths, validate_keyserver_url,
|
||||
};
|
||||
use libfreemkv::keysource::{DiscInputsCtx, ResolveCtx};
|
||||
|
||||
// ── fixture helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// A unique scratch dir for one test, removed on `Drop` so fixtures never leak.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"fmk_ks_{}_{}_{:?}",
|
||||
tag,
|
||||
std::process::id(),
|
||||
std::thread::current().id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
|
||||
fn write(&self, name: &str, body: &str) -> PathBuf {
|
||||
let p = self.0.join(name);
|
||||
let mut f = std::fs::File::create(&p).unwrap();
|
||||
f.write_all(body.as_bytes()).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bare `DiscInputs` keyed only by disc hash — the lookup key a keydb uses.
|
||||
fn inputs(hash: &str) -> DiscInputs {
|
||||
DiscInputs {
|
||||
disc_hash: hash.into(),
|
||||
volume_id: [0u8; 16],
|
||||
version: libfreemkv::aacs::mkb::AACS_MAJOR_UHD,
|
||||
mkb: Vec::new(),
|
||||
unit_key_ro: Vec::new(),
|
||||
samples: Vec::new(),
|
||||
volume_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a source through the public trait over a `DiscInputsCtx`.
|
||||
fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> {
|
||||
let ctx = DiscInputsCtx::new(inp);
|
||||
src.get_unit_keys(&ctx)
|
||||
.expect("get_unit_keys must not error for these fixtures")
|
||||
}
|
||||
|
||||
// ── KeydbSource: real-file lookup by disc hash ──────────────────────────────
|
||||
|
||||
const DISC_HASH: &str = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd";
|
||||
|
||||
/// A `keydb.cfg` with one per-disc entry carrying a **terminal** unit key (the
|
||||
/// `U` token) plus a universal DK pool. The terminal UK path needs no on-disc
|
||||
/// crypto inputs, so it round-trips through a bare `DiscInputs`.
|
||||
fn keydb_with_unit_key() -> String {
|
||||
format!(
|
||||
"; fixture keydb\n\
|
||||
| DK | DEVICE_KEY 0x{dk} | DEVICE_NODE 0x0001 | KEY_UV 0x00000002 | KEY_U_MASK_SHIFT 0x00\n\
|
||||
{hash} = FIXTURE_DISC | U | 1-0x{uk}\n",
|
||||
dk = "22".repeat(16),
|
||||
hash = DISC_HASH,
|
||||
uk = "11".repeat(16),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keydb_source_resolves_terminal_unit_key_by_hash() {
|
||||
let s = Scratch::new("keydb_hit");
|
||||
let path = s.write("keydb.cfg", &keydb_with_unit_key());
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
let uks = resolve(&src, &inputs(DISC_HASH));
|
||||
assert_eq!(uks.len(), 1, "the disc's terminal unit key is resolved");
|
||||
assert_eq!(
|
||||
uks[0].key, [0x11u8; 16],
|
||||
"key bytes come straight from the keydb"
|
||||
);
|
||||
// CPS number 1 in the keydb → positional idx 0 (resolver re-adds the +1).
|
||||
assert_eq!(uks[0].idx, 0, "stored CPS num 1 maps to positional idx 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keydb_source_hash_miss_yields_nothing() {
|
||||
let s = Scratch::new("keydb_miss");
|
||||
let path = s.write("keydb.cfg", &keydb_with_unit_key());
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
let uks = resolve(&src, &inputs("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
|
||||
assert!(
|
||||
uks.is_empty(),
|
||||
"a hash miss resolves no keys from the keydb"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keydb_source_missing_file_is_silent_ok_empty() {
|
||||
// A missing keydb is not an error — it simply offers no keys, so a later
|
||||
// source in the chain can still supply them.
|
||||
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||
let inp = inputs(DISC_HASH);
|
||||
let ctx = DiscInputsCtx::new(&inp);
|
||||
assert!(
|
||||
src.get_unit_keys(&ctx)
|
||||
.expect("missing keydb is Ok, not Err")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keydb_source_label_is_keydb() {
|
||||
assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb");
|
||||
}
|
||||
|
||||
// ── KeydbSource: host-cert serving from a file ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn keydb_source_serves_host_cert_from_hc_row() {
|
||||
let s = Scratch::new("keydb_hc");
|
||||
// `| HC |` row with all-zero placeholder material (never a real key).
|
||||
let line = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||
"00".repeat(20),
|
||||
"00".repeat(92)
|
||||
);
|
||||
let path = s.write("keydb.cfg", &line);
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
// Inherent (scan-options) form.
|
||||
assert_eq!(
|
||||
src.host_certs().len(),
|
||||
1,
|
||||
"inherent host_certs sees the HC row"
|
||||
);
|
||||
// Trait form: `mkb` is ignored; the ranked list is returned.
|
||||
let via_trait = KeySource::host_certs(&src, Some(70));
|
||||
assert_eq!(via_trait.len(), 1, "trait host_certs sees the HC row");
|
||||
assert_eq!(via_trait[0].certificate.len(), 92);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keydb_source_host_certs_empty_when_file_missing() {
|
||||
let src = KeydbSource::new("/nonexistent/keydb.cfg");
|
||||
assert!(src.host_certs().is_empty());
|
||||
assert!(KeySource::host_certs(&src, None).is_empty());
|
||||
}
|
||||
|
||||
// ── path policy: exe-local, local-only ──────────────────────────────────────
|
||||
|
||||
fn expected_local() -> Option<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_paths_is_exactly_exe_local() {
|
||||
let paths = keydb_search_paths();
|
||||
match expected_local() {
|
||||
Some(expected) => assert_eq!(
|
||||
paths,
|
||||
vec![expected],
|
||||
"search list must be exactly [<exe dir>/keydb.cfg], no OS fallback"
|
||||
),
|
||||
None => assert!(paths.is_empty(), "no exe dir → empty, never an OS fallback"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_path_matches_search_head() {
|
||||
assert_eq!(default_keydb_path(), expected_local());
|
||||
assert_eq!(
|
||||
default_keydb_path(),
|
||||
keydb_search_paths().into_iter().next()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_keydb_path_reflects_disk_state() {
|
||||
let head_exists = keydb_search_paths()
|
||||
.first()
|
||||
.map(|p| p.exists())
|
||||
.unwrap_or(false);
|
||||
assert_eq!(existing_keydb_path().is_some(), head_exists);
|
||||
}
|
||||
|
||||
// ── OnlineSource: validation + unconfigured no-op (no network in CI) ─────────
|
||||
|
||||
#[test]
|
||||
fn online_source_unconfigured_is_silent_no_op() {
|
||||
// Empty base URL → a clean "no service" empty, no network.
|
||||
let src = OnlineSource::new("", "");
|
||||
assert!(resolve(&src, &inputs(DISC_HASH)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_source_metadata() {
|
||||
let src = OnlineSource::new("https://example.test/keys", "tok");
|
||||
assert_eq!(src.label(), "online");
|
||||
// No host-cert serving today — a no-op empty, no network touched.
|
||||
assert!(KeySource::host_certs(&src, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_keyserver_url_gates_scheme_and_allows_lan() {
|
||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||
// Home app: LAN/loopback/link-local hosts pass; cleartext http and invalid addresses do not.
|
||||
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_ok());
|
||||
assert!(validate_keyserver_url("https://169.254.1.5/keys").is_ok());
|
||||
assert!(validate_keyserver_url("https://[::1]:9000/keys").is_ok());
|
||||
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
|
||||
assert!(validate_keyserver_url("https://0.0.0.0/keys").is_err());
|
||||
assert!(validate_keyserver_url("ftp://example.com/keys").is_err());
|
||||
assert!(validate_keyserver_url("").is_err());
|
||||
}
|
||||
|
||||
// ── MultiSource: ordering, precedence, aggregation, nesting ─────────────────
|
||||
|
||||
/// A scripted source for composition tests: returns a fixed Unit Key set.
|
||||
struct ScriptedSource {
|
||||
keys: Vec<UnitKey>,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl ScriptedSource {
|
||||
fn new(label: &'static str, keys: Vec<UnitKey>) -> Self {
|
||||
Self { keys, label }
|
||||
}
|
||||
}
|
||||
|
||||
impl KeySource for ScriptedSource {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(self.keys.clone())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
self.label
|
||||
}
|
||||
}
|
||||
|
||||
fn uk(b: u8) -> UnitKey {
|
||||
UnitKey::new(0, [b; 16])
|
||||
}
|
||||
|
||||
/// `MultiSource` itself reports a fixed label, distinct from any inner
|
||||
/// source's own label — this is what a rip's source-attribution log shows
|
||||
/// when the composition, not one of its members, answered.
|
||||
#[test]
|
||||
fn multi_source_label_is_multi() {
|
||||
let multi = MultiSource::new(vec![Box::new(ScriptedSource::new("A", Vec::new()))]);
|
||||
assert_eq!(multi.label(), "multi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_first_non_empty_wins_in_caller_order() {
|
||||
// Caller supplies [A, B]; A is non-empty so A's keys win.
|
||||
let a = ScriptedSource::new("A", vec![uk(0xa1)]);
|
||||
let b = ScriptedSource::new("B", vec![uk(0xb1)]);
|
||||
let multi = MultiSource::new(vec![Box::new(a), Box::new(b)]);
|
||||
let got = resolve(&multi, &inputs("x"));
|
||||
assert_eq!(got, vec![uk(0xa1)], "A (first, non-empty) wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_order_is_reversible() {
|
||||
// The SAME two sources in the opposite order yield the opposite precedence.
|
||||
let a = ScriptedSource::new("A", vec![uk(0xa1)]);
|
||||
let b = ScriptedSource::new("B", vec![uk(0xb1)]);
|
||||
let multi = MultiSource::new(vec![Box::new(b), Box::new(a)]);
|
||||
let got = resolve(&multi, &inputs("x"));
|
||||
assert_eq!(got, vec![uk(0xb1)], "B-first ordering wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_skips_empty_sources() {
|
||||
// An empty source in front is transparently skipped to the next.
|
||||
let empty = ScriptedSource::new("empty", vec![]);
|
||||
let real = ScriptedSource::new("real", vec![uk(0xc1)]);
|
||||
let multi = MultiSource::new(vec![Box::new(empty), Box::new(real)]);
|
||||
assert_eq!(resolve(&multi, &inputs("x")), vec![uk(0xc1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_unions_host_certs() {
|
||||
// A real keydb (1 HC row) composed with a cert-less scripted source: the
|
||||
// composed `host_certs` must UNION — i.e. surface the keydb's cert (the gap
|
||||
// this migration fixes; previously a composed source hid inner certs).
|
||||
let s = Scratch::new("multi_hc");
|
||||
let line = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||
"00".repeat(20),
|
||||
"00".repeat(92)
|
||||
);
|
||||
let keydb = s.write("keydb.cfg", &line);
|
||||
|
||||
let multi = MultiSource::new(vec![
|
||||
Box::new(ScriptedSource::new("plain", vec![])),
|
||||
Box::new(KeydbSource::new(&keydb)),
|
||||
]);
|
||||
let certs = KeySource::host_certs(&multi, None);
|
||||
assert_eq!(
|
||||
certs.len(),
|
||||
1,
|
||||
"the inner keydb's cert must be visible through the union"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_nests() {
|
||||
// MultiSource is itself a KeySource, so it composes inside another. Inner
|
||||
// [empty, A] then outer B: A (first non-empty) wins.
|
||||
let inner = MultiSource::new(vec![
|
||||
Box::new(ScriptedSource::new("empty", vec![])),
|
||||
Box::new(ScriptedSource::new("A", vec![uk(0xa1)])),
|
||||
]);
|
||||
let outer = MultiSource::new(vec![
|
||||
Box::new(inner),
|
||||
Box::new(ScriptedSource::new("B", vec![uk(0xb1)])),
|
||||
]);
|
||||
assert_eq!(resolve(&outer, &inputs("x")), vec![uk(0xa1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_real_keydb_resolves_through_chain() {
|
||||
// End-to-end with the REAL keydb source over a fixture file inside a chain:
|
||||
// a no-key scripted source first, then the keydb that actually resolves.
|
||||
let s = Scratch::new("multi_real");
|
||||
let keydb = s.write("keydb.cfg", &keydb_with_unit_key());
|
||||
|
||||
let multi = MultiSource::new(vec![
|
||||
Box::new(ScriptedSource::new("plain", vec![])),
|
||||
Box::new(KeydbSource::new(&keydb)),
|
||||
]);
|
||||
let got = resolve(&multi, &inputs(DISC_HASH));
|
||||
assert_eq!(
|
||||
got.len(),
|
||||
1,
|
||||
"the keydb resolves the disc once the empty source is skipped"
|
||||
);
|
||||
assert_eq!(
|
||||
got[0].key, [0x11u8; 16],
|
||||
"the keydb's terminal UK is returned"
|
||||
);
|
||||
}
|
||||
|
||||
// ── MultiSource: a source that could not ANSWER is not a source that said no ─.
|
||||
|
||||
/// A source that always fails, with a chosen error — the key-service outage.
|
||||
struct FailingSource {
|
||||
err: fn() -> libfreemkv::Error,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl KeySource for FailingSource {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Err((self.err)())
|
||||
}
|
||||
fn get_fmts_indexes(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Err((self.err)())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
self.label
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable() -> libfreemkv::Error {
|
||||
libfreemkv::Error::KeyServiceUnavailable
|
||||
}
|
||||
|
||||
fn unauthorized() -> libfreemkv::Error {
|
||||
libfreemkv::Error::KeyServiceUnauthorized
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_reports_a_source_failure_instead_of_a_clean_no_key() {
|
||||
let multi = MultiSource::new(vec![
|
||||
Box::new(FailingSource {
|
||||
err: unavailable,
|
||||
label: "down",
|
||||
}),
|
||||
Box::new(ScriptedSource::new("empty", vec![])),
|
||||
]);
|
||||
let err = multi
|
||||
.get_unit_keys(&DiscInputsCtx::new(&inputs("x")))
|
||||
.expect_err("a failed source must not collapse into a clean no-key");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
"the composition must surface the service outage"
|
||||
);
|
||||
assert_ne!(
|
||||
err.code(),
|
||||
libfreemkv::error::E_NO_DISC_KEY,
|
||||
"an outage must never be reported as 'this disc has no key'"
|
||||
);
|
||||
}
|
||||
|
||||
/// The forensic half carried the identical bug and is now driven through the
|
||||
/// same helper — pinned separately so a future edit cannot fix one and leave
|
||||
/// the other.
|
||||
#[test]
|
||||
fn multi_source_fmts_indexes_also_preserve_a_source_failure() {
|
||||
let multi = MultiSource::new(vec![Box::new(FailingSource {
|
||||
err: unavailable,
|
||||
label: "down",
|
||||
})]);
|
||||
assert_eq!(
|
||||
multi
|
||||
.get_fmts_indexes(&DiscInputsCtx::new(&inputs("x")))
|
||||
.expect_err("the forensic path must preserve the failure too")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/// A source failure must NOT block the chain: a later source that actually has
|
||||
/// the key still wins outright. Pins the other half of the contract, so the fix
|
||||
/// cannot be over-applied into "any error aborts the composition".
|
||||
#[test]
|
||||
fn multi_source_failure_never_blocks_a_later_success() {
|
||||
let multi = MultiSource::new(vec![
|
||||
Box::new(FailingSource {
|
||||
err: unavailable,
|
||||
label: "down",
|
||||
}),
|
||||
Box::new(ScriptedSource::new("real", vec![uk(0xc1)])),
|
||||
]);
|
||||
assert_eq!(
|
||||
resolve(&multi, &inputs("x")),
|
||||
vec![uk(0xc1)],
|
||||
"a failing first source must not stop the second from resolving"
|
||||
);
|
||||
}
|
||||
|
||||
/// FIRST failure wins, matching libfreemkv's `source_failure` rule: the
|
||||
/// most-preferred source's reason is the one the operator is told about, in the
|
||||
/// same order the caller expressed a preference for successes.
|
||||
#[test]
|
||||
fn multi_source_reports_the_first_failure_not_the_last() {
|
||||
let multi = MultiSource::new(vec![
|
||||
Box::new(FailingSource {
|
||||
err: unauthorized,
|
||||
label: "bad-token",
|
||||
}),
|
||||
Box::new(FailingSource {
|
||||
err: unavailable,
|
||||
label: "down",
|
||||
}),
|
||||
]);
|
||||
assert_eq!(
|
||||
multi
|
||||
.get_unit_keys(&DiscInputsCtx::new(&inputs("x")))
|
||||
.expect_err("both sources failed")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED,
|
||||
"the first source's failure is the one reported"
|
||||
);
|
||||
}
|
||||
|
||||
/// The contrast case: every source ANSWERED and none holds a key. That is the
|
||||
/// genuine miss, and `E7022` is then the truth — so the fix must not turn an
|
||||
/// honest empty into an error.
|
||||
#[test]
|
||||
fn multi_source_all_sources_answering_empty_is_still_a_clean_no_key() {
|
||||
let multi = MultiSource::new(vec![
|
||||
Box::new(ScriptedSource::new("a", vec![])),
|
||||
Box::new(ScriptedSource::new("b", vec![])),
|
||||
]);
|
||||
assert!(
|
||||
multi
|
||||
.get_unit_keys(&DiscInputsCtx::new(&inputs("x")))
|
||||
.expect("every source answered; this is a genuine miss")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
/// The `Path`-typed constructor accepts a borrowed path.
|
||||
#[test]
|
||||
fn keydb_source_accepts_borrowed_path() {
|
||||
let s = Scratch::new("keydb_borrow");
|
||||
let path: &Path = &s.path("keydb.cfg");
|
||||
std::fs::write(path, keydb_with_unit_key()).unwrap();
|
||||
let src = KeydbSource::new(path);
|
||||
assert_eq!(resolve(&src, &inputs(DISC_HASH)).len(), 1);
|
||||
}
|
||||
Reference in New Issue
Block a user