mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
186
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,63 @@
|
||||
version: 2
|
||||
|
||||
# Dependency updates land on `dev`, never on `main`.
|
||||
#
|
||||
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
|
||||
# commit on it would put work there that no tag contains, which is exactly the
|
||||
# state that aborted the 1.6.2 cascade at the last step -- so pointing
|
||||
# Dependabot at main would recreate that failure on a schedule.
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
# One PR per week for the routine bumps instead of one per crate. Eight
|
||||
# repos times a handful of crates is a volume nobody reads, and an
|
||||
# unread PR queue is indistinguishable from no updates at all.
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The freemkv crates depend on each other by GIT TAG, re-pinned by
|
||||
# release.sh as part of the release commit. Dependabot cannot see that
|
||||
# cascade, so a PR bumping one of these would fight the release process
|
||||
# and could pin a version whose tag does not exist yet.
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
|
||||
# The workflows are now real infrastructure -- the release cascade, the
|
||||
# cross-platform hash matrix, the disc gate -- so their actions need the same
|
||||
# attention as the crates.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
|
||||
# release it installs, and the tag we pin is the toolchain CI is pinned
|
||||
# to on purpose -- precommit.sh runs the same one locally so a lint that
|
||||
# passes on a developer's newer default cannot pass CI by accident.
|
||||
#
|
||||
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
|
||||
# a Rust version that does not exist. Every such PR 404s on toolchain
|
||||
# download across all eight repos, and they regenerate weekly -- eight
|
||||
# permanently-red PRs that promote.yml then has to special-case when it
|
||||
# decides whether dev is green.
|
||||
#
|
||||
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
|
||||
# hand together with precommit.sh. There is nothing here for a bot.
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
@@ -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@v5
|
||||
- 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@v5
|
||||
- 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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
self: freemkv-keysources
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
command: check advisories bans licenses sources
|
||||
manifest-path: freemkv-keysources/Cargo.toml
|
||||
|
||||
# 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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
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@v1
|
||||
@@ -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
|
||||
@@ -1,35 +1,11 @@
|
||||
name: leak-guard
|
||||
|
||||
# Self-contained public-repo leak gate. Public CI cannot reach the private
|
||||
# tooling, so this encodes only the generic net: internal-infra references,
|
||||
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
|
||||
# No project-specific reverse-engineering vocabulary lives here.
|
||||
|
||||
# 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:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
id: range
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
# New branch / first push: github.event.before is all-zeros.
|
||||
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "range=$after" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
- name: Run leak-guard
|
||||
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
|
||||
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@v5
|
||||
- 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@v5
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv freemkv-unlock
|
||||
patch: libfreemkv
|
||||
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@v5
|
||||
- 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@v5
|
||||
- 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@v5
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
@@ -1,5 +1,4 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
|
||||
# session scratch — never track (may contain RE breadcrumbs)
|
||||
scratch/
|
||||
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
# Changelog
|
||||
|
||||
## [1.7.6] — UNRELEASED
|
||||
|
||||
### 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
+1244
File diff suppressed because it is too large
Load Diff
+28
-7
@@ -1,21 +1,31 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.0.0-rc.1"
|
||||
version = "1.7.6"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
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 = "1.0.0-rc.1"
|
||||
# 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.7"
|
||||
# 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"
|
||||
@@ -23,3 +33,14 @@ tracing = "0.1"
|
||||
[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.7.4" }
|
||||
|
||||
[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,14 @@
|
||||
[](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.
|
||||
|
||||
## 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.
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# leak-guard.sh — self-contained public-repo leak gate.
|
||||
#
|
||||
# This is the LAST line of defense in CI. It is intentionally self-contained:
|
||||
# public CI cannot reach the private tooling, so this script encodes ONLY the
|
||||
# generic net — internal infrastructure references, agent-context files, and
|
||||
# AI-attribution in commit messages. It deliberately contains NO project-
|
||||
# specific reverse-engineering vocabulary (those words would themselves be a
|
||||
# leak). The richer private scanner stays private.
|
||||
#
|
||||
# Fails (exit 1) if any of the following appear in the repo:
|
||||
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
|
||||
# 2. tracked file content matching the internal-infra net,
|
||||
# 3. a commit message (in the given range) with AI attribution.
|
||||
#
|
||||
# Usage:
|
||||
# leak-guard.sh [<commit-range>]
|
||||
# <commit-range> optional git rev-list range to scan commit messages
|
||||
# (e.g. "abc..def"). If omitted, commit-message scan is
|
||||
# skipped (path + content checks always run).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Absolute path to this script, resolved before any cd, so we can exclude it
|
||||
# from the content scan (it necessarily contains the detection patterns).
|
||||
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
||||
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO"
|
||||
|
||||
fail=0
|
||||
note() { printf ' ✗ %s\n' "$1"; fail=1; }
|
||||
|
||||
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
|
||||
# the patterns themselves must not name any org-specific identifier (doing so
|
||||
# would itself leak the infra they guard). We catch the leak *class*:
|
||||
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
|
||||
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
|
||||
# - docker.internal.
|
||||
# The full org-specific net (literal hostnames, service names, repo paths,
|
||||
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
|
||||
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
|
||||
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
|
||||
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
|
||||
# path). This names NO specific user — it matches the leak *class* (any home
|
||||
# path), so the pattern itself reveals nothing org- or person-specific. A real
|
||||
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
|
||||
# this regardless of whose machine it came from. The username segment is a
|
||||
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
|
||||
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
|
||||
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
|
||||
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
|
||||
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
|
||||
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
|
||||
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
|
||||
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
|
||||
|
||||
echo "── leak-guard: tracked agent-context paths ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in
|
||||
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
|
||||
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
|
||||
esac
|
||||
done < <(git ls-files)
|
||||
|
||||
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
|
||||
# an argument (not interpolated into a //) so metacharacters like the "/" in a
|
||||
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
|
||||
# don't abort the scan.
|
||||
pcre_matches() {
|
||||
perl -e '
|
||||
my ($file, $re) = @ARGV;
|
||||
open(my $fh, "<:raw", $file) or exit 0;
|
||||
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
|
||||
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
|
||||
' "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# This script's own source necessarily contains the detection patterns (e.g.
|
||||
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
|
||||
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
|
||||
|
||||
echo "── leak-guard: internal-infra references in tracked files ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
|
||||
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "internal-infra reference: $f:$hit"
|
||||
done < <(pcre_matches "$f" "$INFRA_RE")
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
|
||||
done < <(pcre_matches "$f" "$HOMEPATH_RE")
|
||||
done < <(git ls-files)
|
||||
|
||||
RANGE="${1:-}"
|
||||
if [ -n "$RANGE" ]; then
|
||||
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
|
||||
while IFS= read -r sha; do
|
||||
[ -z "$sha" ] && continue
|
||||
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
|
||||
# Pass the pattern as an argument (not interpolated into a //) so the
|
||||
# lookbehind char class and "/" don't break the regex.
|
||||
hit="$(printf '%s' "$msg" | perl -e '
|
||||
my $re = $ARGV[0]; my $rx = qr/$re/i;
|
||||
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
|
||||
' "$ATTR_RE" | head -1 || true)"
|
||||
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
|
||||
done < <(git rev-list "$RANGE" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ leak-guard: clean"
|
||||
+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"]
|
||||
@@ -0,0 +1,159 @@
|
||||
# keydb_format.rs — design notes
|
||||
|
||||
## Full parser API retained on purpose
|
||||
|
||||
The parser is copied verbatim, so it carries the full KeyDb/DiscEntry API
|
||||
even though this crate's consumer (`keydb.rs`) only exercises a subset
|
||||
(`load`, `find_disc`, `iter_disc_entries`, and the public fields read by
|
||||
`KeydbSource::unit_keys_from` / `KeydbSource::host_certs`). NOTE: an earlier
|
||||
revision of this comment cited a `candidates_from` function — no such
|
||||
function has ever existed in this crate; the real consumer is
|
||||
`KeydbSource::unit_keys_from` (keydb.rs), which reads `unit_keys`, `vuk`,
|
||||
`media_key` AND `vid` (`vid` is load-bearing for the MK+VID derivation the
|
||||
kat_c / kat_d KATs pin — it is NOT unused). `DiscEntry::title` is likewise
|
||||
read — by `to_keydb_cfg` and the redacting `Debug` impl — so it is not
|
||||
unused either. The genuinely unused items — `empty` and `find_vuk` — are
|
||||
part of the faithful copy and are retained rather than pruned; allow
|
||||
dead_code so the byte-for-byte copy compiles clean without diverging from
|
||||
the libfreemkv original.
|
||||
|
||||
## ParseStats — what `KeyDb::parse` threw away
|
||||
|
||||
The parser is fed a THIRD-PARTY file: a mirror can serve a truncated or
|
||||
byte-shifted download that is still valid UTF-8, still contains enough
|
||||
recognisable rows to pass `KeydbSource::save`'s "at least one entry" check,
|
||||
and is then atomically committed as the new keydb — after which every disc
|
||||
past the corruption point silently resolves nothing. Before this counter,
|
||||
each malformed `| DK` / `| PK` / `| HC` / `| HC2` / `0x…` row was dropped by
|
||||
an `if let Some(..) = .. { }` with no `else`, no counter and no log, so that
|
||||
file was indistinguishable from a healthy one at every layer above.
|
||||
|
||||
The counts are logged ONCE per parse (a per-line log would be 181k lines on
|
||||
a real keydb, which is why the rate limit is "one summary", never "hide the
|
||||
count").
|
||||
|
||||
## `KeyDb::load_counted` — same-descriptor stamp + parse
|
||||
|
||||
Two callers need this seam, both in `crate::KeydbSource`:
|
||||
|
||||
* The identity stamp and the bytes must come from the SAME descriptor.
|
||||
Stat-the-path-then-load-the-path is two independent resolutions of one
|
||||
name, so an atomic rename landing between them stored a stamp
|
||||
describing one file next to the parsed contents of another — and the
|
||||
cache then served that pairing until the stamp changed again. `fstat`
|
||||
on the open handle cannot be pointed at a different inode.
|
||||
* The rejection counts have to be RETAINED, not just logged, so a cache
|
||||
hit can re-emit them.
|
||||
|
||||
## `to_keydb_cfg` — trailing-comment placement
|
||||
|
||||
The trailing `; <comment>` (MKB version / volume size / UHD) is emitted
|
||||
ONLY after a `U` (unit-keys) field — that is the one place the parser
|
||||
splits the value on `;`. Gluing a comment onto an `M`/`I`/`V` value would
|
||||
make `parse_hex16` reject the whole field, so a comment-bearing entry that
|
||||
has no unit keys drops its comment (keys always survive; the metadata is a
|
||||
derivable hint). Real per-disc rows that carry metadata also carry keys.
|
||||
|
||||
## `parse_disc_entry` title handling
|
||||
|
||||
Title = everything between `= ` and the first ` | ` field (or the trailing
|
||||
`;` comment), kept VERBATIM (trimmed). This is a FAITHFUL copy of the keydb
|
||||
title, so it must round-trip exactly: a previous version extracted a `(...)`
|
||||
substring as a "display title", but that TRUNCATED real titles that
|
||||
legitimately contain parentheses ("Lawrence of Arabia (Restored Version) –
|
||||
Disc 2 …" → "Restored Version") and broke serialize→parse idempotence.
|
||||
Display prettification, if wanted, belongs in the title-display layer, NOT
|
||||
this codec.
|
||||
|
||||
## Real-data test notes
|
||||
|
||||
`to_keydb_cfg_is_idempotent_on_real_keydb`: parse the full keydb -> serialize
|
||||
(S1) -> parse S1 -> serialize again (S2). S1 MUST equal S2 byte-for-byte.
|
||||
This is the right invariant: a raw keydb.cfg has formatting variance
|
||||
(whitespace, optional fields, comment style) that our CANONICAL serializer
|
||||
normalizes, so `text == to_keydb_cfg` is NOT expected — but once normalized,
|
||||
a re-load+re-serialize must be stable. Idempotence here proves `parse` is
|
||||
lossless on its own output and `to_keydb_cfg` is deterministic. Also asserts
|
||||
no rows are dropped. It used to carry no `#[ignore]` and `return` early when
|
||||
`KEYDB_PATH` was unset — so CI ran it, asserted NOTHING about 181k-entry
|
||||
behaviour, and reported green. A skip has to be visible in the test output
|
||||
to mean anything, and an ignored test that is RUN without its fixture must
|
||||
fail loudly rather than silently pass.
|
||||
|
||||
`real_keydb_path`: the old pattern (`match keydb_path() { Some(p) => p, None
|
||||
=> return }`) on a non-ignored test is the worst of both worlds: CI ran the
|
||||
test, asserted nothing, and reported success. These tests are `#[ignore]`d,
|
||||
so running one at all is an explicit request — and an explicit request with
|
||||
no fixture must fail, not quietly pass.
|
||||
|
||||
## `load_counted_reads_the_handle_not_the_path` test
|
||||
|
||||
`load_counted` must read the HANDLE it was given, never re-resolve the
|
||||
path. `KeydbSource::cached_db` stamps the open file with `fstat` and then
|
||||
parses from that same descriptor precisely so the recorded identity and
|
||||
the parsed bytes cannot describe two different files; the moment this
|
||||
function re-opens the path, that guarantee is gone and an atomic rename
|
||||
landing in between stores one file's stamp beside another file's keys —
|
||||
a mismatch the cache then serves as if it were valid.
|
||||
|
||||
The race itself cannot be scheduled deterministically in-process, so the
|
||||
SEAM is what is pinned: with the path already replaced, the handle's
|
||||
contents must still win. Catches the mutation that restores an internal
|
||||
`File::open(path)`.
|
||||
|
||||
## `host_cert_debug_is_redacted_including_the_sibling_wrapper` test
|
||||
|
||||
The SIBLING type. `KeyDb`'s hand-written `Debug` prints `host_certs_len`, so
|
||||
it protects the whole-db rendering and nothing else — `KeyDb::host_certs` is
|
||||
a public field, and `{:?}` on ONE element of it walks a completely different
|
||||
code path: `KeydbHostCert`'s derive, then `HostCert`'s impl. That path
|
||||
carries the AACS host PRIVATE key, the only key material in the crate that
|
||||
authenticates US rather than opening a disc.
|
||||
|
||||
It is safe today only because `libfreemkv::HostCert` hand-writes a redacting
|
||||
`Debug` (`aacs::types`, `private_key: "<redacted>"` + `certificate_len`) —
|
||||
an EXTERNAL guarantee this crate silently inherits through the derive, with
|
||||
nothing on this side pinning it. This test pins it: it catches a libfreemkv
|
||||
upgrade that swaps that impl for `#[derive(Debug)]`, which would leak the
|
||||
host private key out of this crate's public API with no diff in this
|
||||
repository at all.
|
||||
|
||||
Rendered three ways because callers reach the cert three ways: the whole
|
||||
`Vec` (`db.host_certs`), one element, and the inner `HostCert` a caller
|
||||
gets back from `KeyDb::host_certs(mkb)`.
|
||||
|
||||
## KEYDB-parser integration tests relocated from libfreemkv
|
||||
|
||||
These exercise the parser (`KeyDb::load`) end-to-end against a real
|
||||
keydb.cfg and feed its material into libfreemkv's AACS crypto
|
||||
(`derive_vuk`, then `decrypt_unit` + `is_clean_ts`). They live here now
|
||||
that the parser lives here. All are `KEYDB_PATH`-env-gated and no-op in CI
|
||||
when the env is unset; they must still COMPILE.
|
||||
|
||||
## `test_decrypt_real_unit` — no-decrypt outcome
|
||||
|
||||
Reaching the fallback path is the EXPECTED outcome for the AACS 2.0 (BEE)
|
||||
sample this test was written against: the unit is still bus-encrypted, so
|
||||
no unit key alone can open it. That makes "no key worked" an unusable
|
||||
assertion on its own — which is exactly why this test used to end in an
|
||||
`eprintln!` and fall off the end, i.e. pass unconditionally. A no-op
|
||||
`decrypt_unit`, an `is_clean` stuck at `false`, or a parser that yielded
|
||||
zero keys would all have produced this same clean exit.
|
||||
|
||||
What CAN be asserted regardless of the sample: the pipeline actually ran,
|
||||
and the decrypt primitive actually transformed the bytes.
|
||||
|
||||
## Hand-written redacting `Debug`
|
||||
|
||||
Key material must never reach a log, a panic message, or a bug report. Both
|
||||
`KeyDb` and `DiscEntry` carry raw AACS key bytes, and both are public API, so
|
||||
a `#[derive(Debug)]` on either is a leak of the crown jewels: `{:?}` on a
|
||||
loaded `KeyDb` printed every processing key plus every disc's Media Key,
|
||||
VID, VUK and Unit Keys. libfreemkv's equivalent types (`aacs::types`:
|
||||
`DeviceKey`, `HostCert`, `Vid`, `MediaKey`, `Vuk`, `ProcessingKey`,
|
||||
`UnitKey`, its own `DiscEntry`) all hand-write a redacting `Debug` for
|
||||
exactly this reason; this crate's copy of the parser dropped that
|
||||
convention when it was relocated. These restore it, field-for-field in the
|
||||
same style (`<redacted>` for key bytes, a `_len` for key-bearing
|
||||
collections). Pinned by `keydb_debug_is_redacted` /
|
||||
`disc_entry_debug_is_redacted`.
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
# `keydb.rs` design notes
|
||||
|
||||
Long-form rationale moved out of `src/keydb.rs` doc comments to satisfy the
|
||||
repo's comment-guard line caps. Each section is pointed to from the file by a
|
||||
one-line `// See docs/keydb.md#<anchor>` comment.
|
||||
|
||||
## candidate-order
|
||||
|
||||
Full derivation path this source walks, cheapest-first:
|
||||
|
||||
1. per-disc **Unit Keys** (hash hit) → returned terminal, no derivation.
|
||||
2. per-disc **VUK** (hash hit) → `uks_from_vuk` over the disc's encrypted
|
||||
title keys.
|
||||
3. a **Media Key**, then `derive_vuk` → `uks_from_vuk`. The MK comes from, in
|
||||
order: the disc's stored MK (hash hit); the keydb's **Processing Key**
|
||||
pool walked against THIS disc's MKB via `derive_media_key_from_pk`; or the
|
||||
device-key pool via `derive_media_key_from_dk`. The PK and DK pools
|
||||
resolve the Media Key WITHOUT a VID; the final `derive_vuk` still needs
|
||||
one. The VID is the unlocker's physical VID (`ResolveCtx::vid`) when
|
||||
present, else the keydb entry's OWN stored VID (the `I` field, `vid`) for
|
||||
the non-physical / ISO path. With no VID from either source the MK path
|
||||
cannot complete — return nothing.
|
||||
|
||||
The cross-disc MK-pool brute (trying OTHER discs' stored media keys against
|
||||
this disc) stays RETIRED: every MK path here is anchored to the matched
|
||||
disc's own MKB or stored material.
|
||||
|
||||
The library still OWNS the crypto; this source owns only which primitive to
|
||||
call with which material. Returning an empty `Vec` is a genuine "no key for
|
||||
this disc here".
|
||||
|
||||
## file-identity
|
||||
|
||||
`(len, modified)` ALONE IS NOT AN IDENTITY. mtime is stored at whole-second
|
||||
resolution on HFS+, on many container overlays and on most network
|
||||
filesystems, so a writer that replaces `keydb.cfg` within the same second
|
||||
with a file of the same byte length leaves `(len, modified)` bit-identical —
|
||||
and the cache then serves the superseded parse for the rest of the process's
|
||||
life. For a key database that is not a stale page: it is serving retired or
|
||||
since-corrected keys while reporting success. The writers that hit this are
|
||||
real and named in this crate's own docs: the daily-refresh job, a second
|
||||
freemkv process, an operator's editor, a sync tool.
|
||||
|
||||
Two independent discriminators close it:
|
||||
|
||||
1. `dev` + `ino`. Every writer that publishes ATOMICALLY (temp file, fsync,
|
||||
rename — what `KeydbSource::save` does, and what any correct publisher
|
||||
does) creates a NEW inode, so the stamp differs immediately and
|
||||
unconditionally, whatever the clock or the mtime resolution did. Zero on
|
||||
platforms without them (see `FileStamp::of`), which costs nothing: rule 2
|
||||
stands alone.
|
||||
2. A freshness rule on the cache entry — `CacheEntry::is_settled` — which
|
||||
covers the residual in-place rewrite that reuses the inode.
|
||||
|
||||
What this still does NOT catch: a writer that FORGES the timestamp, i.e.
|
||||
restores an mtime equal to the cached one (`touch -t`, `rsync --times`, a
|
||||
tar/backup restore) into the SAME inode with the SAME length. Nothing short
|
||||
of hashing the content can see that, and hashing 62 MiB per lookup is
|
||||
precisely the cost the cache exists to remove (~141 ms × ≥3 loads per rip).
|
||||
`KeydbSource::save` therefore keeps invalidating the cache directly, so the
|
||||
one write path this crate owns never depends on any of the above.
|
||||
|
||||
## settle-proof
|
||||
|
||||
Whether a cache entry's stamp can be TRUSTED to change if the file changes.
|
||||
|
||||
The proof, for a filesystem storing mtime at granularity `G`: suppose this
|
||||
entry was stamped at `stamped_at` with `stamped_at - modified >= G`, and some
|
||||
writer later rewrites the file in place at wall time `T > stamped_at`. The
|
||||
recorded mtime `m'` of that write is within one granule of `T`, so
|
||||
`m' >= T - G > stamped_at - G >= modified`. Hence `m' != modified` and the
|
||||
stamp DOES change. The ambiguous window is therefore exactly the one where
|
||||
the cached entry was stamped less than `G` after the file's own mtime — i.e.
|
||||
we looked at the file while it was still "hot" — and in that window the
|
||||
entry is refused and the file is re-read. Once the file has been quiet for
|
||||
`G`, its entry settles and the cache is fully effective again.
|
||||
|
||||
Cost: after every keydb write, lookups re-parse for up to `G` (2 s). The
|
||||
published keydb is rewritten daily, so that is ~2 s of re-parsing per day
|
||||
against a cache that saves ~420 ms per rip.
|
||||
|
||||
`modified: None` (a filesystem that reports no mtime) never settles: the only
|
||||
two discriminators left would be size and inode, so the entry is re-read
|
||||
every time rather than being trusted on a weaker basis.
|
||||
|
||||
CLOCK CAVEAT: `stamped_at` is the local clock and `modified` is the
|
||||
filesystem's. On a network share whose server clock runs AHEAD of the
|
||||
client's by more than `G`, entries never settle (safe: more re-parsing). A
|
||||
server clock BEHIND the client's makes an entry settle early, which re-opens
|
||||
the same-second window it closes — the inode rule (1) is what covers the
|
||||
realistic writers there.
|
||||
|
||||
## keydbsource-cache
|
||||
|
||||
`KeydbSource` caches the parsed database behind the file's identity stamp.
|
||||
The published keydb is ~62 MiB / ~181k entries and a single AACS-cert rip
|
||||
parses it repeatedly — `host_certs()` for the drive handshake (freemkv's
|
||||
`pipe.rs`/`engine.rs`, autorip's `keysource.rs` each build their own source),
|
||||
then the trait `host_certs(mkb)` during resolution, then `get_unit_keys` —
|
||||
and every one of those calls used to re-read all 62 MiB from disk and
|
||||
re-parse it from scratch, because the struct held nothing but a `PathBuf`.
|
||||
|
||||
A keydb replaced underneath this source is picked up on the next call, with
|
||||
ONE stated exception: a writer that rewrites the file in place, into the
|
||||
same inode, at the same byte length, having restored the previous mtime
|
||||
exactly (`touch -t` / `rsync --times` / a backup restore). Everything else —
|
||||
any atomic rename, any length change, any real clock advance — changes the
|
||||
stamp. `FileStamp` and `CacheEntry::is_settled` carry the full argument and
|
||||
the clock caveats; do not weaken either without re-reading them. (The
|
||||
earlier wording here — "an externally replaced keydb is still picked up" —
|
||||
was simply false for a same-second, same-length replacement, which is the
|
||||
common shape of a daily-refresh job on a whole-second-mtime filesystem.)
|
||||
|
||||
MEMORY RESIDENCY (accepted, and deliberately recorded rather than fixed
|
||||
here): a hit keeps the entire parsed keydb — every disc's Media Key / VID /
|
||||
VUK / Unit Keys, the processing- and device-key pools, and the host-cert
|
||||
PRIVATE keys — resident for the process's life, where the pre-cache code
|
||||
held one parse transiently. Nothing in this crate zeroizes on drop, so that
|
||||
material was always exposed to a core dump or a swapped page; the cache
|
||||
widens the WINDOW, it does not create the class. Closing it properly means
|
||||
zeroize-on-drop across `KeyDb`, `DiscEntry` and libfreemkv's `aacs::types`
|
||||
(which owns most of the buffers) — a cross-crate change, tracked separately,
|
||||
NOT smuggled in behind a cache fix.
|
||||
|
||||
## cached-db-one-open
|
||||
|
||||
Errors are the same ones `KeyDb::load` produces, unchanged: a missing file
|
||||
is `NotFound` (the documented benign case), an over-cap or non-UTF-8 file is
|
||||
`InvalidData`. The stamp costs an `fstat`, not a 62 MiB read.
|
||||
|
||||
ONE OPEN, ONE IDENTITY. The file is opened once and both the stamp (`fstat`
|
||||
on that handle) and, on a miss, the bytes come from it. The previous shape —
|
||||
`std::fs::metadata(path)` and then a separate `KeyDb::load(path)` — resolved
|
||||
the path TWICE with no atomicity between them, so a rename landing in the
|
||||
gap cached the new file's stamp beside the old file's contents (or the
|
||||
reverse); the pairing was then served, looking perfectly valid, until the
|
||||
stamp changed again. The stamp is read BEFORE the bytes on purpose: a write
|
||||
racing the read then leaves the OLD stamp cached, which the next call
|
||||
detects, whereas stamping afterwards would file torn content under a
|
||||
fresh-looking identity and pin it.
|
||||
|
||||
## cached-db-reemit
|
||||
|
||||
Re-emit the parser's rejection summary on EVERY hit, not just on the parse
|
||||
that produced it. `KeyDb::parse` is what logs it, and a hit skips `parse`
|
||||
entirely — so a daemon that cached a corrupt keydb logged its rejected-row
|
||||
counts exactly once, at whatever moment it first read the file, and then
|
||||
served that same damaged database to every later rip in silence. Repetition
|
||||
is the correct failure mode here: the line is emitted only when the file
|
||||
really does have rejected or duplicate rows, and a warning an operator can
|
||||
still see on the tenth rip is worth more than one they had to catch on the
|
||||
first.
|
||||
|
||||
## load-failure
|
||||
|
||||
A MISSING keydb is the documented benign case: the app may simply not have
|
||||
one, another source may hold the key, and "no keydb" is genuinely "no key
|
||||
here". Everything else is NOT that: `InvalidData` is a keydb over the
|
||||
128 MiB cap or one that is not valid UTF-8 — i.e. corrupt, truncated, or a
|
||||
half-finished download — and a permission/IO failure means the file could
|
||||
not be read at all. Reporting either as an empty result made a broken keydb
|
||||
indistinguishable from "this disc has no key" (the same conflation as the
|
||||
seven-hour 502), with no log to notice it by. Both are now logged AND
|
||||
surfaced as errors so the resolver reports a source failure instead of
|
||||
`E7022`.
|
||||
|
||||
## save-mirror-parse
|
||||
|
||||
Mirror `KeyDb::parse`'s disc-entry rule EXACTLY by CALLING it
|
||||
(`is_disc_entry_line`), so `save()` never validates + persists content that
|
||||
parses to zero usable entries (e.g. a stray "0xDEADBEEF" comment line) and
|
||||
the two can no longer drift — they did drift once already, on the `0X` case
|
||||
rule.
|
||||
|
||||
## unit-keys-from
|
||||
|
||||
CPS-unit numbering: a returned `UnitKey::idx` is the POSITIONAL index
|
||||
libfreemkv's `resolve_and_apply` turns into the canonical CPS-unit number
|
||||
`idx + 1`. For the terminal per-disc unit-key path we therefore map the
|
||||
keydb's stored CPS number `num` to `idx = num - 1`, so the committed number
|
||||
is byte-identical to the keydb's `num` (and to what the OLD
|
||||
`Key::Unit(entry.unit_keys)` path committed). For the VUK / MK paths the
|
||||
boil primitive already yields 0-based positional indices, matching
|
||||
`parse_unit_key_ro`'s `(i + 1)` after the resolver's `+ 1`.
|
||||
|
||||
## unit-keys-per-disc-hit
|
||||
|
||||
Per-disc hit (most specific). `find_disc` normalizes the hash form. Without
|
||||
a matched entry this keydb has no per-disc material (Unit Keys / VUK / Media
|
||||
Key / stored VID) to anchor a derivation for the disc, so it resolves
|
||||
nothing. (The PK and DK pools are global, but the cross-disc MK-pool brute —
|
||||
trying OTHER discs' media keys against this disc — stays retired; a PK/DK
|
||||
pool only ever resolves a disc reached through its own matched entry below.)
|
||||
|
||||
## unit-keys-union
|
||||
|
||||
UNION every source of terminal keys, then dedup — never first-hit. A stored
|
||||
`unit_keys` list can be PARTIAL (the key-import tool only ever sampled the
|
||||
CPS units reachable from a playlist, so an orphan unit's key may be
|
||||
missing), while the per-disc VUK boils EVERY declared CPS unit. Taking the
|
||||
stored list alone (the old return-at-first-path) would shadow the VUK and
|
||||
silently drop the orphan unit's key. So gather both and keep a unique-by-key
|
||||
list: the read path tries every key per unit, so an extra or stale key is
|
||||
harmless — only a MISSING key hurts.
|
||||
|
||||
## unit-keys-vuk-or-mk
|
||||
|
||||
2. Per-disc VUK — one step, no VID needed; boils ALL declared units.
|
||||
3. Else a Media Key path (stored MK / PK pool / DK pool) → VUK → all
|
||||
declared units. The MK itself carries no VID, but the final `vuk_from_mk`
|
||||
needs one: physical (unlocker) VID first, else the entry's stored VID
|
||||
(`I` field), else cannot derive. Either branch yields the COMPLETE
|
||||
declared set, so we take the first that resolves (VUK preferred —
|
||||
cheapest).
|
||||
|
||||
## write-atomic
|
||||
|
||||
keydb.cfg is the single source of AACS truth, and save/update run
|
||||
unattended (first-boot download + daily-refresh thread, with a container
|
||||
restart on every release). A bare in-place `fs::write` truncates the file
|
||||
before writing, so a SIGKILL (docker stop's grace window), OOM-kill, power
|
||||
loss, or ENOSPC mid-write would leave the keydb half-written — the prior
|
||||
good copy already gone. A truncated keydb doesn't error at write time; it
|
||||
silently breaks key resolution on every later AACS rip. Writing to a temp
|
||||
file then renaming (POSIX rename is atomic within a filesystem) means an
|
||||
interrupted update leaves the previous keydb fully intact.
|
||||
|
||||
The fsync MUST succeed before the rename: a `sync_all` failure (ENOSPC,
|
||||
ESTALE on the bind-mounted volume) means the kernel never guaranteed the
|
||||
bytes reached stable storage, so publishing them via rename would defeat
|
||||
crash-safety. The temp name is unique per call (pid + monotonic counter) so
|
||||
a concurrent update can't share a fixed temp path and rename a mangled file
|
||||
over the keydb.
|
||||
|
||||
## get-unit-keys-trait
|
||||
|
||||
A MISSING keydb is not an error — it simply yields no keys (another source
|
||||
may have them), the same as the library's own loader. A keydb that exists
|
||||
but cannot be USED (over the size cap, not UTF-8, unreadable) is a source
|
||||
failure and returns `Err`: it says nothing about whether this disc has a
|
||||
key, and reporting it as an empty result is the same looks-like-success
|
||||
failure as a 502 reported as `E7022`.
|
||||
|
||||
The keydb carries no AACS 2.1 forensic index keys today, so it does not
|
||||
override `get_fmts_indexes` — the default (empty) opts it out, and an FMTS
|
||||
disc's forensic set comes from the online source.
|
||||
|
||||
## host-certs-trait
|
||||
|
||||
Expose the keydb's host certs through the trait — the OEM/AACS cert-auth
|
||||
route collects them across every source via this method. Wires the disc's
|
||||
MKB generation through for revocation filtering (the keydb parser's
|
||||
`; Revoked in MKBv<N>` annotation): a cert revoked at generation `R` is
|
||||
withheld once the disc's generation reaches `R`.
|
||||
|
||||
## test-pinned-mtime
|
||||
|
||||
Force a file's mtime to `PINNED_MTIME`, so two different files can be made
|
||||
bit-identical to a `(len, mtime)` stamp on purpose. The cache tests that
|
||||
matter all depend on FORCING the indistinguishable case rather than hoping
|
||||
the filesystem produces it: on APFS (nanosecond mtimes) a same-second
|
||||
replacement is otherwise detected by mtime alone, and a test that relies on
|
||||
that passes with the fix reverted — which is worth nothing.
|
||||
|
||||
## test-committed
|
||||
|
||||
The committed `(cps, key)` pairs libfreemkv's `resolve_and_apply` derives
|
||||
from a source's Unit Keys: positional `idx` → canonical CPS number
|
||||
`idx + 1`. The KATs compare against THIS to prove byte-identical parity with
|
||||
the OLD `Key::Unit` / resolver-derived commit.
|
||||
|
||||
## test-mock-ctx-stub
|
||||
|
||||
`MockCtx` implements the full `ResolveCtx` trait so it can stand in for a
|
||||
real disc, but `KeydbSource` itself never calls `title()`/`samples()` (those
|
||||
matter to the online source, not the local keydb). Exercise them directly
|
||||
so the mock's trait surface is proven correct too.
|
||||
|
||||
## test-kat-a
|
||||
|
||||
Here `enc_title_keys` is empty, so the VUK can't derive anything — only the
|
||||
stored list contributes, and it commits byte-identically to the stored
|
||||
`(cps, key)` pairs.
|
||||
|
||||
## test-orphan-union
|
||||
|
||||
Orphan-unit completeness (the real keydb bug): an entry stores only `uk1`
|
||||
(the key-import tool sampled one reachable CPS unit) but ALSO carries the
|
||||
VUK, which boils BOTH declared units. The old return-at-first-path handed
|
||||
back just `[uk1]`, shadowing the VUK and silently dropping the orphan unit.
|
||||
The union must return BOTH — the stored uk1 AND the VUK-derived second unit.
|
||||
|
||||
## test-kat-c
|
||||
|
||||
A hash hit with a Media Key and a physical VID (from the unlocker) derives
|
||||
`MK → VUK → UK`. The PHYSICAL VID must be used in preference to the keydb's
|
||||
stored VID — proven by giving the entry a DIFFERENT stored VID and showing
|
||||
the result tracks the physical one.
|
||||
|
||||
## test-kat-f
|
||||
|
||||
Owner decision #1 (AACS): a keydb Processing Key must be walked against the
|
||||
matched disc's own MKB to recover the Media Key, then driven down the full
|
||||
chain `PK → MK → VUK → UK`. The disc entry carries NO stored MK/VUK/UK — the
|
||||
only key material is a global `PK` row — and the result must be the real
|
||||
Unit Keys, byte-identical to deriving from the recovered MK.
|
||||
|
||||
The MKB + PK use a known-answer construction (a planted PK whose derived MK
|
||||
satisfies the synthetic verify record); the constants are precomputed AES
|
||||
vectors so this crate needs no AES primitive of its own. They mirror
|
||||
libfreemkv's `boil::mk_from_pk_drives_full_chain_to_uks` KAT.
|
||||
|
||||
## test-repeated-lookups
|
||||
|
||||
A single AACS-cert rip drives this source at least three times — the
|
||||
scan-options builder's inherent `host_certs()`, the trait `host_certs(mkb)`
|
||||
during the handshake, then `get_unit_keys` — and each call used to re-read
|
||||
and re-parse the WHOLE file, because `KeydbSource` held nothing but a
|
||||
`PathBuf`. Measured on the real 62 MiB / 184,860-entry keydb: ~140 ms per
|
||||
parse in `--release`, i.e. ~420 ms and ~186 MiB of file reads per rip,
|
||||
against ~1 µs for a cache hit.
|
||||
|
||||
Catches the mutation that drops the cache (every call re-parses: count 3)
|
||||
and the mutation that makes the cache unconditional (see the invalidation
|
||||
tests below).
|
||||
|
||||
The mtime is pinned into the PAST first, because a cache entry is only
|
||||
trusted once the file has been quiet for the mtime granularity
|
||||
(`CacheEntry::is_settled`) — a keydb written microseconds ago is
|
||||
deliberately re-read. Unix-only: pinning an mtime needs `touch -t` (no
|
||||
`filetime` dependency for a test).
|
||||
|
||||
## test-changed-keydb
|
||||
|
||||
The cache must not go stale: a keydb replaced UNDERNEATH the source (the
|
||||
daily-refresh thread writing through a different `KeydbSource`, or an
|
||||
operator dropping in a new file) has a different size/mtime stamp and must
|
||||
be re-read. Catches the mutation that caches on first load and never
|
||||
re-stats — which would pin a rip to a keydb deleted hours earlier.
|
||||
|
||||
## test-two-discriminators
|
||||
|
||||
The defect both of the inode/settle tests below pin: `(len, mtime)` is not a
|
||||
file identity. An external writer — the daily-refresh job, a second freemkv
|
||||
process, an editor, a sync tool — that replaces `keydb.cfg` inside one mtime
|
||||
granule with a file of the SAME length leaves that pair bit-identical, and
|
||||
the source then serves the superseded parse for the rest of the process's
|
||||
life: retired or since-corrected keys, reported as success.
|
||||
|
||||
Each test disables the OTHER discriminator through `mtime_granularity`, so
|
||||
neither can pass on the strength of the one it is not testing, and neither
|
||||
depends on the host filesystem's real mtime resolution (on APFS the naive
|
||||
scenario is not reproducible at all).
|
||||
|
||||
## test-inode-rename
|
||||
|
||||
A keydb replaced by ATOMIC RENAME — how every correct publisher, including
|
||||
this crate's own `save()`, installs a new file — must be re-read even when
|
||||
length and mtime are identical. `mtime_granularity: 0` makes every entry
|
||||
settle, so ONLY dev+ino can distinguish the two files here.
|
||||
|
||||
Catches the mutation that drops `dev`/`ino` from `FileStamp` (the original
|
||||
`(len, modified)` stamp): the source then keeps answering with the previous
|
||||
keydb's keys.
|
||||
|
||||
## test-inplace-rewrite
|
||||
|
||||
A keydb rewritten IN PLACE — same inode, same length, mtime forced back to
|
||||
the same value — must still be re-read while the cached entry is too young
|
||||
to trust. `mtime_granularity` is set to a decade so nothing ever settles,
|
||||
which is the same condition a real same-second, whole-second-mtime
|
||||
replacement creates and the only way to reproduce it deterministically on a
|
||||
nanosecond-mtime filesystem.
|
||||
|
||||
Catches the mutation that deletes the `is_settled` check from `cached_db`
|
||||
(every stamp match trusted unconditionally, which is what the original code
|
||||
did): dev, ino, len and mtime are ALL identical here, so without the
|
||||
freshness rule the source serves the old keys forever.
|
||||
|
||||
## test-corrupt-warns
|
||||
|
||||
`ParseStats::log` runs inside `KeyDb::parse`, and a cache hit skips `parse`
|
||||
— so once the cache landed, the rejected-row summary for a given file was
|
||||
emitted exactly ONCE for the entire life of the process, while that same
|
||||
damaged file went on being served to every subsequent rip. A long-running
|
||||
daemon (autorip) logs it at startup, the operator misses it in the startup
|
||||
noise or loses it to log rotation, and there is no second chance for as long
|
||||
as the process runs.
|
||||
|
||||
Catches the mutation that deletes the `emit_parse_stats` call from the
|
||||
cache-HIT arm of `cached_db`: warnings would drop to 1 while the file is
|
||||
still served three times.
|
||||
|
||||
## test-healthy-silent
|
||||
|
||||
Catches the mutation that makes `emit_parse_stats` unconditional (drops
|
||||
`ParseStats::log`'s empty-counts early return).
|
||||
|
||||
## test-fresh-not-trusted
|
||||
|
||||
Catches the mutation that widens the window to something unbounded (a
|
||||
granularity of hours would re-parse 62 MiB on every lookup all day) by
|
||||
pairing with `repeated_lookups_parse_the_keydb_once`, which proves the entry
|
||||
DOES settle once the mtime is in the past.
|
||||
|
||||
## test-save-invalidates
|
||||
|
||||
`save()` replaces the very file this source reads, so it drops the cached
|
||||
parse ITSELF rather than trusting the (size, mtime) stamp: a same-size
|
||||
rewrite that lands inside the filesystem's timestamp granularity is the one
|
||||
change the stamp cannot see — and it is exactly the change this crate
|
||||
performs (the daily-refresh thread re-saving a keydb through the source it
|
||||
then reads from).
|
||||
|
||||
The test forces that indistinguishable case rather than hoping for it: the
|
||||
file is written, stamped to a FIXED mtime, cached, re-saved with different
|
||||
keys of the SAME length, and stamped back to the same mtime. To the stamp
|
||||
the two files are identical, so only the explicit invalidation can produce
|
||||
the new key. Unix-only because pinning an mtime needs `touch -t` (no
|
||||
`filetime` dependency for one test).
|
||||
|
||||
## test-corrupt-is-error
|
||||
|
||||
`KeyDb::load` fails distinguishably for a keydb that is over the 128 MiB cap
|
||||
or not valid UTF-8 — a truncated download, a half-written file, a binary
|
||||
blob dropped in by mistake. Both used to collapse into `Ok(Vec::new())` with
|
||||
ZERO tracing, i.e. into the benign "no key for this disc" the resolver
|
||||
reports as `E7022` — the same looks-like-success failure as the seven-hour
|
||||
502. Only a MISSING file is genuinely benign.
|
||||
|
||||
Catches the mutation that restores `Err(_) => Ok(Vec::new())`.
|
||||
</content>
|
||||
@@ -0,0 +1,49 @@
|
||||
# freemkv-keysources: scope and design notes
|
||||
|
||||
## Mechanism vs. policy
|
||||
|
||||
Applications (autorip, the `freemkv` CLI) choose and order the key 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`.
|
||||
|
||||
Each source resolves a disc's terminal **Unit Keys** in one shot via
|
||||
`KeySource::get_unit_keys`, driving libfreemkv's boil-down crypto primitives
|
||||
for whatever level of material it holds. Compose several with `MultiSource`
|
||||
in the caller's chosen order.
|
||||
|
||||
Reading the encrypted content-sample units a key server validates on, and
|
||||
applying the resolved keys against a disc, is decryption *mechanism* — it
|
||||
lives in the library (`libfreemkv::resolve_and_apply`,
|
||||
`libfreemkv::read_encrypted_units`), not here.
|
||||
|
||||
## `MultiSource` failure semantics
|
||||
|
||||
`MultiSource::get_unit_keys` tries each inner source in order and returns the
|
||||
first non-empty Unit Key set (and `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. — 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.
|
||||
|
||||
**A source that could not ANSWER is not a source that answered "no key".**
|
||||
When no inner source produces keys, the composition returns `Err` if any
|
||||
inner source failed, and `Ok(empty)` only when every source genuinely
|
||||
answered and none held a key. This mirrors libfreemkv's own resolver
|
||||
(`keysource::drive_unit_keys`, which tracks an `errored` flag, and
|
||||
`source_failure`, which stamps the first failure onto the disc) — a
|
||||
composition that swallowed the `Err` would hand the resolver a clean
|
||||
`Ok(empty)`, and an outage would once again be reported to the operator as
|
||||
`E7022 No key source has a decryption key for this disc`.
|
||||
|
||||
## `first_non_empty` ordering rule
|
||||
|
||||
A source failure never blocks the chain (a later source is still tried, and
|
||||
a later success still wins outright, error or no error), but it is not
|
||||
forgotten either: with no keys anywhere, the FIRST failure is returned.
|
||||
First (not last) matches libfreemkv's `source_failure` rule, so 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.
|
||||
|
||||
`get` selects which trait method to drive, so the base and forensic paths
|
||||
share ONE implementation of this rule and cannot drift apart — the forensic
|
||||
half carried the identical bug.
|
||||
@@ -0,0 +1,13 @@
|
||||
# `MultiSource`: a source that could not answer is not a source that said no
|
||||
|
||||
`MultiSource` used to re-collapse the Ok/Err distinction `OnlineSource` was
|
||||
engineered to draw: `if let Ok(uks) = s.get_unit_keys(ctx)` discarded every
|
||||
`Err`, so a composition whose sources ALL failed returned `Ok(Vec::new())` —
|
||||
which the resolver reports as `E7022 No key source has a decryption key for
|
||||
this disc`. That is the seven-hour-502 incident reproduced one layer up.
|
||||
|
||||
The regression test `multi_source_reports_a_source_failure_instead_of_a_clean_no_key`
|
||||
is THE regression check, one layer up from `interpret_reply`: when no source
|
||||
holds a key AND a source could not answer, the composition must report the
|
||||
FAILURE, never a clean "no key". It catches the mutation that restores
|
||||
`if let Ok(uks) = ..` (which drops the `Err` and returns `Ok(empty)`).
|
||||
@@ -0,0 +1,39 @@
|
||||
# `src/online.rs` — `OnlineSource::agent` caching
|
||||
|
||||
## Why cache at all
|
||||
|
||||
`query` used to build a FRESH `ureq::Agent` every call, and an FMTS disc
|
||||
makes two calls per rip (`get_unit_keys` + `get_fmts_indexes`), so it paid
|
||||
two TLS handshakes to the same host for no reason — an agent owns the
|
||||
connection pool, and a discarded agent discards the pooled, already
|
||||
negotiated TLS connection with it.
|
||||
|
||||
## Security invariant
|
||||
|
||||
The agent is reused ONLY when the freshly resolved + guarded address SET is
|
||||
identical to the one the cached agent is pinned to. Every query still
|
||||
re-resolves and re-runs the SSRF guard — that is the whole anti-rebinding
|
||||
defence and is deliberately not cached; what is reused is the connection,
|
||||
and only to addresses just re-validated this call.
|
||||
|
||||
## Why the cache key is a sorted, deduped SET, not an ordered sequence
|
||||
|
||||
`to_socket_addrs` hands back whatever order the resolver felt like, and a
|
||||
round-robin keyserver reorders its A/AAAA records on essentially every
|
||||
lookup — an ordered `==` therefore missed the cache every single time
|
||||
against exactly the deployment that most needs it, silently degrading to
|
||||
the per-query TLS handshake this field exists to avoid.
|
||||
|
||||
Set equality is also the honest predicate: the security property being
|
||||
asserted is "these are the same validated addresses", which says nothing
|
||||
about their order. The agent itself stays pinned to the order it was BUILT
|
||||
with; that is safe because an equal set means every address it holds was
|
||||
re-resolved and re-guarded this call.
|
||||
|
||||
## `a_reordered_but_identical_address_set_reuses_the_agent`
|
||||
|
||||
Catches the mutation that drops the sort/dedup from `agent_for`'s key (the
|
||||
reversed order would then build a second agent) and, via the second half of
|
||||
the test, the mutation that "fixes" order-sensitivity by comparing only
|
||||
lengths or the first element — which would wrongly reuse an agent pinned to
|
||||
a DIFFERENT address.
|
||||
@@ -0,0 +1,41 @@
|
||||
# `src/online.rs` — `GuardFail` classification details
|
||||
|
||||
## Malformed-port authority
|
||||
|
||||
A `host:port` authority whose port is not a u16 is a TYPO in the operator's
|
||||
config — `https://example.com:notaport/keys`. This used to fall back to the
|
||||
WHOLE authority as the hostname (port text included), which of course never
|
||||
resolves, so the URL surfaced as `GuardFail::Unreachable` →
|
||||
`Err(KeyServiceUnavailable)`: a standing misconfiguration reported as a
|
||||
transient outage, the exact collapse `GuardFail`'s own doc says must not
|
||||
happen, because the two halves demand OPPOSITE operator actions (fix the URL
|
||||
vs. wait).
|
||||
|
||||
Reject it as `Config`, identical to the bracketed-IPv6 branch a few lines
|
||||
above, which already returns `Config` for an unparseable port. Silently
|
||||
substituting `default_port` would be worse still — it would ship the bearer
|
||||
token and key material to a port the operator never configured.
|
||||
|
||||
## Guard-blocked address (`Config` arm, `resolve_and_guard`)
|
||||
|
||||
Log THAT the URL was rejected, never WHY: the guard's message names the
|
||||
resolved address, and an internal address must not reach a log an operator
|
||||
may paste into a bug report. The static label is enough to separate
|
||||
"misconfigured/blocked key-service URL" from "this disc has no key", which
|
||||
is the only distinction the operator needs here.
|
||||
|
||||
`error!`, not `warn!`, and `Err`, not `Ok(empty)`. This arm used to return
|
||||
`Ok(Vec::new())`, which made the PERMANENT fault quieter than the transient
|
||||
one directly above it: the service was never asked, yet the composition was
|
||||
handed a clean empty and — with no other source holding the key — told the
|
||||
operator `E7022 no key for this disc`. Same lie as the seven-hour 502, from
|
||||
a config typo that never self-heals. A failure to ask is never evidence
|
||||
about the disc, so it leaves as `Err`; `MultiSource::first_non_empty` still
|
||||
lets a later source's real key win outright, so this cannot fail a rip that
|
||||
the local keydb could have served.
|
||||
|
||||
NOTE (reported upstream): libfreemkv has no `KeyServiceMisconfigured` code,
|
||||
so the permanent fault borrows the transient E7028. E7028's contract — "the
|
||||
source never got as far as answering the question" — is exactly true here;
|
||||
only its "transient, retry later" hint is wrong, and the `error!` log
|
||||
carries the correcting detail until a 70xx config code exists.
|
||||
@@ -0,0 +1,20 @@
|
||||
# `src/online.rs` tests — pre-flight guards in `query`
|
||||
|
||||
Each of the three guards (cleartext scheme, over-cap MKB, under-sampled)
|
||||
fires BEFORE any address is resolved and before anything is sent. None was
|
||||
exercised before these tests: a reordering that let the cleartext POST
|
||||
through would have shipped green, and that POST carries the bearer token
|
||||
plus base64 key material.
|
||||
|
||||
Their VERDICTS differ on purpose (see docs/online-query-guards.md): the
|
||||
cleartext-scheme guard is an operator fault and yields `Err`, while the two
|
||||
input-shaped guards yield `Ok(empty)` — this source has nothing for THIS
|
||||
disc, permanently, and calling that an outage would send the operator
|
||||
waiting for nothing.
|
||||
|
||||
The discriminator in all three: the configured host is `.test`, which RFC
|
||||
6761 guarantees never resolves. On the guarded path nothing resolves it, so
|
||||
the test touches no network and returns `Ok(empty)`. Remove the guard and
|
||||
control reaches `resolve_and_guard`, which fails as `Unreachable` ->
|
||||
`Err(KeyServiceUnavailable)` — a different Result, so the mutation cannot
|
||||
pass.
|
||||
@@ -0,0 +1,19 @@
|
||||
# `src/online.rs` — `OnlineSource::query` return contract
|
||||
|
||||
The return type draws THE distinction this source exists to draw:
|
||||
|
||||
* `Ok(non-empty)` — the service answered with a key.
|
||||
* `Ok(empty)` — the service ANSWERED and holds no key for this disc, or this
|
||||
source had nothing to ask with (no service configured, a misconfigured
|
||||
URL, an over-cap MKB, too few samples). A genuine miss; the resolver moves
|
||||
to the next source and `E7022` is the right verdict.
|
||||
* `Err(..)` — the service could not answer: unreachable, timed out, DNS
|
||||
failed, returned 5xx, rejected the token (401/403), rate-limited (429), or
|
||||
replied with something unreadable. NOTHING is known about whether a key
|
||||
exists. Collapsing this into `Ok(empty)` is the bug this signature fixes:
|
||||
a seven-hour run of HTTP 502s was reported to operators as
|
||||
`E7022 No key source has a decryption key for this disc`, and they went
|
||||
hunting for a VUK when the correct action was to wait.
|
||||
|
||||
`&self`: one-shot is the resolver's contract (each source's
|
||||
`get_unit_keys` is called once), so no per-call latch is needed.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `src/online.rs` — `OnlineSource::query` pre-flight guards
|
||||
|
||||
## Refusing cleartext (`http://`)
|
||||
|
||||
The request body always carries base64 AACS key material
|
||||
(inf/mkb/units/vid) and, when configured, a bearer token in a header — so an
|
||||
`http://` POST hands both to any on-path observer, and the token is
|
||||
replayable.
|
||||
|
||||
There is no legitimate plaintext deployment to preserve here: the address
|
||||
guard rejects loopback, RFC1918, ULA and link-local, so an `http://` URL can
|
||||
only ever reach the PUBLIC internet — the single worst case for cleartext. A
|
||||
self-hosted keyserver on a LAN is already impossible by that guard, whatever
|
||||
its scheme.
|
||||
|
||||
Still falls THROUGH to the next key source (a local keydb) — an `Err` here
|
||||
does not block the chain, because `MultiSource::first_non_empty` returns any
|
||||
later source's non-empty key set outright and only surfaces the failure
|
||||
when nothing anywhere resolved. What it does stop is the silent case: a
|
||||
cleartext URL is the same permanent operator fault as the address-guard
|
||||
rejection (see `GuardFail`), so it gets the same verdict. Returning
|
||||
`Ok(empty)` here made "we refused to ask" indistinguishable from "the
|
||||
service answered and has no key".
|
||||
|
||||
## The three-way skip/fault/miss line (MKB cap, sample count)
|
||||
|
||||
Written out after the `Config`-was-quieter-than-`Unreachable` bug, in three
|
||||
parts:
|
||||
|
||||
* NO URL AT ALL is not a fault: the operator did not configure an online
|
||||
source, so there is nothing to report. `Ok(empty)`, silently (the first
|
||||
guard in `query`).
|
||||
* A bad URL — wrong scheme, mistyped port, guard-blocked address — is an
|
||||
OPERATOR FAULT. Every disc is affected, nothing was asked, and the fix is
|
||||
to edit the config. That is `Err`.
|
||||
* An over-cap MKB or too few content samples is a property of THIS DISC's
|
||||
inputs, not a fault: the request cannot be *formed* for this disc, so this
|
||||
source has nothing for it and never will, whatever the operator does and
|
||||
however long they wait. Reporting that as `KeyServiceUnavailable` would
|
||||
send the operator waiting out an outage that does not exist — the same
|
||||
mislabelling in the opposite direction. These stay `Ok(empty)`, and stay
|
||||
logged.
|
||||
|
||||
Logging on the MKB-cap skip matters: a silent empty return there is
|
||||
indistinguishable from "no key", so the real cause is surfaced (the cap is
|
||||
64 MiB, far above any real trimmed MKB).
|
||||
|
||||
## `cleartext_http_url_is_refused_before_anything_is_sent`
|
||||
|
||||
Catches two mutations: sending the request anyway (the host is `.test` and
|
||||
would surface as `Unreachable`, but the assertion would still hold, which is
|
||||
why `too_few_samples_skips_the_request` guards the send-path ordering
|
||||
separately) and — the one this test was rewritten for — returning
|
||||
`Ok(Vec::new())`, which made a permanent cleartext misconfiguration
|
||||
indistinguishable from a genuine miss. A later source's real key still wins
|
||||
(`MultiSource::first_non_empty`), so refusing does not fail a rip the local
|
||||
keydb can serve.
|
||||
@@ -0,0 +1,44 @@
|
||||
# `src/online.rs` — SSRF guard
|
||||
|
||||
## Threat model
|
||||
|
||||
The keyserver URL is operator-supplied and the bearer token is confidential.
|
||||
After `validate_keyserver_url` checked the host at config time, an attacker
|
||||
who controls the keyserver's DNS can rebind it to 169.254.169.254 (cloud
|
||||
metadata) or an RFC1918 host in the window between validation and the actual
|
||||
POST, exfiltrating the key material and the Authorization token.
|
||||
|
||||
## Defence
|
||||
|
||||
Resolve the host once just before the POST, reject any blocked IP, and pin
|
||||
the ureq connection to those validated addresses so a subsequent DNS flip
|
||||
cannot redirect the request. Use `redirects(0)` so a public URL can't
|
||||
30x-redirect to an internal host.
|
||||
|
||||
## Resolver wiring pitfall (`PinnedResolver`)
|
||||
|
||||
ureq 3 replaced v2's resolver closure with the `Resolver` trait. The agent
|
||||
MUST be built through `Agent::with_parts` to take a custom one:
|
||||
`Agent::new_with_config` silently keeps the default resolver, which would
|
||||
send the request to live DNS and quietly reopen the rebinding window this
|
||||
module exists to close. That failure is invisible — it has no symptom short
|
||||
of an actual attack — so it is pinned by the test
|
||||
`hardened_agent_connects_to_the_pinned_address_not_dns`.
|
||||
|
||||
## Why that test is structured the way it is
|
||||
|
||||
`resolve_and_guard` VALIDATES addresses; `hardened_agent` PINS them, so a
|
||||
hostname cannot re-resolve to an internal address between validation and
|
||||
connection. The validation half is well covered by other tests — but every
|
||||
one of those still passes if the pinned resolver is never consulted, because
|
||||
none of them makes a connection. Nothing else in the file proves the agent
|
||||
honours the pin, and a mis-wired resolver fails OPEN: the connection quietly
|
||||
falls back to live DNS and the rebinding window this module exists to close
|
||||
is reopened, with no visible symptom.
|
||||
|
||||
The discriminator: pin the agent to a loopback listener the test owns, then
|
||||
ask it for a host that CANNOT resolve — `.test` is reserved by RFC 6761 and
|
||||
never resolves in the public DNS. Only a consulted resolver can turn that
|
||||
name into a connection; a fallback to live DNS fails instead. No network is
|
||||
touched, and `query` (whose guard blocks loopback by design) is not
|
||||
involved — this drives `hardened_agent` directly.
|
||||
@@ -0,0 +1,13 @@
|
||||
# `keydb.cfg` path policy (`src/paths.rs`)
|
||||
|
||||
Key-path policy belongs with the key sources (this crate), not the library:
|
||||
libfreemkv is handed a path and reads it. The CLI/app asks here for the
|
||||
list of locations to *search* (first existing wins) and for the single
|
||||
*default* location to write to (e.g. `update-keys`/save).
|
||||
|
||||
freemkv is a portable, standalone binary: the `keydb.cfg` lives *next to*
|
||||
the executable — `<dir of current exe>/keydb.cfg` — and nowhere else. There
|
||||
is no OS-specific config-dir lookup (`%APPDATA%`, `%USERPROFILE%\.config`,
|
||||
`$XDG_CONFIG_HOME`, `$HOME/.config`). Drop the exe and its `keydb.cfg` in
|
||||
the same folder and it works. Callers needing a custom location pass
|
||||
`--keydb PATH`, which bypasses this module entirely.
|
||||
+1829
-135
File diff suppressed because it is too large
Load Diff
+2489
File diff suppressed because it is too large
Load Diff
+215
-129
@@ -1,160 +1,246 @@
|
||||
//! 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`].
|
||||
//! See docs/lib-scope.md for the mechanism-vs-policy split with libfreemkv.
|
||||
|
||||
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};
|
||||
pub use online::{
|
||||
DecodeReachability, MIN_SAMPLE_UNITS, OnlineSource, 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. See docs/lib-scope.md for the
|
||||
/// `Err`-vs-`Ok(empty)` failure contract.
|
||||
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. See docs/lib-scope.md.
|
||||
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);
|
||||
// Saturate: start_lba comes from attacker-controlled UDF/MPLS
|
||||
// extents; a malformed extent near u32::MAX would otherwise panic
|
||||
// (debug) or wrap to a wrong LBA (release). Matches the hardened
|
||||
// pattern in mux/disc.rs and verify.rs; an over-capacity LBA then
|
||||
// fails cleanly via the read_sectors().is_err() break below.
|
||||
let lba = ext
|
||||
.start_lba
|
||||
.saturating_add(unit.saturating_mul(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 (filtered at the given MKB
|
||||
/// generation). 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"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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());
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
}
|
||||
}
|
||||
+1587
-170
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
//! 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. See docs/paths.md for rationale.
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
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,514 @@
|
||||
//! 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: MKB-aware 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 (no-MKB, scan-options) form.
|
||||
assert_eq!(
|
||||
src.host_certs().len(),
|
||||
1,
|
||||
"inherent host_certs sees the HC row"
|
||||
);
|
||||
// Trait form now wires the MKB generation through (no revocation annotation
|
||||
// → always 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_ssrf() {
|
||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
|
||||
// SSRF blocking is IP-based, not scheme-gated: https://<private-IP> is rejected too.
|
||||
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_err());
|
||||
assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(validate_keyserver_url("http://[::1]:9000/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 ─
|
||||
// See docs/multi-source-failure-collapse.md — the Ok/Err collapse incident
|
||||
// this section's tests exist to catch.
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
// See docs/multi-source-failure-collapse.md — must report FAILURE, not a
|
||||
// clean "no key", when no source holds a key and one could not answer.
|
||||
#[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