Compare commits

...
25 Commits
Author SHA1 Message Date
Matthew Jackson f9df7e0fb3 Carry the same licence and community files as the other repos
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.
2026-08-09 22:08:22 -07:00
Matthew Jackson 9a4f29b04c Keep the cross-platform jobs for the release candidate
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.
2026-08-09 20:24:05 -07:00
Matthew Jackson 6a8253f889 Release 1.6.3
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.
2026-08-09 20:01:33 -07:00
Matthew Jackson 2684f5f28e base64: match the workspace on 0.23
Every other crate declares 0.23; this one alone said 0.23.1. Cargo
unifies them, so this is uniformity rather than a fix -- but a lone
pinned patch version is the kind of thing that quietly becomes a real
split at the next major.
2026-08-09 18:43:09 -07:00
Matthew Jackson f0d936f4b6 Move to rand 0.10 and aes 0.9
Both are API renames rather than behaviour changes, but this is the AACS
and CSS crypto path, so it is verified by output rather than by the fact
that it compiles.

rand: `RngCore` no longer sits at the crate root -- `fill_bytes` moved to
the re-exported `Rng` trait -- and `thread_rng()` is now `rng()`.

aes: the cipher traits gained their `BlockCipher` prefix and
`generic_array::GenericArray` became `Array`. The obvious translation
uses `Array::from_slice`, which is deprecated in the new version and
would fail clippy's `-D warnings`; these use the `From<[T; N]>`
conversion the crate points at instead.

