mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5077167ec | ||
|
|
9f99745912 | ||
|
|
e543bc4513 | ||
|
|
f13f0a58fe | ||
|
|
8945934ca9 | ||
|
|
99df6f7cb0 | ||
|
|
6ffc839397 | ||
|
|
28a07754e7 | ||
|
|
d6d701e199 | ||
|
|
aeec3d58d7 | ||
|
|
b5a96aa0f7 | ||
|
|
f76356c7ab | ||
|
|
c4259249e1 | ||
|
|
ca60c8fe1c | ||
|
|
2131c2c4e4 | ||
|
|
796da099a8 | ||
|
|
8efc287eb3 | ||
|
|
e963104dcf | ||
|
|
7408a916bf | ||
|
|
efaaab6ab8 | ||
|
|
ca7a0e41c2 | ||
|
|
102895330d | ||
|
|
acc0144870 | ||
|
|
d5261cc097 | ||
|
|
311aef939c | ||
|
|
ad16d745a4 | ||
|
|
1a586239bc | ||
|
|
a89e688866 | ||
|
|
03e42c1f5a | ||
|
|
3175b71fce | ||
|
|
ee6e5efa4e | ||
|
|
65e08c6dc2 | ||
|
|
10c6838506 | ||
|
|
1911e362a8 | ||
|
|
74160f00e9 | ||
|
|
22c37f1b50 | ||
|
|
00dd77d49b | ||
|
|
fe78d7445f | ||
|
|
36fb6f0c61 | ||
|
|
1a9a3befe3 | ||
|
|
d7152e0f73 | ||
|
|
6ceb92ade9 | ||
|
|
32b24c0ba7 | ||
|
|
974c1dc664 | ||
|
|
7766527e49 | ||
|
|
a0e5d0e405 | ||
|
|
26cdab9080 | ||
|
|
05420176d1 | ||
|
|
b90d64fe31 | ||
|
|
f6a1e08593 | ||
|
|
f208d6cf70 | ||
|
|
b577f7fa17 | ||
|
|
87e18c64cd | ||
|
|
c7db7c7f9c | ||
|
|
f18a5b3edf | ||
|
|
3f162511d3 | ||
|
|
9b7a707947 | ||
|
|
a6f73b624b | ||
|
|
cdadd3dc70 | ||
|
|
177ee8c08e | ||
|
|
c81fb8bbd4 | ||
|
|
ec86a22e7c | ||
|
|
5f550be7cf | ||
|
|
5ce5e424d3 | ||
|
|
086e763ab7 | ||
|
|
049a11c6d4 | ||
|
|
15e41136cf | ||
|
|
14f0f8c675 | ||
|
|
56fb0d53c9 | ||
|
|
7b6531069b | ||
|
|
7d935f2002 | ||
|
|
a546b050ec | ||
|
|
9726ba5980 | ||
|
|
dbe7bd257a | ||
|
|
251c08e6e3 | ||
|
|
02931638d8 | ||
|
|
8d4d8cb576 | ||
|
|
a774b483b4 | ||
|
|
dcc1438ae4 | ||
|
|
20e4b9d1c1 | ||
|
|
67f4eeb20e | ||
|
|
9a24177163 | ||
|
|
8fbf5676a0 | ||
|
|
8ad4e5788c | ||
|
|
e5939245be | ||
|
|
140af1d29b | ||
|
|
611901404c | ||
|
|
5b30c5d849 | ||
|
|
2b4014dd24 | ||
|
|
e27e876f49 |
@@ -0,0 +1,175 @@
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
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:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
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:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
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
|
||||
@@ -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@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
|
||||
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
|
||||
# lexical sort gets that backwards from the tenth candidate on.
|
||||
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
|
||||
tag="v$v-rc$(( ${n:-0} + 1 ))"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The debug suite runs on every dev push. Release is a DIFFERENT build:
|
||||
# overflow checks are off, debug_assert! is compiled out, and inlining
|
||||
# changes what the optimiser can prove. A test that only passes in debug is
|
||||
# a test that never guarded the binary anyone actually ships.
|
||||
release-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
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@v5
|
||||
with:
|
||||
path: freemkv-keysources
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: freemkv/libfreemkv
|
||||
ref: qa
|
||||
path: libfreemkv
|
||||
- uses: actions/checkout@v5
|
||||
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
|
||||
@@ -22,26 +27,35 @@ jobs:
|
||||
fi
|
||||
echo "Version match: $CARGO_VER"
|
||||
|
||||
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
|
||||
# release job does NOT `needs:` this job. The tag decision was already gated by
|
||||
# the local precommit (same Rust 1.86, same commit).
|
||||
#
|
||||
# freemkv-keysources is a library — Cargo.lock isn't tracked, so --locked
|
||||
# would always fail (no lockfile to lock against on a fresh runner). Its
|
||||
# libfreemkv dep resolves from crates.io (the dev .cargo/config.toml patch is
|
||||
# gitignored and absent on the runner), so libfreemkv must already be published
|
||||
# at this version — the release script enforces that ordering before tagging.
|
||||
test:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
# freemkv-keysources is a library — Cargo.lock isn't tracked, so --locked
|
||||
# would always fail (no lockfile to lock against on a fresh runner). Its
|
||||
# libfreemkv dep resolves from crates.io (the dev .cargo/config.toml patch
|
||||
# is gitignored and absent on the runner), so libfreemkv must already be
|
||||
# published at this version — the release script enforces that ordering.
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test
|
||||
|
||||
# NOTE: the crates.io publish is driven by the maintainer's release tooling
|
||||
# from local credentials, not from CI — this repo has no CARGO_REGISTRY_TOKEN
|
||||
# secret. CI here only verifies the version/tag match, runs tests, and cuts
|
||||
# the GitHub Release.
|
||||
# secret. release.sh runs `cargo publish --no-verify` locally. CI here only
|
||||
# verifies the version/tag match, runs tests (tripwire), and cuts the GitHub
|
||||
# Release. The binaries git-tag-pin keysources, so they don't wait on the
|
||||
# crates.io publish — it serves external consumers in parallel.
|
||||
|
||||
release:
|
||||
needs: test
|
||||
# Only needs `verify`; the GitHub Release is cut as soon as the version
|
||||
# check passes, in parallel with the test tripwire.
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
+23
-5
@@ -1,21 +1,31 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.0.0-rc.3.1"
|
||||
version = "1.6.1"
|
||||
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.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"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
# 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"] }
|
||||
# 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.5"`
|
||||
# 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.1" }
|
||||
|
||||
@@ -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.
|
||||
+845
-159
File diff suppressed because it is too large
Load Diff
+1605
File diff suppressed because it is too large
Load Diff
+75
-119
@@ -1,162 +1,118 @@
|
||||
//! 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.
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
/// Try each inner source in order; the FIRST to return a non-empty base 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_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
for s in &self.sources {
|
||||
if let Ok(uks) = s.get_unit_keys(ctx)
|
||||
&& !uks.is_empty()
|
||||
{
|
||||
return Ok(uks);
|
||||
}
|
||||
self.idx += 1; // this source is spent — advance to the next
|
||||
}
|
||||
None
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
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. 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> {
|
||||
for s in &self.sources {
|
||||
if let Ok(uks) = s.get_fmts_indexes(ctx)
|
||||
&& !uks.is_empty()
|
||||
{
|
||||
return Ok(uks);
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
+612
-137
@@ -4,11 +4,31 @@ use std::io::Read;
|
||||
use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::uks_from_vuk;
|
||||
use base64::Engine;
|
||||
use libfreemkv::{DiscInputs, Key, KeySource};
|
||||
use libfreemkv::aacs::types::UnitKey;
|
||||
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
|
||||
use libfreemkv::{Error, KeySource};
|
||||
|
||||
const MAX_MKB_BYTES: usize = 10 * 1024 * 1024;
|
||||
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
|
||||
// libfreemkv's `read_mkb_content` MAX_BYTES (64 MiB) so an MKB the library is
|
||||
// willing to capture is never silently un-forwardable here (a trimmed MKB
|
||||
// record stream is normally a few MiB; this is headroom, not an expected size).
|
||||
const MAX_MKB_BYTES: usize = 64 * 1024 * 1024;
|
||||
const TIMEOUT_SECS: u64 = 180;
|
||||
/// Minimum encrypted-content samples the online source will send in one key
|
||||
/// request — re-exported from the base crate ([`libfreemkv::keysource::MIN_SAMPLE_UNITS`])
|
||||
/// so this crate and libfreemkv's own FMTS forensic query share ONE value.
|
||||
///
|
||||
/// The service identifies the key by which of the submitted units it decrypts,
|
||||
/// so too few samples — especially on FMTS, where a segment interleaves several
|
||||
/// variants at the unit level — can return a key that matches an incidental unit
|
||||
/// rather than the one asked about (a false positive). A request carrying fewer
|
||||
/// is refused (empty result → the resolver moves to the next source) rather than
|
||||
/// sent and trusted. Kept public so callers that GATHER the samples (the CLI,
|
||||
/// autorip) sample at least this many — sampling fewer guarantees the request is
|
||||
/// skipped and the online source never consulted.
|
||||
pub use libfreemkv::keysource::MIN_SAMPLE_UNITS;
|
||||
/// Hard cap on the key-service response body. A real unit-key reply is a few
|
||||
/// hundred bytes; bound the read so a malicious/compromised server can't drive
|
||||
/// the client to OOM with an unbounded body.
|
||||
@@ -67,34 +87,59 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`resolve_and_guard`] rejected a URL. The two halves demand OPPOSITE
|
||||
/// operator actions, so they must not be collapsed: a `Config` rejection is a
|
||||
/// standing misconfiguration that will never fix itself, while `Unreachable` is
|
||||
/// the key service being down right now — exactly the condition this module's
|
||||
/// callers must not report as "this disc has no key".
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GuardFail {
|
||||
/// Malformed URL, bad scheme, or a host that is (or resolves to) a
|
||||
/// non-public address. Operator configuration; retrying changes nothing.
|
||||
Config,
|
||||
/// The host did not resolve — DNS failure or DNS timeout. The service is
|
||||
/// unreachable; nothing is known about this disc's key. Transient.
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
/// Resolve `url`'s host and validate every resulting address against the SSRF
|
||||
/// guard. Returns the pinned socket addresses (for use with a custom ureq
|
||||
/// resolver) on success, or an error message on rejection.
|
||||
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
/// resolver) on success, or the rejection reason plus an explanatory message.
|
||||
///
|
||||
/// SECURITY: the message names the resolved address on an SSRF rejection and is
|
||||
/// for the CONFIG-time caller only — never log it (see the call site in
|
||||
/// [`OnlineSource::query`]).
|
||||
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)> {
|
||||
let rest = if let Some(r) = url.strip_prefix("https://") {
|
||||
(r, 443u16)
|
||||
} else if let Some(r) = url.strip_prefix("http://") {
|
||||
(r, 80u16)
|
||||
} else {
|
||||
return Err("URL must start with http:// or https://".into());
|
||||
return Err((
|
||||
GuardFail::Config,
|
||||
"URL must start with http:// or https://".into(),
|
||||
));
|
||||
};
|
||||
let (authority, default_port) = rest;
|
||||
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
|
||||
let authority = authority.rsplit('@').next().unwrap_or(authority);
|
||||
if authority.is_empty() {
|
||||
return Err("URL has no host".into());
|
||||
return Err((GuardFail::Config, "URL has no host".into()));
|
||||
}
|
||||
let (host, port): (String, u16) = if let Some(stripped) = authority.strip_prefix('[') {
|
||||
match stripped.split_once(']') {
|
||||
Some((h, after)) => {
|
||||
let p = after
|
||||
.strip_prefix(':')
|
||||
.map(|s| s.parse::<u16>().map_err(|_| "invalid port".to_string()))
|
||||
.map(|s| {
|
||||
s.parse::<u16>()
|
||||
.map_err(|_| (GuardFail::Config, "invalid port".to_string()))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(default_port);
|
||||
(h.to_string(), p)
|
||||
}
|
||||
None => return Err("malformed IPv6 host".into()),
|
||||
None => return Err((GuardFail::Config, "malformed IPv6 host".into())),
|
||||
}
|
||||
} else if let Some((h, p)) = authority.rsplit_once(':') {
|
||||
match p.parse::<u16>() {
|
||||
@@ -105,7 +150,7 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
(authority.to_string(), default_port)
|
||||
};
|
||||
if host.is_empty() {
|
||||
return Err("URL has no host".into());
|
||||
return Err((GuardFail::Config, "URL has no host".into()));
|
||||
}
|
||||
// `to_socket_addrs` is a BLOCKING DNS lookup that can hang for the OS
|
||||
// resolver timeout (tens of seconds) and freeze the rip thread that called
|
||||
@@ -126,18 +171,29 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
});
|
||||
match rx.recv_timeout(DNS_TIMEOUT) {
|
||||
Ok(Ok(addrs)) => addrs,
|
||||
Ok(Err(e)) => return Err(format!("could not resolve host: {e}")),
|
||||
Err(_) => return Err("DNS resolution timed out".into()),
|
||||
Ok(Err(e)) => {
|
||||
return Err((
|
||||
GuardFail::Unreachable,
|
||||
format!("could not resolve host: {e}"),
|
||||
));
|
||||
}
|
||||
Err(_) => return Err((GuardFail::Unreachable, "DNS resolution timed out".into())),
|
||||
}
|
||||
};
|
||||
if addrs.is_empty() {
|
||||
return Err("host did not resolve to any address".into());
|
||||
return Err((
|
||||
GuardFail::Unreachable,
|
||||
"host did not resolve to any address".into(),
|
||||
));
|
||||
}
|
||||
for a in &addrs {
|
||||
if is_blocked_ip(&a.ip()) {
|
||||
return Err(format!(
|
||||
"refusing to connect to non-public address {} (SSRF guard)",
|
||||
a.ip()
|
||||
return Err((
|
||||
GuardFail::Config,
|
||||
format!(
|
||||
"refusing to connect to non-public address {} (SSRF guard)",
|
||||
a.ip()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -158,7 +214,7 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, String> {
|
||||
/// SAME bounded-resolve, so their verdicts never diverge — the reason this lives
|
||||
/// here, in the key-source crate, rather than being re-rolled per application.
|
||||
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
|
||||
resolve_and_guard(url).map(|_| ())
|
||||
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
|
||||
}
|
||||
|
||||
/// Build a ureq agent that follows zero redirects (so a public URL can't
|
||||
@@ -176,14 +232,6 @@ fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
|
||||
pub struct OnlineSource {
|
||||
base_url: String,
|
||||
secret: String,
|
||||
/// The key service pre-validates server-side and returns a single UK, so it
|
||||
/// is asked **at most once** — this flips true after the first `next_key`,
|
||||
/// and every later ask returns `None` without re-hitting the network.
|
||||
asked: bool,
|
||||
/// Set when the round-trip itself failed (network down, bad response) — as
|
||||
/// opposed to the service simply having no key. Lets the caller report
|
||||
/// "key service unreachable" distinctly from "no key for this disc".
|
||||
errored: bool,
|
||||
}
|
||||
|
||||
impl OnlineSource {
|
||||
@@ -191,48 +239,113 @@ impl OnlineSource {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
secret: secret.into(),
|
||||
asked: false,
|
||||
errored: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The single server-resolved UK for this disc, or `None`. Runs exactly the
|
||||
/// one network round-trip; `next_key` gates it to one call per session.
|
||||
fn query(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
||||
// No configured service: a clean None ("no service"), not an error.
|
||||
/// The server-resolved Unit Keys for this disc. Runs exactly one network
|
||||
/// round-trip. The service returns either a terminal `UK` (used directly) or
|
||||
/// a `VUK` (derived to Unit Keys locally via the disc's encrypted title keys
|
||||
/// from `ctx`).
|
||||
///
|
||||
/// The return type draws THE distinction this source exists to draw:
|
||||
///
|
||||
/// * `Ok(non-empty)` — the service answered with a key.
|
||||
/// * `Ok(empty)` — the service ANSWERED and holds no key for this disc, or
|
||||
/// this source had nothing to ask with (no service configured, a
|
||||
/// misconfigured URL, an over-cap MKB, too few samples). A genuine miss;
|
||||
/// the resolver moves to the next source and `E7022` is the right verdict.
|
||||
/// * `Err(..)` — the service could not answer: unreachable, timed out, DNS
|
||||
/// failed, returned 5xx, rejected the token (401/403), rate-limited (429),
|
||||
/// or replied with something unreadable. NOTHING is known about whether a
|
||||
/// key exists. Collapsing this into `Ok(empty)` is the bug this signature
|
||||
/// fixes: a seven-hour run of HTTP 502s was reported to operators as
|
||||
/// `E7022 No key source has a decryption key for this disc`, and they went
|
||||
/// hunting for a VUK when the correct action was to wait.
|
||||
///
|
||||
/// `&self`: one-shot is the resolver's contract (each source's
|
||||
/// `get_unit_keys` is called once), so no per-call latch is needed.
|
||||
fn query(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
// No configured service: nothing to resolve.
|
||||
if self.base_url.is_empty() {
|
||||
return None;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// An over-cap MKB is a real failure to resolve THIS disc, not "no
|
||||
// service" — flag it so the caller reports it distinctly (and a later
|
||||
// ask doesn't conflate it with a missing key).
|
||||
if inputs.mkb.len() > MAX_MKB_BYTES {
|
||||
self.errored = true;
|
||||
return None;
|
||||
// Refuse to transmit over plaintext. The request body always carries
|
||||
// base64 AACS key material (inf/mkb/units/vid) and, when configured, a
|
||||
// bearer token in a header — so an `http://` POST hands both to any
|
||||
// on-path observer, and the token is replayable.
|
||||
//
|
||||
// There is no legitimate plaintext deployment to preserve here: the
|
||||
// address guard below rejects loopback, RFC1918, ULA and link-local, so
|
||||
// an `http://` URL can only ever reach the PUBLIC internet — the single
|
||||
// worst case for cleartext. A self-hosted keyserver on a LAN is already
|
||||
// impossible by that guard, whatever its scheme.
|
||||
//
|
||||
// Falls through to the next key source (a local keydb) rather than
|
||||
// failing the rip, and says why — misconfiguration should be visible,
|
||||
// not silently indistinguishable from "this disc has no key".
|
||||
if !self.base_url.starts_with("https://") {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
"key-service URL is not https:// — refusing to send key material \
|
||||
and credentials in cleartext; skipping the online source"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||
// An over-cap MKB cannot be forwarded — bound the body. Log it: a silent
|
||||
// empty return here is indistinguishable from "no key", so surface the
|
||||
// real cause (the cap is 64 MiB, far above any real trimmed MKB).
|
||||
if mkb.len() > MAX_MKB_BYTES {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
mkb_len = mkb.len(),
|
||||
cap = MAX_MKB_BYTES,
|
||||
"MKB exceeds the key-service forward cap; skipping the online source for this disc (no key from online)"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Gather encrypted-content samples and prove the minimum by TYPE: a
|
||||
// `DecodeSampleSet` only exists with >= MIN_SAMPLE_UNITS units, so from here
|
||||
// on the request cannot be built under-sized. The service resolves a key by
|
||||
// which submitted unit it decrypts, so a request carrying too few can return
|
||||
// a key matching an incidental unit (a false positive, seen on FMTS variant
|
||||
// units) — too few → skip this source and fall through to the next.
|
||||
let gathered = ctx.samples(64).unwrap_or_default();
|
||||
let n = gathered.len();
|
||||
let Some(samples) = DecodeSampleSet::new(gathered) else {
|
||||
tracing::info!(
|
||||
target: "freemkv::keysource",
|
||||
samples = n,
|
||||
min = MIN_SAMPLE_UNITS,
|
||||
"too few content samples for a reliable online key request; skipping the online source"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let mut body = serde_json::json!({
|
||||
"inf_b64": b64.encode(&inputs.unit_key_ro),
|
||||
"mkb_b64": b64.encode(&inputs.mkb),
|
||||
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
|
||||
// derivation, so it needs the unparsed blob (not enc_title_keys).
|
||||
"inf_b64": b64.encode(ctx.unit_key_ro()),
|
||||
"mkb_b64": b64.encode(mkb),
|
||||
});
|
||||
if inputs.volume_id != [0u8; 16] {
|
||||
body["vid_b64"] = serde_json::Value::String(b64.encode(inputs.volume_id));
|
||||
}
|
||||
if !inputs.samples.is_empty() {
|
||||
body["units_b64"] = serde_json::Value::Array(
|
||||
inputs
|
||||
.samples
|
||||
.iter()
|
||||
.map(|u| serde_json::Value::String(b64.encode(u)))
|
||||
.collect(),
|
||||
);
|
||||
if let Some(vid) = ctx.vid() {
|
||||
body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0));
|
||||
}
|
||||
// Encrypted-content samples for server-side ciphertext validation (already
|
||||
// gathered + minimum-checked above).
|
||||
body["units_b64"] = serde_json::Value::Array(
|
||||
samples
|
||||
.units()
|
||||
.iter()
|
||||
.map(|u| serde_json::Value::String(b64.encode(u)))
|
||||
.collect(),
|
||||
);
|
||||
// The disc's own title (UDF/ISO volume id), plain text. The key service
|
||||
// catalogs it by disc_hash (its disc-titles.json) — independent of keydb.
|
||||
if let Some(label) = inputs.volume_label.as_deref().map(str::trim) {
|
||||
if !label.is_empty() {
|
||||
body["title"] = serde_json::Value::String(label.to_string());
|
||||
}
|
||||
if let Some(label) = ctx.title().map(str::trim)
|
||||
&& !label.is_empty()
|
||||
{
|
||||
body["title"] = serde_json::Value::String(label.to_string());
|
||||
}
|
||||
// Resolve + SSRF-guard the host just before the POST; pin the
|
||||
// validated addresses into a redirect-disabled agent so a DNS
|
||||
@@ -240,9 +353,32 @@ impl OnlineSource {
|
||||
// request (and the bearer token) to an internal/metadata host.
|
||||
let pinned = match resolve_and_guard(&self.base_url) {
|
||||
Ok(addrs) => addrs,
|
||||
Err(_) => {
|
||||
self.errored = true;
|
||||
return None;
|
||||
// A host that did not RESOLVE is the service being unreachable, not a
|
||||
// bad URL — DNS failure and DNS timeout are exactly what a key-service
|
||||
// outage looks like from here, and reporting them as a genuine miss is
|
||||
// the same conflation as reporting a 502 that way.
|
||||
Err((GuardFail::Unreachable, _)) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key-service host did not resolve (DNS failure or timeout); \
|
||||
the service is unreachable, not out of keys"
|
||||
);
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
Err((GuardFail::Config, _)) => {
|
||||
// Log THAT the URL was rejected, never WHY: the guard's message
|
||||
// names the resolved address, and an internal address must not
|
||||
// reach a log an operator may paste into a bug report. The
|
||||
// static label is enough to separate "misconfigured/blocked
|
||||
// key-service URL" from "this disc has no key", which is the
|
||||
// only distinction the operator needs here.
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key-service URL failed the address guard; skipping the online source"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let agent = hardened_agent(pinned);
|
||||
@@ -258,80 +394,236 @@ impl OnlineSource {
|
||||
// material.
|
||||
tracing::info!(target: "freemkv::keysource", phase = "keyserver_post", "begin");
|
||||
let post_t0 = std::time::Instant::now();
|
||||
let resp = match req.send_json(body) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
||||
"keyserver request failed (timeout, network, or HTTP error)"
|
||||
);
|
||||
self.errored = true;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms = post_t0.elapsed().as_millis() as u64,
|
||||
"end"
|
||||
);
|
||||
// Bounded read: cap the body so a hostile server can't OOM the client.
|
||||
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
|
||||
let mut buf = Vec::new();
|
||||
if resp
|
||||
.into_reader()
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.is_err()
|
||||
|| buf.len() > MAX_RESPONSE_BYTES
|
||||
{
|
||||
self.errored = true;
|
||||
return None;
|
||||
}
|
||||
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||
Ok(j) => j,
|
||||
Err(_) => {
|
||||
self.errored = true;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
json.get("UK")
|
||||
.and_then(|u| u.as_str())
|
||||
.and_then(parse_uk)
|
||||
.map(|uk| Key::Unit(vec![(1, uk)]))
|
||||
let sent = req.send_json(body);
|
||||
interpret_reply(sent, ctx, post_t0.elapsed().as_millis() as u64)
|
||||
}
|
||||
}
|
||||
|
||||
impl KeySource for OnlineSource {
|
||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key> {
|
||||
// One shot: the service pre-validates and returns a single UK, so a
|
||||
// second ask has nothing new to offer — don't re-hit the network.
|
||||
if self.asked {
|
||||
return None;
|
||||
/// Map a key-service HTTP status the client never asked for into the operator
|
||||
/// action it implies. THE table this fix exists for — each arm is a different
|
||||
/// thing for a person to do, and none of them is "your disc has no key":
|
||||
///
|
||||
/// * 401 / 403 → the token is wrong or expired. Fix the credentials.
|
||||
/// * 429 → rate limited. Back off and retry more slowly.
|
||||
/// * 5xx → the service is down. Wait; this is the seven-hour-502 case.
|
||||
/// * anything else non-2xx → the service is misbehaving in a way this client
|
||||
/// cannot interpret. Treated as unavailable, deliberately: the genuine miss is
|
||||
/// a **200 with no key in the body**, so a non-2xx status is never evidence
|
||||
/// about this disc, and guessing "no key" from one is the original bug.
|
||||
fn classify_http_status(code: u16) -> Error {
|
||||
match code {
|
||||
401 | 403 => Error::KeyServiceUnauthorized,
|
||||
429 => Error::KeyServiceRateLimited,
|
||||
_ => Error::KeyServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the raw outcome of the key-service POST into this source's answer.
|
||||
///
|
||||
/// Split out of [`OnlineSource::query`] so the reply → verdict mapping is
|
||||
/// testable WITHOUT a network: the SSRF guard in `resolve_and_guard` blocks
|
||||
/// loopback by design, so a stub HTTP server on 127.0.0.1 can never be reached
|
||||
/// through `query`, and this seam is the only honest way to drive "the service
|
||||
/// returned 502" and "the service returned 200 with no entry" through the same
|
||||
/// code the rip uses.
|
||||
///
|
||||
/// `elapsed_ms` is the round-trip time, for the timing logs only.
|
||||
///
|
||||
/// SECURITY: logs status codes, byte counts and static labels only. The request
|
||||
/// body carries base64 AACS key material and the reply carries keys — neither is
|
||||
/// ever logged, and nor is a serde parse error (it quotes the offending input).
|
||||
fn interpret_reply(
|
||||
sent: Result<ureq::Response, ureq::Error>,
|
||||
ctx: &dyn ResolveCtx,
|
||||
elapsed_ms: u64,
|
||||
) -> Result<Vec<UnitKey>, Error> {
|
||||
let resp = match sent {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Record the HTTP status when the service actually answered:
|
||||
// 401/403 (bad or expired token), 429 (rate limited) and
|
||||
// 5xx (service down) are completely different operator actions,
|
||||
// and collapsing them into one line is why a 502 was read as
|
||||
// "this disc has no key". The status is the service's own
|
||||
// response code — it carries no key material and no address.
|
||||
// A transport error has no status; report it as such rather
|
||||
// than inventing one.
|
||||
//
|
||||
// Each arm now RETURNS the classified error instead of an empty
|
||||
// vec, so the distinction survives past this function.
|
||||
return Err(match e {
|
||||
ureq::Error::Status(code, _) => {
|
||||
let err = classify_http_status(code);
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
http_status = code,
|
||||
error_code = err.code(),
|
||||
elapsed_ms,
|
||||
"key service returned an HTTP error; no ANSWER from online (not a missing key)"
|
||||
);
|
||||
err
|
||||
}
|
||||
ureq::Error::Transport(_) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms,
|
||||
"key service unreachable (connect/timeout/TLS); no ANSWER from online (not a missing key)"
|
||||
);
|
||||
Error::KeyServiceUnavailable
|
||||
}
|
||||
});
|
||||
}
|
||||
self.asked = true;
|
||||
self.query(inputs)
|
||||
};
|
||||
tracing::info!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
elapsed_ms,
|
||||
"end"
|
||||
);
|
||||
// Bounded read: cap the body so a hostile server can't OOM the client.
|
||||
// Reading MAX_RESPONSE_BYTES+1 lets us detect (and reject) an over-cap body.
|
||||
let mut buf = Vec::new();
|
||||
if resp
|
||||
.into_reader()
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut buf)
|
||||
.is_err()
|
||||
|| buf.len() > MAX_RESPONSE_BYTES
|
||||
{
|
||||
// Length only — never the body, which carries base64 key material.
|
||||
// A truncated / over-cap body is the service failing mid-answer: the
|
||||
// question went unanswered, so this is a source failure, not a miss.
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
cap = MAX_RESPONSE_BYTES,
|
||||
"key-service reply was unreadable or over the size cap; no ANSWER from online"
|
||||
);
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
let json: serde_json::Value = match serde_json::from_slice(&buf) {
|
||||
Ok(j) => j,
|
||||
Err(_) => {
|
||||
// Never log the parse error or the payload: a serde message
|
||||
// quotes the offending input, which here is key material.
|
||||
// Unparseable is not "no key" — it is the service answering in a
|
||||
// language this client does not speak. Transient / a service bug.
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key-service reply was not valid JSON; no ANSWER from online"
|
||||
);
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
};
|
||||
// `UK` is an ARRAY of hex keys (the service always returns an array now,
|
||||
// even of one). A single element is the base Unit Key. A full set (one per
|
||||
// forensic index, ordered index 1..N) is returned for a forensic sample.
|
||||
// Preserve array order and tag each key with its array position, so the
|
||||
// caller can map position → index (element i = index i+1). A bare string is
|
||||
// still accepted for backward compatibility.
|
||||
if let Some(uk) = json.get("UK") {
|
||||
let mut out = Vec::new();
|
||||
if let Some(s) = uk.as_str() {
|
||||
if let Some(k) = parse_uk(s) {
|
||||
out.push(UnitKey::new(0, k));
|
||||
}
|
||||
} else if let Some(arr) = uk.as_array() {
|
||||
// A forensic set is only usable COMPLETE: the mux trusts any
|
||||
// non-empty result as the whole set and never assumes 32 (see
|
||||
// `get_fmts_indexes`). Skipping an unparseable element would
|
||||
// hand back a short set that silently omits an index, so a
|
||||
// malformed element rejects the whole reply — that is a service
|
||||
// bug, not a usable answer, and therefore a source FAILURE.
|
||||
let mut bad = false;
|
||||
for (i, v) in arr.iter().enumerate() {
|
||||
match v.as_str().and_then(parse_uk) {
|
||||
Some(k) => out.push(UnitKey::new(i as u32, k)),
|
||||
None => {
|
||||
bad = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad {
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
keys = arr.len(),
|
||||
"key-service returned a malformed key in the UK set; rejecting the whole reply"
|
||||
);
|
||||
return Err(Error::KeyServiceUnavailable);
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
// A VUK is derived to the terminal keys locally, via the disc's
|
||||
// encrypted title keys from the context — the library owns the crypto.
|
||||
if let Some(vuk) = json.get("VUK").and_then(|u| u.as_str()).and_then(parse_uk) {
|
||||
match ctx.enc_title_keys() {
|
||||
Ok(enc) => return Ok(uks_from_vuk(&vuk, enc)),
|
||||
Err(_) => {
|
||||
// The SERVICE answered correctly; the DISC's encrypted title keys
|
||||
// are what could not be read. Not a service failure, so not an
|
||||
// `Err` from this source — the disc-side reason is the library's
|
||||
// to report.
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key-service returned a VUK but the disc's encrypted title keys \
|
||||
are unreadable; cannot derive unit keys"
|
||||
);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
// The genuine miss — and the ONLY path that returns `Ok(empty)` from a
|
||||
// completed round-trip. Logged distinctly from every failure above so an
|
||||
// operator can tell "the service has no key for this disc" from "the
|
||||
// service did not answer"; collapsing the two is what made a 502 look like
|
||||
// a missing key. This is the one case where `E7022` is the truth.
|
||||
tracing::info!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
"key service has no key for this disc"
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
impl KeySource for OnlineSource {
|
||||
/// Base per-CPS-unit Unit Keys: submit the ctx's content samples and take the
|
||||
/// service's reply (a terminal `UK`, or a `VUK` derived locally). One network
|
||||
/// round-trip. An empty `Ok` means the service ANSWERED and holds no key; an
|
||||
/// `Err` means it could not answer (see [`OnlineSource::query`]). Both let the
|
||||
/// resolver try the next source — the difference is what the operator is told
|
||||
/// when no source has one.
|
||||
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
self.query(ctx)
|
||||
}
|
||||
|
||||
fn needs_samples(&self) -> bool {
|
||||
true
|
||||
/// AACS 2.1 forensic index set: the mux injects an index-1 single-phase anchor
|
||||
/// batch as the ctx's samples; the service maps it to the full ordered set of
|
||||
/// forensic index keys, tagged by array position (element `i` → forensic index
|
||||
/// `i + 1`). Same one round-trip as [`get_unit_keys`](Self::get_unit_keys) —
|
||||
/// the difference is purely which samples the mux gathered and how the caller
|
||||
/// reads the reply. The count is whatever the service returns; the mux trusts
|
||||
/// any non-empty result as the complete set and never assumes 32.
|
||||
fn get_fmts_indexes(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
self.query(ctx)
|
||||
}
|
||||
|
||||
fn errored(&self) -> bool {
|
||||
self.errored
|
||||
fn label(&self) -> &'static str {
|
||||
"online"
|
||||
}
|
||||
|
||||
fn host_certs(&self) -> Vec<libfreemkv::aacs::HostCert> {
|
||||
// NO-OP STUB. The online service does not serve host certs today: there
|
||||
// is no client-side fetch and no server-side endpoint for them. Returning
|
||||
// empty makes the OEM cert route fall back to whatever other source
|
||||
// (e.g. the keydb) supplies — and fail gracefully if none does. No
|
||||
// network is touched here.
|
||||
// TODO(owner): online host-cert serving — design when 0x83 cert is recovered
|
||||
Vec::new()
|
||||
}
|
||||
// host_certs: the no-op default. The online service does not serve host
|
||||
// certs today (no client-side fetch, no server-side endpoint), so the OEM
|
||||
// cert route falls back to whatever other source (e.g. the keydb) supplies.
|
||||
// No network is touched. (Future task: online host-cert serving.)
|
||||
}
|
||||
|
||||
/// The `Authorization` header value for a key-service request, or `None` when no
|
||||
@@ -347,20 +639,9 @@ fn bearer_header(secret: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
fn parse_uk(hex: &str) -> Option<[u8; 16]> {
|
||||
if hex.len() != 32 {
|
||||
return None;
|
||||
}
|
||||
// Reject any non-hex byte up front. `u8::from_str_radix` on a 2-char
|
||||
// window otherwise accepts sign prefixes (e.g. "+5", "-A"), letting a
|
||||
// signed/whitespace-tainted string slip through as a valid key.
|
||||
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
for (i, b) in out.iter_mut().enumerate() {
|
||||
*b = u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
// The one workspace hex parser: byte-based (rejects sign chars / multi-byte),
|
||||
// 32 hex digits → [u8; 16], with an optional 0x/0X prefix tolerated.
|
||||
libfreemkv::hex::parse_hex_fixed::<16>(hex)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -427,11 +708,15 @@ mod tests {
|
||||
/// it's the deliberate no-op stub.
|
||||
#[test]
|
||||
fn host_certs_is_noop_empty_no_network() {
|
||||
let src = OnlineSource::new("http://example.invalid/keys", "secret");
|
||||
let src = OnlineSource::new("http://example.test/keys", "secret");
|
||||
assert!(
|
||||
KeySource::host_certs(&src).is_empty(),
|
||||
KeySource::host_certs(&src, None).is_empty(),
|
||||
"online host_certs must be an empty no-op (no network)"
|
||||
);
|
||||
assert!(
|
||||
KeySource::host_certs(&src, Some(68)).is_empty(),
|
||||
"still empty regardless of the MKB generation"
|
||||
);
|
||||
}
|
||||
|
||||
// ── resolve_and_guard ──────────────────────────────────────────────────
|
||||
@@ -495,6 +780,196 @@ mod tests {
|
||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||
}
|
||||
|
||||
// ── the reply → verdict mapping (THE defect) ───────────────────────────
|
||||
//
|
||||
// Driven through `interpret_reply`, the seam `query` hands its round-trip
|
||||
// to. A stub HTTP server is NOT usable here: `resolve_and_guard` blocks
|
||||
// loopback by design (SSRF guard), so 127.0.0.1 can never be reached through
|
||||
// `query`, and pointing the test at a public host would make it a network
|
||||
// test. Everything from the response onward is this function.
|
||||
|
||||
/// A `ResolveCtx` that carries nothing — enough for the reply paths that do
|
||||
/// not derive from a VUK.
|
||||
struct BareCtx;
|
||||
impl ResolveCtx for BareCtx {
|
||||
fn disc_hash(&self) -> &str {
|
||||
"0x422EB"
|
||||
}
|
||||
fn title(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn vid(&self) -> Option<libfreemkv::aacs::types::Vid> {
|
||||
None
|
||||
}
|
||||
fn mkb(&self) -> Result<&[u8], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn enc_title_keys(&self) -> Result<&[[u8; 16]], Error> {
|
||||
Ok(&[])
|
||||
}
|
||||
fn samples(&self, _n: usize) -> Result<Vec<Vec<u8>>, Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(status: u16, body: &str) -> ureq::Response {
|
||||
ureq::Response::new(status, "stub", body).expect("stub response")
|
||||
}
|
||||
|
||||
/// THE regression. The key service returned HTTP 502 for ~seven hours and
|
||||
/// the CLI told operators the disc had no key, so they went hunting for a
|
||||
/// VUK that was never missing. A 5xx must be a source FAILURE, and it must
|
||||
/// be distinguishable from the service answering 200 with no entry.
|
||||
#[test]
|
||||
fn http_5xx_is_a_source_failure_not_a_missing_key() {
|
||||
for status in [500u16, 502, 503, 504] {
|
||||
let out = interpret_reply(
|
||||
Err(ureq::Error::Status(status, reply(status, ""))),
|
||||
&BareCtx,
|
||||
7,
|
||||
);
|
||||
assert_eq!(
|
||||
out.expect_err("a 5xx must not look like an answer").code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
"HTTP {status} must report the service as unavailable"
|
||||
);
|
||||
}
|
||||
|
||||
// The contrast case, through the SAME function: the service answered,
|
||||
// and genuinely holds nothing for this disc.
|
||||
let miss = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 7)
|
||||
.expect("a 200 with no key is an ANSWER, not a failure");
|
||||
assert!(miss.is_empty(), "no key in the body → no keys out");
|
||||
|
||||
// And the two must not be the same outcome — the whole point.
|
||||
let down = interpret_reply(Err(ureq::Error::Status(502, reply(502, ""))), &BareCtx, 7);
|
||||
assert!(
|
||||
down.is_err(),
|
||||
"502 and 200-with-no-entry must not collapse to the same result"
|
||||
);
|
||||
}
|
||||
|
||||
/// Each status the service can return maps to a DIFFERENT operator action:
|
||||
/// fix the token (401/403), back off (429), wait (5xx). A non-2xx status is
|
||||
/// never evidence about this disc, so anything else is "unavailable" too —
|
||||
/// guessing "no key" from a status code is the original bug.
|
||||
#[test]
|
||||
fn http_status_maps_to_the_operator_action() {
|
||||
let cases: &[(u16, u16)] = &[
|
||||
(401, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED),
|
||||
(403, libfreemkv::error::E_KEY_SERVICE_UNAUTHORIZED),
|
||||
(429, libfreemkv::error::E_KEY_SERVICE_RATE_LIMITED),
|
||||
(500, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||
(502, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||
(400, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||
(404, libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE),
|
||||
];
|
||||
for (status, want) in cases {
|
||||
assert_eq!(
|
||||
classify_http_status(*status).code(),
|
||||
*want,
|
||||
"HTTP {status} classified wrongly"
|
||||
);
|
||||
assert_ne!(
|
||||
classify_http_status(*status).code(),
|
||||
libfreemkv::error::E_NO_DISC_KEY,
|
||||
"no HTTP status may ever mean \"this disc has no key\""
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport failure — nothing answered at all — is the same verdict as a
|
||||
/// 5xx and equally not a missing key. Uses a refused connection to
|
||||
/// 127.0.0.1:1 to obtain a REAL `ureq::Error::Transport` (which has no public
|
||||
/// constructor); this touches no network and never reaches `query`, so the
|
||||
/// SSRF guard is not involved.
|
||||
#[test]
|
||||
fn transport_failure_is_a_source_failure() {
|
||||
let sent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(Duration::from_secs(2))
|
||||
.build()
|
||||
.post("http://127.0.0.1:1/")
|
||||
.send_string("{}");
|
||||
assert!(
|
||||
matches!(sent, Err(ureq::Error::Transport(_))),
|
||||
"a refused connection must surface as a ureq transport error"
|
||||
);
|
||||
assert_eq!(
|
||||
interpret_reply(sent, &BareCtx, 3)
|
||||
.expect_err("an unreachable service must not look like an answer")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/// A reply the client cannot read is the service failing mid-answer, not an
|
||||
/// answer of "no key".
|
||||
#[test]
|
||||
fn unparseable_reply_is_a_source_failure() {
|
||||
assert_eq!(
|
||||
interpret_reply(Ok(reply(200, "<html>gateway error</html>")), &BareCtx, 1)
|
||||
.expect_err("non-JSON is not an answer")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
// A malformed element in the UK set rejects the whole reply (a partial
|
||||
// forensic set silently omits an index) — also a service fault.
|
||||
assert_eq!(
|
||||
interpret_reply(
|
||||
Ok(reply(
|
||||
200,
|
||||
r#"{"UK":["000102030405060708090a0b0c0d0e0f","zz"]}"#
|
||||
)),
|
||||
&BareCtx,
|
||||
1
|
||||
)
|
||||
.expect_err("a malformed key in the set is not an answer")
|
||||
.code(),
|
||||
libfreemkv::error::E_KEY_SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression pin (passes before and after): a service that DOES hold the
|
||||
/// key still resolves it, and array order is preserved as the forensic index
|
||||
/// order. The Result-returning signature must not have changed the happy path.
|
||||
#[test]
|
||||
fn service_with_a_key_still_resolves_it_in_order() {
|
||||
let keys = interpret_reply(
|
||||
Ok(reply(
|
||||
200,
|
||||
r#"{"UK":["000102030405060708090a0b0c0d0e0f","0f0e0d0c0b0a09080706050403020100"]}"#,
|
||||
)),
|
||||
&BareCtx,
|
||||
1,
|
||||
)
|
||||
.expect("a 200 carrying keys resolves");
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[1].idx, 1);
|
||||
assert_eq!(keys[0].key[0], 0x00);
|
||||
assert_eq!(keys[1].key[0], 0x0f);
|
||||
}
|
||||
|
||||
/// A URL that fails the ADDRESS guard is operator configuration, not the
|
||||
/// service being down — the two must stay separable, since only one of them
|
||||
/// is worth retrying.
|
||||
#[test]
|
||||
fn address_guard_rejections_are_config_not_unreachable() {
|
||||
for url in [
|
||||
"http://127.0.0.1/keys",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"ftp://example.com/keys",
|
||||
"not a url",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(
|
||||
resolve_and_guard(url).expect_err("must be rejected").0,
|
||||
GuardFail::Config,
|
||||
"{url} is a configuration fault, not an outage"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finding #9 regression: parse_uk must reject any non-hex byte up front so
|
||||
/// sign prefixes / whitespace can't slip through the windowed 2-char parse
|
||||
/// (`u8::from_str_radix` accepts "+5", "-A", etc.).
|
||||
|
||||
+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("/home/matt")), ("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("/home/matt")
|
||||
.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("/home/matt/.cfg")),
|
||||
("HOME", Some("/home/matt")),
|
||||
],
|
||||
|| {
|
||||
let paths = keydb_search_paths();
|
||||
assert_eq!(paths.len(), 2, "XDG dir + $HOME/.config fallback");
|
||||
assert_eq!(
|
||||
paths[0],
|
||||
PathBuf::from("/home/matt/.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,373 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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