Compare commits

...
34 Commits
Author SHA1 Message Date
Matthew Jackson d6d701e199 v1.6.0: bump version (unified release) 2026-08-03 05:57:34 -07:00
Matthew Jackson aeec3d58d7 v1.6.0: bump version (unified release) 2026-08-02 22:35:52 -07:00
Matthew Jackson b5a96aa0f7 Record the 1.6.0 fixes in the changelog
The fvi provenance fix, the key-service outage codes, the settings BOM
data-loss fix and the per-track-kind picker rows all landed on dev without a
changelog line. A release that ships undocumented fixes is one nobody can
audit later.
2026-08-02 15:08:27 -07:00
Matthew Jackson f76356c7ab Merge branch 'fix/key-service-outage-not-missing-key' into dev 2026-08-02 14:53:34 -07:00
Matthew Jackson c4259249e1 Return a failure from the online source instead of an empty result
`query` returned `Vec::new()` for every outcome: a 502, a refused token, a DNS
failure, an unreadable body, and a service that answered 200 holding no key for
this disc. Only the last one means what the caller then reported -- E7022, "no
key source has a decryption key for this disc". The module already knew the
difference and logged it; the distinction died at the `return`, and the tracing
line that carried it is not on by default (RUST_LOG=info did not surface it
through the CLI in practice).

`query` now returns `Result<Vec<UnitKey>, Error>`:

  Ok(non-empty)  the service answered with a key
  Ok(empty)      the service ANSWERED and holds nothing, or there was nothing
                 to ask with (no service configured, blocked URL, over-cap MKB,
                 too few samples) -- a genuine miss, E7022 is correct
  Err(..)        the service could not answer -- nothing is known

`classify_http_status` is the table the fix exists for: 401/403 ->
KeyServiceUnauthorized, 429 -> KeyServiceRateLimited, 5xx -> unavailable, and
anything else non-2xx -> unavailable too. Deliberately: the genuine miss is a
**200 with no key in the body**, so a status code is never evidence about this
disc, and guessing "no key" from one is the original bug. An unreadable,
over-cap or unparseable body is the service failing mid-answer, so those are
failures as well.

`resolve_and_guard` now reports WHY it rejected a URL. A host that did not
resolve (DNS failure or DNS timeout) is the service being unreachable, and was
being folded in with "this URL is not allowed" -- one is transient, the other
never fixes itself. `GuardFail::Config` still falls through to the next source
as before.

The reply-to-verdict mapping moved into `interpret_reply` so it is testable
without a network. A stub HTTP server is not an option here: the SSRF guard
blocks loopback by design, so 127.0.0.1 can never be reached through `query`.

Nothing new is logged. The request body carries base64 key material and the
reply carries keys; the added fields are the error code and the status already
being recorded.

Red before green: http_5xx_is_a_source_failure_not_a_missing_key,
transport_failure_is_a_source_failure and unparseable_reply_is_a_source_failure
all fail against the old collapse-to-empty behaviour and pass after.
service_with_a_key_still_resolves_it_in_order is a regression pin -- it passes
either way, and is here because the happy path went through a signature change.
2026-08-02 11:53:39 -07:00
Matthew Jackson ca60c8fe1c ci: build against libfreemkv's dev tip, not its last release
Two things were wrong here.

`cargo test --locked` could never pass. Cargo.lock is gitignored for
this crate (library convention), so there is no committed lockfile to
lock against and cargo refuses to create one — every run failed before
compiling a line. That is the immediate red.

The deeper problem is what the workflow was building against. The
committed Cargo.toml resolves libfreemkv from a git tag, so CI compiled
this branch against the LAST RELEASE of libfreemkv and never against
libfreemkv's dev tip. A change spanning the two repos could not be
verified until release.sh cut the tags — at which point a break is
discovered in the middle of shipping. Green meant "compiles against last
month's library".

Now each repo is checked out into a subdirectory of the workspace,
reproducing the local ~/Developer/freemkv layout, and a generated
.cargo/config.toml redirects the git-tag patch at those paths. That is
the same config-level [patch.crates-io] mechanism local development
already uses, and it wins over the manifest patch. Subdirectories
because actions/checkout refuses a path: outside $GITHUB_WORKSPACE.
Nothing in the manifests or the release cascade changes.

Adds macOS and Windows jobs: this crate ships wherever freemkv and
autorip ship and drags in all of libfreemkv's platform code, and neither
OS was compiling it outside a release build.

clippy gains --all-targets, which is what turned up the manual
str::repeat in keydb_format's round-trip test — test code had never been
linted.
2026-07-31 13:47:38 -07:00
Matthew Jackson 2131c2c4e4 ci: build dev as well as main
Work now lands on dev and CI must be green there; main only moves at
release time, to the tagged commit, so a push to main is the release
validation run rather than day-to-day feedback.

