The query worker honours ctx.halt() and reports liveness (following
libfreemkv's Progress -> Liveness rename). A definitive key-service miss
allows the forensic phase fallback. LAN, loopback and link-local hosts
are allowed, vetted with libfreemkv's is_blocked_ip.
CI path-patches freemkv-unlock and runs cargo-deny from the repo dir;
Stop tests use idle bounds instead of tight wall-clock limits.
keydb and MultiSource say whether an answer depends on samples; online
failures are classed as transport or not without erasing the
reachability slot; OnlineSource and MultiSource report uses_vid, and
partial stored keys plus a Km still report NoVid. Factory builds get a
DNS-free key-service URL check. Tests decrypt through libfreemkv's
test_util.
Key-service timeouts measure stalls rather than totals, and a Stop ends
a query mid-flight. A query stopped before it starts sends nothing,
builds no body and reads no disc material.
The key service never uses an environment proxy: it was resolved to the
service's pinned address, so every lookup failed. Keyserver URLs get a
typed verdict, and a test-hooks feature lets consumers' tests set the
last reachability. keydb hands out host certs best first, with no disc
input.
The 1.7.6 unified release ships no rip-path change (Linux desktop shell,
notifications, menu_layout refactor, XDG paths). Real-media QA on EC2
adds ~2h + real money to the release cycle to prove code that never
enters the rip pipeline. This empty commit puts `[skip-media]` in qa's
HEAD so the media matrix in qa.yml auto-skips per the documented bypass;
the full-platform builds/tests still run.
Release-orchestrate committed to qa (v1.7.5 version bump, etc.) but did
not push those commits back to dev, so promote's fast-forward gate
refused (qa was diverged from dev). This is an ours-merge: the commit
records qa as an ancestor of dev without applying its content, since
dev has already moved past 1.7.5 to 1.7.6 with the equivalent
information in place.
Bumps Cargo.toml + Cargo.lock to 1.7.6. Also fixes a CI-shape defect the
previous release left behind: dev's CHANGELOG still said
`## [1.7.5] — UNRELEASED` after 1.7.5 shipped (release-orchestrate dated
main but did not push a fresh UNRELEASED heading back to dev). Corrects
that here so the changelog job accepts 1.7.6 on the way to qa:
## [1.7.6] — UNRELEASED
- Version aligned; unified release driven by freemkv 1.7.6.
## [1.7.5] — 2026-09-23
- Version aligned (or actual content, per the shipped main entry).
The driver for 1.7.6 is the freemkv Linux desktop shell (GTK4 +
libadwaita) + rip-finished desktop notification (issue #56).
Version aligned to 1.7.5. No functional change to this crate — the release
is driven by freemkv-unlock mirroring the freemkv-firmware 0.9.0 ABI, where
the drive's separate Ake (0x06) and Bus (0x07) levers are retired into a
single Encryption (0x06) lever.
ureq 3.4.1 (#1194, "Fix timeout budgets restarting and applying to later
phases") made timeout_recv_response bound only the response HEADERS and
turned timeout_recv_body into an ABSOLUTE total-body deadline. The online
key-service POST set recv_response only, so under 3.4.2 the reply body read
had NO deadline at all — a server that sent headers then stalled mid-reply
would hang the key request forever.
The /decode reply is a small JSON key answer that arrives in one read (a
few hundred bytes; the 1 MiB cap is a defensive anti-OOM ceiling, not an
expected size), so this is a fine total-cap case, not a large-body slow-link
download: no rolling-idle connector is warranted. Set timeout_recv_body to
the same TIMEOUT_SECS total ceiling so the POST can never hang forever.
Regression guard the_reply_body_read_is_bounded_not_only_the_headers asserts
recv_body is now set. Full suite + precommit (fmt/clippy/deny/tests) green.
Implement KeySource::resolve_unit_keys for KeydbSource by splitting
unit_keys_from into resolve_from, which reports: whether find_disc
matched; the specific miss reason (NoVid when a Media Key has no VID,
else NoDerivableKey); the matched entry's shape (vuk / unit_keys count /
media_key / keydb_vid / enc_title_keys len / vid_available); and the
loaded entry count for a self-diagnosing true-miss verdict.
MultiSource forwards resolve_unit_keys so a matched inner source's richer
verdict survives composition. No derivation-logic change: the locked
VID-per-path rule is intentional and the investigation found no
correctness bug there — only the conflated verdict. Tests cover
matched-no-VID, matched-no-material, true miss, and the success path.
- Remove the MAX_DISC_ENTRIES cap: a keydb is a database and must hold every
disc row it contains (a count-based drop silently lost real discs).
- Strip a leading UTF-8 BOM before parsing so a Windows-saved keydb's FIRST
disc row survives (U+FEFF is not trim()-able whitespace).
- Gate the disc branch on the 0x/0X prefix alone and COUNT a malformed 0x row
(missing " = ") as rejected instead of silently ignoring it — surfaces the
"hash is in the file but nothing resolves" class of bug.
- Tests: BOM, CRLF, leading whitespace, case/prefix-insensitive lookup,
duplicate-hash last-wins, unit-key-only rows, 600k-entry no-drop, and the
load_failure verdicts (permission / invalid / missing).
[skip-media]
Restructure 10 over-cap comment blocks in keydb.rs and online.rs to fit
comment-guard's caps (inline // asides <=3 lines, /// item-doc prose <=8)
by tightening wording. No code changes; behavior unchanged.
online.rs:
- validate_keyserver_url/resolve_and_guard now reject any non-https:// scheme
as a Config fault, so a cleartext misconfig is caught ONCE at config time
instead of failing every rip via query()'s runtime https check.
- is_blocked_ip: add 198.18.0.0/15 (RFC 2544 benchmarking) and 192.0.0.0/24
(RFC 6890 IETF protocol assignments, incl. 192.0.0.170/171) to the SSRF guard.
- DNS in-flight cap is now PER HOST, not process-global, so black-holed lookups
to one dead keyserver can't starve resolution of a healthy different keyserver.
- reject a non-empty, non-":port" tail after a bracketed IPv6 authority
(e.g. https://[::1]extra/) instead of silently dropping it.
keydb.rs / keydb_format.rs:
- MAX_KEYDB_BYTES defined once in keydb_format and re-used by keydb.rs.
- CacheEntry::is_settled falls back to inode identity + stamped_at age when the
mtime is absent (mtime-less FS) or in the future (clock skew), so the ~62 MiB
keydb no longer re-parses on every lookup.
- save()'s entry-count guard counts DK/PK/HC/HC2 rows via the real parsers
(is_parseable_entry_line), so a prefixed-but-malformed row can't pass entries>0.
Tests added for each fix.
OnlineSource now records the HTTP outcome of its actual /decode POST — a
Status(code) for any answer (200/404/422/429/5xx) or Transport for a
connect/timeout/DNS/TLS failure — in a per-thread slot, readable via the new
take_last_decode_reachability() / DecodeReachability. This lets a caller
(autorip) classify a no-key result from the REAL decode instead of firing a
second, redundant probe against the POST-only endpoint. No change to key
resolution itself. Targets 1.7.2.
Several tests asserted only key[0] (the first byte), which a byte-transposition bug could pass — assert the full 16-byte key everywhere, including a KAT that recomputes the expected VUK-derived keys directly. test_parse_disc_entry used identical all-zero placeholders for M/I/V/U, which couldn't catch a field mix-up; give each field a distinct byte pattern and assert every field.
Docs: keydb-format.md claimed DiscEntry::title was genuinely unused dead code, but it's read by to_keydb_cfg and the redacting Debug impl — correct the comment to only list empty/find_vuk as truly unused. Also cross-reference the two independent MAX_KEYDB_BYTES constants (keydb.rs's decompression cap and keydb_format.rs's on-disk load cap) so they're kept in sync, and bump Cargo.lock's version metadata.
Two disc-side reads were silently swallowed with unwrap_or(&[]): a failure reading the disc's encrypted title keys or its MKB looked identical to a disc that genuinely had none, hiding real I/O/parse errors from the operator. Log a warning (mirroring online.rs's existing pattern) before falling back to empty in both cases.
Also, a stored unit key numbered 0 (an invalid CPS unit number; valid numbering is >= 1) was mapped via num.saturating_sub(1) onto idx 0, silently colliding with and shadowing a legitimate unit 1. Skip unit number 0 instead of mis-mapping it, and add a regression test.
cached_db held self.cache locked across the full KeyDb::load_counted parse of the ~62 MiB keydb, so a reparse (triggered by a changed mtime) stalled every other worker waiting on the cache. Take a brief lock for the fast-path settled-cache-hit check, release it, parse outside the lock, then re-acquire and double-check: if a peer installed the same settled stamp while we parsed, adopt their entry and drop our redundant parse rather than racing a lost update. Stamp and bytes still come from the one already-open file handle, preserving the one-open-one-identity invariant.
Each DNS lookup for the SSRF guard spawns a thread that is never joined and can hang for the full OS resolver timeout, so a black-holed keyserver leaks one thread+stack per attempt. Track outstanding resolver threads with an atomic counter; over a cap of 4, report the host as unreachable instead of spawning another. Decrement only when the (possibly long-hung) lookup actually returns, so the cap reflects threads truly in flight.
The IPv6 SSRF guard already re-checked IPv4-mapped/-compatible addresses, but 6to4 (2002::/16) and Teredo (2001:0000::/32) also tunnel an embedded IPv4 and were passed through untouched, letting an attacker wrap 127.0.0.1 or a cloud metadata address in either encapsulation to bypass the block list. Decode both forms and re-check the embedded IPv4 through is_blocked_ip. Add a test covering 6to4/Teredo-wrapped loopback and metadata addresses, plus a 6to4-wrapped public IP that must still be allowed.
keydb.cfg carries AACS key material plus the host private key/cert. write_atomic renamed a File::create()'d temp file onto it, so the final mode depended on umask and could leave the keys world-readable. On Unix, open the temp file with mode 0600 explicitly via OpenOptionsExt; non-Unix keeps the plain create().
CI/lint hardening since v1.6.14: clippy on stable with the MSRV single-sourced
from Cargo.toml, cargo-deny dependency-audit gate with first-party git sources
allowed (#15), and audience-based comment-guard (#17). No library code changes.
Declared rust-version and the ci.yml/qa.yml toolchain pins both sit at 1.88 (the
edition-2024 floor once zip 8 and let-chains are accounted for), so CI builds on
exactly the MSRV: an accidental bump to a newer toolchain fails CI by design and
moving the floor takes a deliberate pin edit. The uninlined_format_args allow
reconciles 1.88's clippy with the allow-by-default it became in 1.97+.
Adds targeted unit tests for previously-uncovered branches: keydb load
failure classification (invalid-UTF-8 / unreadable path), zip extraction
success/no-cfg-member paths, write_atomic create/rename failure modes,
keydb_format hex-digit-parsing edge cases, iter_disc_entries, to_keydb_cfg
HC2 emission and empty-title/comment-field combinations, MultiSource::label,
and the online key-service client's SSRF-guard malformed-authority and
unresolvable-host paths, UK-as-string and VUK-derivation reply handling,
and get_fmts_indexes. Local line coverage moves from ~90.3% to ~92.1%.
Add an additive coverage job to ci.yml that mirrors the test job's
sibling-checkout layout and pinned 1.97.0 toolchain, runs cargo llvm-cov,
and uploads to Codecov tokenlessly (fail_ci_if_error:false). Nothing in
the release cascade depends on it. Also add codecov.yml (informational,
no PR comment) and a README with the coverage badge.
Adds a 'changelog' job that fails when Cargo.toml's version has no matching
'## [X.Y.Z]' heading in CHANGELOG.md, hard on main and PRs into main, warning
on dev/qa — so the unified release can't ship a crate with a blank changelog.
parse_disc_entry's U arm stripped a `; MKBv…` comment before hex-parsing the
value, but the M/I/V arms did not. When a real keydb.cfg disc row's LAST field
is M/I/V (no U after it), the comment glues onto that value and parse_hex16 of
"0x… ; MKBv64" fails — silently dropping a media/id/VUK key the file actually
holds, so the disc reports no key. Strip the comment in M/I/V too (a hex16 never
contains ';', so it is safe), with a red-before-green regression test.
The round-2 guard tests used `keyserver.invalid` — RFC 6761 reserved and
guaranteed non-resolvable, which is the correct property. But the shared
leak-guard net lists `.invalid` among the private-infra TLD suffixes
(.internal/.local/.lan/.corp), so every such host tripped it and turned the
repo red on push — the exact cost the precommit script's own comment records
from 1.6.3. `.test` is equally RFC-6761-reserved and equally non-resolvable,
carries the same test guarantee, and is not in the infra net. No behaviour
change; the tests assert the identical never-resolves path.
Round 1's parse cache keyed on `(len, modified)`, which is not a file
identity. mtime is stored at whole-second resolution on HFS+, on many
container overlays and on most network filesystems, so an external
writer — the daily-refresh job, a second freemkv process, an editor, a
sync tool — replacing keydb.cfg within one second with a file of the
same byte length left the stamp bit-identical and the source served the
superseded parse for the rest of the process's life. Retired or
since-corrected keys, reported as success. The doc claiming "an
externally replaced keydb is still picked up on the next call" was false
in exactly that case.
Two discriminators, chosen so neither costs a 62 MiB content hash (the
141 ms x 3+ loads per rip the cache removed):
1. dev + ino. Every atomic publisher (temp + fsync + rename, including
this crate's save()) creates a new inode, so the stamp differs
unconditionally, whatever the clock did.
2. A settle window. An entry is trusted only once the file has been
quiet for the mtime granularity (2 s); the proof that this closes
the residual in-place case, and the network-clock caveats, are in
CacheEntry::is_settled. Cost: re-parsing for up to 2 s after each
write of a file that is rewritten daily.
What it still does NOT catch, now stated in the doc instead of implied
away: a writer that forges the timestamp back to the cached value
(touch -t, rsync --times, a backup restore) into the same inode at the
same length. save() therefore keeps invalidating directly.
Also fixed, all in the same cache:
- The corruption warning was swallowed. ParseStats::log runs inside
KeyDb::parse and a cache hit skips parse, so a damaged keydb warned
once per process and was then served in silence to every later rip.
It is re-emitted on every hit that serves the file; an intact keydb
still says nothing.
- TOCTOU. cached_db stat'd the path and then separately loaded the
path — two resolutions of one name with no atomicity, so a rename in
the gap filed one file's stamp beside another file's keys. The file
is opened once; the stamp is an fstat on that handle and the bytes
come from it.
- Memory residency is recorded rather than quietly widened: a hit
holds every disc's key material plus host-cert private keys for the
process's life, unzeroized. Nothing in this crate zeroizes on drop
and that predates the cache; closing it means zeroize-on-drop across
KeyDb, DiscEntry and libfreemkv's aacs::types, which is cross-crate
work and not smuggled in behind a cache fix.
On KeydbHostCert's derived Debug: audited, NOT a leak. libfreemkv's
HostCert hand-writes a redacting Debug (private_key: "<redacted>",
certificate_len), so `{:?}` on a host cert prints no key bytes. But that
is an external guarantee this crate inherits silently, and the existing
redaction test only covered the outer types. It is now pinned here, for
host certs and device keys, verified red against a libfreemkv patched to
derive Debug instead.
Two KEYDB_PATH-gated tests that could not fail are fixed too:
test_decrypt_real_unit fell off the end after trying every key (a no-op
decrypt_unit passed it), and test_resolve_keys_vuk_path returned early
with zero assertions when the fixture had no qualifying entry.
CHANGELOG: entries for this and for dd9a5dc, including the previously
unrecorded Arc<str> break to KeyDb::disc_entries / DiscEntry::disc_hash.
Round 1 separated a config fault from a transient one and then routed
them to OPPOSITE severities. `GuardFail::Config` returned
`Ok(Vec::new())` from `query` while `GuardFail::Unreachable` returned
`Err`, so a mistyped port — `https://host:notaport/` — produced one
`warn!` and a benign-looking empty, which `MultiSource::first_non_empty`
cannot distinguish from "no source has a row for this disc". The disc
was reported as E7022 "no key for this disc". That is the seven-hour-502
collapse with the polarity reversed, and worse: an outage self-heals, a
typo in a config file does not.
Every way of failing to ASK is now `Err(KeyServiceUnavailable)`, logged
at error level with text that names it as a URL to fix rather than an
outage to wait out. That covers the address-guard rejection (bad port,
malformed URL, a host resolving to a non-public address) and the
cleartext `http://` refusal, which had the same silent shape.
The two input-shaped guards — an over-cap MKB, too few content samples —
deliberately stay `Ok(empty)`: they are properties of this disc, not
faults, and calling them an outage would mislabel them in the other
direction. The line between the three cases is now written down at the
guards themselves.
Not a rip-breaking change: an `Err` never blocks the chain, because
`first_non_empty` still returns any later source's key set outright and
surfaces a failure only when nothing anywhere resolved.
Also: the agent-reuse cache key is now sorted and deduped. It was an
ordered `Vec<SocketAddr>` compare, so a round-robin keyserver — the one
deployment the connection reuse exists for — reordered its A/AAAA
records and missed the cache on every query, silently paying a fresh TLS
handshake each time. Set equality is also the honest predicate for
"the same validated addresses".
Note for libfreemkv: there is no `KeyServiceMisconfigured` code, so a
permanent fault borrows the transient E7028. E7028's "the source never
answered" is exactly right here; only its "retry later" hint is wrong.
Two defect classes dominate this change, and both are the same shape: something
that should have been loud was silent.
KEY MATERIAL IN A PUBLIC `Debug`. `KeyDb` and `DiscEntry` both carried a derived
`Debug` over raw key bytes — every processing key, and every disc's Media Key,
VID, VUK and Unit Keys. Both are public API, so a single `{:?}` in a caller's
log line, a `tracing` field, or an `assert_eq!` panic message published the
whole keydb: on the real file that is 184,860 discs' key material plus the
processing-key pool. libfreemkv's equivalent types (`aacs::types`) all
hand-write a redacting `Debug` for exactly this reason; this crate's copy of the
parser dropped the convention when it was relocated. Both impls are restored,
field-for-field in the same style, and pinned by tests that fail against the
derive.
A SOURCE THAT COULD NOT ANSWER IS NOT A SOURCE THAT SAID "NO KEY". `OnlineSource`
was engineered at length to separate `Err` (the service could not answer) from
`Ok(empty)` (it answered and holds nothing) after a seven-hour run of HTTP 502s
was reported to operators as E7022 "no key for this disc". Three places
re-collapsed that distinction:
* `MultiSource` discarded every inner `Err` (`if let Ok(uks) = ..`), so a
composition whose sources had all failed returned a clean `Ok(empty)` — the
incident reproduced one layer up, in both the base and the forensic path. It
now returns the FIRST source failure when nothing resolved, matching
libfreemkv's own resolver, while a later success still wins outright.
* `KeydbSource` turned every `KeyDb::load` failure into `Ok(empty)` with no
log at all. A keydb over the 128 MiB cap or one that is not valid UTF-8 is a
corrupt, truncated or half-written file, not a disc without a key; those are
now logged and returned as errors. A MISSING keydb stays the documented
benign empty.
* A mistyped port in the key-service URL (`https://host:notaport/`) reused the
whole authority as the hostname, so it failed DNS and surfaced as
`Unreachable` — a standing misconfiguration reported as a transient outage,
the one collapse `GuardFail`'s doc says must never happen because the two
halves demand opposite operator actions. It is now a `Config` rejection, the
same verdict the bracketed-IPv6 branch already gave.
The keydb parser was equally silent: every malformed `| DK` / `| PK` / `| HC` /
`| HC2` / `0x…` row was dropped with no counter and no log, and so was a row
lost to the entry cap or overwritten by a duplicate hash. A byte-shifted mirror
download therefore passed `save()`'s validation, was atomically committed as the
new keydb, and resolved nothing for every disc past the damage — indistinguishably
from a healthy file. Rejections are now counted by reason and summarised in one
log line per parse. The real published keydb parses with zero rejections and zero
duplicates, so the summary stays quiet on healthy input.
Also fixed, with tests:
* The 62 MiB / 184,860-entry keydb was re-read and re-parsed on EVERY call —
~140 ms each in release, three or more times per AACS-cert rip. `KeydbSource`
now caches the parse behind the file's (size, mtime) stamp (~1 µs on a hit),
re-reads a file replaced underneath it, and drops its own cache on `save()`
so a same-size rewrite inside the timestamp resolution cannot be missed.
Peak RSS is unchanged; the parse simply is not repeated.
* A disc row prefixed `0X` was dropped silently, though `parse_hex` in the same
file deliberately accepts both cases. `save()`'s entry count now CALLS the
parser's own predicate instead of restating it, so the two cannot drift again.
* Each disc hash was stored twice — once as the map key, once in the entry —
~13 MB of duplicate text at real scale, now one shared allocation.
* `OnlineSource` built a fresh agent per query, paying a second TLS handshake
on every FMTS disc. The agent is reused only while the freshly re-resolved
and re-guarded address set is identical, so the anti-rebinding guarantee is
untouched.
Test coverage for defences that were never exercised: the cleartext-`http://`
refusal, the over-cap MKB skip and the too-few-samples skip (a reordering that
let a plaintext POST through — carrying the bearer token and base64 key material
— would have shipped green), and the 1 MiB reply cap. The real-scale tests used
to `return` early when `KEYDB_PATH` was unset, so CI ran them, asserted nothing
about 181k-entry behaviour and reported success; they are `#[ignore]`d with a
documented run command and now fail loudly if run without their fixture.
`dtolnay/rust-toolchain` is not a dependency in the sense Dependabot
means: it is versioned by the Rust release it installs, and the tag we
pin is the toolchain CI is pinned to on purpose — precommit.sh runs the
same one locally, so a lint that passes on a developer's newer default
cannot pass CI by accident.
Read as semver, those tags produced a 1.97.0 -> 1.100.0 proposal, a Rust
version that does not exist. Every such PR 404s on toolchain download
across all eight repos and regenerates weekly: eight permanently-red PRs
that promote.yml then has to special-case when it decides whether dev is
green.
freemkv already carries this ignore; this is the same block in the other
seven, so the fleet stays uniform. Bumping the toolchain stays a
deliberate, all-eight-repos change made by hand together with
precommit.sh.
These are eight public repos that ship one product on one version, and
they had drifted: freemkv-engine carried no LICENSE at all (GitHub
reported its licence as none), two crates had no code of conduct, four
had no contributing guide, and none had a security policy — so there was
no private route to report a vulnerability in a disc-decryption tool.
SECURITY.md names GitHub Security Advisories on each repo rather than an
address, so there is nothing to keep in sync and no inbox to go stale.
dev is where work lands and is meant to be pushed to often, so what runs
there should be the cheap answer to "did I break it": lint, tests and the
Linux build. The macOS and Windows jobs now run on qa and main instead of
on every push to dev.
Nothing is deleted and no platform goes unchecked before a release. qa.yml
already covers macOS and Windows independently, and the jobs that live only
here -- the Intel macOS build, the Windows release build -- still run, on
the branches where a cross-platform break is worth blocking on.
They are SKIPPED on dev via `if`, not left unscheduled. A queued job would
be worse than a slow one: release.sh's CI gate refuses while any run for
the commit is still in progress, so a job that never gets a runner blocks
releases silently, with no error anywhere. That is the failure the
real-media note in qa.yml describes, and it is why this is an `if` on the
job rather than a narrower set of trigger branches.
leak-guard's internal-infra net lists .invalid alongside .internal,
.local, .lan and .corp, so the hostname these tests use to prove the
pinned resolver is consulted tripped it. The point is only that the name
never resolves, and .test is reserved for exactly that by RFC 6761 -- it
is also what the rest of this suite already uses (example.test).
No change to what the tests assert.
Version bump and changelog for the 1.6.3 sync. All eight crates ship the
same version, so the crates with no functional change this cycle say so
rather than carrying an empty section.
The three crates move together because they must: freemkv resolves its
siblings from their git TAGS in its own CI, where keysources still wanted
ureq 2, so cargo saw no conflict and the earlier split bump went green on
a dependency graph the release never uses. The qa and hash-matrix
workflows resolve those siblings from their dev PATHS, cargo unifies to a
single ureq, and the mismatch stops compiling. Verified here the way
those workflows see it: freemkv built against the sibling paths now
resolves one ureq 3.4.0 and no ureq 2 anywhere.
Notable API changes:
AgentBuilder::new()..build() -> Config::builder()..build() + Agent
.redirects(0) -> .max_redirects(0)
.timeout_read(d) -> .timeout_recv_response(Some(d))
.resolver(closure) -> the Resolver trait
.set(k, v) -> .header(k, v)
ureq::Response -> http::Response<ureq::Body>
Error::Status(code, _) -> Error::StatusCode(code)
Error::Transport(_) -> fans out; matched with a catch-all
Two hazards the new resolver API introduces, both handled:
Agent::new_with_config compiles fine and then silently ignores a custom
resolver, so the pinned addresses would be dropped and every request
would go back to live DNS -- reopening the DNS-rebinding window the
guards exist to close, with no symptom. Only Agent::with_parts takes a
resolver, and that is what every pinned agent here uses.
ResolvedSocketAddrs is a fixed 16-slot array whose push writes straight
into it, so a host publishing more than 16 addresses would panic the
calling thread. Truncated to the first 16.
The four reply stubs used ureq::Response::new, which v3 removes; they are
rebuilt with http::Response::builder over a ureq::Body, so they stay
server-free unit tests.
resolve_and_guard validates the keyserver's addresses and hardened_agent
pins them, so a hostname cannot re-resolve to an internal address between
the guard and the POST. The validation half has seven tests; the pin had
none, and none of the existing tests makes a connection, so all 87 of them
pass even with the resolver wiring removed entirely.
That is a bad failure mode to leave uncovered, because a mis-wired
resolver fails open: connections fall back to live DNS, the rebinding
window reopens, and nothing looks wrong.
Pin the agent at a loopback listener the test owns, then ask it for a host
that cannot resolve (.invalid, reserved by RFC 2606). Only a consulted
resolver can turn that name into a connection. Asserts the connection
arrives, the round-trip completes, and the original host still travels in
the request.
Verified by removing the resolver wiring: this test fails on the timeout
while the other 87 stay green.
Version updates were never configured here -- only security alerts, which
report but never open a pull request. So the absence of Dependabot PRs was
not "nothing to update", it was "nothing was checking".
Updates target `dev`, never `main`. main is a release pointer that
release.sh moves to each tag, so a bot commit on it would put work there
that no tag contains -- the exact state that aborted the 1.6.2 cascade at
its last step, and pointing a scheduled bot at main would recreate it
weekly.
Minor and patch bumps are grouped into one PR rather than one per crate:
eight repos times a handful of dependencies is a volume nobody reads, and
an unread PR queue is indistinguishable from no updates at all.
The freemkv crates are excluded. They depend on each other by git tag,
re-pinned by release.sh inside the release commit, and Dependabot cannot
see that cascade -- a PR bumping one could pin a version whose tag does
not exist yet.
release.sh dates the heading when it cuts the tag, and refuses a bare
'## [1.6.1]' because it cannot tell an undated heading from one it has
already stamped. Shipping a tag whose public changelog says UNRELEASED is
permanent and cannot be corrected inside that tag, so the check is right
to stop rather than guess.
The first cut ran release-profile tests on all three platforms everywhere,
which invented coverage no crate had ever claimed. bdemu is Linux-only — its
ci.yml has no macOS or Windows job at all, and its tests call
ExitStatus::signal(), which does not exist on Windows, so they do not compile
there let alone run. The rest follow the policy ci.yml already set: tests
execute on Linux and macOS, Windows compiles them so the cfg(windows) halves
still get codegen before release time.
That policy is now explicit in qa.yml as a windows-build job rather than
implied by which jobs happen to exist.
Recorded, not papered over: running the suites on Windows DOES find real
defects — autorip has five Windows path-handling failures in mover.rs and
freemkv-engine has three tests hardcoding /dev/null. freemkv ships a Windows
GUI, so they matter. They are pre-existing rather than 1.6.1 regressions, and
fixing them is its own piece of work.
Also build the binary before cli-parity.sh. cli-integration.sh builds its own;
cli-parity.sh only checks for one and exits 2 with "build it first", so both
legs died before running a single case.
cargo loads every [patch.crates-io] path whether or not the patch is used, so
naming a crate that was not cloned fails resolution with "failed to load source
for dependency freemkv-i18n" — which reads like a missing dependency rather
than the workflow bug it is.
The generated stanza listed all four libraries for every crate that needs
patching, while each job only checks out the siblings it actually depends on.
freemkv-keysources, freemkv-engine and bdemu therefore could not resolve at
all; freemkv and autorip happened to be fine because they check out all four.
Caught by the qa gate on its first run, which is the gate working.
Every push to qa now tags v<version>-rc<N>, N incrementing, before the gates
run. That answers "which build is on qa, and is it the one I tested?" without
anyone having to remember it.
The tag lands whether the run goes green or red, 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 the gate doing
its job — the branch saying this is not production worth yet.
release.yml now excludes v*-rc*. Its trigger was v*, which matches the
candidate tags, so without this every push to qa would have built and PUBLISHED
a GitHub release — including for the candidates that failed.
dev -> qa -> main, across every public repo.
dev is where work lands and is meant to be pushed to often, so ci.yml stays
the fast answer: fmt, clippy, unit tests, leak-guard. qa is the release
candidate, and qa.yml is the claim that a commit is production worth —
release-profile tests on all three platforms, and the Linux cross-target
clippy that the local precommit gate has always run but CI never did.
Release profile matters as its own gate: overflow checks are off, debug_assert
is compiled out, and inlining changes what the optimiser can prove. A test
that only passes in debug never guarded the binary anyone ships.
qa.yml checks siblings out at qa rather than dev. This is not a monorepo, and
a qa run resolving its dependencies from unreleased dev tips would go green on
a combination that is not the one shipping — the exact mismatch the branch
model exists to prevent. ci.yml now tracks whichever branch triggered it for
the same reason, since it also fires on qa.
The consequence is that repos move to qa together, in dependency order. qa is
backfilled from main so an unchanged crate still presents working code to the
crates built on it.
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.
`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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
leak-guard bans the .invalid TLD (grouped with private .internal/.local/etc);
example.test is the RFC 6761 reserved test TLD — equally unreachable, not on
the ban list — so the leak-guard CI check passes.
- to_keydb_cfg emits the sibling `| HC2 |` line (AACS 2.0 host cert),
the inverse of parse_host_cert_v2, so a round-trip never silently
drops v2 host certs.
- save-validation mirrors the parser's real disc-entry rule exactly: a
`0x` line counts as an entry only if it also contains ` = `, so
validating + persisting content that parses to zero usable entries
(e.g. a stray `0xDEADBEEF` line) can no longer succeed.
- disc-entry title is kept verbatim (parens and all) on parse, matching
the emit path so it round-trips.
libfreemkv deleted the aacs::boil veneer, so switch the resolve path to the
raw primitives: derive_media_key_from_{pk,dk}, derive_vuk, decrypt_unit_key
from aacs::derive, and the newtypes from aacs::types. Add a local uks_from_vuk
helper composing decrypt_unit_key. No behaviour change; fmt/clippy/test green
on Rust 1.86.
libfreemkv's AACS work changes mk_from_dk from (dk, mkb, vid) to (dk, mkb) —
the VID enters at the VUK step, not the MK step. Adopt the 2-arg call so the DK
pool is tried unconditionally (no longer gated on a VID being present), matching
libfreemkv. Without this keysources fails to compile against the new libfreemkv.
(Equivalent to the stranded b80d4cb; applied directly since that branch had
diverged surrounding context.)
keysources deps libfreemkv, now git-only, so keysources follows it off
crates.io. A committed [patch.crates-io] redirects the bare libfreemkv version
req to the git tag (local dev overrides to a path via the gitignored config).
The 'KeydbSource owns save+update' (Added) and 'Processing-Key
decryption restored' (Fixed) entries were stale carryover from the
never-released 1.1.0-beta.1; that code shipped in v1.1.0 (zero diff in
v1.1.0..HEAD). The 1.2.0 entry now lists only the real 1.2.0 changes
(hex unification, DiscInputs version stride, MKB cap).
The online source dropped any MKB over 10 MiB while libfreemkv's reader
captures up to 64 MiB — an MKB in that band was silently un-forwardable
(no key, no surfaced cause). Match the cap and log when it is exceeded.
unit_keys_from now loads all stored unit keys AND the VUK/MK-derived ones, deduping by key value instead of first-hit, so a disc with partial stored UKs plus a VUK yields every declared CPS unit key. Plus keydb.cfg format round-trip (to_keydb_cfg inverse of parse).
Found via a parse->serialize->parse->serialize byte-identical check over the full
182,535-disc OEM keydb:
- Title was reduced to a parenthesised substring, truncating real titles
("Lawrence of Arabia (Restored Version) - Disc 2 - 4K Ultra HD" -> "Restored
Version"). Keep the title VERBATIM (faithful copy); display prettification is a
separate layer.
- A disc titled exactly "M" (`= M | M | 0x...`) had its title eaten as the M
field tag, dropping the real media key. Field scan now skips parts[0] (always
the title), so a title that is a tag letter (M/I/V/U/D) can't shadow a field.
- Add to_keydb_cfg_is_idempotent_on_real_keydb (KEYDB_PATH-gated): proves parse
is lossless on its own output and the serializer is deterministic.
Central round-trippable codec: parse + to_keydb_cfg in one place.
Emits HC, DK, PK, then disc entries sorted by hash. Round-trip test
(parse -> serialize -> re-parse) covers HC/DK/PK/disc. disc_id -> vid
names the field for what it is (the I-field volume ID).
Make the parser public — parsing a keydb is not secret (freemkv uses it, and so
do tools that build a per-disc registry from it, e.g. a Volume-ID index). Purely
additive: a private module in this public crate becomes nameable; no behaviour
change, nothing removed.
Move the keydb save/validation/atomic-write path out of libfreemkv onto
KeydbSource. New KeydbSource::save(bytes) validates + decompresses (zip /
gz / plain, decompressed-size capped) and crash-safely writes to the
source's OWN path (sibling-temp + fsync + rename + parent-dir fsync) —
not a hardcoded default, so the caller chooses the destination.
KeydbSource::update(fetch, url) calls an INJECTED fetch closure then
save, keeping this crate transport-agnostic on the update path (the app
supplies its own TLS / SSRF-guarded transport). UpdateResult moves here
and is re-exported. Add flate2 + zip (already in the resolved graph via
libfreemkv) for decompression; no new HTTP stack.
unit_keys_from now resolves the Media Key in order: stored per-disc MK
-> keydb Processing Key pool (mk_from_pk vs this disc's own MKB) ->
device-key pool (mk_from_dk), then MK+VID -> VUK -> UK. MK/VUK entries
still honored directly; cross-disc MK-pool brute stays retired. Fixes the
factually-wrong justifying comment + adds PK-pool KATs.
The crate previously had only leak-guard.yml and release.yml, so its ~42
tests never ran on push/PR — only on tag. Mirror the sibling crates'
ci.yml (lint + test jobs, dtolnay/rust-toolchain@1.86.0, rust-cache).
No --locked: this library does not track Cargo.lock (release.yml notes
--locked would fail on a fresh runner).
Add tests/key_sources.rs exercising the published KeySource impls
end-to-end over real fixture files and the libfreemkv parsers:
- KeydbSource: disc-hash lookup from a real keydb.cfg (VUK hit ranks
ahead of the universal DK pool), hash miss yields only the pool,
missing file is silent/not errored, label + needs_samples, and
host-cert serving from a | HC | row (inherent and trait paths).
- paths: exe-local search list, default == search head, existing path
reflects on-disk state (local-only, no OS fallback).
- MapfileSource: persisted # freemkv-uk: keys read back as a terminal
Key::Unit, one-shot exhaustion, missing/keyless mapfile offers nothing.
- OnlineSource: unconfigured no-op (no network), one-shot latch,
metadata, and validate_keyserver_url scheme/SSRF gating.
- MultiSource: caller-supplied order/precedence (and its reverse),
empty-source skip, needs_samples/errored OR-aggregation, nesting, and
a real keydb-then-mapfile precedence chain over fixtures.
Move the encrypted sample reader and the key-resolution loop into libfreemkv
(they read the disc and validate keys — decryption mechanism, not lookup). A
key source now only looks a key up and hands it back.
- The online key service ignores `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`: with one set, every key lookup failed (the pinned resolver also answered the proxy's address, so the proxy request went to the key service).
### Changed
-`KeydbSource` hands out host certs best first, with no disc input: certs with no `Revoked in MKBv<N>` note in keydb order, then revoked ones by latest revocation. The inherent `host_certs()` and the `KeySource::host_certs` trait method both return this order; the trait ignores its `mkb` argument.
### Deprecated
-`KeyDb::host_certs(mkb)`: use `KeyDb::host_certs_ranked`.
### Added
-`KeyDb::host_certs_ranked`.
-`check_keyserver_url` with `KeyserverUrlRejection`/`KeyserverUrlFault`: a typed Permanent vs Temporary verdict, so callers need not match `validate_keyserver_url`'s text.
-`test-hooks` feature exposing `set_last_decode_reachability`, to test decode-verdict handling without a network.
## [1.7.7] — 2026-09-26
### Maintenance
- Replace comment-overflow documentation with concise source contracts and README instructions; enforce the shared comment policy in CI.
## [1.7.6] — 2026-09-26
### Changed
- Version aligned to 1.7.6 for the unified release. No functional changes to this crate; the release is driven by the freemkv 1.7.6 Linux desktop shell (GTK4 + libadwaita) and the rip-finished desktop notification (issue #56).
## [1.7.5] — 2026-09-23
### Changed
- Version aligned to 1.7.5 for the unified release. No functional changes to this crate; the release is driven by freemkv-unlock mirroring the freemkv-firmware 0.9.0 ABI (the drive's `Ake` and `Bus` levers retired into a single `Encryption` lever).
## [1.7.4] — 2026-09-21
### Fixed
- keydb lookups now report match state and entry shape, so a "no entry" result tells you whether the disc was truly absent or matched-but-underivable (no usable VID) (#46).
- ureq 3.4.2 with a bounded, rolling-idle reply body, so a slow-but-progressing download is no longer killed by an absolute deadline.
### Maintenance
- CI moved to the central reusable workflows.
## [1.7.3] — 2026-09-19
### Changed
- Unified release with freemkv 1.7.3 (`info --share` disc-structure capture for bug reports + the multi-angle UHD main-title fix in libfreemkv; see the libfreemkv/freemkv 1.7.3 notes). No functional changes to this crate.
## [1.7.2] — UNRELEASED
### Added
-`OnlineSource` now records the reachability outcome of its actual `/decode` POST (an HTTP `Status(code)` for any answer, including 200/404/422/429/5xx, or `Transport` for a connect/timeout/DNS/TLS failure) in a per-thread slot, readable via the new `take_last_decode_reachability()` / `DecodeReachability`. This lets a caller (autorip) classify a no-key result from the REAL decode's outcome instead of firing a second, redundant probe against the POST-only endpoint. No behavioural change to key resolution itself.
## [1.7.1] — UNRELEASED
### Changed
- Version aligned to 1.7.1 for the unified release (freemkv-unlock 1.7.1 LibreDrive unlock fix); no functional changes.
## [1.7.0] — 2026-09-02
### Changed
- Version aligned to 1.7.0 for the unified release. Internal CI/lint hardening only (stable-clippy MSRV split, cargo-deny dependency-audit gate, audience-based comment-guard); no functional changes.
## [1.6.14] — 2026-08-31
### Changed
- Version aligned to 1.6.14 for the unified release.
## [1.6.13] — 2026-08-28
### Changed
- Version aligned to 1.6.13 for the unified release.
## [1.6.12] — 2026-08-27
### Changed
- Version aligned to 1.6.12 for the unified release; no functional changes.
## [1.6.11] — 2026-08-26
### Changed
- Version aligned to 1.6.11 for the unified release. No functional changes to
this crate; the release was driven by the libfreemkv main-feature selection
improvements and the autorip mux-quarantine fix (see the libfreemkv and
autorip 1.6.11 notes).
### Added
- Codecov coverage reporting and badge.
- Substantially expanded unit-test coverage.
## [1.6.10] — 2026-08-23
### Changed
- Version aligned to 1.6.10 for the unified release. No functional changes to
this crate; the release was driven by libfreemkv (TrueHD/MLP audio now resyncs
to the next major-sync access unit after a source transport-stream
discontinuity, instead of splicing post-gap audio mid-stream — fixing
decoder-choking seams on discs whose stream carries a continuity-counter gap;
see the libfreemkv 1.6.10 notes).
## [1.6.9] — 2026-08-22
### Changed
- Version aligned to 1.6.9 for the unified release. No functional changes to
this crate; the release was driven by autorip (automatic per-episode TV
ripping — each episode named `S{NN}E{MM}`, with TMDB runtime-aligned episode
numbering across multi-disc seasons — a Manual Rename option, and a unified
per-disc staging state file — see the autorip 1.6.9 notes).
## [1.6.8] — 2026-08-21
### Changed
- Version aligned to 1.6.8 for the unified release. No functional changes to
this crate; the release was driven by autorip (webhooks now fire per pipeline
stage — Rip / Mux / Move — with the Rip hook firing the moment the drive is
free again, plus a Ripper-tab activity-banner fix so it also shows during
moves — see the autorip 1.6.8 notes).
## [1.6.7] — 2026-08-21
### Changed
- Version aligned to 1.6.7 for the unified release. No functional changes to
this crate; the release was driven by autorip (per-webhook event selection,
a progress bar per moved artifact, and move-queue / webhook-error fixes —
see the autorip 1.6.7 notes).
## [1.6.6] — 2026-08-20
### Changed
- Version aligned to 1.6.6 for the unified release. No functional changes
to this crate; the release was driven by autorip (webhooks may now target
private/LAN addresses — see the autorip 1.6.6 notes).
## [1.6.5] — 2026-08-20
### Fixed
- **A media, disc-ID, or VUK key at the end of a keydb row is no longer
lost to the comment beside it.** Many `keydb.cfg` rows end with a
`; MKBv…` note. When that note sat directly after the row's final
field and that field was the media key (`M`), disc ID (`I`), or VUK
(`V`) — rather than a unit key (`U`) — the note was read as part of the
value, the hex parse failed, and the key was silently dropped, so the
disc reported no key even though the file held one. The note is now
stripped from those three fields exactly as it already was from the
unit-key field (a hex value never contains `;`, so the split is safe).
A disc that stopped resolving for this reason resolves again.
- **A misconfigured key service is no longer quieter than an outage.** A
mistyped port, an `http://` URL, or a key-service address the SSRF guard
refuses all mean the same thing: the service was never asked, so nothing is
known about the disc. They were reported as an empty result — the same value a
service returns when it genuinely holds no key — so a standing
misconfiguration surfaced to the operator as "no key for this disc" and never
self-corrected, while a transient DNS failure was correctly reported as a
failure. All three are now failures, logged at error level and separated in
the log text from an outage: one says fix the URL, the other says wait. A
later key source's real key still wins, so nothing that used to rip stops
ripping.
- **A replaced `keydb.cfg` can no longer be missed.** The parsed keydb is cached
behind the file's identity, and that identity was size plus modification time
— which are byte-identical when a file is replaced within the same second by
one of the same length, the common shape of a daily refresh on a filesystem
that records whole seconds. The old, superseded keys were then served for the
rest of the process's life while every lookup reported success. The identity
now includes the file's inode, and a cache entry is not trusted until the file
has been unmodified long enough for its timestamp to be meaningful.
- **A corrupt keydb keeps saying so.** The summary warning that counts rejected
and duplicated rows is produced while parsing, and the cache skips parsing —
so a damaged keydb warned once, at whatever moment it was first read, and was
then served in silence to every later disc. The summary is now re-emitted on
every lookup that serves that file. An intact keydb stays silent.
- **A keydb's identity and its contents are read through one file handle.** The
cache used to stat the path and then separately open it, so a replacement
landing between the two stored one file's identity beside another file's keys.
### Security
- **`{:?}` on a keydb no longer spells out its keys.** `KeyDb` and
`DiscEntry` derived their `Debug`, so a single `{:?}` in a log line, a
tracing field, or a panic message wrote out every processing key and
every disc's media key, disc ID, VUK, and unit keys in full — on the
published file, all 184,860 discs' worth. Both are public API, so any
caller could trip it. Both types now format through a redacting `Debug`
that names each field without its bytes, matching the AACS types this
parser was split from, and a test fails if the plain derive ever returns.
- **The host certificate's redaction is now pinned by a test.** `{:?}` on a
keydb host certificate does not print the AACS host private key, and never
did — but that depended entirely on libfreemkv's own redacting formatter,
with nothing in this crate to notice if it changed. The whole-database
formatter's test did not cover it. It does now, along with device keys.
### Changed
- **Breaking (API):** `KeyDb::disc_entries`' key and `DiscEntry::disc_hash` are
`Arc<str>` rather than `String` (a ~13 MB saving on the published keydb, which
is now held in memory). Code that requires an owned `String` from either needs
`.to_string()`. Unreleased in 1.6.4; recorded here because it shipped to git
consumers without a note.
## [1.6.4] — 2026-08-15
### Changed
- **No functional change.** This crate ships alongside the rest of freemkv at a
matching version; its behaviour is untouched.
## [1.6.3] — 2026-08-10
### Changed
- **The key service is reached over a newer HTTP client, with the same
protections.** Before contacting a key service this crate resolves the host,
rejects any private or link-local address, and then pins the connection to the
addresses it just checked, so a hostname cannot be re-pointed at an internal
machine in the moment between the check and the request. That behaviour is
unchanged; only the client underneath it moved.
### Security
- **The address pinning is now proven by a test rather than assumed.** The checks
that reject a bad address were well covered, but nothing verified that the
approved addresses were the ones actually connected to — and every existing
test still passed with that wiring removed entirely, because none of them
opened a connection. A connection made to an unreachable name can only
succeed if the pinned address is honoured, and that is now asserted. No
defect is being fixed here: the pinning was correct. It simply had no way of
telling anyone if it stopped being correct.
## [1.6.2] — 2026-08-08
Version sync with the workspace. No functional change in this crate.
## [1.6.1] — 2026-08-07
Version sync with the workspace. No functional change in this crate.
## [1.6.0] — 2026-08-03
### Fixed
- **Key-service failures are no longer indistinguishable from "no key for this
disc".** `query` returned an empty vector for a 502, a 401, a 429, a
transport error and an unparseable body alike — the same value a successful
lookup with no match returns. It now returns a typed error, and the HTTP
status is classified into the operator action it implies: 401/403 the token,
429 back off, any other non-2xx the service. A genuine miss is a 200 with no
key, so every non-2xx is a failure by definition. A DNS failure or timeout
now counts as unreachable rather than as a bad URL.
Version sync with the workspace (freemkv-engine split release). No source change
in this crate; it remains a pluggable AACS key-source provider consumed by the
`freemkv` CLI and libfreemkv's key resolver.
## [1.5.2] — 2026-07-22
Version sync with the workspace; inherits libfreemkv 1.5.2 (CSS DVD descramble
fix). No source change in this crate.
## [1.4.5] — 2026-07-18
Version sync; inherits libfreemkv 1.4.5. `KeySource` split into `get_unit_keys` +
`get_fmts_indexes`, and a keydb device-key parse bug on an uppercase `0X` hex
prefix was fixed (case-insensitive hex parsing across the toolchain).
## [1.4.4] — 2026-07-17
Version sync; inherits libfreemkv 1.4.4. The online `/decode` request is built from
a `DecodeSampleSet` proven sufficient by type rather than a runtime length check.
## [1.4.3] — 2026-07-17
Version sync; inherits libfreemkv 1.4.3. The online unit-key reply is parsed as a
list (one key for an ordinary disc, the ordered set for a forensic-variant disc),
and `MIN_SAMPLE_UNITS` is re-exported from libfreemkv.
## [1.4.2] — 2026-07-15
Version sync with the workspace; inherits libfreemkv 1.4.2. The keydb test that
feeds real key material into the AACS crypto was adapted to the segregated
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].
description="Pluggable AACS key sources (keydb, online key service, mapfile) for libfreemkv. Each source looks a disc up and hands libfreemkv a Key; the library does all derivation."
rust-version="1.88"
license="MIT"
description="Pluggable AACS key sources (keydb, online key service) for libfreemkv. Each source looks a disc up and hands libfreemkv its terminal Unit Keys via get_unit_keys; the library does all derivation."
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.