Compare commits

...
13 Commits
Author SHA1 Message Date
Matthew Jackson e251e0bf6f docs: mark 1.7.4 changelog heading UNRELEASED (release workflow dates it) 2026-09-21 15:07:11 -07:00
Matthew Jackson 6704c1e05b docs: 1.7.4 changelog + consumer release notes 2026-09-21 11:47:20 -07:00
Matthew Jackson a34980970b docs/ci: drop stale local-precommit/release.sh references — CI is the only gate 2026-09-21 09:11:35 -07:00
Matthew Jackson 948511853e test(online): trim interpret_reply reachability aside to 3 lines for comment-guard 2026-09-21 08:51:04 -07:00
Matthew Jackson 373cdc4cf2 ci: adopt central freemkv/.github guards + checkout-siblings composite
Thin-caller comment/leak/identity guards → freemkv/.github reusable
workflows; drop local ci/comment-guard.py and ci/leak-guard.sh. Replace
per-job sibling checkout + .cargo/config heredoc with the shared
checkout-siblings@v1 composite (checkout libfreemkv+freemkv-unlock,
patch libfreemkv). Add cancel-in-progress concurrency to ci.yml/qa.yml;
standardize actions/checkout to @v5.
2026-09-21 08:48:58 -07:00
Matthew Jackson a60b021c35 Merge: ureq 3.4.2 + bounded reply body (supersedes Dependabot #12) 2026-09-20 20:15:19 -07:00
Matthew Jackson fc8b6e3a05 bump ureq 3.4.2 + preserve rolling-idle body timeout
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.
2026-09-20 20:03:54 -07:00
Matthew Jackson bdbc805ba5 keydb: report match state + entry shape to de-conflate "no entry" (#46)
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.
2026-09-20 19:16:06 -07:00
Matthew Jackson fa75d28441 freemkv-keysources 1.7.4: keydb loads every entry — drop entry cap, strip BOM, count malformed rows
- 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]
2026-09-20 09:43:41 -07:00
Matthew Jackson 5d2e11a4dd freemkv-keysources 1.7.4: version bump (unified 1.7.4) 2026-09-19 14:03:36 -07:00
Matthew Jackson 50bd0e4be2 v1.7.3: bump version (unified release) 2026-09-19 05:31:15 +00:00
Matthew Jackson 148419c2bb changelog: 1.7.3 unified-release entry (freemkv --share + libfreemkv multi-angle UHD fix) 2026-09-18 22:18:41 -07:00
Matthew Jackson 1b97e6186d merge: record v1.7.2 release into dev (-s ours; keep path deps + dev's next-cycle work) 2026-09-18 21:39:15 -07:00
15 changed files with 524 additions and 702 deletions
+46 -126
View File
@@ -10,16 +10,22 @@ on:
branches: [main, dev, qa]
pull_request:
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# WHY THE SIBLING CHECKOUTS — this is NOT a monorepo.
#
# The committed Cargo.toml resolves libfreemkv from a git TAG (it is off
# crates.io). That means CI was building this branch against the LAST RELEASE
# of libfreemkv, never against libfreemkv's dev tip — so a coordinated change
# across the two repos could not be verified until release.sh cut the tags.
# across the two repos could not be verified until release-orchestrate.yml cut the tags.
# Green here meant "compiles against last month's library".
#
# The fix mirrors the developer's local layout. Every repo is checked out into
# a subdirectory of $GITHUB_WORKSPACE, reproducing
# The fix mirrors the developer's local layout via the shared composite
# freemkv/.github//actions/checkout-siblings: every sibling repo is checked out
# into a subdirectory of $GITHUB_WORKSPACE, reproducing
# ~/Developer/freemkv/{freemkv-keysources,libfreemkv,freemkv-unlock}, and a
# generated .cargo/config.toml redirects the git-tag patch to those paths.
# Config-level [patch.crates-io] wins over the manifest one, which is the same
@@ -36,7 +42,7 @@ on:
# the `deny` job audits the resolved graph, but CI's sibling path-patch layout
# resolves the freemkv crates as path packages, which `--locked` can't satisfy —
# the same reason the binary crates omit it. The lock that has to hold is the one
# release.sh regenerates in each consuming binary.
# release-orchestrate.yml regenerates in each consuming binary.
jobs:
# Single source of truth for the MSRV — read rust-version from Cargo.toml once
@@ -47,7 +53,7 @@ jobs:
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -61,7 +67,7 @@ jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- name: Require a CHANGELOG entry for the crate version on main
shell: bash
run: |
@@ -82,28 +88,14 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
# fmt runs on the MSRV pin; clippy runs on stable so new-lint debt is a
# blocking gate the pinned toolchain would hide. Stable is installed first
# for clippy; the MSRV pin goes second so it stays fmt's default toolchain.
@@ -127,28 +119,14 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -165,28 +143,14 @@ jobs:
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
@@ -206,35 +170,20 @@ jobs:
# 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.
# than a slow one: a never-scheduled job leaves the run pending forever, so
# the branch's required checks never report complete.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -254,28 +203,14 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -303,35 +238,20 @@ jobs:
# 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.
# than a slow one: a never-scheduled job leaves the run pending forever, so
# the branch's required checks never report complete.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
path: freemkv-unlock
- name: Resolve siblings from their dev tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
+7 -12
View File
@@ -1,17 +1,12 @@
name: comment-guard
# Caps explanatory prose by COMMENT INTENT (not visibility):
# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text)
# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded)
# // / /* */ asides — 3 lines (single-blank-separated runs count as one)
# Overflow belongs in the module //! or docs/<topic>.md with a one-line // pointer.
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
comment-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run comment-guard
run: python3 ci/comment-guard.py
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-comment.yml@v1
+7 -34
View File
@@ -1,41 +1,14 @@
name: identity-guard
# Prevention: no commit may carry an unrelated-business or non-canonical author/
# committer email. dev@example.com (a dead corp address) once slipped onto the
# release cascade + a batch of local commits; this fails the build if it recurs.
# Thin caller → the canonical reusable guard in freemkv/.github.
on:
push:
branches: [dev, qa, main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Reject banned author/committer emails
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Unrelated business domains + a personal address normalized to the
# GitHub noreply. Real external contributors are unaffected.
BANNED='@example-a\.invalid|@example-b\.invalid|owner@example\.invalid'
if [ "$EVENT" = "pull_request" ]; then
range="$PR_BASE..$PR_HEAD"
elif [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
range="$BEFORE..$AFTER"
else
range="$AFTER~1..$AFTER"
fi
bad=$(git log --format='%an <%ae>%n%cn <%ce>' "$range" 2>/dev/null | sort -u | grep -iE "$BANNED" || true)
if [ -n "$bad" ]; then
echo "::error::commit(s) carry a banned author/committer identity:"; echo "$bad" | sed 's/^/ /'
echo "::error::Commit as your GitHub noreply (1085847+MattJackson@users.noreply.github.com). Fix: git config user.email <noreply>; git commit --amend --reset-author."
exit 1
fi
echo "identity-guard: clean"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
+6 -30
View File
@@ -1,35 +1,11 @@
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.
# Thin caller → the canonical reusable guard in freemkv/.github.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
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 }}"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
+25 -62
View File
@@ -13,16 +13,21 @@ name: qa
# 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.
# The shared checkout-siblings composite resolves qa->qa from the caller branch.
#
# 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.
# and no hosted runner has an optical drive or the image hoard. That coverage
# lives in freemkv's qa.yml (the real-media EC2 matrix), not in this file.
on:
push:
branches: [qa]
workflow_dispatch:
# Supersede an in-progress qa run when a newer candidate lands on the branch.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
# the MSRV read needs.resolve-msrv.outputs.v from Cargo.toml's rust-version;
@@ -32,7 +37,7 @@ jobs:
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -56,7 +61,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Stamp the next rc
@@ -85,28 +90,14 @@ jobs:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
- name: Resolve siblings from their qa tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -122,28 +113,14 @@ jobs:
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
- name: Resolve siblings from their qa tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -162,28 +139,14 @@ jobs:
needs: resolve-msrv
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-keysources
- uses: actions/checkout@v7
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
repository: freemkv/libfreemkv
ref: qa
path: libfreemkv
- uses: actions/checkout@v7
with:
repository: freemkv/freemkv-unlock
ref: qa
path: freemkv-unlock
- name: Resolve siblings from their qa tips
shell: bash
working-directory: freemkv-keysources
run: |
mkdir -p .cargo
cat > .cargo/config.toml <<'EOF'
[patch.crates-io]
libfreemkv = { path = "../libfreemkv" }
EOF
checkout: libfreemkv freemkv-unlock
patch: libfreemkv
self: freemkv-keysources
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
+9 -11
View File
@@ -17,7 +17,7 @@ jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- name: Verify Cargo.toml version matches tag
run: |
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
@@ -29,19 +29,19 @@ jobs:
# Tests run as a PARALLEL TRIPWIRE: they fail the run if they fail, but the
# release job does NOT `needs:` this job. The tag decision was already gated by
# the local precommit (same MSRV, same commit).
# CI (ci.yml on dev, then a green qa.yml on the same commit).
#
# Cargo.lock is now committed (issue #15), but --locked is still omitted: on
# the runner the libfreemkv dep resolves from crates.io, not the local path
# patch, which --locked can't reconcile. Its
# libfreemkv dep resolves from crates.io (the dev .cargo/config.toml patch is
# gitignored and absent on the runner), so libfreemkv must already be published
# at this version — the release script enforces that ordering before tagging.
# at this version — release-orchestrate.yml enforces that ordering before tagging.
test:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -51,12 +51,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
- run: cargo test
# NOTE: the crates.io publish is driven by the maintainer's release tooling
# from local credentials, not from CI — this repo has no CARGO_REGISTRY_TOKEN
# secret. release.sh runs `cargo publish --no-verify` locally. CI here only
# verifies the version/tag match, runs tests (tripwire), and cuts the GitHub
# Release. The binaries git-tag-pin keysources, so they don't wait on the
# crates.io publish — it serves external consumers in parallel.
# NOTE: there is no crates.io publish — keysources is `publish = false` and
# git-tag-only. Consumers git-tag-pin it via a committed [patch.crates-io], so
# the git tag itself is the release artifact. CI here only verifies the
# version/tag match, runs tests (tripwire), and cuts the GitHub Release.
release:
# Only needs `verify`; the GitHub Release is cut as soon as the version
@@ -64,7 +62,7 @@ jobs:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
+17 -1
View File
@@ -1,6 +1,22 @@
# Changelog
## [1.7.2] — 2026-09-18
## [1.7.4] — UNRELEASED
### 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
Generated
+7 -7
View File
@@ -268,7 +268,7 @@ dependencies = [
[[package]]
name = "freemkv-keysources"
version = "1.7.1"
version = "1.7.4"
dependencies = [
"base64",
"flate2",
@@ -281,7 +281,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.1"
version = "1.7.4"
dependencies = [
"aes",
"base64",
@@ -487,7 +487,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libfreemkv"
version = "1.7.1"
version = "1.7.4"
dependencies = [
"aes",
"base64",
@@ -946,9 +946,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.0"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64",
"cookie_store",
@@ -1237,8 +1237,8 @@ dependencies = [
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.1"
version = "1.7.4"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.1"
version = "1.7.4"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-keysources"
version = "1.7.2"
version = "1.7.4"
edition = "2024"
rust-version = "1.88"
license = "MIT"
@@ -40,7 +40,7 @@ codegen-units = 1
# overrides it with a path patch via the gitignored .cargo/config.toml (a
# config-level [patch.crates-io] wins over this manifest one for the same crate).
[patch.crates-io]
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.7.2" }
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.7.3" }
[lints.clippy]
uninlined_format_args = "allow"
-252
View File
@@ -1,252 +0,0 @@
#!/usr/bin/env python3
"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility.
Three tiers, keyed on the syntax the author reached for:
* `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per
file, at the top, documenting the module as a whole, so it can't be used to
smuggle per-item rationale the way item docs can. Long correctness / design
essays (e.g. a memory-ordering proof) legitimately live here.
* `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced
``` code blocks do NOT count, so real examples aren't punished. This holds
whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc
paragraph just like a `pub` one. Capping `///` here (rather than exempting
it, as the old guard did — issue #17) is what stops rationale being hidden
behind a doc sigil, while still leaving room for a genuine paragraph.
* `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the
shape that sprawls into walls, so it's kept tight: stanzas separated by a
single blank line count CUMULATIVELY, so one essay can't be split into runs.
Overflow belongs in the module `//!`, or in docs/<topic>.md with a one-line `//`
pointer — both show up in review.
Scans src/ and tests/ under the given root (default cwd). `--selftest` runs
built-in cases. Exit non-zero if any block exceeds its cap.
"""
import re
import sys
from pathlib import Path
INLINE_CAP = 3
DOC_CAP = 8
_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## …
def _strip_line_comment(s):
"""Return (kind, body) for a whole-line comment, else (None, None).
kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on
a code line is not a whole-line comment and is ignored (single-line, cheap).
"""
if s.startswith("//!"):
return "doc", s[3:]
if s.startswith("///") and not s.startswith("////"):
return "doc", s[3:]
if s.startswith("//"):
return "plain", s[2:]
return None, None
def _fence_toggle(body):
"""True if this doc body line opens/closes a ``` fenced code block."""
return body.strip().startswith("```")
def _classify(lines):
"""Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of
block comments, and code info. Block comments are tracked across lines.
"""
info = []
in_block = False
block_is_doc = False
raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#'
for raw in lines:
s = raw.lstrip()
rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False}
# Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the
# bytes are string data, not Rust comments, so the guard is blind to them
# until the string closes. Prevents `/* */` or `//` lines in an embedded
# page from being counted as comment blocks.
if raw_hashes is not None:
if ('"' + "#" * raw_hashes) in raw:
raw_hashes = None
info.append(rec)
continue
if in_block:
rec["comment"] = "doc" if block_is_doc else "plain"
rec["is_doc"] = block_is_doc
if "*/" in raw:
in_block = False
info.append(rec)
continue
# A block comment only starts a comment-line if it's the first token.
if s.startswith("/*"):
rec["is_doc"] = s.startswith("/**") or s.startswith("/*!")
rec["comment"] = "doc" if rec["is_doc"] else "plain"
if "*/" not in s[2:]:
in_block = True
block_is_doc = rec["is_doc"]
info.append(rec)
continue
kind, _ = _strip_line_comment(s)
if kind is not None:
rec["comment"] = kind
rec["is_doc"] = kind == "doc"
info.append(rec)
continue
# Code (or blank). Blank = empty after strip.
rec["code"] = s != ""
# A code line may OPEN a raw string that spans following lines. Detected
# only on code (comments were handled above), so `// see r"x"` is safe.
m = _RAW_OPEN.search(raw)
if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]:
raw_hashes = len(m.group(1))
info.append(rec)
return info
def violations(path):
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
info = _classify(lines)
n = len(info)
out = []
i = 0
while i < n:
rec = info[i]
if rec["comment"] is None:
i += 1
continue
start = i
if rec["is_doc"]:
# Doc run: contiguous doc lines. `//!` / `/*!` module docs are
# UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at
# DOC_CAP, regardless of the documented item's visibility.
j = i
prose = 0
in_fence = False
module_doc = False
while j < n and info[j]["comment"] == "doc":
s = info[j]["s"]
if s.startswith("//!") or s.startswith("/*!"):
module_doc = True
body = (
s[3:] if (s.startswith("///") or s.startswith("//!")) else s
)
if _fence_toggle(body):
in_fence = not in_fence
elif not in_fence and body.strip() != "":
prose += 1
j += 1
if not module_doc and prose > DOC_CAP:
out.append((start + 1, prose, DOC_CAP, "doc prose"))
i = j
continue
# Internal (plain) run, with single-blank bridging (cumulative).
j = i
count = 0
while j < n:
r = info[j]
if r["comment"] == "plain":
count += 1
j += 1
continue
if r["comment"] == "doc":
break
if r["comment"] is None and not r["code"]:
# blank: bridge only a SINGLE blank, else stop
if j + 1 < n and info[j + 1]["comment"] == "plain":
j += 1
continue
break
break # code
if count > INLINE_CAP:
out.append((start + 1, count, INLINE_CAP, "inline comment"))
i = j
return out
def main(argv):
if "--selftest" in argv:
return _selftest()
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
total = 0
for f in files:
for start, length, cap, kind in violations(f):
rel = f.relative_to(root)
print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})")
total += 1
if total:
print(f"\ncomment-guard: {total} comment block(s) exceed their cap.")
print(
f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at "
f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. "
"Trim, move the rationale into the module //!, or docs/<topic>.md "
"with a // pointer."
)
return 1
return 0
def _selftest():
import tempfile
cases = [
# (rust source, expected number of violations)
# short /// doc -> ok
("/// short doc\npub fn a() {}\n", 0),
# /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine
("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0),
# ...but /// is NOT unlimited: 9 prose lines on a private item fails
("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1),
# 8 prose /// on a pub item -> ok (boundary)
("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0),
# 9 prose /// on a pub item -> violation
("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1),
# /// with a long fenced example -> example excluded, ok
(
"/// summary\n/// # Examples\n/// ```\n"
+ "".join("/// code\n" for _ in range(12))
+ "/// ```\npub fn e() {}\n",
0,
),
# pub(crate) gets the SAME doc cap as pub (no pub/private split anymore)
("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0),
# bare inline 4 lines -> violation
("// a\n// b\n// c\n// d\nlet x = 1;\n", 1),
# blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation
("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1),
# plain /* */ block, 5 lines -> inline cap -> violation
("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1),
# comment-like lines INSIDE a raw string are string data, not comments
(
'const H: &str = r##"<style>\n'
+ "".join("/* css note */\n" for _ in range(8))
+ '</style>"##;\nfn h() {}\n',
0,
),
# //! module doc may run long -> EXEMPT (no cap)
("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0),
# /*! block module doc may run long -> EXEMPT
("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0),
]
ok = True
with tempfile.TemporaryDirectory() as d:
for idx, (src, want) in enumerate(cases):
p = Path(d) / f"case{idx}.rs"
p.write_text(src)
got = len(violations(p))
flag = "ok" if got == want else "FAIL"
if got != want:
ok = False
print(f" selftest {idx}: want {want} got {got} [{flag}]")
print("selftest PASSED" if ok else "selftest FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
-120
View File
@@ -1,120 +0,0 @@
#!/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"
+207 -7
View File
@@ -15,8 +15,9 @@ use std::sync::{Arc, Mutex};
use crate::uks_from_vuk;
use libfreemkv::aacs::derive::{derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk};
use libfreemkv::aacs::trace::{KeyNode, MatchedEntry};
use libfreemkv::aacs::types::{HostCert, MediaKey, UnitKey, Vid};
use libfreemkv::keysource::ResolveCtx;
use libfreemkv::keysource::{ResolveCtx, UnitKeyResolution};
use libfreemkv::{Error, KeySource};
use crate::keydb_format::KeyDb;
@@ -348,15 +349,23 @@ impl KeydbSource {
}
}
// Derive this disc's terminal Unit Keys from a parsed keydb. Pure (no
// I/O). Empty Vec = no key for this disc from this keydb. CPS-unit
// numbering (idx = num - 1) is explained in docs/keydb.md#unit-keys-from.
// Terminal Unit Keys for this disc from a parsed keydb; empty Vec = none.
// CPS numbering (idx = num - 1): docs/keydb.md#unit-keys-from. Thin wrapper
// over [`resolve_from`] for callers that want only the keys (pure, no I/O).
fn unit_keys_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
Self::resolve_from(db, ctx).keys
}
// De-conflated resolution: the keys PLUS whether the disc matched and — on a
// keyless match — WHY nothing derived, so a matched-but-underivable disc is
// never reported as a flat "no entry" (issue #46). Pure (no I/O).
fn resolve_from(db: &KeyDb, ctx: &dyn ResolveCtx) -> KeydbResolution {
// Per-disc hit (most specific); find_disc normalizes the hash form.
// Without a match there is no per-disc anchor, so the global PK/DK
// pools are never consulted. See docs/keydb.md#unit-keys-per-disc-hit.
let entries_loaded = db.disc_entries.len();
let Some(entry) = db.find_disc(ctx.disc_hash()) else {
return Vec::new();
return KeydbResolution::miss(entries_loaded);
};
// UNION every source of terminal keys, then dedup — never first-hit,
@@ -391,6 +400,11 @@ impl KeydbSource {
&[]
}
};
// Why the keyless derivation could not finish, for the trace's miss
// path. `None` once any key lands (or when there was no material to try).
let mut miss_reason: Option<KeyNode> = None;
if !enc_title_keys.is_empty() {
// VUK path, else MK path (stored/PK/DK) → VUK. See
// docs/keydb.md#unit-keys-vuk-or-mk for the VID rules.
@@ -425,7 +439,15 @@ impl KeydbSource {
uks_from_vuk(&derive_vuk(&mk.0, &vid.0), enc_title_keys)
}
// Locked VID-per-path rule: an MK with no VID cannot derive.
_ => Vec::new(),
// De-conflate the two reasons so the trace can say WHICH.
(Some(_), None) => {
miss_reason = Some(KeyNode::NoVid);
Vec::new()
}
(None, _) => {
miss_reason = Some(KeyNode::NoDerivableKey);
Vec::new()
}
}
};
keys.extend(derived);
@@ -434,7 +456,60 @@ impl KeydbSource {
// Unique by key value, first occurrence wins (stored numbering kept).
let mut seen = std::collections::HashSet::new();
keys.retain(|u| seen.insert(u.key));
keys
// Booleans-and-lengths shape of the matched entry, for the app to log —
// answers "what did the matched entry actually carry?" (issue #46).
let shape = MatchedEntry {
has_vuk: entry.vuk.is_some(),
has_unit_keys: !entry.unit_keys.is_empty(),
unit_keys_len: entry.unit_keys.len(),
has_media_key: entry.media_key.is_some(),
has_keydb_vid: entry.vid.is_some(),
enc_title_keys_len: enc_title_keys.len(),
vid_available: ctx.vid().is_some() || entry.vid.is_some(),
};
// On a match with no key: carry the specific reason if we have one, else
// leave it empty for the library to render a bare `NoDerivableKey`.
let miss_path = if keys.is_empty() {
miss_reason.map(|n| vec![n]).unwrap_or_default()
} else {
Vec::new()
};
KeydbResolution {
keys,
matched: true,
shape: Some(shape),
miss_path,
entries_loaded,
}
}
}
// The de-conflated result of a keydb lookup: keys plus enough context that a
// MATCHED-but-underivable disc is reported distinctly from a true miss. See
// [`KeydbSource::resolve_from`] and issue #46.
struct KeydbResolution {
keys: Vec<UnitKey>,
matched: bool,
shape: Option<MatchedEntry>,
miss_path: Vec<KeyNode>,
// Per-disc entries loaded in the keydb — named in a true-miss verdict so a
// reporter can confirm a wrong-pressing (`… not in keydb (N entries loaded)`).
entries_loaded: usize,
}
impl KeydbResolution {
// A true miss: the disc hash was not in the keydb at all.
fn miss(entries_loaded: usize) -> Self {
Self {
keys: Vec::new(),
matched: false,
shape: None,
miss_path: Vec::new(),
entries_loaded,
}
}
}
@@ -547,6 +622,30 @@ impl KeySource for KeydbSource {
}
}
// De-conflated resolution (issue #46): besides the keys, report whether the
// disc MATCHED, why nothing derived on a keyless match, and the matched
// entry's shape to log — so a matched-no-key disc is not a mute "no entry".
fn resolve_unit_keys(&self, ctx: &dyn ResolveCtx) -> Result<UnitKeyResolution, Error> {
match self.cached_db() {
Ok(db) => {
let r = Self::resolve_from(&db, ctx);
Ok(UnitKeyResolution {
keys: r.keys,
matched: r.matched,
miss_path: r.miss_path,
matched_entry: r.shape,
store_entries: Some(r.entries_loaded),
})
}
Err(e) => match self.load_failure(&e) {
// Missing keydb: the documented benign miss (nothing matched).
None => Ok(UnitKeyResolution::default()),
// Corrupt / unreadable keydb: a SOURCE FAILURE, not a miss.
Some(err) => Err(err),
},
}
}
// Expose the keydb's host certs through the trait, wiring the disc's MKB
// generation through for revocation filtering. See
// docs/keydb.md#host-certs-trait.
@@ -687,6 +786,74 @@ mod tests {
assert_eq!(c.samples(4).unwrap(), Vec::<Vec<u8>>::new());
}
// Issue #46 — de-conflated resolution: a matched entry with a Media Key but
// NO VID on this path (locked VID-per-path rule) is reported `matched` with a
// `NoVid` miss path — the exact case behind the reporter's "no entry".
#[test]
fn resolve_matched_media_key_without_vid_reports_no_vid_not_a_miss() {
let mut e = blank_entry(HASH);
e.media_key = Some([0x33u8; 16]);
let db = db_with(e, Vec::new());
// enc_title_keys present so derivation is attempted; ctx supplies no VID.
let r = KeydbSource::resolve_from(&db, &ctx(HASH, vec![[0x44u8; 16]], None));
assert!(r.keys.is_empty(), "no VID -> nothing derivable");
assert!(r.matched, "the disc WAS found — not a true miss");
assert_eq!(r.miss_path, vec![KeyNode::NoVid]);
let shape = r.shape.expect("a matched entry carries a shape");
assert!(shape.has_media_key);
assert!(!shape.has_vuk);
assert!(!shape.has_unit_keys);
assert_eq!(shape.enc_title_keys_len, 1);
assert!(!shape.vid_available);
}
// A hash that is NOT in the keydb is the one true miss: not matched, no
// shape, no miss path (the library renders this as `no entry`).
#[test]
fn resolve_unmatched_hash_is_a_true_miss() {
let db = db_with(blank_entry(HASH), Vec::new());
let r = KeydbSource::resolve_from(&db, &ctx("0xdeadbeef", Vec::new(), None));
assert!(!r.matched);
assert!(r.keys.is_empty());
assert!(r.shape.is_none());
assert!(r.miss_path.is_empty());
// The store size travels with the miss so the verdict can name it.
assert_eq!(r.entries_loaded, 1);
}
// Matched but no derivation material at all (no stored keys, no enc title
// keys): matched with an EMPTY miss path, which the library renders as the
// generic `no derivable key` — still distinct from a true miss.
#[test]
fn resolve_matched_with_no_material_has_empty_miss_path() {
let db = db_with(blank_entry(HASH), Vec::new());
let r = KeydbSource::resolve_from(&db, &ctx(HASH, Vec::new(), None));
assert!(r.matched);
assert!(r.keys.is_empty());
assert!(
r.miss_path.is_empty(),
"no specific reason -> library supplies NoDerivableKey"
);
let shape = r.shape.expect("matched -> shape present");
assert!(!shape.has_vuk && !shape.has_media_key && !shape.has_unit_keys);
}
// Matched entry that DOES resolve: keys present, matched, empty miss path,
// and the shape reflects the stored unit keys.
#[test]
fn resolve_matched_with_stored_unit_keys_reports_keys_and_shape() {
let mut e = blank_entry(HASH);
e.unit_keys = vec![(1, [0xA0u8; 16])];
let db = db_with(e, Vec::new());
let r = KeydbSource::resolve_from(&db, &ctx(HASH, Vec::new(), None));
assert!(!r.keys.is_empty());
assert!(r.matched);
assert!(r.miss_path.is_empty());
let shape = r.shape.expect("matched -> shape");
assert!(shape.has_unit_keys);
assert_eq!(shape.unit_keys_len, 1);
}
// KAT (a): disc with terminal Unit Keys, no enc_title_keys. Stored
// terminal unit keys are returned with CPS numbering preserved. See
// docs/keydb.md#test-kat-a.
@@ -1729,4 +1896,37 @@ mod tests {
));
assert_eq!(std::fs::read_to_string(&good).unwrap(), "0xGOOD = keep\n");
}
/// Load-failure verdicts by IO kind — a keydb that can't be read must NEVER
/// masquerade as "this disc has no key". Permission-denied and corrupt/oversized
/// files surface as distinct loud errors; only a genuinely-absent file is the
/// documented benign case (online-only is a valid mode).
#[test]
fn load_failure_maps_io_kind_to_the_right_verdict() {
use std::io::{Error as IoErr, ErrorKind};
let src = KeydbSource::new("/nonexistent/keydb.cfg");
// Missing file → benign None (no keydb present; not an error on its own).
assert!(
src.load_failure(&IoErr::from(ErrorKind::NotFound))
.is_none(),
"a missing keydb is the benign online-only case"
);
// Permission denied → LOUD KeydbLoad, never a silent no-key.
assert!(
matches!(
src.load_failure(&IoErr::from(ErrorKind::PermissionDenied)),
Some(Error::KeydbLoad { .. })
),
"no read permission must surface as KeydbLoad, not 'no key'"
);
// Over the size cap / non-UTF8 → LOUD KeydbInvalid (corrupt/truncated).
assert!(
matches!(
src.load_failure(&IoErr::from(ErrorKind::InvalidData)),
Some(Error::KeydbInvalid)
),
"an unusable keydb must surface as KeydbInvalid"
);
}
}
+135 -34
View File
@@ -24,11 +24,6 @@ pub type NumberedUnitKey = (u32, [u8; 16]);
/// can never drift out of lockstep.
pub(crate) const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
/// Upper bound on parsed disc entries. The real public keydb carries
/// ~170k+ entries, so the cap sits well above that while still bounding
/// memory against a pathological input. Surplus lines are ignored.
const MAX_DISC_ENTRIES: usize = 500_000;
/// Parsed AACS key database.
///
/// NO `#[derive(Debug)]`: see the hand-written redacting impl below. The
@@ -193,8 +188,6 @@ pub(crate) struct ParseStats {
pub hc2_rejected: usize,
/// `0x… = …` disc rows the field parser refused outright.
pub disc_rejected: usize,
/// Disc rows dropped because [`MAX_DISC_ENTRIES`] was already reached.
pub disc_over_cap: usize,
/// Disc rows that REPLACED an earlier row with the same hash (last wins).
/// Not necessarily corruption, but never something to discover silently:
/// a duplicated hash means one of the two rows' keys is now unreachable.
@@ -210,7 +203,6 @@ impl ParseStats {
+ self.hc_rejected
+ self.hc2_rejected
+ self.disc_rejected
+ self.disc_over_cap
}
// Emit the one summary line, if there is anything to say; returns whether
@@ -227,7 +219,6 @@ impl ParseStats {
hc_rejected = self.hc_rejected,
hc2_rejected = self.hc2_rejected,
disc_rejected = self.disc_rejected,
disc_over_cap = self.disc_over_cap,
disc_duplicate = self.disc_duplicate,
"keydb.cfg lines were rejected while parsing; the file may be truncated or corrupt (keys past the damage will not resolve)"
);
@@ -305,6 +296,11 @@ impl KeyDb {
disc_entries: HashMap::new(),
};
// Strip a leading UTF-8 BOM (U+FEFF): it is NOT trim()-able whitespace, so
// it would cling to line 1's `0x…` hash and silently drop the first disc
// row (a keydb saved by a Windows editor).
let data = data.strip_prefix('\u{feff}').unwrap_or(data);
for line in data.lines() {
let line = line.trim();
@@ -378,15 +374,10 @@ impl KeyDb {
continue;
}
// Disc entry: starts with 0x / 0X (see `is_disc_entry_line`).
if is_disc_entry_line(line) {
// The cap is a memory bound against a pathological file, but
// hitting it means real discs are being dropped — count it, the
// way `load`'s size/UTF-8 caps produce a diagnostic.
if db.disc_entries.len() >= MAX_DISC_ENTRIES {
stats.disc_over_cap += 1;
continue;
}
// Gate on the `0x` PREFIX alone (not `is_disc_entry_line`, which also
// needs " = "): a malformed `0x…` row is COUNTED as rejected, never
// silently dropped. No entry cap — a keydb must hold every row.
if line.starts_with("0x") || line.starts_with("0X") {
match Self::parse_disc_entry(line) {
// The map key IS the entry's own `disc_hash` allocation
// (`Arc` clone, no second copy of the string).
@@ -1443,12 +1434,16 @@ mod tests {
assert_eq!(stats.pk_rejected, 1, "the bad PK row must be counted");
assert_eq!(stats.hc_rejected, 1, "the short HC row must be counted");
assert_eq!(stats.hc2_rejected, 1, "the short HC2 row must be counted");
assert_eq!(stats.rejected(), 4, "four rows rejected in total");
// A `0x…` row missing " = " is malformed (a hash the user sees but that
// never resolves); it is COUNTED now, not silently ignored — exactly the
// "hash in the file but nothing resolves" bug class.
assert_eq!(
stats.disc_rejected, 1,
"a prefixed row missing \" = \" is counted"
);
assert_eq!(stats.rejected(), 5, "five rows rejected in total");
// The one GOOD row still loaded — counting must not change parsing.
assert_eq!(db.processing_keys, vec![[0xABu8; 16]]);
// A `0x` line with no " = " is not a disc row at all, so it is not a
// rejection (it is a comment-shaped line the format allows).
assert_eq!(stats.disc_rejected, 0);
}
/// A duplicated disc hash silently last-wins through `HashMap::insert`:
@@ -1469,26 +1464,132 @@ mod tests {
assert_eq!(db.get_uk("0xaaaa"), vec![(1, [0x02u8; 16])]);
}
/// Hitting `MAX_DISC_ENTRIES` drops real discs, so it must produce a
/// diagnostic like `load`'s size / UTF-8 caps do — not a bare `continue`.
/// Driven through the cap constant itself so it cannot rot if the cap moves.
/// A keydb is a database: EVERY disc row must load, with no count-based cap
/// silently dropping entries. Regression guard for the removed
/// `MAX_DISC_ENTRIES` cap — a disc verifiably in the file must resolve.
#[test]
fn parse_counts_disc_rows_dropped_over_the_entry_cap() {
fn parse_keeps_every_disc_row_no_entry_cap() {
let k = "01".repeat(16);
let mut cfg = String::with_capacity((MAX_DISC_ENTRIES + 2) * 16);
for i in 0..MAX_DISC_ENTRIES + 2 {
let n = 600_000; // comfortably past the old 500k cap
let mut cfg = String::with_capacity(n * 16);
for i in 0..n {
cfg.push_str(&format!("0x{i:x} = T | U | 1-0x{k}\n"));
}
let (db, stats) = KeyDb::parse_counted(&cfg);
assert_eq!(
db.disc_entries.len(),
MAX_DISC_ENTRIES,
"cap still enforced"
assert_eq!(db.disc_entries.len(), n, "every disc row must be kept");
assert_eq!(stats.rejected(), 0, "no row dropped");
}
// ── whole-flow robustness: every way a row a user can SEE in the file could
// silently fail to load. Each is a regression guard for a real gap. ──
/// A minimal, well-formed disc row for `hash` (lowercase hex, no 0x).
fn disc_line(hash: &str) -> String {
let uk = "01".repeat(16);
let vuk = "02".repeat(16);
format!("0x{hash} = TITLE | V | 0x{vuk} | U | 1-0x{uk}")
}
/// A UTF-8 BOM on the first line must not eat the first disc row.
#[test]
fn parse_strips_leading_bom_first_row_survives() {
let h = "aa".repeat(20);
let cfg = format!("\u{feff}{}\n", disc_line(&h));
let (db, stats) = KeyDb::parse_counted(&cfg);
assert_eq!(db.disc_entries.len(), 1, "BOM must not drop the first row");
assert!(db.find_disc(&format!("0x{h}")).is_some());
assert_eq!(stats.rejected(), 0);
}
/// CRLF (`\r\n`) line endings must load — `.lines()` + `trim()` handle the `\r`.
#[test]
fn parse_handles_crlf_line_endings() {
let h = "bb".repeat(20);
let cfg = format!("{}\r\n{}\r\n", disc_line(&h), disc_line(&"cc".repeat(20)));
let (db, _) = KeyDb::parse_counted(&cfg);
assert_eq!(db.disc_entries.len(), 2);
assert!(db.find_disc(&format!("0x{h}")).is_some());
}
/// Leading whitespace on a disc row must not drop it (trim covers it).
#[test]
fn parse_tolerates_leading_whitespace() {
let h = "dd".repeat(20);
let cfg = format!(" \t{}\n", disc_line(&h));
let (db, _) = KeyDb::parse_counted(&cfg);
assert!(db.find_disc(&format!("0x{h}")).is_some());
}
/// An uppercase `0X` prefix AND uppercase hash must load and be findable by
/// any case / prefix form (the lookup normalizes).
#[test]
fn parse_and_lookup_are_case_and_prefix_insensitive() {
let cfg = "0XABCDEF0123456789ABCDEF0123456789ABCDEF01 = T | V | 0x0202020202020202020202020202020202 | U | 1-0x0101010101010101010101010101010101\n"
.replace("0202020202020202020202020202020202", &"02".repeat(16))
.replace("0101010101010101010101010101010101", &"01".repeat(16));
let (db, _) = KeyDb::parse_counted(&cfg);
// uppercase-with-0x, lowercase-with-0x, and no-prefix all resolve.
assert!(
db.find_disc("0xABCDEF0123456789ABCDEF0123456789ABCDEF01")
.is_some()
);
assert!(
db.find_disc("0xabcdef0123456789abcdef0123456789abcdef01")
.is_some()
);
assert!(
db.find_disc("abcdef0123456789abcdef0123456789abcdef01")
.is_some()
);
}
/// A `0x…` row missing the `" = "` separator must be COUNTED as rejected,
/// never silently ignored — otherwise a visibly-present disc vanishes.
#[test]
fn parse_counts_a_prefixed_row_missing_the_separator() {
let h = "ee".repeat(20);
// no " = " (bare "=") — malformed for the grammar.
let cfg = format!(
"0x{h}=TITLE|V|0x{}|U|1-0x{}\n",
"02".repeat(16),
"01".repeat(16)
);
let (db, stats) = KeyDb::parse_counted(&cfg);
assert!(
db.find_disc(&format!("0x{h}")).is_none(),
"malformed row not loaded"
);
assert_eq!(
stats.disc_over_cap, 2,
"the two rows dropped by the cap must be counted"
stats.disc_rejected, 1,
"the drop MUST be counted, not silent"
);
assert!(
stats.log() || stats.rejected() > 0,
"a rejection is diagnosable"
);
}
/// Duplicate hash: last row wins AND the collision is counted (a keydb with
/// the same hash twice means one row's keys become unreachable).
#[test]
fn parse_counts_duplicate_hash_last_wins() {
let h = "ff".repeat(20);
let cfg = format!("{}\n{}\n", disc_line(&h), disc_line(&h));
let (db, stats) = KeyDb::parse_counted(&cfg);
assert_eq!(db.disc_entries.len(), 1);
assert_eq!(stats.disc_duplicate, 1, "the overwrite must be counted");
}
/// A row carrying ONLY terminal unit keys (no V field) still loads and is
/// findable — VUK-less discs must resolve from their stored unit keys.
#[test]
fn parse_keeps_unit_key_only_rows() {
let h = "12".repeat(20);
let cfg = format!("0x{h} = TITLE | U | 1-0x{}\n", "01".repeat(16));
let (db, _) = KeyDb::parse_counted(&cfg);
let e = db.find_disc(&format!("0x{h}")).expect("row loads");
assert!(e.vuk.is_none());
assert_eq!(e.unit_keys.len(), 1, "the stored unit key is kept");
}
/// A disc row rejected by the FIELD parser is counted separately from one
+33
View File
@@ -97,6 +97,39 @@ impl KeySource for MultiSource {
first_non_empty(&self.sources, |s, c| s.get_unit_keys(c), ctx)
}
// De-conflated counterpart: first inner source with keys wins; else the
// richest miss survives composition (a MATCHED inner source beats a plain
// miss). Same Err/Ok failure contract as `get_unit_keys`.
fn resolve_unit_keys(
&self,
ctx: &dyn ResolveCtx,
) -> Result<libfreemkv::keysource::UnitKeyResolution, libfreemkv::Error> {
let mut first_failure: Option<libfreemkv::Error> = None;
let mut matched: Option<libfreemkv::keysource::UnitKeyResolution> = None;
for s in &self.sources {
match s.resolve_unit_keys(ctx) {
Ok(r) if !r.keys.is_empty() => return Ok(r),
Ok(r) => {
if r.matched && matched.is_none() {
matched = Some(r);
}
}
Err(e) => {
if first_failure.is_none() {
first_failure = Some(e);
}
}
}
}
if let Some(r) = matched {
return Ok(r);
}
match first_failure {
Some(e) => Err(e),
None => Ok(libfreemkv::keysource::UnitKeyResolution::default()),
}
}
// Forensic-index counterpart to `get_unit_keys`, same failure-preserving
// rule. A source with no forensic material (keydb, via the trait default)
// contributes empty and is skipped; today the online source answers.
+23 -4
View File
@@ -287,6 +287,10 @@ fn hardened_agent(pinned: Vec<SocketAddr>) -> ureq::Agent {
.max_redirects(0)
.timeout_connect(Some(Duration::from_secs(10)))
.timeout_recv_response(Some(Duration::from_secs(TIMEOUT_SECS)))
// Since ureq 3.4.1 (#1194) recv_response bounds only the HEADERS; without
// a body deadline a stalled reply hangs the POST forever. The JSON key
// answer arrives in one read, so a TOTAL cap is the right shape here.
.timeout_recv_body(Some(Duration::from_secs(TIMEOUT_SECS)))
.build();
// `with_parts`, never `new_with_config` — see [`PinnedResolver`].
ureq::Agent::with_parts(config, DefaultConnector::new(), PinnedResolver(pinned))
@@ -917,6 +921,22 @@ mod tests {
);
}
// The POST reply body has its own deadline, not just the headers: ureq
// 3.4.1+ recv_response covers headers only, so without recv_body a stalled
// reply would hang the key request forever.
#[test]
fn the_reply_body_read_is_bounded_not_only_the_headers() {
let agent = hardened_agent(Vec::new());
let t = agent.config().timeouts();
assert_eq!(
t.recv_body,
Some(Duration::from_secs(TIMEOUT_SECS)),
"ureq 3.4.1+ recv_response covers headers only; without recv_body the \
reply read has no deadline at all"
);
assert_eq!(t.recv_response, Some(Duration::from_secs(TIMEOUT_SECS)));
}
#[test]
fn resolve_and_guard_accepts_public_literal() {
// Public numeric hosts resolve without DNS — must be accepted.
@@ -1172,10 +1192,9 @@ mod tests {
);
}
// The decode POST records its reachability so the caller can classify a
// no-key from the REAL answer instead of firing a second empty probe: an
// HTTP answer (200/404/422) → `Status(code)`, a transport failure →
// `Transport`. This is the signal that removes autorip's redundant probe.
// The decode POST records its reachability so the caller classifies a
// no-key from the REAL answer, not a second empty probe: an HTTP answer
// (200/404/422) → `Status(code)`, a transport failure → `Transport`.
#[test]
fn interpret_reply_records_the_decode_reachability() {
// A 200 with no entry — the service answered; record its status.