leak-guard already runs on every branch (on: [push, pull_request]) and
release.yml stays tag-triggered, so neither needed a change.
2026-07-31 13:16:56 -07:00
Matthew Jackson 796da099a8 Sweep the pinned toolchain to Rust 1.97
The Windows UI needs current winsafe, whose real minimum is 1.89 (its manifest
under-declares 1.87 while it uses NonNull::from_ref). Rather than stop at the
minimum, this goes to current stable and fixes what that costs.

The counter-intuitive result: 1.97 is CHEAPER than 1.89. libfreemkv had 54
clippy errors at 1.89 and 6 at 1.97, because clippy tightened the noisy
collapsible_if lint in between. Stopping at the minimum would have been the most
expensive choice available.

Roughly 47 lints across the eight repos, the large majority auto-fixed:
libfreemkv 6, freemkv-engine 14, bdemu 8, freemkv-keysources 7, autorip 6,
freemkv-unlock 3, freemkv-i18n 3. The hand-fixed ones are a descending sort to
sort_by_key(Reverse), four manual checked-division sites, a loop counter replaced
by enumerate, and a loop whose first let-else became a while-let.

Worth recording for whoever bumps next: clippy is MSRV-AWARE. Those 54 lints only
appear once the crate DECLARES 1.89 or later, because let-chains become
available. A bare `cargo +1.89 clippy` against a manifest still pinned at 1.87
reports clean and is meaningless — gate with the real precommit script, which is
also the only thing that covers build scripts.

The pin still sits below the Mac default, so it keeps doing its job: catching
lint drift locally before CI sees it.
2026-07-29 21:00:55 -07:00
Matthew Jackson 8efc287eb3 Pin the toolchain to Rust 1.87
The Windows UI needs winsafe, whose current release requires rustc 1.87. The
alternative was pinning winsafe back to an older release, which would bake a
stale API surface into a brand-new UI permanently to dodge one minor version.

The pin's purpose is to sit BELOW the Mac default so clippy drift is caught
locally before CI, not to stay on 1.86 specifically, so 1.87 preserves the
discipline exactly.

Verified before moving anything, not after: `cargo +1.87 clippy -- -D warnings`
and `cargo +1.87 fmt --check` are clean across all eight repos, and the full
precommit gate (fmt + clippy + tests) passes on libfreemkv, autorip, bdemu,
freemkv-engine and freemkv-keysources. Zero new lints, zero formatting drift.
2026-07-29 20:02:02 -07:00
Matthew Jackson e963104dcf Require https for the online key service
Every request to the key service carries base64 AACS key material in its body —
the unit-key file, the MKB, content samples, the volume ID — and a bearer token
in a header when one is configured. Over http:// all of that goes to any on-path
observer, and the token is replayable.

There is no plaintext deployment to preserve. The address guard already rejects
loopback, RFC1918, ULA and link-local, so an http:// URL can only ever reach the
public internet, which is the worst case for cleartext; a self-hosted keyserver
on a LAN is impossible under that guard whatever its scheme. A non-https URL now
skips the online source with a warning rather than transmitting, so a
misconfiguration falls through to the local keydb and says why instead of
leaking quietly.
2026-07-29 16:36:43 -07:00
Matthew Jackson 7408a916bf Report why an online key lookup failed, and cap the keydb load properly
A key-service failure was indistinguishable from "this disc has no key".
Five of the seven exits in the online query returned an empty result with no
log at all, and the one that did log threw away the HTTP status the service
had already handed us — so a 502, an expired token, a rate limit and a
genuine miss all produced the same single line, and the disc was reported as
having no key. Each exit now says which one it was, and the genuine miss is
logged distinctly from every failure.

Nothing new is disclosed: the address that failed the guard, the response
body and the bearer token are all still kept out of the log. The HTTP status
is the service's own response code, and the size cap is a constant.

A malformed key in a returned UK set no longer skips that one element. The
mux trusts any non-empty result as the complete forensic set, so a short set
silently omits an index; the whole reply is rejected instead.