The proof this is byte-exact is `test_aes_cmac_nist_kat_full_block`,
which checks the exact MAC from NIST SP 800-38B Appendix D.1 -- a wrong
byte order or a mis-sized array cannot produce that value. All 95 tests
pass in both debug and release.
2026-08-09 17:50:01 -07:00
dependabot[bot] 5fd090fc8e build(deps): update sha2 requirement from 0.10 to 0.11 (#5)
Updates the requirements on [sha2](https://github.com/RustCrypto/hashes) to permit the latest version.
- [Commits](https://github.com/RustCrypto/hashes/compare/groestl-v0.10.0...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:59 -07:00
dependabot[bot] 44bd80e8df build(deps): update num-bigint requirement from 0.4 to 0.5 (#3)
Updates the requirements on [num-bigint](https://github.com/rust-num/num-bigint) to permit the latest version.
- [Changelog](https://github.com/rust-num/num-bigint/blob/main/RELEASES.md)
- [Commits](https://github.com/rust-num/num-bigint/compare/num-bigint-0.4.0...num-bigint-0.5.1)

---
updated-dependencies:
- dependency-name: num-bigint
  dependency-version: 0.5.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:55 -07:00
dependabot[bot] fb3d045509 build(deps): update base64 requirement from 0.22.1 to 0.23.1 (#4)
Updates the requirements on [base64](https://github.com/marshallpierce/rust-base64) to permit the latest version.
- [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.1...v0.23.1)

---
updated-dependencies:
- dependency-name: base64
  dependency-version: 0.23.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:52 -07:00
dependabot[bot] f9182df204 build(deps): bump actions/checkout from 5 to 7 (#7)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:24 -07:00
Matthew Jackson 53d9e15d3d Have Dependabot watch the dependencies, and land its PRs on dev
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.
2026-08-09 16:48:29 -07:00
Matthew Jackson 65e02f4b79 v1.6.2: bump version (unified release) 2026-08-08 22:18:13 -07:00
Matthew Jackson 10126cdbf6 changelog: 1.6.2 2026-08-08 08:53:11 -07:00
Matthew Jackson 887d154014 v1.6.1: bump version (unified release) 2026-08-07 19:08:30 -07:00
Matthew Jackson e9ed2ffa85 changelog: mark 1.6.1 UNRELEASED so release.sh can date it
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.
2026-08-07 17:57:22 -07:00
Matthew Jackson 3a5f92da91 Scope the qa matrix to the platforms each crate actually supports
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.
2026-08-05 21:22:30 -07:00
Matthew Jackson 58dc46037e Stamp every qa push as a release candidate
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.
2026-08-05 21:03:01 -07:00
Matthew Jackson 0ed613d7a9 Add the qa gate: dev for speed, qa for proof
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.
2026-08-05 20:59:34 -07:00
Matthew Jackson 4663cdf2bf changelog: 1.6.1 section 2026-08-05 13:59:41 -07:00
Matthew Jackson e7a60d461c v1.6.0: bump version (unified release) 2026-08-03 05:57:16 -07:00
Matthew Jackson e7490240a1 Add CI to the crate everything else depends on
freemkv-unlock had no automated checks at all: no lint, no tests, no
build on any OS but the one a developer happened to be sitting at. It is
the base of the dependency graph — libfreemkv path-deps it, bdemu
path-deps it with the emulation feature, and the rest pull it in
transitively — so a break here takes all seven other repos down, and
until now the first machine to notice would have been the release
workflow.

Full OS matrix, matching libfreemkv's: lint and test on Linux, plus
macOS and Windows building the tests (not `cargo check`, which skips
codegen and never compiles test-only code).

The `emulation` feature gets its own test run. It is off by default and
it is the face bdemu builds against, so nothing was compiling it.

Also adds the leak-guard workflow the other public repos already carry.
2026-07-31 13:46:04 -07:00
Matthew Jackson 3c535ec874 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 21cfb17d11 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 33e65f739c Route conduct reports through GitHub instead of a private address
The enforcement contact was an address on a domain used for internal
infrastructure, which does not belong in a public repository. GitHub needs no
mailbox to exist and is reachable for anyone who can read this file.
2026-07-29 16:07:14 -07:00
Matthew Jackson ac94c80163 1.6.0: version sync + fix stale docs
Bump to 1.6.0 (workspace sync, no functional change). Fix the README
example that called the removed matches()/unlock() API — rewrite to the
real unlock_features()/unlock_bus() contract (+ ld/README).
2026-07-28 15:35:19 -07:00
Matthew Jackson 94cad15a98 changelog: date the released 1.5.x sections (were stale UNRELEASED) 2026-07-28 13:55:23 -07:00
16 changed files with 536 additions and 48 deletions
+48
View File
@@ -0,0 +1,48 @@
version: 2
# Dependency updates land on `dev`, never on `main`.
#
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
# commit on it would put work there that no tag contains, which is exactly the
# state that aborted the 1.6.2 cascade at the last step -- so pointing
# Dependabot at main would recreate that failure on a schedule.
updates:
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate. Eight
# repos times a handful of crates is a volume nobody reads, and an
# unread PR queue is indistinguishable from no updates at all.
groups:
minor-and-patch:
update-types:
- minor
- patch
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release.sh as part of the release commit. Dependabot cannot see that
# cascade, so a PR bumping one of these would fight the release process
# and could pin a version whose tag does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
# The workflows are now real infrastructure -- the release cascade, the
# cross-platform hash matrix, the disc gate -- so their actions need the same
# attention as the crates.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
actions:
update-types:
- minor
- patch
+98
View File
@@ -0,0 +1,98 @@
name: CI
on:
push:
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
# to often: these are the FAST checks, so a mistake surfaces in minutes.
# `qa` is the release candidate — it runs these too, plus the expensive
# suite in qa.yml. `main` only ever moves at release time, to a tagged
# commit that was already green on qa.
branches: [main, dev, qa]
pull_request:
# freemkv-unlock is the BASE of the whole ecosystem: libfreemkv path-deps it,
# bdemu path-deps it with the `emulation` feature, and every other crate pulls
# it in transitively. It has no freemkv dependencies of its own, so no sibling
# checkout is needed here — but it is also the crate whose breakage takes all
# seven others down with it, which is why it gets the full OS matrix rather
# than a token Linux run.
#
# Cargo.lock is gitignored (library convention), so `--locked` is deliberately
# NOT used: with no committed lockfile it fails rather than resolves.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.97.0
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo fmt --check
# --all-targets so the test code is linted too, not just the library.
- run: cargo clippy --all-targets -- -D warnings
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo test --tests
# `emulation` is off by default and is the face bdemu builds against. A
# feature nothing compiles is a feature that breaks at release time.
- run: cargo test --tests --features emulation
# The crate ships on macOS and Windows through its consumers, so it has to
# compile there. Build the tests (not just `cargo check`) — that forces
# codegen and catches errors in test-only code, which `check` skips.
check-macos:
# dev is the FAST lane: this job still runs, but on the release-candidate
# branches rather than on every push to dev. Nothing is deleted and no
# platform stops being checked before a release -- qa.yml independently
# covers macOS and Windows, and the jobs unique to this file (the Intel
# macOS build, the Windows release build) run here on qa and main. A push
# to dev is meant to be cheap and frequent; waiting on three runner pools
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo build --tests --all-features
check-windows:
# dev is the FAST lane: this job still runs, but on the release-candidate
# branches rather than on every push to dev. Nothing is deleted and no
# platform stops being checked before a release -- qa.yml independently
# covers macOS and Windows, and the jobs unique to this file (the Intel
# macOS build, the Windows release build) run here on qa and main. A push
# to dev is meant to be cheap and frequent; waiting on three runner pools
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo build --tests --all-features
+35
View File
@@ -0,0 +1,35 @@
name: leak-guard
# Self-contained public-repo leak gate. Public CI cannot reach the private
# tooling, so this encodes only the generic net: internal-infra references,
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
# No project-specific reverse-engineering vocabulary lives here.
on: [push, pull_request]
jobs:
leak-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Compute commit range
id: range
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
else
before="${{ github.event.before }}"
after="${{ github.sha }}"
# New branch / first push: github.event.before is all-zeros.
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
echo "range=$after" >> "$GITHUB_OUTPUT"
else
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
fi
fi
- name: Run leak-guard
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
+118
View File
@@ -0,0 +1,118 @@
name: qa
# ── The qa gate: "is this production worth?" ────────────────────────────────
#
# dev -> qa -> main.
#
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
# that a commit is production worth: everything expensive that can run without
# physical media. `main` only ever receives a qa that went green here.
#
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
# dependencies from dev tips would be validating a combination that is not the
# one being released, which is the exact failure this branch exists to prevent.
#
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
# and no hosted runner has an optical drive or the image hoard. Those run on a
# self-hosted runner (see the media job at the end) and are the one leg that
# stays on hardware.
on:
push:
branches: [qa]
workflow_dispatch:
jobs:
# ── Name the candidate ────────────────────────────────────────────────
#
# Every push to `qa` is a release candidate, so every push gets a tag:
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
# qa right now, and is it the one I tested?" — a question that otherwise gets
# answered from memory.
#
# This runs FIRST and does not depend on the gates, deliberately. A red
# candidate needs a name more than a green one does: "rc3 failed
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
# Red on qa is a working gate, not an incident — it is the branch saying this
# is not production worth yet. Fix on dev, get dev green, push qa again.
#
# release.yml excludes v*-rc* so a candidate never publishes a release.
rc-tag:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Stamp the next rc
shell: bash
run: |
set -euo pipefail
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
# lexical sort gets that backwards from the tenth candidate on.
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
tag="v$v-rc$(( ${n:-0} + 1 ))"
git tag "$tag"
git push origin "$tag"
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
# The debug suite runs on every dev push. Release is a DIFFERENT build:
# overflow checks are off, debug_assert! is compiled out, and inlining
# changes what the optimiser can prove. A test that only passes in debug is
# a test that never guarded the binary anyone actually ships.
release-tests:
strategy:
fail-fast: false
matrix:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
- run: cargo test --release --tests
working-directory: freemkv-unlock
# clippy's output is target-dependent: cfg-gated code only gets linted on
# the target it compiles for. Linting solely on the dev machine's host
# target is how a lint that CI rejects reaches a push.
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.97.0
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
- run: rustup target add x86_64-unknown-linux-gnu
- run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings
working-directory: freemkv-unlock
# Windows compiles the tests but does not run them, matching the policy
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
# of the SCSI transport and platform layers compile on no other runner, so
# without this they are first built at release time.
windows-build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.97.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
- run: cargo build --release --tests
working-directory: freemkv-unlock
+22 -1
View File
@@ -1,6 +1,27 @@
# Changelog
## [1.5.2] — UNRELEASED
## [1.6.3] — 2026-08-10
### Changed
- **Housekeeping only — nothing about how a drive is unlocked has changed.** The
cryptography and compression crates this crate builds on were moved to their
current releases and brought into line with the versions the rest of freemkv
uses, so one version of each is built instead of two.
## [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
Version sync with the workspace. No functional change in this crate.
## [1.5.2] — 2026-07-22
### 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.
+18
View File
@@ -0,0 +1,18 @@
# Contributing
Thanks for your interest!
- **Report a bug** — open an issue
- **Fix a bug** — fork, branch, PR
- **Suggest a feature** — open an issue first
## Development
```bash
cargo build
cargo test
```
## License
MIT
+7 -7
View File
@@ -1,8 +1,8 @@
[package]
name = "freemkv-unlock"
version = "1.5.2"
version = "1.6.3"
edition = "2024"
rust-version = "1.86"
rust-version = "1.97"
license = "MIT"
description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()."
repository = "https://github.com/freemkv/freemkv-unlock"
@@ -18,11 +18,11 @@ emulation = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22.1"
base64 = "0.23"
tracing = "0.1"
num-bigint = "0.4"
num-bigint = "0.5"
num-traits = "0.2"
sha1 = "0.10"
sha2 = "0.10"
aes = "0.8"
rand = "0.8"
sha2 = "0.11"
aes = "0.9"
rand = "0.10"
+10 -2
View File
@@ -14,9 +14,17 @@ clients of libfreemkv are oblivious to unlockers entirely (as they are to the
SCSI layer).
```rust
use freemkv_unlock::UnlockError;
// Drive-prep: try each unlocker's feature unlock until one claims the drive.
// `NotApplicable` means "not this unlocker's drive" — move on; a transport
// error means a dead bus — abort. `unlock_bus` follows the same contract for
// removing per-disc bus encryption.
for u in freemkv_unlock::all_unlockers() {
if u.matches(&ctx) {
return u.unlock(&mut scsi, &ctx);
match u.unlock_features(&mut scsi, &ctx) {
Ok(unlocked) => return Ok(unlocked),
Err(UnlockError::NotApplicable) => continue,
Err(e) => return Err(e),
}
}
```
+22
View File
@@ -0,0 +1,22 @@
# Security Policy
## Supported versions
| Version | Supported |
| ------- | --------- |
| 1.6.x | Yes |
| < 1.6 | No |
Only the current 1.6.x line receives security fixes.
## Reporting a vulnerability
Report vulnerabilities privately through GitHub Security Advisories:
https://github.com/freemkv/freemkv-unlock/security/advisories/new
Do not open a public issue for a security report. Include the affected
version, steps to reproduce, and the impact you believe the issue has.
## Response time
You will get an initial response within 7 days.
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# leak-guard.sh — self-contained public-repo leak gate.
#
# This is the LAST line of defense in CI. It is intentionally self-contained:
# public CI cannot reach the private tooling, so this script encodes ONLY the
# generic net — internal infrastructure references, agent-context files, and
# AI-attribution in commit messages. It deliberately contains NO project-
# specific reverse-engineering vocabulary (those words would themselves be a
# leak). The richer private scanner stays private.
#
# Fails (exit 1) if any of the following appear in the repo:
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
# 2. tracked file content matching the internal-infra net,
# 3. a commit message (in the given range) with AI attribution.
#
# Usage:
# leak-guard.sh [<commit-range>]
# <commit-range> optional git rev-list range to scan commit messages
# (e.g. "abc..def"). If omitted, commit-message scan is
# skipped (path + content checks always run).
set -euo pipefail
# Absolute path to this script, resolved before any cd, so we can exclude it
# from the content scan (it necessarily contains the detection patterns).
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
REPO="$(git rev-parse --show-toplevel)"
cd "$REPO"
fail=0
note() { printf ' ✗ %s\n' "$1"; fail=1; }
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
# the patterns themselves must not name any org-specific identifier (doing so
# would itself leak the infra they guard). We catch the leak *class*:
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
# - docker.internal.
# The full org-specific net (literal hostnames, service names, repo paths,
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
# path). This names NO specific user — it matches the leak *class* (any home
# path), so the pattern itself reveals nothing org- or person-specific. A real
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
# this regardless of whose machine it came from. The username segment is a
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
echo "── leak-guard: tracked agent-context paths ──"
while IFS= read -r f; do
case "$f" in
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
esac
done < <(git ls-files)
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
# an argument (not interpolated into a //) so metacharacters like the "/" in a
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
# don't abort the scan.
pcre_matches() {
perl -e '
my ($file, $re) = @ARGV;
open(my $fh, "<:raw", $file) or exit 0;
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
' "$1" "$2" 2>/dev/null
}
# This script's own source necessarily contains the detection patterns (e.g.
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
echo "── leak-guard: internal-infra references in tracked files ──"
while IFS= read -r f; do
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
[ -f "$f" ] || continue
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "internal-infra reference: $f:$hit"
done < <(pcre_matches "$f" "$INFRA_RE")
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
done < <(pcre_matches "$f" "$HOMEPATH_RE")
done < <(git ls-files)
RANGE="${1:-}"
if [ -n "$RANGE" ]; then
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
while IFS= read -r sha; do
[ -z "$sha" ] && continue
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
# Pass the pattern as an argument (not interpolated into a //) so the
# lookbehind char class and "/" don't break the regex.
hit="$(printf '%s' "$msg" | perl -e '
my $re = $ARGV[0]; my $rx = qr/$re/i;
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
' "$ATTR_RE" | head -1 || true)"
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
done < <(git rev-list "$RANGE" 2>/dev/null || true)
fi
echo
if [ "$fail" -ne 0 ]; then
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
exit 1
fi
echo "✓ leak-guard: clean"
+21 -21
View File
@@ -391,8 +391,8 @@ fn ecdsa_sign(priv_key: &[u8; 20], data: &[u8]) -> ([u8; 20], [u8; 20]) {
// two); a biased ECDSA nonce is a known key-recovery weakness, so
// we reject and redraw any candidate >= n instead.
let mut k_bytes = [0u8; 20];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut k_bytes);
use rand::Rng;
rand::rng().fill_bytes(&mut k_bytes);
let k = BigUint::from_bytes_be(&k_bytes);
if k.is_zero() || k >= n {
continue;
@@ -490,8 +490,8 @@ fn ecdsa_sign_p256(priv_key: &[u8; 32], data: &[u8]) -> ([u8; 32], [u8; 32]) {
// (avoid the modulo bias that reducing raw RNG bytes mod n would
// introduce).
let mut k_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut k_bytes);
use rand::Rng;
rand::rng().fill_bytes(&mut k_bytes);
let k = BigUint::from_bytes_be(&k_bytes);
if k.is_zero() || k >= n {
continue;
@@ -703,8 +703,8 @@ fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) {
let (d, q) = loop {
let mut priv_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut priv_bytes);
use rand::Rng;
rand::rng().fill_bytes(&mut priv_bytes);
// d == 0 (prob ~1/n) would yield the point at infinity / an
// all-zero key and degenerate the bus key — reject and retry,
// matching the AACS 1.0 sibling generate_host_key_pair.
@@ -735,8 +735,8 @@ fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
let (d, q) = loop {
let mut priv_bytes = [0u8; 20];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut priv_bytes);
use rand::Rng;
rand::rng().fill_bytes(&mut priv_bytes);
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
if d.is_zero() {
continue;
@@ -771,13 +771,13 @@ fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
/// padding.
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
let cipher = Aes128::new(GenericArray::from_slice(key));
let cipher = Aes128::new(&(*key).into());
// For single-block CMAC:
// 1. Generate subkey K1
let mut l = GenericArray::clone_from_slice(&[0u8; 16]);
let mut l: Array<u8, _> = [0u8; 16].into();
cipher.encrypt_block(&mut l);
let mut k1 = [0u8; 16];
@@ -795,7 +795,7 @@ fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
for i in 0..16 {
block[i] = data[i] ^ k1[i];
}
let mut ga = GenericArray::clone_from_slice(&block);
let mut ga: Array<u8, _> = block.into();
cipher.encrypt_block(&mut ga);
let mut mac = [0u8; 16];
@@ -903,8 +903,8 @@ pub fn aacs_authenticate(
// Step 3: Generate host nonce and ephemeral key pair
let mut host_nonce = [0u8; 20];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_nonce);
use rand::Rng;
rand::rng().fill_bytes(&mut host_nonce);
let (host_key, host_key_point_x, host_key_point_y) = generate_host_key_pair();
// Step 4: Send host certificate + nonce (SEND KEY format 0x01)
@@ -1066,8 +1066,8 @@ fn aacs2_authenticate_p256(
// Step 3: Generate host nonce + P-256 ephemeral key pair
let mut host_nonce = [0u8; 20];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_nonce);
use rand::Rng;
rand::rng().fill_bytes(&mut host_nonce);
let (host_eph_key, host_eph_pub_x, host_eph_pub_y) = generate_host_key_pair_p256();
// Step 4: Send AACS 2.0 host certificate + nonce
@@ -1490,8 +1490,8 @@ mod tests {
// Generate random P-256 key pair
let mut priv_bytes = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut priv_bytes);
use rand::Rng;
rand::rng().fill_bytes(&mut priv_bytes);
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
let priv_key: [u8; 32] = to_bytes_be_padded(&d, 32).try_into().unwrap();
@@ -1514,9 +1514,9 @@ mod tests {
let mut pa = [0u8; 32];
let mut pb = [0u8; 32];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut pa);
rand::thread_rng().fill_bytes(&mut pb);
use rand::Rng;
rand::rng().fill_bytes(&mut pa);
rand::rng().fill_bytes(&mut pb);
let da = BigUint::from_bytes_be(&pa) % &n;
let db = BigUint::from_bytes_be(&pb) % &n;
let priv_a: [u8; 32] = to_bytes_be_padded(&da, 32).try_into().unwrap();
+3 -3
View File
@@ -9,7 +9,7 @@ mod error;
mod handshake;
use aes::Aes128;
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
use crate::scsi::ScsiTransport;
use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
@@ -17,8 +17,8 @@ use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
/// AES-128-ECB decrypt a single 16-byte block — used to decrypt the bus key /
/// read_data_key the drive returns after the handshake.
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
let cipher = Aes128::new(&(*key).into());
let mut block: Array<u8, _> = (*data).into();
cipher.decrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
+2 -2
View File
@@ -299,8 +299,8 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
// handshake.
let mut host_challenge = [0u8; 10];
{
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_challenge);
use rand::Rng;
rand::rng().fill_bytes(&mut host_challenge);
}
let mut hc_buf = [0u8; 16];
hc_buf[0] = 0x00;
+2 -2
View File
@@ -10,8 +10,8 @@ decryption is a separate concern, handled by the consumer.
Clients never name this module directly —
[libfreemkv](https://github.com/freemkv/libfreemkv) dispatches through
`freemkv_unlock::all_unlockers()`, and this module answers `matches()` /
`unlock()` when the drive identity is one it supports.
`freemkv_unlock::all_unlockers()`, and this module answers `unlock_features()` /
`unlock_bus()` when the drive identity is one it supports.
## Scope: non-persistent unlock only
+9 -9
View File
@@ -141,7 +141,7 @@ pub struct ProfileMatch {
/// translatable English message.
fn decode_hex(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
let bytes = s.as_bytes();
if bytes.len() % 2 != 0 {
if !bytes.len().is_multiple_of(2) {
return Err("hex");
}
let mut out = Vec::with_capacity(bytes.len() / 2);
@@ -304,19 +304,19 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
(Platform::Mt1959B, &profiles.mt1959_b),
(Platform::Renesas, &profiles.renesas),
] {
if !prod.is_empty() {
if let Some(p) = list.iter().find(|p| {
if !prod.is_empty()
&& let Some(p) = list.iter().find(|p| {
p.identity.vendor_id.trim() == v
&& p.identity.product_id.trim() == prod
&& p.identity.product_revision.trim() == r
&& p.identity.vendor_specific.trim() == vs
&& p.identity.firmware_date.trim() == date
}) {
return Some(ProfileMatch {
profile: p.clone(),
platform,
});
}
})
{
return Some(ProfileMatch {
profile: p.clone(),
platform,
});
}
if let Some(p) = list.iter().find(|p| {