mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
56
Commits
v1.4.2
...
v1.6.4-rc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
+179
-14
@@ -2,35 +2,200 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# 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:
|
||||
|
||||
# freemkv-keysources depends on libfreemkv from crates.io (see Cargo.toml).
|
||||
# No sibling-repo checkout is needed.
|
||||
# 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@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- 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
|
||||
# Cache ~/.cargo/registry + target/ across runs. Saves ~30-60s
|
||||
# of crates.io index download per job; incremental compilation
|
||||
# helps on re-runs that don't bump Cargo.lock.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-keysources-ci
|
||||
workspaces: freemkv-keysources
|
||||
- run: cargo fmt --check
|
||||
- run: cargo clippy -- -D warnings
|
||||
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@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- 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:
|
||||
shared-key: freemkv-keysources-ci
|
||||
- run: cargo test --locked
|
||||
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
|
||||
|
||||
+124
@@ -1,5 +1,129 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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
|
||||
|
||||
- **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
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at owner@example.invalid. All complaints will be reviewed and investigated promptly and fairly.
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
+9
-9
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.4.2"
|
||||
version = "1.6.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
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_uk; the library does all derivation."
|
||||
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"]
|
||||
@@ -17,15 +17,15 @@ publish = false
|
||||
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
||||
# 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.4"
|
||||
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 = "2", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "8", default-features = false, features = ["deflate"] }
|
||||
# Structural begin/end logging around the keyserver round-trip (never logs
|
||||
# key material).
|
||||
tracing = "0.1"
|
||||
@@ -34,10 +34,10 @@ tracing = "0.1"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.2"`
|
||||
# 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.4.2" }
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.4" }
|
||||
|
||||
+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.
|
||||
+779
-21
@@ -31,6 +31,8 @@
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::uks_from_vuk;
|
||||
use libfreemkv::aacs::derive::{derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk};
|
||||
@@ -41,9 +43,11 @@ use libfreemkv::{Error, KeySource};
|
||||
use crate::keydb_format::KeyDb;
|
||||
|
||||
/// Upper bound on decompressed keydb size. The published keydb is a few MiB;
|
||||
/// 64 MiB is a generous ceiling that still caps a decompression bomb (a tiny
|
||||
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread).
|
||||
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
|
||||
/// 128 MiB is a generous ceiling that still caps a decompression bomb (a tiny
|
||||
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread). The
|
||||
/// public UHD keydb is ~62 MiB and growing, so 64 MiB was getting tight;
|
||||
/// 128 MiB leaves years of headroom.
|
||||
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
|
||||
#[derive(Debug)]
|
||||
@@ -53,15 +57,324 @@ pub struct UpdateResult {
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
/// Widest observed granularity of a filesystem's stored mtime. HFS+, many
|
||||
/// container overlay filesystems and most network filesystems record whole
|
||||
/// seconds; 2 s leaves room for that plus rounding. Used by
|
||||
/// [`CacheEntry::is_settled`] — see the argument there.
|
||||
const MTIME_GRANULARITY: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// The identity of the keydb file a cache entry was parsed from.
|
||||
///
|
||||
/// `(len, modified)` ALONE IS NOT AN IDENTITY. 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 [`Self::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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct FileStamp {
|
||||
/// Filesystem device id. `0` where unavailable.
|
||||
dev: u64,
|
||||
/// Inode number — the discriminator an atomic rename always changes. `0`
|
||||
/// where unavailable.
|
||||
ino: u64,
|
||||
len: u64,
|
||||
modified: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
impl FileStamp {
|
||||
/// Stamp from a metadata block. Take it from `File::metadata` on an OPEN
|
||||
/// handle (an `fstat`), never from a second `std::fs::metadata` on the path:
|
||||
/// only the handle guarantees the stamp and the bytes describe one file.
|
||||
fn of(meta: &std::fs::Metadata) -> Self {
|
||||
// dev/ino are a Unix concept. Windows has a rough equivalent
|
||||
// (volume serial + file index) but exposes it only through
|
||||
// `MetadataExt` methods that are documented as unreliable on some
|
||||
// filesystems; rule 2 alone is correct there, so the cross-platform
|
||||
// fallback is simply "no inode discriminator" rather than a shaky one.
|
||||
#[cfg(unix)]
|
||||
let (dev, ino) = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(meta.dev(), meta.ino())
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let (dev, ino) = (0u64, 0u64);
|
||||
Self {
|
||||
dev,
|
||||
ino,
|
||||
len: meta.len(),
|
||||
modified: meta.modified().ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cached parse: the file identity it came from, WHEN that identity was
|
||||
/// observed, the parsed database, and the parser's rejection counts.
|
||||
struct CacheEntry {
|
||||
stamp: FileStamp,
|
||||
/// Wall-clock time at which `stamp` was read off the open handle. The whole
|
||||
/// point of storing it is [`Self::is_settled`].
|
||||
stamped_at: std::time::SystemTime,
|
||||
db: Arc<KeyDb>,
|
||||
/// Retained, not just logged: [`KeydbSource::cached_db`] re-emits the
|
||||
/// summary on every hit, because a cache hit skips `KeyDb::parse` and with
|
||||
/// it the one warning that a corrupt keydb is being served.
|
||||
stats: crate::keydb_format::ParseStats,
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
/// Whether this 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.
|
||||
fn is_settled(&self, granularity: std::time::Duration) -> bool {
|
||||
self.stamp
|
||||
.modified
|
||||
.and_then(|m| self.stamped_at.duration_since(m).ok())
|
||||
.is_some_and(|age| age >= granularity)
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`KeySource`] backed by a local `keydb.cfg` file.
|
||||
///
|
||||
/// The parsed database is CACHED 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.
|
||||
pub struct KeydbSource {
|
||||
path: PathBuf,
|
||||
/// `Mutex` (not `RwLock`): the guarded section is a stamp comparison plus an
|
||||
/// `Arc` clone, so there is nothing for concurrent readers to win. Poisoning
|
||||
/// is recovered from rather than propagated — a panic elsewhere must not
|
||||
/// turn every later key lookup into a panic.
|
||||
cache: Mutex<Option<CacheEntry>>,
|
||||
/// How many times the file was actually read + parsed (cache MISSES). The
|
||||
/// only honest way to assert the cache from a test: timing is flaky and the
|
||||
/// parsed value is identical either way. Cheap enough to keep in release.
|
||||
parses: AtomicUsize,
|
||||
/// The `G` of [`CacheEntry::is_settled`]. A field, not the constant inlined,
|
||||
/// so the two discriminators can be tested ONE AT A TIME: with `G` at zero
|
||||
/// every entry settles, which isolates the inode/len rule; with `G` enormous
|
||||
/// nothing settles, which isolates the freshness rule. Testing them together
|
||||
/// is how a stale-cache test ends up passing whether or not the fix is
|
||||
/// present — on APFS (nanosecond mtimes) the naive same-second scenario is
|
||||
/// simply not reproducible, so a test that "reproduces" it there is
|
||||
/// asserting nothing.
|
||||
mtime_granularity: std::time::Duration,
|
||||
/// How many corruption summaries were emitted (see
|
||||
/// [`KeydbSource::emit_parse_stats`]). Same rationale as `parses`: the
|
||||
/// alternative instrument is a `tracing` subscriber in dev-dependencies.
|
||||
warnings: AtomicUsize,
|
||||
}
|
||||
|
||||
impl KeydbSource {
|
||||
/// A keydb source reading the given `keydb.cfg` path.
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
Self {
|
||||
path: path.into(),
|
||||
cache: Mutex::new(None),
|
||||
parses: AtomicUsize::new(0),
|
||||
mtime_granularity: MTIME_GRANULARITY,
|
||||
warnings: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log the parser's rejection summary and count the emission.
|
||||
///
|
||||
/// The count is the only way to assert, without pulling a `tracing`
|
||||
/// subscriber into dev-dependencies, that the corruption warning is NOT
|
||||
/// swallowed by the cache. See [`Self::cached_db`].
|
||||
fn emit_parse_stats(&self, stats: &crate::keydb_format::ParseStats) {
|
||||
if stats.log() {
|
||||
self.warnings.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Corruption summaries emitted so far. Test-only observability, paired with
|
||||
/// [`Self::parse_count`]: a corrupt keydb must warn once per LOOKUP, while
|
||||
/// still parsing once.
|
||||
#[cfg(test)]
|
||||
fn warning_count(&self) -> usize {
|
||||
self.warnings.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Test-only: override the settle window (see the field's doc).
|
||||
#[cfg(test)]
|
||||
fn with_mtime_granularity(mut self, g: std::time::Duration) -> Self {
|
||||
self.mtime_granularity = g;
|
||||
self
|
||||
}
|
||||
|
||||
/// The parsed keydb, from cache when the file is provably unchanged.
|
||||
///
|
||||
/// 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.
|
||||
fn cached_db(&self) -> std::io::Result<Arc<KeyDb>> {
|
||||
let file = std::fs::File::open(&self.path)?;
|
||||
let stamp = FileStamp::of(&file.metadata()?);
|
||||
let stamped_at = std::time::SystemTime::now();
|
||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = guard.as_ref()
|
||||
&& entry.stamp == stamp
|
||||
&& entry.is_settled(self.mtime_granularity)
|
||||
{
|
||||
// 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.
|
||||
self.emit_parse_stats(&entry.stats);
|
||||
return Ok(entry.db.clone());
|
||||
}
|
||||
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
|
||||
self.emit_parse_stats(&stats);
|
||||
let db = Arc::new(db);
|
||||
self.parses.fetch_add(1, Ordering::Relaxed);
|
||||
*guard = Some(CacheEntry {
|
||||
stamp,
|
||||
stamped_at,
|
||||
db: db.clone(),
|
||||
stats,
|
||||
});
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Drop the cached parse. Called after this source WRITES the file, so the
|
||||
/// next lookup re-reads it without depending on mtime resolution.
|
||||
fn invalidate_cache(&self) {
|
||||
*self.cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Cache misses so far — the number of real file reads + parses. Test-only
|
||||
/// observability for the cache (see [`Self::cached_db`]).
|
||||
#[cfg(test)]
|
||||
fn parse_count(&self) -> usize {
|
||||
self.parses.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Turn a [`KeyDb::load`] failure into this source's verdict.
|
||||
///
|
||||
/// 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`.
|
||||
fn load_failure(&self, e: &std::io::Error) -> Option<Error> {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => None,
|
||||
std::io::ErrorKind::InvalidData => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
path = %self.path.display(),
|
||||
"keydb.cfg is unusable (over the size cap or not valid UTF-8) — this is a corrupt or truncated keydb, NOT a disc without a key"
|
||||
);
|
||||
Some(Error::KeydbInvalid)
|
||||
}
|
||||
kind => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
path = %self.path.display(),
|
||||
io_kind = ?kind,
|
||||
"keydb.cfg could not be read — NOT a disc without a key"
|
||||
);
|
||||
Some(Error::KeydbLoad {
|
||||
path: self.path.display().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate, decompress, and crash-safely persist raw keydb bytes (plain
|
||||
@@ -90,11 +403,12 @@ impl KeydbSource {
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim();
|
||||
// Mirror KeyDb::parse's disc-entry rule EXACTLY (keydb_format.rs:
|
||||
// a "0x" line is only an entry if it also contains " = "), so
|
||||
// save() never validates + persists content that parses to zero
|
||||
// usable entries (e.g. a stray "0xDEADBEEF" comment line).
|
||||
(t.starts_with("0x") && t.contains(" = "))
|
||||
// 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.
|
||||
crate::keydb_format::is_disc_entry_line(t)
|
||||
|| t.starts_with("| DK")
|
||||
|| t.starts_with("| PK")
|
||||
|| t.starts_with("| HC")
|
||||
@@ -106,6 +420,8 @@ impl KeydbSource {
|
||||
}
|
||||
|
||||
write_atomic(&self.path, &text)?;
|
||||
// The file this source reads was just replaced; drop the parsed copy.
|
||||
self.invalidate_cache();
|
||||
|
||||
Ok(UpdateResult {
|
||||
path: self.path.clone(),
|
||||
@@ -141,9 +457,14 @@ impl KeydbSource {
|
||||
/// filtering is applied (passes `None`). The [`KeySource::host_certs`] TRAIT
|
||||
/// method wires the real MKB generation through for revocation filtering.
|
||||
pub fn host_certs(&self) -> Vec<HostCert> {
|
||||
match KeyDb::load(&self.path) {
|
||||
match self.cached_db() {
|
||||
Ok(db) => db.host_certs(None),
|
||||
Err(_) => Vec::new(),
|
||||
// No error channel here (the scan-options builder wants a list), so
|
||||
// the failure can only be LOGGED — but it must not be invisible.
|
||||
Err(e) => {
|
||||
let _ = self.load_failure(&e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,13 +649,27 @@ fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
|
||||
}
|
||||
|
||||
impl KeySource for KeydbSource {
|
||||
/// Resolve this disc's terminal Unit Keys from the keydb. A missing /
|
||||
/// unreadable keydb is not an error — it simply yields no keys (another
|
||||
/// source may have them), the same as the library's own loader.
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
match KeyDb::load(&self.path) {
|
||||
/// Resolve this disc's base per-CPS-unit Unit Keys from the keydb.
|
||||
///
|
||||
/// 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.
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
match self.cached_db() {
|
||||
Ok(db) => Ok(Self::unit_keys_from(&db, ctx)),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
Err(e) => match self.load_failure(&e) {
|
||||
// Missing keydb: the documented benign miss.
|
||||
None => Ok(Vec::new()),
|
||||
// Corrupt / unreadable keydb: a SOURCE FAILURE, not a miss.
|
||||
Some(err) => Err(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,9 +679,13 @@ impl KeySource for KeydbSource {
|
||||
/// `; Revoked in MKBv<N>` annotation): a cert revoked at generation `R` is
|
||||
/// withheld once the disc's generation reaches `R`.
|
||||
fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
|
||||
match KeyDb::load(&self.path) {
|
||||
match self.cached_db() {
|
||||
Ok(db) => db.host_certs(mkb),
|
||||
Err(_) => Vec::new(),
|
||||
// Vec-returning trait method: log the failure, return nothing.
|
||||
Err(e) => {
|
||||
let _ = self.load_failure(&e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,11 +698,34 @@ impl KeySource for KeydbSource {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::keydb_format::DiscEntry;
|
||||
|
||||
/// A fixed, in-the-past mtime — `touch -t CCYYMMDDhhmm.ss`.
|
||||
#[cfg(unix)]
|
||||
const PINNED_MTIME: &str = "202601011200.00";
|
||||
|
||||
/// 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.
|
||||
#[cfg(unix)]
|
||||
fn pin_mtime(p: &std::path::Path) {
|
||||
let ok = std::process::Command::new("touch")
|
||||
.arg("-t")
|
||||
.arg(PINNED_MTIME)
|
||||
.arg(p)
|
||||
.status()
|
||||
.expect("touch must run")
|
||||
.success();
|
||||
assert!(ok, "touch -t failed");
|
||||
}
|
||||
|
||||
use libfreemkv::aacs::derive::derive_vuk;
|
||||
use libfreemkv::aacs::types::DeviceKey;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ── A test ResolveCtx, so get_uk's path selection can be exercised without
|
||||
// ── A test ResolveCtx, so get_unit_keys's path selection can be exercised without
|
||||
// a real Disc. Each accessor returns exactly what a case needs. ──────────
|
||||
struct MockCtx {
|
||||
disc_hash: String,
|
||||
@@ -748,7 +1110,7 @@ mod tests {
|
||||
fn get_uk_missing_keydb_is_ok_empty() {
|
||||
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||
let got = src
|
||||
.get_uk(&ctx(HASH, Vec::new(), None))
|
||||
.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.expect("missing keydb is not an error");
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
@@ -790,6 +1152,402 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── Caching: the 62 MiB / 181k-entry keydb is parsed ONCE ────────────────
|
||||
|
||||
/// 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).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn repeated_lookups_parse_the_keydb_once() {
|
||||
let dir = scratch("cache-hit");
|
||||
let path = dir.join("keydb.cfg");
|
||||
let line = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||
"00".repeat(20),
|
||||
"00".repeat(92)
|
||||
);
|
||||
std::fs::write(&path, &line).unwrap();
|
||||
pin_mtime(&path);
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
assert_eq!(src.host_certs().len(), 1);
|
||||
assert_eq!(KeySource::host_certs(&src, Some(70)).len(), 1);
|
||||
assert!(
|
||||
src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
src.parse_count(),
|
||||
1,
|
||||
"three lookups over an unchanged file must parse it once"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn a_changed_keydb_file_is_reparsed() {
|
||||
let dir = scratch("cache-stamp");
|
||||
let path = dir.join("keydb.cfg");
|
||||
let hc = |b: &str, n: usize| {
|
||||
format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||
"00".repeat(20),
|
||||
b.repeat(n)
|
||||
)
|
||||
};
|
||||
std::fs::write(&path, hc("00", 92)).unwrap();
|
||||
let src = KeydbSource::new(&path);
|
||||
assert_eq!(src.host_certs()[0].certificate.len(), 92);
|
||||
|
||||
// A different LENGTH guarantees a different stamp regardless of the
|
||||
// filesystem's mtime resolution.
|
||||
std::fs::write(&path, hc("11", 100)).unwrap();
|
||||
assert_eq!(
|
||||
src.host_certs()[0].certificate.len(),
|
||||
100,
|
||||
"a replaced keydb must be re-read, not served from a stale cache"
|
||||
);
|
||||
assert_eq!(src.parse_count(), 2, "one parse per distinct file");
|
||||
}
|
||||
|
||||
// ── The two stale-cache discriminators, tested ONE AT A TIME ────────────
|
||||
//
|
||||
// The defect both of these 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).
|
||||
|
||||
/// Discriminator 1, the inode: 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.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_same_length_same_mtime_rename_is_still_re_read() {
|
||||
let dir = scratch("cache-inode");
|
||||
let path = dir.join("keydb.cfg");
|
||||
let replacement = dir.join("keydb.cfg.new");
|
||||
let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n");
|
||||
|
||||
std::fs::write(&path, body(&"01".repeat(16))).unwrap();
|
||||
pin_mtime(&path);
|
||||
let src = KeydbSource::new(&path).with_mtime_granularity(std::time::Duration::ZERO);
|
||||
assert_eq!(
|
||||
src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key,
|
||||
[0x01u8; 16]
|
||||
);
|
||||
|
||||
// Same length, same mtime, different inode.
|
||||
std::fs::write(&replacement, body(&"02".repeat(16))).unwrap();
|
||||
pin_mtime(&replacement);
|
||||
std::fs::rename(&replacement, &path).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().len(),
|
||||
body(&"01".repeat(16)).len() as u64,
|
||||
"the two keydbs must be the same size for this test to mean anything"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key,
|
||||
[0x02u8; 16],
|
||||
"a renamed-in keydb of the same size and mtime must not be served from the stale cache"
|
||||
);
|
||||
assert_eq!(src.parse_count(), 2, "the replacement must be re-parsed");
|
||||
}
|
||||
|
||||
/// Discriminator 2, the settle window: 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.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_in_place_rewrite_under_an_unsettled_stamp_is_still_re_read() {
|
||||
use std::io::Write as _;
|
||||
let dir = scratch("cache-settle");
|
||||
let path = dir.join("keydb.cfg");
|
||||
let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n");
|
||||
|
||||
std::fs::write(&path, body(&"01".repeat(16))).unwrap();
|
||||
pin_mtime(&path);
|
||||
let ino_before = std::fs::metadata(&path)
|
||||
.map(|m| std::os::unix::fs::MetadataExt::ino(&m))
|
||||
.unwrap();
|
||||
let src = KeydbSource::new(&path)
|
||||
.with_mtime_granularity(std::time::Duration::from_secs(10 * 365 * 24 * 3600));
|
||||
assert_eq!(
|
||||
src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key,
|
||||
[0x01u8; 16]
|
||||
);
|
||||
|
||||
// Overwrite the SAME inode in place (no truncate, no rename), same
|
||||
// length, then force the mtime back: every field of the old stamp
|
||||
// matches.
|
||||
let mut f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
|
||||
f.write_all(body(&"02".repeat(16)).as_bytes()).unwrap();
|
||||
f.sync_all().unwrap();
|
||||
drop(f);
|
||||
pin_mtime(&path);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path)
|
||||
.map(|m| std::os::unix::fs::MetadataExt::ino(&m))
|
||||
.unwrap(),
|
||||
ino_before,
|
||||
"the rewrite must reuse the inode for this test to mean anything"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap()[0].key,
|
||||
[0x02u8; 16],
|
||||
"an in-place rewrite under an indistinguishable stamp must not be served from cache"
|
||||
);
|
||||
}
|
||||
|
||||
/// A corrupt-but-parseable keydb must keep saying so. `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.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_corrupt_keydb_warns_on_every_lookup_not_only_on_the_parse() {
|
||||
let dir = scratch("cache-warn");
|
||||
let path = dir.join("keydb.cfg");
|
||||
// Parseable (one good disc row) but damaged: two rows the parser must
|
||||
// reject and count.
|
||||
std::fs::write(
|
||||
&path,
|
||||
"0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n\
|
||||
| PK | 0xnothex\n\
|
||||
| DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n",
|
||||
)
|
||||
.unwrap();
|
||||
pin_mtime(&path);
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
for _ in 0..3 {
|
||||
assert!(
|
||||
!src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"the healthy row must still resolve — corruption is not a source failure here"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
src.parse_count(),
|
||||
1,
|
||||
"the cache must still do its job: one parse for three lookups"
|
||||
);
|
||||
assert_eq!(
|
||||
src.warning_count(),
|
||||
3,
|
||||
"the corruption summary must be re-emitted on every cache hit, not once per process"
|
||||
);
|
||||
}
|
||||
|
||||
/// A HEALTHY keydb must stay silent — the re-emission above must not turn
|
||||
/// into a per-lookup log line for every operator with an intact file.
|
||||
///
|
||||
/// Catches the mutation that makes `emit_parse_stats` unconditional (drops
|
||||
/// `ParseStats::log`'s empty-counts early return).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_healthy_keydb_warns_on_no_lookup() {
|
||||
let dir = scratch("cache-quiet");
|
||||
let path = dir.join("keydb.cfg");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n",
|
||||
)
|
||||
.unwrap();
|
||||
pin_mtime(&path);
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
for _ in 0..3 {
|
||||
let _ = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
src.warning_count(),
|
||||
0,
|
||||
"an intact keydb must produce no corruption summary at all"
|
||||
);
|
||||
}
|
||||
|
||||
/// The cost side of the settle rule, asserted so it is a decision and not an
|
||||
/// accident: a keydb written just now is re-read on every lookup until it
|
||||
/// has been quiet for the granularity. That is the price of never serving a
|
||||
/// same-second replacement, and it is bounded — 2 s after each write of a
|
||||
/// file that is rewritten daily.
|
||||
///
|
||||
/// 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]
|
||||
fn a_freshly_written_keydb_is_not_trusted_from_cache() {
|
||||
let dir = scratch("cache-fresh");
|
||||
let path = dir.join("keydb.cfg");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"0xaabb = T | U | 1-0x00112233445566778899aabbccddeeff\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
assert!(
|
||||
!src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
!src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
src.parse_count(),
|
||||
2,
|
||||
"a keydb whose mtime is younger than the granularity must be re-read, not trusted"
|
||||
);
|
||||
}
|
||||
|
||||
/// `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).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn save_invalidates_the_cache_even_when_the_file_stamp_is_unchanged() {
|
||||
let dir = scratch("cache-save");
|
||||
let path = dir.join("keydb.cfg");
|
||||
let k1 = "01".repeat(16);
|
||||
let k2 = "02".repeat(16);
|
||||
let body = |k: &str| format!("0xaabb = T | U | 1-0x{k}\n");
|
||||
|
||||
std::fs::write(&path, body(&k1)).unwrap();
|
||||
pin_mtime(&path);
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
let first = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap();
|
||||
assert_eq!(first[0].key, [0x01u8; 16]);
|
||||
|
||||
// Same byte length, written THROUGH this source, then stamped back so
|
||||
// (size, mtime) is bit-identical to what the cache recorded.
|
||||
src.save(body(&k2).as_bytes()).expect("save must succeed");
|
||||
pin_mtime(&path);
|
||||
assert_eq!(
|
||||
std::fs::metadata(&path).unwrap().len(),
|
||||
body(&k1).len() as u64,
|
||||
"the two keydbs must be the same size for this test to mean anything"
|
||||
);
|
||||
|
||||
let second = src.get_unit_keys(&ctx(HASH, Vec::new(), None)).unwrap();
|
||||
assert_eq!(
|
||||
second[0].key, [0x02u8; 16],
|
||||
"a save through this source must invalidate its own cache, not wait for the stamp to change"
|
||||
);
|
||||
}
|
||||
|
||||
// ── A corrupt keydb is a source FAILURE, not "this disc has no key" ──────
|
||||
|
||||
/// `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())`.
|
||||
#[test]
|
||||
fn a_corrupt_keydb_is_an_error_not_an_empty_answer() {
|
||||
let dir = scratch("corrupt");
|
||||
let path = dir.join("keydb.cfg");
|
||||
// Invalid UTF-8 (a lone continuation byte) → InvalidData from load.
|
||||
std::fs::write(&path, [0x30u8, 0x78, 0xFF, 0xFE, 0x0A]).unwrap();
|
||||
|
||||
let src = KeydbSource::new(&path);
|
||||
let err = src
|
||||
.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.expect_err("a corrupt keydb must not look like a disc with no key");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
libfreemkv::error::E_KEYDB_INVALID,
|
||||
"an unusable keydb must report itself, not E7022"
|
||||
);
|
||||
assert_ne!(
|
||||
err.code(),
|
||||
libfreemkv::error::E_NO_DISC_KEY,
|
||||
"a corrupt keydb says NOTHING about whether this disc has a key"
|
||||
);
|
||||
}
|
||||
|
||||
/// The contrast case, through the same code: a keydb that simply is not
|
||||
/// there stays the documented benign miss (`Ok(empty)`), so the fix above
|
||||
/// cannot have turned "no keydb configured" into a hard failure.
|
||||
#[test]
|
||||
fn a_missing_keydb_is_still_a_benign_empty() {
|
||||
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
|
||||
assert!(
|
||||
src.get_unit_keys(&ctx(HASH, Vec::new(), None))
|
||||
.expect("a missing keydb is not an error")
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
// ── save / update (moved from libfreemkv::keydb) ──────────────────────────
|
||||
|
||||
// Per project convention, tests never touch /tmp (wiped on reboot). Anchor
|
||||
|
||||
+786
-101
File diff suppressed because it is too large
Load Diff
+69
-16
@@ -12,7 +12,7 @@
|
||||
//! 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_uk`], driving libfreemkv's boil-down crypto primitives for
|
||||
//! [`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
|
||||
@@ -30,7 +30,7 @@ mod paths;
|
||||
|
||||
pub use keydb::{KeydbSource, UpdateResult};
|
||||
pub use keydb_format::{DiscEntry, KeyDb};
|
||||
pub use online::{OnlineSource, validate_keyserver_url};
|
||||
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
|
||||
@@ -51,13 +51,25 @@ pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<U
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An ordered composition of key sources, driven as one. [`MultiSource::get_uk`]
|
||||
/// tries each inner source in order and returns the first non-empty Unit Key
|
||||
/// set. **The caller supplies the list AND the order** — local-first `[Keydb,
|
||||
/// 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>>,
|
||||
}
|
||||
@@ -69,20 +81,61 @@ impl MultiSource {
|
||||
}
|
||||
}
|
||||
|
||||
impl KeySource for MultiSource {
|
||||
/// Try each inner source in order; the FIRST to return a non-empty Unit Key
|
||||
/// set wins. An inner source that returns empty OR errors is treated as "no
|
||||
/// key here" and the next is tried (a single source failure never blocks the
|
||||
/// chain). All sources exhausted → empty.
|
||||
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
for s in &self.sources {
|
||||
if let Ok(uks) = s.get_uk(ctx) {
|
||||
if !uks.is_empty() {
|
||||
return Ok(uks);
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
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 {
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// UNION every inner source's host certs (filtered at the given MKB
|
||||
|
||||
+1130
-104
File diff suppressed because it is too large
Load Diff
+147
-6
@@ -1,8 +1,8 @@
|
||||
//! 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`/`Mapfile` parsers from libfreemkv, and
|
||||
//! the `KeySource` trait (`get_uk` over a `ResolveCtx`) the applications drive.
|
||||
//! 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
|
||||
@@ -71,8 +71,8 @@ fn inputs(hash: &str) -> DiscInputs {
|
||||
/// 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_uk(&ctx)
|
||||
.expect("get_uk must not error for these fixtures")
|
||||
src.get_unit_keys(&ctx)
|
||||
.expect("get_unit_keys must not error for these fixtures")
|
||||
}
|
||||
|
||||
// ── KeydbSource: real-file lookup by disc hash ──────────────────────────────
|
||||
@@ -130,7 +130,7 @@ fn keydb_source_missing_file_is_silent_ok_empty() {
|
||||
let inp = inputs(DISC_HASH);
|
||||
let ctx = DiscInputsCtx::new(&inp);
|
||||
assert!(
|
||||
src.get_uk(&ctx)
|
||||
src.get_unit_keys(&ctx)
|
||||
.expect("missing keydb is Ok, not Err")
|
||||
.is_empty()
|
||||
);
|
||||
@@ -258,7 +258,7 @@ impl ScriptedSource {
|
||||
}
|
||||
|
||||
impl KeySource for ScriptedSource {
|
||||
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(self.keys.clone())
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
@@ -362,6 +362,147 @@ fn multi_source_real_keydb_resolves_through_chain() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
|
||||
Reference in New Issue
Block a user