Also make the on-disk keydb cap real. It was a metadata pre-check followed by
an unbounded read, which is not a cap: it is skipped when the stat fails, and
a FIFO or character device reports zero length, so --keydb /dev/zero passed
the guard and then read until the process was OOM-killed (NUL is valid UTF-8,
so it never even failed the decode). Read through the same take() cap the
download path already used, and add the boundary test the load path never had.
2026-07-29 16:06:02 -07:00
Matthew Jackson efaaab6ab8 docs: drop a source name from the two keydb size-cap comments
Both MAX_KEYDB_BYTES comments justified the 128 MiB ceiling by naming
the public keydb they were sized against. The number and the reasoning
are the useful part; the name is not, and it trips the pre-commit
scan. Keep the justification, drop the name.
2026-07-29 15:29:48 -07:00
Matthew Jackson ca7a0e41c2 Raise keydb size cap 64 MiB -> 128 MiB
The public UHD keydb is ~62 MiB and growing, leaving only ~2 MiB
under the old 64 MiB ceiling — updates would start failing as it grows.
128 MiB restores years of headroom while still bounding a decompression
bomb. Both cap sites (keydb.rs download/save, keydb_format.rs on-disk load)
bumped; the unrelated MKB cap in online.rs is untouched.
2026-07-29 07:03:19 -07:00
Matthew Jackson 102895330d 1.6.0: version sync + doc fixes
Bump to 1.6.0 (workspace sync, no source change). Fix stale get_uk ->
get_unit_keys references in the crate description + comments, correct the
libfreemkv version note, and drop a phantom Mapfile mention from the tests.
2026-07-28 15:35:19 -07:00
Matthew Jackson acc0144870 changelog: date the released 1.5.x sections (were stale UNRELEASED) 2026-07-28 13:55:23 -07:00
Matthew Jackson d5261cc097 v1.5.2: bump version (unified release) 2026-07-22 22:19:45 -07:00
Matthew Jackson 311aef939c Version sync 1.5.2 (inherits libfreemkv CSS DVD descramble fix) 2026-07-22 11:00:39 -07:00
Matthew Jackson ad16d745a4 v1.5.1: bump version (unified release) 2026-07-20 17:02:17 -07:00
Matthew Jackson 1a586239bc v1.5.0: bump version (unified release) 2026-07-19 15:37:30 -07:00
Matthew Jackson a89e688866 v1.4.5: bump version (unified release) 2026-07-18 16:08:06 -07:00
Matthew Jackson 03e42c1f5a changelog: backfill 1.4.3/1.4.4 and add 1.4.5 2026-07-18 13:31:59 -07:00
Matthew Jackson 3175b71fce keydb: fix device keys silently dropped on uppercase 0X hex prefix
parse_device_key parsed node/uv/shift via from_str_radix(trim_start_matches("0x")),
a CASE-SENSITIVE strip: an uppercase 0X prefix was not removed, from_str_radix
failed, and the WHOLE device key was dropped. Now uses the canonical
libfreemkv::hex::parse_hex_u16/u32/u8. Red->green regression test added.
2026-07-17 21:25:59 -07:00
Matthew Jackson ee6e5efa4e keysources: split KeySource into get_unit_keys + get_fmts_indexes
Base per-CPS-unit keys and the AACS 2.1 forensic index set are now two explicit
trait operations instead of one overloaded get_uk whose return count was implicit.
keydb serves unit keys (forensic opts out via the default); online implements both;
MultiSource drives both in order. Teed up for 1.4.5. Local WIP baseline.
2026-07-17 20:51:24 -07:00
Matthew Jackson 65e08c6dc2 v1.4.4: bump version (unified release) 2026-07-16 21:44:10 -07:00
Matthew Jackson 10c6838506 online: build the /decode request from a DecodeSampleSet (proven sufficient by type, not a runtime len check) 2026-07-16 21:43:03 -07:00
Matthew Jackson 1911e362a8 v1.4.3: bump version (unified release) 2026-07-16 21:06:07 -07:00
Matthew Jackson 74160f00e9 online: re-export MIN_SAMPLE_UNITS from libfreemkv 2026-07-16 21:01:16 -07:00
Matthew Jackson 22c37f1b50 online: parse UK as an array (1 key plain, 32 keys forensic)
The key service now always returns {"UK":[...]} - an array of one for a
plain movie sample, or all 32 index-ordered variant keys for a forensic
sample. OnlineSource::query accepts both the legacy string form and the
array form, emitting one UnitKey per element (index = array position).
Re-export MIN_SAMPLE_UNITS so callers size their samples correctly.
2026-07-16 19:41:44 -07:00
Matthew Jackson 00dd77d49b v1.4.2: bump version (unified release) 2026-07-15 19:37:56 -07:00
Matthew Jackson fe78d7445f Adapt keydb test to segregated decrypt primitives; 1.4.2 changelog
The KEYDB-gated test that feeds real key material into libfreemkv's AACS crypto
now composes decrypt_unit + is_clean (the removed decrypt_unit_try_keys wrapper's
behaviour), unchanged semantics.
2026-07-15 19:35:25 -07:00
Matthew Jackson 36fb6f0c61 v1.4.1: bump version (unified release) 2026-07-14 14:45:36 -07:00
Matthew Jackson 1a9a3befe3 Changelog for 1.4.1 2026-07-14 14:43:14 -07:00
Matthew Jackson d7152e0f73 v1.4.0: bump version (unified release) 2026-07-13 19:28:58 -07:00
Matthew Jackson 6ceb92ade9 Changelog: 1.4.0 (Blu-ray 3D / MVC) 2026-07-13 12:47:03 -07:00
10 changed files with 949 additions and 195 deletions
+149 -12
View File
@@ -2,35 +2,172 @@ name: CI
on:
push:
branches: [main]
# `dev` is where work lands and where CI must be green. `main` only ever
# moves at release time, to the tagged commit, so a push to it is the
# release validation run rather than day-to-day feedback.
branches: [main, dev]
pull_request:
# freemkv-keysources depends on libfreemkv from crates.io (see Cargo.toml).
# No sibling-repo checkout is needed.
# WHY THE SIBLING CHECKOUTS — this is NOT a monorepo.
#
# The committed Cargo.toml resolves libfreemkv from a git TAG (it is off
# crates.io). That means CI was building this branch against the LAST RELEASE
# of libfreemkv, never against libfreemkv's dev tip — so a coordinated change
# across the two repos could not be verified until release.sh cut the tags.
# Green here meant "compiles against last month's library".
#
# The fix mirrors the developer's local layout. Every repo is checked out into
# a subdirectory of $GITHUB_WORKSPACE, reproducing
# ~/Developer/freemkv/{freemkv-keysources,libfreemkv,freemkv-unlock}, and a
# generated .cargo/config.toml redirects the git-tag patch to those paths.
# Config-level [patch.crates-io] wins over the manifest one, which is the same
# mechanism local development already uses (see the note in Cargo.toml).
#
# Subdirectories rather than `../libfreemkv` because actions/checkout refuses
# any `path:` outside $GITHUB_WORKSPACE, and the workspace IS the repo dir.
# Repos stay separate, tags stay separate, the release cascade is untouched.
#
# libfreemkv path-deps ../freemkv-unlock, which resolves to the workspace root
# under this layout — hence the third checkout.
#
# `--locked` is deliberately absent: Cargo.lock is gitignored here (library
# convention), so there is nothing to lock against and it fails rather than
# resolves. That is what was breaking this workflow. The lock that has to hold
# is the one release.sh regenerates in each consuming binary.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
with:
path: freemkv-keysources
- uses: actions/checkout@v5
with:
repository: freemkv/libfreemkv
ref: dev
path: libfreemkv
- uses: actions/checkout@v5
with:
repository: freemkv/freemkv-unlock
ref: dev
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
- uses: dtolnay/rust-toolchain@1.97.0
with:
components: clippy, rustfmt
# Cache ~/.cargo/registry + target/ across runs. Saves ~30-60s
# of crates.io index download per job; incremental compilation
# helps on re-runs that don't bump Cargo.lock.
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-keysources-ci
workspaces: freemkv-keysources
- run: cargo fmt --check
- run: cargo clippy -- -D warnings
working-directory: freemkv-keysources
# --all-targets so the test code is linted too, not just the library.
- run: cargo clippy --all-targets -- -D warnings
working-directory: freemkv-keysources
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
with:
path: freemkv-keysources
- uses: actions/checkout@v5
with:
repository: freemkv/libfreemkv
ref: dev
path: libfreemkv
- uses: actions/checkout@v5
with:
repository: freemkv/freemkv-unlock
ref: dev
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-keysources-ci
- run: cargo test --locked
workspaces: freemkv-keysources
- run: cargo test --tests
working-directory: freemkv-keysources
# This crate ships wherever freemkv and autorip ship, and it drags in all of
# libfreemkv's platform code (SCSI transports included). Build the tests, not
# `cargo check` — check skips codegen and never compiles test-only code.
check-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v5
with:
repository: freemkv/libfreemkv
ref: dev
path: libfreemkv
- uses: actions/checkout@v5
with:
repository: freemkv/freemkv-unlock
ref: 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: dev
path: libfreemkv
- uses: actions/checkout@v5
with:
repository: freemkv/freemkv-unlock
ref: 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
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
- run: cargo test
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [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
+1 -1
View File
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at owner@example.invalid. All complaints will be reviewed and investigated promptly and fairly.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the project maintainer via GitHub at https://github.com/MattJackson. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+6 -6
View File
@@ -1,10 +1,10 @@
[package]
name = "freemkv-keysources"
version = "1.3.2"
version = "1.6.0"
edition = "2024"
rust-version = "1.86"
rust-version = "1.97"
license = "MIT"
description = "Pluggable AACS key sources (keydb, online key service) for libfreemkv. Each source looks a disc up and hands libfreemkv its terminal Unit Keys via get_uk; the library does all derivation."
description = "Pluggable AACS key sources (keydb, online key service) for libfreemkv. Each source looks a disc up and hands libfreemkv its terminal Unit Keys via get_unit_keys; the library does all derivation."
repository = "https://github.com/freemkv/freemkv-keysources"
keywords = ["aacs", "blu-ray", "uhd", "decryption", "keydb"]
categories = ["multimedia"]
@@ -17,7 +17,7 @@ publish = false
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
# libfreemkv is git-only now; the committed [patch.crates-io] below redirects this
# bare version req to the libfreemkv git tag (local dev overrides it to a path).
libfreemkv = "1.3"
libfreemkv = "1.6"
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
ureq = { version = "2", features = ["json"] }
serde_json = "1"
@@ -34,10 +34,10 @@ tracing = "0.1"
lto = "thin"
codegen-units = 1
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.2"`
# libfreemkv is git-only (off crates.io). Redirect the bare `libfreemkv = "1.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.3.2" }
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.6.0" }
+13 -7
View File
@@ -41,9 +41,11 @@ use libfreemkv::{Error, KeySource};
use crate::keydb_format::KeyDb;
/// Upper bound on decompressed keydb size. The published keydb is a few MiB;
/// 64 MiB is a generous ceiling that still caps a decompression bomb (a tiny
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread).
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
/// 128 MiB is a generous ceiling that still caps a decompression bomb (a tiny
/// zip/gz can otherwise inflate to GiB and OOM the daily refresh thread). The
/// public UHD keydb is ~62 MiB and growing, so 64 MiB was getting tight;
/// 128 MiB leaves years of headroom.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
#[derive(Debug)]
@@ -328,10 +330,14 @@ fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
}
impl KeySource for KeydbSource {
/// Resolve this disc's terminal Unit Keys from the keydb. A missing /
/// Resolve this disc's base per-CPS-unit Unit Keys from the keydb. A missing /
/// unreadable keydb is not an error — it simply yields no keys (another
/// source may have them), the same as the library's own loader.
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
///
/// The keydb carries no AACS 2.1 forensic index keys today, so it does not
/// override `get_fmts_indexes` — the default (empty) opts it out, and an FMTS
/// disc's forensic set comes from the online source.
fn get_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
match KeyDb::load(&self.path) {
Ok(db) => Ok(Self::unit_keys_from(&db, ctx)),
Err(_) => Ok(Vec::new()),
@@ -363,7 +369,7 @@ mod tests {
use libfreemkv::aacs::types::DeviceKey;
use std::collections::HashMap;
// ── A test ResolveCtx, so get_uk's path selection can be exercised without
// ── A test ResolveCtx, so get_unit_keys's path selection can be exercised without
// a real Disc. Each accessor returns exactly what a case needs. ──────────
struct MockCtx {
disc_hash: String,
@@ -748,7 +754,7 @@ mod tests {
fn get_uk_missing_keydb_is_ok_empty() {
let src = KeydbSource::new("/nonexistent/path/keydb.cfg");
let got = src
.get_uk(&ctx(HASH, Vec::new(), None))
.get_unit_keys(&ctx(HASH, Vec::new(), None))
.expect("missing keydb is not an error");
assert!(got.is_empty());
}
+115 -56
View File
@@ -23,9 +23,10 @@ use libfreemkv::aacs::types::{DeviceKey, HostCert};
pub type NumberedUnitKey = (u32, [u8; 16]);
/// Upper bound on the on-disk keydb.cfg size accepted by [`KeyDb::load`].
/// The real public UHD keydb is a few MiB; 64 MiB is generous headroom while
/// still bounding the worst-case allocation from a hostile/corrupt file.
const MAX_KEYDB_BYTES: u64 = 64 * 1024 * 1024;
/// The public UHD keydb is ~62 MiB and growing; 128 MiB is generous
/// headroom while still bounding the worst-case allocation from a hostile or
/// corrupt file.
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Upper bound on parsed disc entries. The real public keydb carries
/// ~170k+ entries, so the cap sits well above that while still bounding
@@ -264,22 +265,33 @@ impl KeyDb {
/// [`KeyDb`] rather than an error — callers needing a non-empty db must
/// check the parsed contents.
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
// Stat-and-cap before reading so a hostile/corrupt file can't force an
// unbounded allocation. A file strictly over the cap is rejected (a
// file exactly at MAX_KEYDB_BYTES is accepted, matching the `>` guard
// and libfreemkv's original at-cap-is-allowed semantics).
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_KEYDB_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"keydb.cfg exceeds {MAX_KEYDB_BYTES} byte cap: {}",
path.display()
),
));
}
// Read through a hard `take` cap rather than trusting a stat. A
// `metadata` pre-check is only advisory: it is skipped entirely when the
// stat fails, and it reports len 0 for a FIFO or character device, so
// `--keydb /dev/zero` passed the guard and then read forever (NUL is
// valid UTF-8, so it did not even fail the decode — it just grew until
// the process was OOM-killed). Reading one byte past the cap accepts an
// exactly-at-cap file and rejects anything larger, matching
// `read_capped_to_string` on the download path.
use std::io::Read;
let f = std::fs::File::open(path)?;
let mut buf = Vec::new();
f.take(MAX_KEYDB_BYTES + 1).read_to_end(&mut buf)?;
if buf.len() as u64 > MAX_KEYDB_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"keydb.cfg exceeds {MAX_KEYDB_BYTES} byte cap: {}",
path.display()
),
));
}
let data = std::fs::read_to_string(path)?;
let data = String::from_utf8(buf).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("keydb.cfg is not valid UTF-8: {}", path.display()),
)
})?;
Ok(Self::parse(&data))
}
@@ -506,9 +518,12 @@ impl KeyDb {
Some(DeviceKey {
key: parse_hex16(key_str)?,
node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?,
uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?,
u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?,
// Canonical hex parsers (one prefix/case rule for the whole workspace)
// — NOT an ad-hoc `from_str_radix(trim_start_matches("0x"))`, whose
// case-sensitive strip silently dropped an uppercase-`0X` value.
node: libfreemkv::hex::parse_hex_u16(node_str)?,
uv: libfreemkv::hex::parse_hex_u32(uv_str)?,
u_mask_shift: libfreemkv::hex::parse_hex_u8(shift_str)?,
})
}
@@ -683,22 +698,19 @@ impl KeyDb {
i += 1;
}
}
"U" => {
if i + 1 < parts.len() {
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
for uk in uk_str.split(' ') {
let uk = uk.trim();
if let Some((num, key)) = uk.split_once('-') {
if let Ok(n) = num.parse::<u32>() {
if let Some(k) = parse_hex16(key) {
unit_keys.push((n, k));
}
}
}
"U" if i + 1 < parts.len() => {
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
for uk in uk_str.split(' ') {
let uk = uk.trim();
if let Some((num, key)) = uk.split_once('-')
&& let Ok(n) = num.parse::<u32>()
&& let Some(k) = parse_hex16(key)
{
unit_keys.push((n, k));
}
i += 1;
}
i += 1;
}
_ => {}
}
@@ -723,6 +735,43 @@ impl KeyDb {
mod tests {
use super::*;
/// `load` must enforce `MAX_KEYDB_BYTES` through a bounded read, not a stat.
///
/// Regression: the cap used to be a `std::fs::metadata` pre-check followed by
/// an unbounded `read_to_string`. That is not a cap — it is skipped when the
/// stat fails, and a FIFO or character device reports len 0, so the guard
/// passed and the read never terminated. NUL bytes are valid UTF-8, so
/// `--keydb /dev/zero` did not even fail the decode; it allocated until the
/// process died. Asserts both edges of the boundary so a future cap change
/// cannot quietly re-introduce an off-by-one.
#[test]
fn load_enforces_the_size_cap_at_both_edges() {
let dir = std::env::temp_dir()
.join("fmkv-keydb-cap")
.join(format!("{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// Exactly at the cap is accepted. Use a comment line so `parse` is happy
// and the content is valid UTF-8 of a known length.
let at = dir.join("at_cap.cfg");
let body = vec![b'#'; MAX_KEYDB_BYTES as usize];
std::fs::write(&at, &body).unwrap();
assert!(
KeyDb::load(&at).is_ok(),
"a file exactly at MAX_KEYDB_BYTES must load"
);
// One byte over is rejected as InvalidData, not truncated and not OOM.
let over = dir.join("over_cap.cfg");
let mut body = vec![b'#'; MAX_KEYDB_BYTES as usize];
body.push(b'#');
std::fs::write(&over, &body).unwrap();
let err = KeyDb::load(&over).expect_err("over-cap file must be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
std::fs::remove_dir_all(&dir).ok();
}
/// `to_keydb_cfg` is the exact inverse of `parse`: parse a known line set,
/// serialize it, re-parse, and every field survives — device key, processing
/// key, host cert (priv key + cert + revocation), and the per-disc M/I(vid)/V/U
@@ -730,11 +779,7 @@ mod tests {
/// internal key forms (e.g. the `0x`-prefixed disc-hash) match by construction.
#[test]
fn to_keydb_cfg_round_trips_through_parse() {
let h = |b: u8, n: usize| {
std::iter::repeat(format!("{b:02x}"))
.take(n)
.collect::<String>()
};
let h = |b: u8, n: usize| format!("{b:02x}").repeat(n);
let cert = h(0x99, 92); // AACS 1.0 host cert is 92 bytes
let src = format!(
"| HC | HOST_PRIV_KEY 0x{priv20} | HOST_CERT 0x{cert} ; Revoked in MKBv72\n\
@@ -1219,6 +1264,20 @@ mod tests {
assert!(db.processing_keys.is_empty());
}
#[test]
fn parse_device_key_accepts_uppercase_0x_prefix() {
// Regression: node/uv/shift parsed via a case-sensitive
// `trim_start_matches("0x")`, so an uppercase `0X` prefix was not
// stripped, `from_str_radix` failed, and the WHOLE device key was
// silently dropped. All four fields must parse regardless of prefix case.
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F \
| DEVICE_NODE 0X0001 | KEY_UV 0X00000002 | KEY_U_MASK_SHIFT 0X03";
let dk = KeyDb::parse_device_key(line).expect("uppercase 0X prefix must parse");
assert_eq!(dk.node, 1);
assert_eq!(dk.uv, 2);
assert_eq!(dk.u_mask_shift, 3);
}
#[test]
fn parse_host_cert_v2_rejects_wrong_priv_len_and_short_cert() {
// v2 priv must be exactly 32 bytes; cert must be >= 132.
@@ -1412,7 +1471,7 @@ mod tests {
//
// These exercise the parser (KeyDb::load) end-to-end against a real
// keydb.cfg and feed its material into libfreemkv's AACS crypto
// (derive_vuk / decrypt_unit_try_keys). They live here now that the
// (derive_vuk, then decrypt_unit + is_clean_ts). They live here now that the
// parser lives here. All are KEYDB_PATH-env-gated and no-op in CI when
// the env is unset; they must still COMPILE.
// ════════════════════════════════════════════════════════════════════
@@ -1466,7 +1525,7 @@ mod tests {
let original = std::fs::read(&unit_path).unwrap();
assert_eq!(original.len(), libfreemkv::aacs::content::ALIGNED_UNIT_LEN);
assert!(
libfreemkv::aacs::content::ts_sync_destroyed(&original),
!libfreemkv::aacs::content::is_clean(&original, libfreemkv::disc::ContentFormat::BdTs),
"Unit should be encrypted"
);
@@ -1485,20 +1544,20 @@ mod tests {
eprintln!("Found {} entries with unit keys", candidate_entries.len());
// Try each entry's unit keys
// Try each entry's unit keys: apply the key, then ask whether it opened
// the unit (the segregated primitives — decrypt, then structural check).
for entry in &candidate_entries {
let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect();
let mut unit = original.clone();
if let Some(res) = libfreemkv::aacs::content::decrypt_unit_try_keys(&mut unit, &keys) {
eprintln!(
"SUCCESS: Decrypted with entry {} ({res:?})",
entry.disc_hash
);
// Count TS sync bytes
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
eprintln!(" TS sync bytes: {}/32", ts);
return;
for (_, key) in &entry.unit_keys {
let mut unit = original.clone();
libfreemkv::aacs::content::decrypt_unit(&mut unit, key);
if libfreemkv::aacs::content::is_clean(&unit, libfreemkv::disc::ContentFormat::BdTs)
{
eprintln!("SUCCESS: Decrypted with entry {}", entry.disc_hash);
// Count TS sync bytes
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
eprintln!(" TS sync bytes: {}/32", ts);
return;
}
}
}
@@ -1527,7 +1586,7 @@ mod tests {
let entry = entry.unwrap();
let vuk = entry.vuk.unwrap();
let vid = entry.vid.unwrap();
let hash_hex = format!("0x{}", entry.disc_hash.trim_start_matches("0x"));
let hash_hex = format!("0x{}", libfreemkv::hex::strip_hex_prefix(&entry.disc_hash));
// We need the actual Unit_Key_RO.inf from the disc to compute disc hash.
// Since we don't have it, we can at least test that the KEYDB lookup
+31 -14
View File
@@ -12,7 +12,7 @@
//! in — then resolve and hand the resulting key to `Disc::decrypt_with`.
//!
//! Each source resolves a disc's terminal **Unit Keys** in one shot via
//! [`KeySource::get_uk`], driving libfreemkv's boil-down crypto primitives for
//! [`KeySource::get_unit_keys`], driving libfreemkv's boil-down crypto primitives for
//! whatever level of material it holds. Compose several with [`MultiSource`] in
//! the caller's chosen order. Reading the encrypted content-sample units a key
//! server validates on, and applying the resolved keys against a disc, is
@@ -30,7 +30,7 @@ mod paths;
pub use keydb::{KeydbSource, UpdateResult};
pub use keydb_format::{DiscEntry, KeyDb};
pub use online::{OnlineSource, validate_keyserver_url};
pub use online::{MIN_SAMPLE_UNITS, OnlineSource, validate_keyserver_url};
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
// Re-exported for downstream convenience so apps need only depend on this crate
@@ -51,9 +51,11 @@ pub(crate) fn uks_from_vuk(vuk: &[u8; 16], enc_title_keys: &[[u8; 16]]) -> Vec<U
.collect()
}
/// An ordered composition of key sources, driven as one. [`MultiSource::get_uk`]
/// tries each inner source in order and returns the first non-empty Unit Key
/// set. **The caller supplies the list AND the order** — local-first `[Keydb,
/// An ordered composition of key sources, driven as one.
/// [`MultiSource::get_unit_keys`] tries each inner source in order and returns
/// the first non-empty Unit Key set (and [`MultiSource::get_fmts_indexes`] does
/// the same for the forensic set). **The caller supplies the list AND the
/// order** — local-first `[Keydb,
/// Online]`, online-first `[Online, Keydb]`, etc. —
/// so the "which sources, in what order" policy lives entirely with the
/// application, not the library. `MultiSource` is itself a [`KeySource`], so it
@@ -70,16 +72,31 @@ impl MultiSource {
}
impl KeySource for MultiSource {
/// Try each inner source in order; the FIRST to return a non-empty Unit Key
/// set wins. An inner source that returns empty OR errors is treated as "no
/// key here" and the next is tried (a single source failure never blocks the
/// chain). All sources exhausted → empty.
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
/// 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_uk(ctx) {
if !uks.is_empty() {
return Ok(uks);
}
if let Ok(uks) = s.get_unit_keys(ctx)
&& !uks.is_empty()
{
return Ok(uks);
}
}
Ok(Vec::new())
}
/// 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())
+574 -92
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use crate::uks_from_vuk;
use base64::Engine;
use libfreemkv::aacs::types::UnitKey;
use libfreemkv::keysource::ResolveCtx;
use libfreemkv::keysource::{DecodeSampleSet, ResolveCtx};
use libfreemkv::{Error, KeySource};
// Upper bound on the MKB forwarded to the key service — kept in lockstep with
@@ -16,6 +16,19 @@ use libfreemkv::{Error, KeySource};
// 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.
@@ -74,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>() {
@@ -112,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
@@ -133,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()
),
));
}
}
@@ -165,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
@@ -193,18 +242,54 @@ impl OnlineSource {
}
}
/// The server-resolved Unit Keys for this disc, or an empty `Vec`. 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`). Any failure — no service,
/// over-cap MKB, network/parse error, or no key for this disc — yields an
/// empty `Vec` (the resolver tries the next source). `&self`: one-shot is
/// the resolver's contract (each source's `get_uk` is called once), so no
/// per-call latch is needed.
fn query(&self, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
/// 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 Vec::new();
return Ok(Vec::new());
}
// 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
@@ -217,8 +302,25 @@ impl OnlineSource {
cap = MAX_MKB_BYTES,
"MKB exceeds the key-service forward cap; skipping the online source for this disc (no key from online)"
);
return Vec::new();
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!({
// Raw Unit_Key_RO.inf, verbatim — the server does its own parse /
@@ -229,24 +331,21 @@ impl OnlineSource {
if let Some(vid) = ctx.vid() {
body["vid_b64"] = serde_json::Value::String(b64.encode(vid.0));
}
// Up to a generous cap of encrypted content samples for server-side
// ciphertext validation.
if let Ok(samples) = ctx.samples(64) {
if !samples.is_empty() {
body["units_b64"] = serde_json::Value::Array(
samples
.iter()
.map(|u| serde_json::Value::String(b64.encode(u)))
.collect(),
);
}
}
// 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) = ctx.title().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
@@ -254,7 +353,33 @@ 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(_) => return Vec::new(),
// 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);
let mut req = agent.post(&self.base_url);
@@ -269,59 +394,226 @@ 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)"
);
return Vec::new();
}
};
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
{
return Vec::new();
}
let json: serde_json::Value = match serde_json::from_slice(&buf) {
Ok(j) => j,
Err(_) => return Vec::new(),
};
// A terminal UK is used directly (CPS unit 0 → committed cps 1, matching
// the old `Key::Unit(vec![(1, uk)])`).
if let Some(uk) = json.get("UK").and_then(|u| u.as_str()).and_then(parse_uk) {
return vec![UnitKey::new(0, uk)];
}
// 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) {
if let Ok(enc) = ctx.enc_title_keys() {
return uks_from_vuk(&vuk, enc);
}
}
Vec::new()
let sent = req.send_json(body);
interpret_reply(sent, ctx, post_t0.elapsed().as_millis() as u64)
}
}
/// 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
}
});
}
};
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 {
fn get_uk(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(self.query(ctx))
/// 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)
}
/// 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 label(&self) -> &'static str {
@@ -488,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.).
+6 -6
View File
@@ -1,8 +1,8 @@
//! Fixture-based integration tests for the published key sources.
//!
//! These exercise the *public* surface of `freemkv-keysources` end-to-end —
//! real files on disk, the real `KeyDb`/`Mapfile` parsers from libfreemkv, and
//! the `KeySource` trait (`get_uk` over a `ResolveCtx`) the applications drive.
//! real files on disk, the real `KeyDb` parser from libfreemkv, and
//! the `KeySource` trait (`get_unit_keys` over a `ResolveCtx`) the applications drive.
//!
//! Covered:
//! - `KeydbSource`: terminal unit-key lookup by disc hash through a real
@@ -71,8 +71,8 @@ fn inputs(hash: &str) -> DiscInputs {
/// Resolve a source through the public trait over a `DiscInputsCtx`.
fn resolve(src: &dyn KeySource, inp: &DiscInputs) -> Vec<UnitKey> {
let ctx = DiscInputsCtx::new(inp);
src.get_uk(&ctx)
.expect("get_uk must not error for these fixtures")
src.get_unit_keys(&ctx)
.expect("get_unit_keys must not error for these fixtures")
}
// ── KeydbSource: real-file lookup by disc hash ──────────────────────────────
@@ -130,7 +130,7 @@ fn keydb_source_missing_file_is_silent_ok_empty() {
let inp = inputs(DISC_HASH);
let ctx = DiscInputsCtx::new(&inp);
assert!(
src.get_uk(&ctx)
src.get_unit_keys(&ctx)
.expect("missing keydb is Ok, not Err")
.is_empty()
);
@@ -258,7 +258,7 @@ impl ScriptedSource {
}
impl KeySource for ScriptedSource {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
Ok(self.keys.clone())
}
fn label(&self) -> &'static str {