mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,201 @@
|
||||
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:
|
||||
|
||||
# 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.sh cut the tags.
|
||||
# Green here meant "compiles against last month's library".
|
||||
#
|
||||
# The fix mirrors the developer's local layout. Every 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 gitignored here (library
|
||||
# convention), so there is nothing to lock against and it fails rather than
|
||||
# resolves. That is what was breaking this workflow. The lock that has to hold
|
||||
# is the one release.sh regenerates in each consuming binary.
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- name: Resolve siblings from their dev tips
|
||||
shell: bash
|
||||
working-directory: freemkv-keysources
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
EOF
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
with:
|
||||
components: clippy, 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 clippy --all-targets -- -D warnings
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- name: Resolve siblings from their dev tips
|
||||
shell: bash
|
||||
working-directory: freemkv-keysources
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
EOF
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo test --tests
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
# This crate ships wherever freemkv and autorip ship, and it drags in all of
|
||||
# libfreemkv's platform code (SCSI transports included). Build the tests, not
|
||||
# `cargo check` — check skips codegen and never compiles test-only code.
|
||||
check-macos:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- name: Resolve siblings from their dev tips
|
||||
shell: bash
|
||||
working-directory: freemkv-keysources
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
EOF
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo build --tests
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
check-windows:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: freemkv-unlock
|
||||
- name: Resolve siblings from their dev tips
|
||||
shell: bash
|
||||
working-directory: freemkv-keysources
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
EOF
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo build --tests
|
||||
working-directory: freemkv-keysources
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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.
|
||||
#
|
||||
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
|
||||
# and no hosted runner has an optical drive or the image hoard. Those run on a
|
||||
# self-hosted runner (see the media job at the end) and are the one leg that
|
||||
# stays on hardware.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
|
||||
# qa right now, and is it the one I tested?" — a question that otherwise gets
|
||||
# answered from memory.
|
||||
#
|
||||
# This runs FIRST and does not depend on the gates, deliberately. A red
|
||||
# candidate needs a name more than a green one does: "rc3 failed
|
||||
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
|
||||
# Red on qa is a working gate, not an incident — it is the branch saying this
|
||||
# is not production worth yet. Fix on dev, get dev green, push qa again.
|
||||
#
|
||||
# release.yml excludes v*-rc* so a candidate never publishes a release.
|
||||
rc-tag:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
|
||||
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
|
||||
# lexical sort gets that backwards from the tenth candidate on.
|
||||
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
|
||||
tag="v$v-rc$(( ${n:-0} + 1 ))"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The debug suite runs on every dev push. Release is a DIFFERENT build:
|
||||
# overflow checks are off, debug_assert! is compiled out, and inlining
|
||||
# changes what the optimiser can prove. A test that only passes in debug is
|
||||
# a test that never guarded the binary anyone actually ships.
|
||||
release-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- name: Resolve siblings from their qa tips
|
||||
shell: bash
|
||||
working-directory: freemkv-keysources
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
EOF
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo test --release --tests
|
||||
working-directory: freemkv-keysources
|
||||
|
||||
# clippy's output is target-dependent: cfg-gated code only gets linted on
|
||||
# the target it compiles for. Linting solely on the dev machine's host
|
||||
# target is how a lint that CI rejects reaches a push.
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- name: Resolve siblings from their qa tips
|
||||
shell: bash
|
||||
working-directory: freemkv-keysources
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
EOF
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
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:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
repository: freemkv/freemkv-unlock
|
||||
ref: qa
|
||||
path: freemkv-unlock
|
||||
- name: Resolve siblings from their qa tips
|
||||
shell: bash
|
||||
working-directory: freemkv-keysources
|
||||
run: |
|
||||
mkdir -p .cargo
|
||||
cat > .cargo/config.toml <<'EOF'
|
||||
[patch.crates-io]
|
||||
libfreemkv = { path = "../libfreemkv" }
|
||||
EOF
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo build --release --tests
|
||||
working-directory: freemkv-keysources
|
||||
@@ -4,6 +4,11 @@ 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
|
||||
@@ -12,7 +17,7 @@ jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Verify Cargo.toml version matches tag
|
||||
run: |
|
||||
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
|
||||
@@ -35,8 +40,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test
|
||||
|
||||
@@ -53,8 +58,8 @@ jobs:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
+25
-7
@@ -1,21 +1,31 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.0.0-rc.4.3"
|
||||
version = "1.6.4"
|
||||
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.97"
|
||||
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.4.3"
|
||||
# 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.6"
|
||||
# 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,11 @@ 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.6.4" }
|
||||
|
||||
@@ -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.
|
||||
+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.
|
||||
+1589
-186
File diff suppressed because it is too large
Load Diff
+2257
File diff suppressed because it is too large
Load Diff
+113
-121
@@ -1,162 +1,154 @@
|
||||
//! 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:
|
||||
//! libfreemkv owns the AACS crypto; this crate provides the published
|
||||
//! [`KeySource`] implementations that look a disc up and drive the boil-down
|
||||
//! primitives down to terminal Unit Keys:
|
||||
//!
|
||||
//! - [`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.
|
||||
//! 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.
|
||||
|
||||
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, validate_keyserver_url};
|
||||
pub use keydb::{KeydbSource, UpdateResult};
|
||||
pub use keydb_format::{DiscEntry, KeyDb};
|
||||
pub use online::{MIN_SAMPLE_UNITS, OnlineSource, 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. Composes the raw `aacs::derive::decrypt_unit_key`
|
||||
/// primitive directly — replaces the removed libfreemkv `aacs::boil::uk_from_vuk`
|
||||
/// wrapper (that veneer is gone; libfreemkv owns only the AES).
|
||||
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. —
|
||||
/// 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. —
|
||||
/// 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`.
|
||||
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 and return the first non-empty result — preserving
|
||||
/// the Ok/Err distinction when nothing resolves.
|
||||
///
|
||||
/// 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.
|
||||
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
|
||||
/// Try each inner source in order; the FIRST to return a non-empty base Unit
|
||||
/// Key set wins, even if an earlier source failed. All sources exhausted →
|
||||
/// `Ok(empty)` if they all ANSWERED, or the first source failure if any could
|
||||
/// not (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())
|
||||
/// Forensic-index counterpart: try each inner source's `get_fmts_indexes` in
|
||||
/// the same order and return the first non-empty set, under the same
|
||||
/// failure-preserving rule. A source with no forensic material (the keydb,
|
||||
/// via the trait default) contributes empty and is skipped; on today's discs
|
||||
/// 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)
|
||||
}
|
||||
|
||||
fn errored(&self) -> bool {
|
||||
self.sources.iter().any(|s| s.errored())
|
||||
/// 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"
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
unit += units_this;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
+1163
-148
File diff suppressed because it is too large
Load Diff
+52
-181
@@ -1,78 +1,37 @@
|
||||
//! Where the `keydb.cfg` lives, per OS.
|
||||
//! Where the `keydb.cfg` lives: local to the executable, local ONLY.
|
||||
//!
|
||||
//! 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
|
||||
//! ordered list of locations to *search* (first existing wins) and for the
|
||||
//! single *default* location to write to (e.g. `update-keys`/save).
|
||||
//! list of locations to *search* (first existing wins) and for the single
|
||||
//! *default* location to write to (e.g. `update-keys`/save).
|
||||
//!
|
||||
//! Resolution order:
|
||||
//!
|
||||
//! - **Windows**: `%APPDATA%\freemkv\keydb.cfg` FIRST (the idiomatic per-user
|
||||
//! roaming config dir), then the legacy `%USERPROFILE%\.config\freemkv\keydb.cfg`
|
||||
//! for back-compat with installs that predate this fix.
|
||||
//! - **Linux / macOS**: `$XDG_CONFIG_HOME/freemkv/keydb.cfg` (if `XDG_CONFIG_HOME`
|
||||
//! is set), then `$HOME/.config/freemkv/keydb.cfg` — the long-standing default,
|
||||
//! unchanged so existing users keep working.
|
||||
//!
|
||||
//! Pure `std::env` — `%APPDATA%`, `%USERPROFILE%`, `$HOME`, `$XDG_CONFIG_HOME`
|
||||
//! are all environment variables, so no `dirs`-style crate is pulled in.
|
||||
//! 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.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The keydb filename plus its `freemkv` subdir, joined onto a base dir.
|
||||
fn keydb_under(base: PathBuf) -> PathBuf {
|
||||
base.join("freemkv").join("keydb.cfg")
|
||||
}
|
||||
|
||||
/// The ordered list of `keydb.cfg` locations to search, most-idiomatic first.
|
||||
/// 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`] (the first entry — the canonical location).
|
||||
///
|
||||
/// Always returns at least one entry on a normally-configured system; returns
|
||||
/// an empty list only if none of the relevant env vars are set.
|
||||
/// [`default_keydb_path`].
|
||||
pub fn keydb_search_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if cfg!(windows) {
|
||||
// Idiomatic Windows location first.
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
if !appdata.is_empty() {
|
||||
paths.push(keydb_under(PathBuf::from(appdata)));
|
||||
}
|
||||
}
|
||||
// Legacy XDG-style dotfolder under the user profile, for back-compat.
|
||||
if let Ok(profile) = std::env::var("USERPROFILE") {
|
||||
if !profile.is_empty() {
|
||||
paths.push(
|
||||
PathBuf::from(profile)
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Honour XDG_CONFIG_HOME if the user set it, then the historical default.
|
||||
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||
if !xdg.is_empty() {
|
||||
paths.push(keydb_under(PathBuf::from(xdg)));
|
||||
}
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
if !home.is_empty() {
|
||||
paths.push(
|
||||
PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
);
|
||||
}
|
||||
}
|
||||
match std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join("keydb.cfg")))
|
||||
{
|
||||
Some(path) => vec![path],
|
||||
None => Vec::new(),
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// The first search path that exists on disk, if any.
|
||||
@@ -85,9 +44,8 @@ pub fn existing_keydb_path() -> Option<PathBuf> {
|
||||
|
||||
/// The canonical default location to WRITE the keydb to (e.g. after a download).
|
||||
///
|
||||
/// This is the first (most idiomatic) entry of [`keydb_search_paths`]:
|
||||
/// `%APPDATA%\freemkv\keydb.cfg` on Windows, `~/.config/freemkv/keydb.cfg`
|
||||
/// elsewhere. Returns `None` only when the relevant env vars are unset.
|
||||
/// 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()
|
||||
}
|
||||
@@ -96,129 +54,42 @@ pub fn default_keydb_path() -> Option<PathBuf> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Serialize env-mutating tests: they share the process environment.
|
||||
fn lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
M.lock().unwrap_or_else(|e| e.into_inner())
|
||||
/// 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")))
|
||||
}
|
||||
|
||||
/// Build the search list under an explicit env, restoring the prior env
|
||||
/// afterwards. Avoids depending on the host's real HOME/APPDATA.
|
||||
fn with_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
|
||||
let _g = lock();
|
||||
let keys = ["APPDATA", "USERPROFILE", "HOME", "XDG_CONFIG_HOME"];
|
||||
let saved: Vec<(&str, Option<String>)> =
|
||||
keys.iter().map(|k| (*k, std::env::var(k).ok())).collect();
|
||||
// Clear all, then apply the requested overrides.
|
||||
for k in keys {
|
||||
unsafe { std::env::remove_var(k) };
|
||||
}
|
||||
for (k, v) in vars {
|
||||
match v {
|
||||
Some(val) => unsafe { std::env::set_var(k, val) },
|
||||
None => unsafe { std::env::remove_var(k) },
|
||||
#[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]"
|
||||
);
|
||||
}
|
||||
}
|
||||
f();
|
||||
// Restore.
|
||||
for (k, v) in saved {
|
||||
match v {
|
||||
Some(val) => unsafe { std::env::set_var(k, val) },
|
||||
None => unsafe { std::env::remove_var(k) },
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_prefers_appdata_then_legacy_userprofile() {
|
||||
with_env(
|
||||
&[
|
||||
("APPDATA", Some(r"C:\Users\matt\AppData\Roaming")),
|
||||
("USERPROFILE", Some(r"C:\Users\matt")),
|
||||
],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 2, "APPDATA + legacy USERPROFILE");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from(r"C:\Users\matt\AppData\Roaming")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"APPDATA location must be searched first on Windows"
|
||||
);
|
||||
assert_eq!(
|
||||
paths[1],
|
||||
PathBuf::from(r"C:\Users\matt")
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"legacy .config dotfolder is the back-compat fallback"
|
||||
);
|
||||
assert_eq!(default_keydb_path(), Some(paths[0].clone()));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_falls_back_to_legacy_when_appdata_unset() {
|
||||
with_env(
|
||||
&[("APPDATA", None), ("USERPROFILE", Some(r"C:\Users\matt"))],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 1, "only the legacy USERPROFILE path");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from(r"C:\Users\matt")
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg")
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn unix_default_is_home_dotconfig() {
|
||||
with_env(
|
||||
&[("HOME", Some("/u/me")), ("XDG_CONFIG_HOME", None)],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 1, "just the $HOME/.config default");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from("/u/me")
|
||||
.join(".config")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"Linux/macOS default must remain ~/.config/freemkv/keydb.cfg"
|
||||
);
|
||||
assert_eq!(default_keydb_path(), Some(paths[0].clone()));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn unix_honours_xdg_config_home_first() {
|
||||
with_env(
|
||||
&[
|
||||
("XDG_CONFIG_HOME", Some("/u/me/.cfg")),
|
||||
("HOME", Some("/u/me")),
|
||||
],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 2, "XDG dir + $HOME/.config fallback");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from("/u/me/.cfg")
|
||||
.join("freemkv")
|
||||
.join("keydb.cfg"),
|
||||
"XDG_CONFIG_HOME, when set, is searched before ~/.config"
|
||||
);
|
||||
},
|
||||
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 through a real
|
||||
//! `keydb.cfg`; the exe-local path helpers; MKB-aware host-cert serving.
|
||||
//! - `OnlineSource`: SSRF/scheme validation and the unconfigured no-op.
|
||||
//! - `MultiSource`: caller-supplied 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])
|
||||
}
|
||||
|
||||
#[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 ─
|
||||
//
|
||||
// `MultiSource` re-collapsed 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.
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// THE regression, 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". Catches the mutation that restores
|
||||
/// `if let Ok(uks) = ..` (which drops the `Err` and returns `Ok(empty)`).
|
||||
#[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