mirror of
https://github.com/freemkv/freemkv-keysources.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
35
Commits
v1.7.0-rc2
...
v1.7.5-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146f53873d | ||
|
|
b925a4fa50 | ||
|
|
4f169a4881 | ||
|
|
e251e0bf6f | ||
|
|
6704c1e05b | ||
|
|
a34980970b | ||
|
|
948511853e | ||
|
|
373cdc4cf2 | ||
|
|
a60b021c35 | ||
|
|
fc8b6e3a05 | ||
|
|
bdbc805ba5 | ||
|
|
fa75d28441 | ||
|
|
5d2e11a4dd | ||
|
|
50bd0e4be2 | ||
|
|
148419c2bb | ||
|
|
1b97e6186d | ||
|
|
23988922eb | ||
|
|
e4ecc3cf83 | ||
|
|
55ef44e74e | ||
|
|
173a532a6e | ||
|
|
6879df55eb | ||
|
|
0a735498fb | ||
|
|
531e3a40bf | ||
|
|
ae8e5ade3c | ||
|
|
8b914e7121 | ||
|
|
62ebd61ff1 | ||
|
|
30c16bc415 | ||
|
|
8c3afbee67 | ||
|
|
6e4f05dec4 | ||
|
|
5bf6c4875b | ||
|
|
1c29377780 | ||
|
|
7c544e3780 | ||
|
|
4a241e04bc | ||
|
|
d19481ae59 | ||
|
|
707c8bdc57 |
+46
-126
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
name: identity-guard
|
||||
|
||||
# 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:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## [1.7.5] — 2026-09-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.5 for the unified release. No functional changes to this crate; the release is driven by freemkv-unlock mirroring the freemkv-firmware 0.9.0 ABI (the drive's `Ake` and `Bus` levers retired into a single `Encryption` lever).
|
||||
|
||||
## [1.7.4] — 2026-09-21
|
||||
### Fixed
|
||||
|
||||
- keydb lookups now report match state and entry shape, so a "no entry" result tells you whether the disc was truly absent or matched-but-underivable (no usable VID) (#46).
|
||||
- ureq 3.4.2 with a bounded, rolling-idle reply body, so a slow-but-progressing download is no longer killed by an absolute deadline.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- CI moved to the central reusable workflows.
|
||||
|
||||
## [1.7.3] — 2026-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Unified release with freemkv 1.7.3 (`info --share` disc-structure capture for bug reports + the multi-angle UHD main-title fix in libfreemkv; see the libfreemkv/freemkv 1.7.3 notes). No functional changes to this crate.
|
||||
|
||||
## [1.7.2] — UNRELEASED
|
||||
|
||||
### Added
|
||||
|
||||
- `OnlineSource` now records the reachability outcome of its actual `/decode` POST (an HTTP `Status(code)` for any answer, including 200/404/422/429/5xx, or `Transport` for a connect/timeout/DNS/TLS failure) in a per-thread slot, readable via the new `take_last_decode_reachability()` / `DecodeReachability`. This lets a caller (autorip) classify a no-key result from the REAL decode's outcome instead of firing a second, redundant probe against the POST-only endpoint. No behavioural change to key resolution itself.
|
||||
|
||||
## [1.7.1] — UNRELEASED
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.1 for the unified release (freemkv-unlock 1.7.1 LibreDrive unlock fix); no functional changes.
|
||||
|
||||
## [1.7.0] — 2026-09-02
|
||||
|
||||
### Changed
|
||||
|
||||
Generated
+37
-71
@@ -16,7 +16,7 @@ checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.1",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -31,15 +31,6 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
@@ -84,7 +75,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
@@ -94,7 +85,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
@@ -139,15 +130,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
@@ -200,16 +182,6 @@ version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
@@ -225,25 +197,15 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -306,7 +268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.6.14"
|
||||
version = "1.7.4"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
@@ -319,7 +281,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.6.14"
|
||||
version = "1.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -331,16 +293,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -534,7 +487,7 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libfreemkv"
|
||||
version = "1.6.14"
|
||||
version = "1.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -542,7 +495,6 @@ dependencies = [
|
||||
"freemkv-unlock",
|
||||
"libc",
|
||||
"memchr",
|
||||
"rand",
|
||||
"rayon",
|
||||
"roxmltree",
|
||||
"serde",
|
||||
@@ -736,9 +688,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
@@ -814,13 +766,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -830,8 +782,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -994,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",
|
||||
@@ -1014,9 +966,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
||||
checksum = "5b0809a01d1ca5a51ca70db32bb2a19157582a526505ef3c19e3b343a59aa5ad"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http",
|
||||
@@ -1197,6 +1149,20 @@ name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -1271,8 +1237,8 @@ dependencies = [
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-engine"
|
||||
version = "1.6.14"
|
||||
version = "1.7.4"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-i18n"
|
||||
version = "1.6.14"
|
||||
version = "1.7.4"
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.7.0"
|
||||
version = "1.7.5"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
@@ -17,7 +17,7 @@ publish = false
|
||||
# The crate provides the `KeySource` trait + `Key`/`DiscInputs` types these impls fill.
|
||||
# libfreemkv is git-only now; the committed [patch.crates-io] below redirects this
|
||||
# bare version req to the libfreemkv git tag (local dev overrides it to a path).
|
||||
libfreemkv = "1.6"
|
||||
libfreemkv = "1.7"
|
||||
# OnlineSource: POST disc inputs + samples to a key service over HTTP.
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
serde_json = "1"
|
||||
@@ -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.6.14" }
|
||||
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "v1.7.5" }
|
||||
|
||||
[lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -10,10 +10,12 @@ revision of this comment cited a `candidates_from` function — no such
|
||||
function has ever existed in this crate; the real consumer is
|
||||
`KeydbSource::unit_keys_from` (keydb.rs), which reads `unit_keys`, `vuk`,
|
||||
`media_key` AND `vid` (`vid` is load-bearing for the MK+VID derivation the
|
||||
kat_c / kat_d KATs pin — it is NOT unused). The genuinely unused items —
|
||||
`empty`, `find_vuk`, `DiscEntry::title` — are part of the faithful copy and
|
||||
are retained rather than pruned; allow dead_code so the byte-for-byte copy
|
||||
compiles clean without diverging from the libfreemkv original.
|
||||
kat_c / kat_d KATs pin — it is NOT unused). `DiscEntry::title` is likewise
|
||||
read — by `to_keydb_cfg` and the redacting `Debug` impl — so it is not
|
||||
unused either. The genuinely unused items — `empty` and `find_vuk` — are
|
||||
part of the faithful copy and are retained rather than pruned; allow
|
||||
dead_code so the byte-for-byte copy compiles clean without diverging from
|
||||
the libfreemkv original.
|
||||
|
||||
## ParseStats — what `KeyDb::parse` threw away
|
||||
|
||||
|
||||
+394
-32
@@ -15,16 +15,16 @@ 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;
|
||||
|
||||
// Upper bound on decompressed keydb size: caps a decompression bomb (a tiny
|
||||
// zip/gz could otherwise inflate to GiB and OOM the daily refresh thread).
|
||||
// The public UHD keydb is ~62 MiB and growing; 128 MiB leaves headroom.
|
||||
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
// Decompression-bomb cap on decompressed keydb size: a tiny zip/gz could
|
||||
// inflate to GiB and OOM the refresh thread. Same constant `KeyDb::load`
|
||||
// uses on-disk, defined once in keydb_format so the two can't drift.
|
||||
use crate::keydb_format::MAX_KEYDB_BYTES;
|
||||
|
||||
/// Result of a KEYDB save/update -- path written, entry count, and byte size.
|
||||
#[derive(Debug)]
|
||||
@@ -96,10 +96,22 @@ impl CacheEntry {
|
||||
// Proof, cost, and the clock caveat are in docs/keydb.md#settle-proof;
|
||||
// don't weaken this without re-reading them.
|
||||
fn is_settled(&self, granularity: std::time::Duration) -> bool {
|
||||
self.stamp
|
||||
match self
|
||||
.stamp
|
||||
.modified
|
||||
.and_then(|m| self.stamped_at.duration_since(m).ok())
|
||||
.is_some_and(|age| age >= granularity)
|
||||
{
|
||||
// The normal case: a real mtime that was already `granularity` in
|
||||
// the past when we stamped it, so any later write bumps the mtime
|
||||
// past our stamp and is detected. Trust it.
|
||||
Some(age) => age >= granularity,
|
||||
// No mtime (mtime-less FS) or a future mtime (clock skew): `duration_since`
|
||||
// yields None, so the mtime can't discriminate change — settle via inode
|
||||
// identity once this observation has itself aged past the granularity.
|
||||
None => std::time::SystemTime::now()
|
||||
.duration_since(self.stamped_at)
|
||||
.is_ok_and(|age| age >= granularity),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,20 +186,36 @@ impl KeydbSource {
|
||||
let file = std::fs::File::open(&self.path)?;
|
||||
let stamp = FileStamp::of(&file.metadata()?);
|
||||
let stamped_at = std::time::SystemTime::now();
|
||||
// Fast path: a settled cache hit under a brief lock (stamp compare +
|
||||
// Arc clone). Re-emit the rejection summary on EVERY hit — a hit skips
|
||||
// `KeyDb::parse`, else a corrupt keydb warns once then serves silently.
|
||||
{
|
||||
let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = guard.as_ref()
|
||||
&& entry.stamp == stamp
|
||||
&& entry.is_settled(self.mtime_granularity)
|
||||
{
|
||||
self.emit_parse_stats(&entry.stats);
|
||||
return Ok(entry.db.clone());
|
||||
}
|
||||
}
|
||||
// Miss: parse the ~62 MiB file OUTSIDE the lock so a reparse can't stall
|
||||
// every other worker. Stamp (fstat) and bytes still come from the ONE
|
||||
// open handle, preserving the one-open-one-identity invariant.
|
||||
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
|
||||
let db = Arc::new(db);
|
||||
// Re-acquire and double-check: a peer may have installed the same
|
||||
// settled stamp while we parsed — adopt theirs and drop our redundant
|
||||
// parse rather than racing a lost update.
|
||||
let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(entry) = guard.as_ref()
|
||||
&& entry.stamp == stamp
|
||||
&& entry.is_settled(self.mtime_granularity)
|
||||
{
|
||||
// Re-emit the rejection summary on EVERY hit, not just the parse
|
||||
// that produced it (a hit skips `KeyDb::parse`) — otherwise a
|
||||
// corrupt keydb warns once, then serves silently. See docs/keydb.md#cached-db-reemit.
|
||||
self.emit_parse_stats(&entry.stats);
|
||||
return Ok(entry.db.clone());
|
||||
}
|
||||
let (db, stats) = KeyDb::load_counted(file, &self.path)?;
|
||||
self.emit_parse_stats(&stats);
|
||||
let db = Arc::new(db);
|
||||
self.parses.fetch_add(1, Ordering::Relaxed);
|
||||
*guard = Some(CacheEntry {
|
||||
stamp,
|
||||
@@ -262,16 +290,10 @@ impl KeydbSource {
|
||||
|
||||
let entries = text
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim();
|
||||
// Mirror KeyDb::parse's disc-entry rule EXACTLY by CALLING it,
|
||||
// so save() never persists content that parses to zero usable
|
||||
// entries. See docs/keydb.md#save-mirror-parse.
|
||||
crate::keydb_format::is_disc_entry_line(t)
|
||||
|| t.starts_with("| DK")
|
||||
|| t.starts_with("| PK")
|
||||
|| t.starts_with("| HC")
|
||||
})
|
||||
// Mirror KeyDb::parse by running the same parsers, not a prefix check: a
|
||||
// right-prefix row with bad hex / short cert parses to nothing, so a prefix
|
||||
// count let junk past `entries > 0`. See docs/keydb.md#save-mirror-parse.
|
||||
.filter(|l| crate::keydb_format::is_parseable_entry_line(l))
|
||||
.count();
|
||||
|
||||
if entries == 0 {
|
||||
@@ -327,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,
|
||||
@@ -346,13 +376,35 @@ impl KeydbSource {
|
||||
// 1. Terminal Unit Keys stored in the entry — directly usable, no
|
||||
// derivation. Preserve the keydb's CPS numbering (idx = num - 1).
|
||||
for (num, key) in &entry.unit_keys {
|
||||
keys.push(UnitKey::new(num.saturating_sub(1), *key));
|
||||
// A valid CPS unit number is >= 1; num 0 would collide with unit 1
|
||||
// at idx 0 (num - 1), so skip it rather than mis-map two units.
|
||||
if *num == 0 {
|
||||
continue;
|
||||
}
|
||||
keys.push(UnitKey::new(num - 1, *key));
|
||||
}
|
||||
|
||||
// The disc's encrypted title keys (from Unit_Key_RO.inf). Empty when
|
||||
// the scan captured none, in which case only the stored list (1)
|
||||
// contributes.
|
||||
let enc_title_keys = ctx.enc_title_keys().unwrap_or(&[]);
|
||||
let enc_title_keys = match ctx.enc_title_keys() {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
// Mirror online.rs: surface the read failure, then fall back to
|
||||
// empty (only the stored unit-key list can contribute).
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
error = %e,
|
||||
"keydb: disc encrypted title keys unreadable; deriving without them"
|
||||
);
|
||||
&[]
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
@@ -360,7 +412,19 @@ impl KeydbSource {
|
||||
uks_from_vuk(&vuk, enc_title_keys)
|
||||
} else {
|
||||
let vid = ctx.vid().or_else(|| entry.vid.map(Vid));
|
||||
let mkb = ctx.mkb().unwrap_or(&[]);
|
||||
let mkb = match ctx.mkb() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
// Mirror online.rs: log the failure, then fall back to
|
||||
// empty (PK/DK derivation may then find no Media Key).
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
error = %e,
|
||||
"keydb: disc MKB unreadable; media-key derivation may fail"
|
||||
);
|
||||
&[]
|
||||
}
|
||||
};
|
||||
let mk: Option<MediaKey> = entry
|
||||
.media_key
|
||||
.map(MediaKey)
|
||||
@@ -375,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);
|
||||
@@ -384,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +568,20 @@ fn write_atomic(path: &Path, text: &str) -> Result<(), Error> {
|
||||
))
|
||||
};
|
||||
let write_result = (|| -> std::io::Result<()> {
|
||||
// The temp file is renamed onto keydb.cfg, which holds AACS key
|
||||
// material and the host private key/cert — create it 0600 on Unix so
|
||||
// umask can't leave the keys world-readable. Non-unix keeps create().
|
||||
#[cfg(unix)]
|
||||
let mut f = {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)?
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
f.write_all(text.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
@@ -483,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.
|
||||
@@ -623,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.
|
||||
@@ -642,6 +873,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// A CPS unit number of 0 is invalid (valid numbering is >= 1); it must be
|
||||
// skipped, not mapped to idx 0 where `num - 1` would collide with unit 1.
|
||||
#[test]
|
||||
fn stored_unit_key_with_cps_number_zero_is_skipped() {
|
||||
let mut e = blank_entry(HASH);
|
||||
e.unit_keys = vec![(0, [0xEEu8; 16]), (1, [0xA0u8; 16])];
|
||||
let db = db_with(e, Vec::new());
|
||||
let got = KeydbSource::unit_keys_from(&db, &ctx(HASH, Vec::new(), None));
|
||||
let keys: Vec<[u8; 16]> = got.iter().map(|u| u.key).collect();
|
||||
assert!(
|
||||
!keys.contains(&[0xEEu8; 16]),
|
||||
"a unit-0 key must be dropped, never mapped onto idx 0"
|
||||
);
|
||||
assert_eq!(committed(&got), vec![(1u32, [0xA0u8; 16])]);
|
||||
}
|
||||
|
||||
// Orphan-unit completeness (the real keydb bug): a PARTIAL stored list
|
||||
// plus a VUK that boils BOTH declared units must return BOTH, not shadow
|
||||
// the VUK with the partial list. See docs/keydb.md#test-orphan-union.
|
||||
@@ -920,6 +1167,53 @@ mod tests {
|
||||
assert_eq!(KeydbSource::new("/nonexistent/keydb.cfg").label(), "keydb");
|
||||
}
|
||||
|
||||
// A stamp with no mtime (mtime-less FS) or a future mtime (clock skew) must
|
||||
// not be stuck un-settled forever — it now settles via the inode-identity
|
||||
// fallback once the observation has aged past the granularity.
|
||||
#[test]
|
||||
fn is_settled_falls_back_when_mtime_is_absent_or_in_the_future() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
let entry = |modified: Option<SystemTime>, stamped_at: SystemTime| CacheEntry {
|
||||
stamp: FileStamp {
|
||||
dev: 1,
|
||||
ino: 2,
|
||||
len: 3,
|
||||
modified,
|
||||
},
|
||||
stamped_at,
|
||||
db: Arc::new(KeyDb::empty()),
|
||||
stats: crate::keydb_format::ParseStats::default(),
|
||||
};
|
||||
let gran = Duration::from_secs(2);
|
||||
let now = SystemTime::now();
|
||||
|
||||
// None mtime, stamped well in the past → settled via the fallback.
|
||||
assert!(
|
||||
entry(None, now - Duration::from_secs(3600)).is_settled(gran),
|
||||
"a mtime-less entry must settle once it has aged past the granularity"
|
||||
);
|
||||
// None mtime, just stamped → still inside the settle window, NOT settled.
|
||||
assert!(
|
||||
!entry(None, now).is_settled(gran),
|
||||
"a freshly stamped mtime-less entry is too young to trust yet"
|
||||
);
|
||||
// A FUTURE mtime (clock skew) with an old stamp → still settles via the
|
||||
// fallback rather than being wedged un-settled forever.
|
||||
assert!(
|
||||
entry(
|
||||
Some(now + Duration::from_secs(3600)),
|
||||
now - Duration::from_secs(3600)
|
||||
)
|
||||
.is_settled(gran),
|
||||
"a future mtime must not wedge the entry un-settled forever"
|
||||
);
|
||||
// Sanity: a real past mtime still settles the normal way.
|
||||
assert!(
|
||||
entry(Some(now - Duration::from_secs(3600)), now).is_settled(gran),
|
||||
"a normal past mtime still settles"
|
||||
);
|
||||
}
|
||||
|
||||
/// No keydb → no host credentials, not an error (inherent and trait forms).
|
||||
#[test]
|
||||
fn host_certs_empty_when_keydb_missing() {
|
||||
@@ -1356,6 +1650,41 @@ mod tests {
|
||||
assert!(matches!(src.save(garbage), Err(Error::KeydbInvalid)));
|
||||
}
|
||||
|
||||
/// DK/PK/HC rows with the RIGHT prefix but MALFORMED content (bad hex, a
|
||||
/// short cert) parse to nothing, so `save` must count zero real entries and
|
||||
/// reject — a prefix-only count let unparseable content persist past the
|
||||
/// `entries > 0` guard.
|
||||
#[test]
|
||||
fn save_rejects_prefixed_but_unparseable_dk_pk_hc_rows() {
|
||||
let dir = scratch("save-prefixed-junk");
|
||||
let src = KeydbSource::new(dir.join("k.cfg"));
|
||||
// Each line has a valid keydb PREFIX but content no parser accepts: PK not
|
||||
// 16 bytes of hex, DK's DEVICE_KEY not valid hex, HC cert far shorter than
|
||||
// the 92-byte minimum.
|
||||
let junk = b"| PK | 0xnothex\n\
|
||||
| DK | DEVICE_KEY 0xZZ | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17\n\
|
||||
| HC | HOST_PRIV_KEY 0x00 | HOST_CERT 0x0011\n";
|
||||
assert!(
|
||||
matches!(src.save(junk), Err(Error::KeydbInvalid)),
|
||||
"prefixed-but-unparseable rows must not count as entries"
|
||||
);
|
||||
|
||||
// Contrast: the SAME shapes, but well-formed, ARE counted and saved.
|
||||
let good = format!(
|
||||
"| PK | 0x{}\n| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||
"11".repeat(16),
|
||||
"00".repeat(20),
|
||||
"00".repeat(92),
|
||||
);
|
||||
let ok = src
|
||||
.save(good.as_bytes())
|
||||
.expect("well-formed rows must save");
|
||||
assert_eq!(
|
||||
ok.entries, 2,
|
||||
"one PK + one HC row are the two real entries"
|
||||
);
|
||||
}
|
||||
|
||||
// A keydb.cfg over the size cap or not valid UTF-8 must surface as a
|
||||
// SOURCE FAILURE (KeydbInvalid), never a silent empty result.
|
||||
#[test]
|
||||
@@ -1567,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+378
-45
@@ -17,15 +17,12 @@ use libfreemkv::aacs::types::{DeviceKey, HostCert};
|
||||
/// A keydb per-disc unit key: the CPS-unit number paired with its 16-byte key.
|
||||
pub type NumberedUnitKey = (u32, [u8; 16]);
|
||||
|
||||
// Upper bound on the on-disk keydb.cfg size accepted by `KeyDb::load`. The
|
||||
// public UHD keydb is ~62 MiB and growing; 128 MiB bounds worst-case
|
||||
// allocation from a hostile or corrupt file.
|
||||
const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Upper bound on parsed disc entries. The real public keydb carries
|
||||
/// ~170k+ entries, so the cap sits well above that while still bounding
|
||||
/// memory against a pathological input. Surplus lines are ignored.
|
||||
const MAX_DISC_ENTRIES: usize = 500_000;
|
||||
/// Upper bound on the keydb.cfg byte size (the public UHD keydb is ~62 MiB;
|
||||
/// 128 MiB bounds a hostile/corrupt file). The SINGLE definition for the crate:
|
||||
/// [`KeyDb::load`] uses it as the on-disk load cap and [`crate::KeydbSource`]
|
||||
/// re-uses it as the decompression-bomb cap on the save/update path, so the two
|
||||
/// can never drift out of lockstep.
|
||||
pub(crate) const MAX_KEYDB_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Parsed AACS key database.
|
||||
///
|
||||
@@ -191,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.
|
||||
@@ -208,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
|
||||
@@ -225,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)"
|
||||
);
|
||||
@@ -240,6 +233,35 @@ pub(crate) fn is_disc_entry_line(line: &str) -> bool {
|
||||
(line.starts_with("0x") || line.starts_with("0X")) && line.contains(" = ")
|
||||
}
|
||||
|
||||
/// True when `line` is a row `KeyDb::parse` would actually ACCEPT into the db —
|
||||
/// a disc row OR a DK/PK/HC/HC2 row that its real parser accepts, not merely one
|
||||
/// carrying the right `| XX` prefix. `KeydbSource::save`'s "won't persist
|
||||
/// unparseable content" guard counts entries with THIS, so a syntactically
|
||||
/// prefixed but malformed DK/PK/HC row (right prefix, bad hex/short cert) can no
|
||||
/// longer inflate the entry count past the `entries > 0` check. Mirrors the
|
||||
/// dispatch in [`KeyDb::parse_counted`] exactly. See docs/keydb.md#save-mirror-parse.
|
||||
pub(crate) fn is_parseable_entry_line(line: &str) -> bool {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||||
return false;
|
||||
}
|
||||
// Order matches `parse_counted`: HC2 before HC (both share the `| HC`
|
||||
// prefix); an orphan DK (no position fields) still counts as a DK.
|
||||
if line.starts_with("| DK") {
|
||||
return KeyDb::parse_device_key(line).is_some() || KeyDb::parse_orphan_dk(line).is_some();
|
||||
}
|
||||
if line.starts_with("| PK") {
|
||||
return KeyDb::parse_processing_key(line).is_some();
|
||||
}
|
||||
if line.starts_with("| HC2") {
|
||||
return KeyDb::parse_host_cert_v2(line).is_some();
|
||||
}
|
||||
if line.starts_with("| HC") {
|
||||
return KeyDb::parse_host_cert(line).is_some();
|
||||
}
|
||||
is_disc_entry_line(line) && KeyDb::parse_disc_entry(line).is_some()
|
||||
}
|
||||
|
||||
impl KeyDb {
|
||||
/// Construct an empty KeyDb. Used by unit tests; production code
|
||||
/// reaches a populated KeyDb via [`KeyDb::load`] or [`KeyDb::parse`].
|
||||
@@ -274,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();
|
||||
|
||||
@@ -347,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).
|
||||
@@ -1017,18 +1039,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_disc_entry() {
|
||||
// All-zero placeholders — synthetic; no real key material in code.
|
||||
// Synthetic placeholders, DISTINCT per field so a field mix-up (M read
|
||||
// as V, I as U, …) is caught — no real key material in code.
|
||||
let z40 = "00".repeat(20);
|
||||
let z32 = "00".repeat(16);
|
||||
let mk = "11".repeat(16);
|
||||
let id = "22".repeat(16);
|
||||
let vuk = "33".repeat(16);
|
||||
let uk = "44".repeat(16);
|
||||
let line = format!(
|
||||
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
|
||||
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{mk} | I | 0x{id} | V | 0x{vuk} | U | 1-0x{uk} ; MKBv77"
|
||||
);
|
||||
let entry = KeyDb::parse_disc_entry(&line).unwrap();
|
||||
assert_eq!(entry.title, "SAMPLE_FILM (Sample Film)"); // faithful, verbatim
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
assert_eq!(entry.unit_keys.len(), 1);
|
||||
assert_eq!(entry.unit_keys[0].0, 1);
|
||||
assert_eq!(entry.media_key, Some([0x11u8; 16]), "M field");
|
||||
assert_eq!(entry.vid, Some([0x22u8; 16]), "I field");
|
||||
assert_eq!(entry.vuk, Some([0x33u8; 16]), "V field");
|
||||
assert_eq!(entry.unit_keys, vec![(1u32, [0x44u8; 16])], "U field");
|
||||
assert_eq!(entry.mkb_version, Some(77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1407,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`:
|
||||
@@ -1433,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
|
||||
@@ -2003,6 +2140,202 @@ mod tests {
|
||||
assert_eq!(eb2.volume_size, None);
|
||||
}
|
||||
|
||||
// ── HC line: full parse into (priv[20], cert[92], revoked) ──────────────
|
||||
|
||||
/// A real-world-shaped `| HC |` line — a 20-byte private key, a 92-byte
|
||||
/// cert that begins with the AACS host-cert header `02 01 00 5c ff ff 80 00
|
||||
/// 02 10 …`, and a `; Revoked in MKBv72` note — must parse to exactly those
|
||||
/// three: 20-byte key, 92-byte cert, and `revoked_at_mkb = Some(72)`.
|
||||
#[test]
|
||||
fn hc_line_parses_into_key_cert_and_revocation_generation() {
|
||||
// Cert = 4-byte header + 6-byte host id + filler to the 92-byte length.
|
||||
// Synthetic bytes only; no real key material.
|
||||
let mut cert = vec![0x02u8, 0x01, 0x00, 0x5c, 0xff, 0xff, 0x80, 0x00, 0x02, 0x10];
|
||||
cert.resize(92, 0xcc);
|
||||
let cert_hex: String = cert.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let line = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{cert_hex} ; Revoked in MKBv72",
|
||||
"88".repeat(20),
|
||||
);
|
||||
let hc = KeyDb::parse_host_cert(&line).expect("a well-formed HC line must parse");
|
||||
assert_eq!(hc.cert.private_key, [0x88u8; 20], "20-byte private key");
|
||||
assert_eq!(hc.cert.certificate.len(), 92, "AACS 1.0 cert is 92 bytes");
|
||||
assert_eq!(
|
||||
&hc.cert.certificate[..4],
|
||||
&[0x02, 0x01, 0x00, 0x5c],
|
||||
"header preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
hc.revoked_at_mkb,
|
||||
Some(72),
|
||||
"the ; Revoked in MKBvN note captures N"
|
||||
);
|
||||
// A 1.0 HC line carries no AACS 2.0 credentials.
|
||||
assert!(hc.cert.private_key_v2.is_none());
|
||||
assert!(hc.cert.certificate_v2.is_none());
|
||||
}
|
||||
|
||||
/// A `| HC |` line with NO `; Revoked …` note is live: `revoked_at_mkb`
|
||||
/// is `None`, so `host_certs` returns it at every generation.
|
||||
#[test]
|
||||
fn hc_line_without_note_is_live() {
|
||||
let line = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}",
|
||||
"00".repeat(20),
|
||||
"ab".repeat(92),
|
||||
);
|
||||
let hc = KeyDb::parse_host_cert(&line).unwrap();
|
||||
assert_eq!(hc.revoked_at_mkb, None, "no note ⇒ never revoked");
|
||||
}
|
||||
|
||||
// ── host_certs(mkb): the exact per-generation revocation filter ─────────
|
||||
|
||||
/// The hardware-critical filter: several certs revoked at DIFFERENT
|
||||
/// generations plus one live cert. Each cert carries a distinct marker byte
|
||||
/// so the returned SET is identified exactly, not merely counted. A cert
|
||||
/// revoked at generation R is usable iff the disc's generation is `< R`.
|
||||
#[test]
|
||||
fn host_certs_filters_several_certs_across_mkb_generations() {
|
||||
let hc = |marker: u8, note: &str| {
|
||||
format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}{}\n",
|
||||
"00".repeat(20),
|
||||
format!("{marker:02x}").repeat(92),
|
||||
note
|
||||
)
|
||||
};
|
||||
let cfg = format!(
|
||||
"{}{}{}{}{}",
|
||||
hc(0x53, " ; Revoked in MKBv53"),
|
||||
hc(0x58, " ; Revoked in MKBv58"),
|
||||
hc(0x70, " ; Revoked in MKBv70"),
|
||||
hc(0x72, " ; Revoked in MKBv72"),
|
||||
hc(0x11, ""), // live: no revocation note
|
||||
);
|
||||
let db = KeyDb::parse(&cfg);
|
||||
assert_eq!(db.host_certs.len(), 5);
|
||||
|
||||
// The first cert byte of each cert host_certs(mkb) hands back, sorted.
|
||||
let markers = |mkb: Option<u32>| {
|
||||
let mut m: Vec<u8> = db
|
||||
.host_certs(mkb)
|
||||
.iter()
|
||||
.map(|c| c.certificate[0])
|
||||
.collect();
|
||||
m.sort_unstable();
|
||||
m
|
||||
};
|
||||
|
||||
// gen 77: every revocation has fired; only the live cert survives.
|
||||
assert_eq!(markers(Some(77)), vec![0x11]);
|
||||
// gen 50: below every revocation ⇒ all five are usable.
|
||||
assert_eq!(markers(Some(50)), vec![0x11, 0x53, 0x58, 0x70, 0x72]);
|
||||
// gen 58: revoked-at-53 and revoked-at-58 are out at the boundary
|
||||
// (disc_gen < revoked is false); 70, 72 and the live cert remain.
|
||||
assert_eq!(markers(Some(58)), vec![0x11, 0x70, 0x72]);
|
||||
// gen 70: 70 itself is out at its own generation; only 72 + live remain.
|
||||
assert_eq!(markers(Some(70)), vec![0x11, 0x72]);
|
||||
// unknown disc MKB ⇒ cannot filter ⇒ every cert is returned.
|
||||
assert_eq!(markers(None), vec![0x11, 0x53, 0x58, 0x70, 0x72]);
|
||||
}
|
||||
|
||||
// ── Cert selection by the embedded 6-byte Host ID ──────────────────────
|
||||
|
||||
/// The AACS host ID lives INSIDE the 92-byte cert (offset 4, 6 bytes): a
|
||||
/// cert beginning `02 01 00 5c ff ff 80 00 02 10 …` carries host id
|
||||
/// `ff ff 80 00 02 10`. The parser keeps the cert bytes verbatim, so a
|
||||
/// caller can select the cert for a specific host by that embedded id.
|
||||
#[test]
|
||||
fn host_cert_selected_by_embedded_6_byte_host_id() {
|
||||
let cert_hex = |host_id: [u8; 6]| {
|
||||
let mut bytes = vec![0x02u8, 0x01, 0x00, 0x5c]; // 4-byte header
|
||||
bytes.extend_from_slice(&host_id); // host id at offset 4
|
||||
bytes.resize(92, 0xcc); // pad to the 92-byte AACS 1.0 cert length
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()
|
||||
};
|
||||
let id_a = [0xff, 0xff, 0x80, 0x00, 0x02, 0x10];
|
||||
let id_b = [0xff, 0xff, 0x80, 0x00, 0x02, 0x11];
|
||||
let cfg = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{p} | HOST_CERT 0x{a}\n\
|
||||
| HC | HOST_PRIV_KEY 0x{p} | HOST_CERT 0x{b}\n",
|
||||
p = "00".repeat(20),
|
||||
a = cert_hex(id_a),
|
||||
b = cert_hex(id_b),
|
||||
);
|
||||
let db = KeyDb::parse(&cfg);
|
||||
assert_eq!(db.host_certs.len(), 2);
|
||||
|
||||
let find = |want: [u8; 6]| {
|
||||
db.host_certs
|
||||
.iter()
|
||||
.find(|hc| hc.cert.certificate.get(4..10) == Some(&want[..]))
|
||||
};
|
||||
let hit = find(id_b).expect("the cert carrying host id …0211 must be found");
|
||||
assert_eq!(&hit.cert.certificate[4..10], &id_b);
|
||||
assert_eq!(hit.cert.certificate.len(), 92);
|
||||
// A host id no cert carries selects nothing — and the byte-range slice
|
||||
// must not panic when it does not match.
|
||||
assert!(find([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).is_none());
|
||||
}
|
||||
|
||||
/// A 1.0-only keydb (only `| HC |` rows, no `| HC2 |`) must leave every
|
||||
/// cert's AACS 2.0 fields empty — no phantom v2 credentials appear.
|
||||
#[test]
|
||||
fn keydb_with_only_hc_rows_yields_no_v2_certs() {
|
||||
let cfg = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n\
|
||||
| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||
"00".repeat(20),
|
||||
"ab".repeat(92),
|
||||
"01".repeat(20),
|
||||
"cd".repeat(92),
|
||||
);
|
||||
let db = KeyDb::parse(&cfg);
|
||||
assert_eq!(db.host_certs.len(), 2);
|
||||
assert!(
|
||||
db.host_certs
|
||||
.iter()
|
||||
.all(|hc| hc.cert.private_key_v2.is_none()),
|
||||
"no HC2 rows ⇒ no v2 private keys"
|
||||
);
|
||||
assert!(
|
||||
db.host_certs
|
||||
.iter()
|
||||
.all(|hc| hc.cert.certificate_v2.is_none()),
|
||||
"no HC2 rows ⇒ no v2 certs"
|
||||
);
|
||||
}
|
||||
|
||||
/// The HC2 (AACS 2.0, type 0x11) slot: a full HC + HC2 pair parses into the
|
||||
/// 1.0 fields AND the v2 fields, with the v2 private key exactly 32 bytes
|
||||
/// and the v2 cert 132 bytes. Complements the field-ordering HC2 tests by
|
||||
/// asserting the concrete v2 byte lengths a valid slot must yield.
|
||||
#[test]
|
||||
fn hc2_slot_parses_into_v2_key_and_cert_with_correct_lengths() {
|
||||
let cfg = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n\
|
||||
| HC2 | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{}\n",
|
||||
"00".repeat(20),
|
||||
"00".repeat(92),
|
||||
"5b".repeat(32),
|
||||
"6c".repeat(132),
|
||||
);
|
||||
let db = KeyDb::parse(&cfg);
|
||||
assert_eq!(db.host_certs.len(), 1, "HC2 augments the preceding HC");
|
||||
let c = &db.host_certs[0].cert;
|
||||
assert_eq!(c.certificate.len(), 92, "1.0 cert retained");
|
||||
assert_eq!(
|
||||
c.private_key_v2,
|
||||
Some([0x5bu8; 32]),
|
||||
"32-byte v2 private key"
|
||||
);
|
||||
assert_eq!(
|
||||
c.certificate_v2.as_ref().map(|v| v.len()),
|
||||
Some(132),
|
||||
"132-byte v2 cert"
|
||||
);
|
||||
}
|
||||
|
||||
// KEYDB-parser integration tests relocated from libfreemkv; exercise the
|
||||
// parser end-to-end against a real keydb.cfg. See docs/keydb-format.md.
|
||||
|
||||
|
||||
+130
-1
@@ -17,7 +17,10 @@ mod paths;
|
||||
|
||||
pub use keydb::{KeydbSource, UpdateResult};
|
||||
pub use keydb_format::{DiscEntry, KeyDb};
|
||||
pub use online::{MIN_SAMPLE_UNITS, OnlineSource, validate_keyserver_url};
|
||||
pub use online::{
|
||||
DecodeReachability, MIN_SAMPLE_UNITS, OnlineSource, take_last_decode_reachability,
|
||||
validate_keyserver_url,
|
||||
};
|
||||
pub use paths::{default_keydb_path, existing_keydb_path, keydb_search_paths};
|
||||
|
||||
// Re-exported for downstream convenience so apps need only depend on this crate
|
||||
@@ -94,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.
|
||||
@@ -115,3 +151,96 @@ impl KeySource for MultiSource {
|
||||
"multi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use libfreemkv::aacs::types::HostCert;
|
||||
|
||||
// A minimal KeySource that only supplies host certs — the sole MultiSource
|
||||
// behaviour under test. It never resolves a unit key.
|
||||
struct CertOnly {
|
||||
certs: Vec<HostCert>,
|
||||
}
|
||||
impl KeySource for CertOnly {
|
||||
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, _mkb: Option<u32>) -> Vec<HostCert> {
|
||||
self.certs.clone()
|
||||
}
|
||||
}
|
||||
|
||||
// A host cert tagged with a distinct marker byte so a returned set can be
|
||||
// identified exactly. Synthetic bytes only; no real key material.
|
||||
fn cert(marker: u8) -> HostCert {
|
||||
HostCert {
|
||||
private_key: [marker; 20],
|
||||
certificate: vec![marker; 92],
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed(certs: Vec<HostCert>) -> Box<dyn KeySource> {
|
||||
Box::new(CertOnly { certs }) as Box<dyn KeySource>
|
||||
}
|
||||
|
||||
/// `MultiSource::host_certs` must UNION every inner source's certs. Without
|
||||
/// this a composed source hides an inner source's cert from the OEM
|
||||
/// cert-auth route — the gap the union closes.
|
||||
#[test]
|
||||
fn multi_source_host_certs_unions_every_inner_source() {
|
||||
let multi = MultiSource::new(vec![
|
||||
boxed(vec![cert(0x01)]),
|
||||
boxed(vec![cert(0x02), cert(0x03)]),
|
||||
]);
|
||||
let mut markers: Vec<u8> = multi
|
||||
.host_certs(Some(50))
|
||||
.iter()
|
||||
.map(|c| c.certificate[0])
|
||||
.collect();
|
||||
markers.sort_unstable();
|
||||
assert_eq!(
|
||||
markers,
|
||||
vec![0x01, 0x02, 0x03],
|
||||
"certs from every inner source must be unioned"
|
||||
);
|
||||
}
|
||||
|
||||
/// With no inner source holding a cert, the union is empty (not an error).
|
||||
#[test]
|
||||
fn multi_source_host_certs_empty_when_no_source_has_one() {
|
||||
let multi = MultiSource::new(vec![boxed(vec![]), boxed(vec![])]);
|
||||
assert!(multi.host_certs(None).is_empty());
|
||||
}
|
||||
|
||||
/// The `mkb` generation must reach each inner source so its OWN revocation
|
||||
/// filter can act; a source that drops everything at a given generation is
|
||||
/// honoured by the union.
|
||||
#[test]
|
||||
fn multi_source_host_certs_passes_mkb_through_to_inner_sources() {
|
||||
struct GenAware;
|
||||
impl KeySource for GenAware {
|
||||
fn get_unit_keys(
|
||||
&self,
|
||||
_c: &dyn ResolveCtx,
|
||||
) -> Result<Vec<UnitKey>, libfreemkv::Error> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn host_certs(&self, mkb: Option<u32>) -> Vec<HostCert> {
|
||||
match mkb {
|
||||
// Modelled on a cert revoked in MKBv72.
|
||||
Some(g) if g >= 72 => Vec::new(),
|
||||
_ => vec![cert(0xaa)],
|
||||
}
|
||||
}
|
||||
}
|
||||
let multi = MultiSource::new(vec![Box::new(GenAware) as Box<dyn KeySource>]);
|
||||
assert_eq!(multi.host_certs(Some(50)).len(), 1, "usable below gen 72");
|
||||
assert!(
|
||||
multi.host_certs(Some(72)).is_empty(),
|
||||
"the gen must reach the inner source's filter"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+361
-39
@@ -52,17 +52,40 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 0x40)
|
||||
// "This network" 0.0.0.0/8.
|
||||
|| v4.octets()[0] == 0
|
||||
// Benchmarking 198.18.0.0/15 (RFC 2544) — 198.18.x and 198.19.x
|
||||
// (the /15 second octet is 18 with the low bit free, i.e. 18|19).
|
||||
|| (v4.octets()[0] == 198 && (v4.octets()[1] & 0xfe) == 18)
|
||||
// IETF protocol assignments 192.0.0.0/24 (RFC 6890), which
|
||||
// includes 192.0.0.170/171 (NAT64/DNS64 discovery). Distinct
|
||||
// from 192.0.2.0/24 TEST-NET-1, already caught by is_documentation.
|
||||
|| (v4.octets()[0] == 192 && v4.octets()[1] == 0 && v4.octets()[2] == 0)
|
||||
// Class E reserved 240.0.0.0/4.
|
||||
|| v4.octets()[0] >= 240
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
let seg = v6.segments();
|
||||
// 6to4 (2002::/16) embeds an IPv4 in segments[1..3]; Teredo
|
||||
// (2001:0000::/32) embeds the client IPv4 in the last two segments,
|
||||
// each XOR 0xffff. Both must be re-checked as their embedded IPv4.
|
||||
let sixtofour = (seg[0] == 0x2002)
|
||||
.then(|| std::net::Ipv4Addr::from(((seg[1] as u32) << 16) | (seg[2] as u32)));
|
||||
let teredo = (seg[0] == 0x2001 && seg[1] == 0x0000).then(|| {
|
||||
std::net::Ipv4Addr::from(
|
||||
(((seg[6] ^ 0xffff) as u32) << 16) | ((seg[7] ^ 0xffff) as u32),
|
||||
)
|
||||
});
|
||||
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) embeds the IPv4 in
|
||||
// the last 32 bits (segments[6..8]); re-check it too so an internal
|
||||
// target does not slip through a NAT64 translator.
|
||||
let nat64 = (seg[0] == 0x0064 && seg[1] == 0xff9b)
|
||||
.then(|| std::net::Ipv4Addr::from(((seg[6] as u32) << 16) | (seg[7] as u32)));
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_multicast()
|
||||
// Unique-local fc00::/7.
|
||||
|| (v6.segments()[0] & 0xfe00) == 0xfc00
|
||||
|| (seg[0] & 0xfe00) == 0xfc00
|
||||
// Link-local fe80::/10.
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||
|| (seg[0] & 0xffc0) == 0xfe80
|
||||
// IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x,
|
||||
// deprecated by RFC 4291 §2.5.5.1) — to_ipv4() returns Some for
|
||||
// both forms; re-check the embedded address as IPv4.
|
||||
@@ -70,6 +93,9 @@ fn is_blocked_ip(ip: &IpAddr) -> bool {
|
||||
.to_ipv4()
|
||||
.map(|m| is_blocked_ip(&IpAddr::V4(m)))
|
||||
== Some(true)
|
||||
|| sixtofour.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|
||||
|| teredo.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|
||||
|| nat64.is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,17 +117,16 @@ enum GuardFail {
|
||||
// returns pinned socket addrs or a rejection reason + message. SECURITY: the
|
||||
// message names the address — log only at config time, never in `query`.
|
||||
fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)> {
|
||||
let rest = if let Some(r) = url.strip_prefix("https://") {
|
||||
(r, 443u16)
|
||||
} else if let Some(r) = url.strip_prefix("http://") {
|
||||
(r, 80u16)
|
||||
} else {
|
||||
// ONLY https: the POST body carries base64 key material and a replayable
|
||||
// bearer token, so cleartext `http://` is refused here in the shared guard —
|
||||
// a standing operator fault (`Config`) caught once at config time, not per rip.
|
||||
let Some(authority) = url.strip_prefix("https://") else {
|
||||
return Err((
|
||||
GuardFail::Config,
|
||||
"URL must start with http:// or https://".into(),
|
||||
"URL scheme must be https:// (cleartext http:// is refused)".into(),
|
||||
));
|
||||
};
|
||||
let (authority, default_port) = rest;
|
||||
let default_port = 443u16;
|
||||
let authority = authority.split(['/', '?', '#']).next().unwrap_or(authority);
|
||||
let authority = authority.rsplit('@').next().unwrap_or(authority);
|
||||
if authority.is_empty() {
|
||||
@@ -110,14 +135,18 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
|
||||
let (host, port): (String, u16) = if let Some(stripped) = authority.strip_prefix('[') {
|
||||
match stripped.split_once(']') {
|
||||
Some((h, after)) => {
|
||||
let p = after
|
||||
.strip_prefix(':')
|
||||
.map(|s| {
|
||||
s.parse::<u16>()
|
||||
.map_err(|_| (GuardFail::Config, "invalid port".to_string()))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(default_port);
|
||||
// The only thing allowed after `]` is an optional `:port`. A non-empty
|
||||
// tail that isn't `:port` (e.g. `[::1]extra`) is garbage — reject as
|
||||
// `Config` rather than silently drop it and connect on the default port.
|
||||
let p = if after.is_empty() {
|
||||
default_port
|
||||
} else if let Some(port_str) = after.strip_prefix(':') {
|
||||
port_str
|
||||
.parse::<u16>()
|
||||
.map_err(|_| (GuardFail::Config, "invalid port".to_string()))?
|
||||
} else {
|
||||
return Err((GuardFail::Config, "malformed IPv6 authority".into()));
|
||||
};
|
||||
(h.to_string(), p)
|
||||
}
|
||||
None => return Err((GuardFail::Config, "malformed IPv6 host".into())),
|
||||
@@ -142,6 +171,23 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
|
||||
let addrs: Vec<SocketAddr> = {
|
||||
use std::sync::mpsc;
|
||||
const DNS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
// Resolver threads can hang for the OS timeout and are never joined, leaking
|
||||
// a thread+stack per attempt — so cap outstanding ones PER HOST (a global cap
|
||||
// lets one dead keyserver starve a healthy different one). Const-init map.
|
||||
const MAX_DNS_THREADS_PER_HOST: usize = 4;
|
||||
static DNS_INFLIGHT: Mutex<std::collections::BTreeMap<String, usize>> =
|
||||
Mutex::new(std::collections::BTreeMap::new());
|
||||
{
|
||||
let mut inflight = DNS_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let n = inflight.entry(host.clone()).or_insert(0);
|
||||
if *n >= MAX_DNS_THREADS_PER_HOST {
|
||||
return Err((
|
||||
GuardFail::Unreachable,
|
||||
"too many concurrent DNS resolutions in flight for this host".into(),
|
||||
));
|
||||
}
|
||||
*n += 1;
|
||||
}
|
||||
let host = host.clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
@@ -150,6 +196,15 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
|
||||
.map(|it| it.collect::<Vec<SocketAddr>>());
|
||||
// Receiver may be gone after the timeout — ignore the send error.
|
||||
let _ = tx.send(res);
|
||||
// Release this host's slot only when the (possibly long-hung) lookup
|
||||
// actually returns, so the cap reflects lookups truly in flight.
|
||||
let mut inflight = DNS_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(n) = inflight.get_mut(&host) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
inflight.remove(&host);
|
||||
}
|
||||
}
|
||||
});
|
||||
match rx.recv_timeout(DNS_TIMEOUT) {
|
||||
Ok(Ok(addrs)) => addrs,
|
||||
@@ -183,14 +238,13 @@ fn resolve_and_guard(url: &str) -> Result<Vec<SocketAddr>, (GuardFail, String)>
|
||||
}
|
||||
|
||||
/// Validate a key-service base URL before it is handed to [`OnlineSource`].
|
||||
/// Requires an `http(s)` scheme, extracts the host, and rejects any host that
|
||||
/// is — or resolves to — a loopback / link-local (incl. the 169.254.169.254
|
||||
/// cloud-metadata endpoint) / RFC1918 / ULA / other non-public address (SSRF /
|
||||
/// metadata-exfiltration guard). Returns `Ok(())` on success so a caller can
|
||||
/// gate `OnlineSource` construction; the error string explains the rejection.
|
||||
/// Requires `https` (cleartext `http` is rejected as a `Config` fault — see
|
||||
/// `resolve_and_guard`), extracts the host, and rejects any host that is — or
|
||||
/// resolves to — loopback / link-local (incl. 169.254.169.254 cloud metadata)
|
||||
/// / RFC1918 / ULA / other non-public address (SSRF guard). Returns `Ok(())`
|
||||
/// so a caller can gate `OnlineSource`; the error string says why.
|
||||
///
|
||||
/// This is the *config-time* check; [`OnlineSource`] re-resolves and
|
||||
/// re-guards the host again before each POST, closing the DNS-rebind window.
|
||||
/// The *config-time* check; [`OnlineSource`] re-guards before each POST, closing the DNS-rebind window.
|
||||
pub fn validate_keyserver_url(url: &str) -> Result<(), String> {
|
||||
resolve_and_guard(url).map(|_| ()).map_err(|(_, msg)| msg)
|
||||
}
|
||||
@@ -233,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))
|
||||
@@ -286,6 +344,10 @@ impl OnlineSource {
|
||||
// a terminal `UK` or a `VUK` derived locally. `Ok`/`Err` draw the
|
||||
// miss-vs-outage distinction — see docs/online-query-contract.md.
|
||||
fn query(&self, ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
|
||||
// Reset the per-thread decode-reachability slot so it reflects ONLY this
|
||||
// query afterwards: `Some(..)` once a POST answers/transport-fails, `None`
|
||||
// if we short-circuit before the network. See `take_last_decode_reachability`.
|
||||
clear_decode_reachability();
|
||||
// No configured service: nothing to resolve.
|
||||
if self.base_url.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -363,6 +425,10 @@ impl OnlineSource {
|
||||
// Did-not-RESOLVE is the service unreachable, not a bad URL — see
|
||||
// docs/online-guardfail.md.
|
||||
Err((GuardFail::Unreachable, _)) => {
|
||||
// The host did not resolve — the service never answered, so this
|
||||
// is a transport-class outcome for the decode-reachability slot
|
||||
// (transient/down), the same verdict a refused connection gets.
|
||||
record_decode_reachability(DecodeReachability::Transport);
|
||||
tracing::warn!(
|
||||
target: "freemkv::keysource",
|
||||
phase = "keyserver_post",
|
||||
@@ -400,6 +466,48 @@ impl OnlineSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reachability outcome of a single online `/decode` POST — the raw signal
|
||||
/// a caller needs to tell a *genuine no-key* (the service answered, e.g. a 200
|
||||
/// with no entry or a definitive 422/404) from a *transient outage* (it did not
|
||||
/// — transport failure or a 5xx) WITHOUT firing a second probe. Recorded per
|
||||
/// thread by [`OnlineSource`] on every decode attempt; read (and cleared) with
|
||||
/// [`take_last_decode_reachability`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecodeReachability {
|
||||
/// The service answered with this HTTP status — any 2xx/3xx/4xx/5xx,
|
||||
/// including a 200 no-key, a 404/422 ("licensed but unresolved"), a 429, or
|
||||
/// a 502. The caller maps the code to a verdict.
|
||||
Status(u16),
|
||||
/// No HTTP answer at all — connection refused, timeout, DNS/TLS failure, so
|
||||
/// the request was sent (or attempted) but nothing on the other end replied.
|
||||
Transport,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
// The reachability of the most recent decode POST on THIS thread. A
|
||||
// thread-local (not a struct field) because resolution runs synchronously on
|
||||
// the caller's thread while `OnlineSource` is boxed behind `dyn KeySource`.
|
||||
static LAST_DECODE_REACHABILITY: std::cell::Cell<Option<DecodeReachability>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
fn clear_decode_reachability() {
|
||||
LAST_DECODE_REACHABILITY.with(|c| c.set(None));
|
||||
}
|
||||
|
||||
fn record_decode_reachability(outcome: DecodeReachability) {
|
||||
LAST_DECODE_REACHABILITY.with(|c| c.set(Some(outcome)));
|
||||
}
|
||||
|
||||
/// Take — read and clear — the reachability outcome of the most recent online
|
||||
/// `/decode` POST made ON THIS THREAD, or `None` if no decode reached the
|
||||
/// network since the last take (the online source was not attempted, or a
|
||||
/// short-circuit path that never POSTed). Lets a caller classify a no-key result
|
||||
/// from the REAL decode's HTTP outcome instead of a second, redundant probe.
|
||||
pub fn take_last_decode_reachability() -> Option<DecodeReachability> {
|
||||
LAST_DECODE_REACHABILITY.with(std::cell::Cell::take)
|
||||
}
|
||||
|
||||
// Map a key-service HTTP status into the operator action it implies: 401/403
|
||||
// fix credentials, 429 back off, 5xx wait — none of them is "no key" (the
|
||||
// genuine miss is a 200 with an empty body), the original bug this fixes.
|
||||
@@ -420,8 +528,21 @@ fn interpret_reply(
|
||||
elapsed_ms: u64,
|
||||
) -> Result<Vec<UnitKey>, Error> {
|
||||
let mut resp = match sent {
|
||||
Ok(r) => r,
|
||||
Ok(r) => {
|
||||
// The service answered — record its status for the reachability slot
|
||||
// (a 200/404/422 is "up"; a 5xx is "down"), read by the caller so a
|
||||
// no-key needs no second probe.
|
||||
record_decode_reachability(DecodeReachability::Status(r.status().as_u16()));
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
// A 4xx/5xx is still an ANSWER (record its status); a transport
|
||||
// error is not (record `Transport`). Kept separate from the
|
||||
// error-mapping below, which is unchanged.
|
||||
record_decode_reachability(match &e {
|
||||
ureq::Error::StatusCode(code) => DecodeReachability::Status(*code),
|
||||
_ => DecodeReachability::Transport,
|
||||
});
|
||||
// 401/403/429/5xx demand different operator actions; collapsing
|
||||
// them is why a 502 was read as "no key". Each arm RETURNS the
|
||||
// classified error, never an empty vec, so it survives past here.
|
||||
@@ -648,6 +769,71 @@ mod tests {
|
||||
))));
|
||||
}
|
||||
|
||||
// Benchmarking 198.18.0.0/15 (RFC 2544) and IETF protocol assignments
|
||||
// 192.0.0.0/24 (RFC 6890, incl. the 192.0.0.170/171 NAT64/DNS64 anycast)
|
||||
// are non-public and must be blocked outbound.
|
||||
#[test]
|
||||
fn ssrf_guard_blocks_benchmarking_and_protocol_assignment_ranges() {
|
||||
// 198.18.0.0/15 spans 198.18.x AND 198.19.x — both octets blocked.
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 255, 255))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 0, 1))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 19, 200, 5))));
|
||||
// 198.17.x and 198.20.x are OUTSIDE the /15 — must stay allowed.
|
||||
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 17, 0, 1))));
|
||||
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(198, 20, 0, 1))));
|
||||
// 192.0.0.0/24, including 192.0.0.170 / 192.0.0.171.
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 0))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 170))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 171))));
|
||||
assert!(is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 0, 255))));
|
||||
// The adjacent 192.0.1.0 is a different block — not covered here.
|
||||
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(192, 0, 1, 1))));
|
||||
}
|
||||
|
||||
// 6to4 (2002::/16) and Teredo (2001:0000::/32) tunnel an IPv4 inside an
|
||||
// IPv6 address; the guard must decode and re-check that embedded IPv4 or an
|
||||
// internal target slips through the tunnel.
|
||||
#[test]
|
||||
fn ssrf_guard_blocks_embedded_ipv4_via_6to4_and_teredo() {
|
||||
// 6to4 for 127.0.0.1: 2002:7f00:0001:: (embedded in segments[1..3]).
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2002, 0x7f00, 0x0001, 0, 0, 0, 0, 0
|
||||
))));
|
||||
// 6to4 for 169.254.169.254 (cloud metadata): 2002:a9fe:a9fe::.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2002, 0xa9fe, 0xa9fe, 0, 0, 0, 0, 0
|
||||
))));
|
||||
// Teredo for 127.0.0.1: client IPv4 lives in the last two segments XOR
|
||||
// 0xffff, so 0x7f00^0xffff=0x80ff and 0x0001^0xffff=0xfffe.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2001, 0x0000, 0, 0, 0, 0, 0x80ff, 0xfffe
|
||||
))));
|
||||
// A 6to4 wrapping a PUBLIC IPv4 (8.8.8.8 → 2002:0808:0808::) is allowed.
|
||||
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x2002, 0x0808, 0x0808, 0, 0, 0, 0, 0
|
||||
))));
|
||||
}
|
||||
|
||||
// NAT64 well-known prefix 64:ff9b::/96 (RFC 6052) translates IPv4 targets
|
||||
// into IPv6; the guard must decode the trailing IPv4 and re-check it or an
|
||||
// internal address slips through the translator.
|
||||
#[test]
|
||||
fn ssrf_guard_blocks_nat64_wellknown_prefix() {
|
||||
// NAT64 for 169.254.169.254 (cloud metadata): 64:ff9b::a9fe:a9fe.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x0064, 0xff9b, 0, 0, 0, 0, 0xa9fe, 0xa9fe
|
||||
))));
|
||||
// NAT64 for 127.0.0.1: 64:ff9b::7f00:0001.
|
||||
assert!(is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x0064, 0xff9b, 0, 0, 0, 0, 0x7f00, 0x0001
|
||||
))));
|
||||
// NAT64 wrapping a PUBLIC IPv4 (8.8.8.8 → 64:ff9b::0808:0808) is allowed.
|
||||
assert!(!is_blocked_ip(&IpAddr::V6(Ipv6Addr::new(
|
||||
0x0064, 0xff9b, 0, 0, 0, 0, 0x0808, 0x0808
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_guard_allows_public_ips() {
|
||||
assert!(!is_blocked_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
|
||||
@@ -678,12 +864,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_and_guard_rejects_internal_literals() {
|
||||
// Numeric literals resolve without DNS — must still be rejected.
|
||||
assert!(resolve_and_guard("http://127.0.0.1/keys").is_err());
|
||||
assert!(resolve_and_guard("http://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(resolve_and_guard(&format!("http://{}.{}.{}.{}:8080/keys", 10, 0, 0, 5)).is_err());
|
||||
// Numeric literals resolve without DNS — must still be rejected. All
|
||||
// https:// so the rejection is the SSRF address guard, not the scheme
|
||||
// check (cleartext http:// is covered by its own test below).
|
||||
assert!(resolve_and_guard("https://127.0.0.1/keys").is_err());
|
||||
assert!(resolve_and_guard("https://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}:8080/keys", 10, 0, 0, 5)).is_err());
|
||||
assert!(resolve_and_guard(&format!("https://{}.{}.{}.{}/keys", 192, 168, 0, 1)).is_err());
|
||||
assert!(resolve_and_guard("http://[::1]:9000/keys").is_err());
|
||||
assert!(resolve_and_guard("https://[::1]:9000/keys").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -703,7 +891,7 @@ mod tests {
|
||||
// Scheme immediately followed by a path — empty authority.
|
||||
assert!(resolve_and_guard("https:///keys").is_err());
|
||||
// Bracketed IPv6 host missing its closing `]`.
|
||||
assert!(resolve_and_guard("http://[::1/keys").is_err());
|
||||
assert!(resolve_and_guard("https://[::1/keys").is_err());
|
||||
// `host:port` split with an empty host before the colon.
|
||||
assert!(resolve_and_guard("https://:8080/keys").is_err());
|
||||
}
|
||||
@@ -733,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.
|
||||
@@ -741,7 +945,7 @@ mod tests {
|
||||
assert_eq!(addrs[0].port(), 443);
|
||||
|
||||
let addrs =
|
||||
resolve_and_guard("http://1.1.1.1:8080/keys").expect("public IP with port accepted");
|
||||
resolve_and_guard("https://1.1.1.1:8080/keys").expect("public IP with port accepted");
|
||||
assert!(!addrs.is_empty());
|
||||
assert_eq!(addrs[0].port(), 8080);
|
||||
}
|
||||
@@ -823,16 +1027,52 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_keyserver_url_rejects_internal_and_bad_scheme() {
|
||||
// Mirrors resolve_and_guard: the public wrapper rejects the same hosts.
|
||||
assert!(validate_keyserver_url("http://127.0.0.1/keys").is_err());
|
||||
assert!(validate_keyserver_url("http://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(validate_keyserver_url(&format!("http://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_err());
|
||||
assert!(validate_keyserver_url("http://[::1]:9000/keys").is_err());
|
||||
assert!(validate_keyserver_url("https://127.0.0.1/keys").is_err());
|
||||
assert!(validate_keyserver_url("https://169.254.169.254/latest/meta-data/").is_err());
|
||||
assert!(validate_keyserver_url(&format!("https://{}.{}.{}.{}/k", 10, 0, 0, 5)).is_err());
|
||||
assert!(validate_keyserver_url("https://[::1]:9000/keys").is_err());
|
||||
assert!(validate_keyserver_url("ftp://example.com/keys").is_err());
|
||||
assert!(validate_keyserver_url("").is_err());
|
||||
// A public literal IP passes (no DNS needed, deterministic).
|
||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||
}
|
||||
|
||||
// Cleartext http:// must be refused at CONFIG time (validate_keyserver_url),
|
||||
// not per-rip — the body carries key material + a replayable token. A public
|
||||
// host proves it's the SCHEME being rejected, not the address guard.
|
||||
#[test]
|
||||
fn non_https_scheme_is_rejected_at_config_time() {
|
||||
// A perfectly reachable public host — only the http:// scheme is wrong.
|
||||
assert!(validate_keyserver_url("http://8.8.8.8/keys").is_err());
|
||||
assert!(resolve_and_guard("http://8.8.8.8/keys").is_err());
|
||||
// The rejection is a standing Config fault, never a transient outage.
|
||||
let (kind, _msg) = resolve_and_guard("http://8.8.8.8/keys")
|
||||
.expect_err("cleartext http:// must be refused");
|
||||
assert_eq!(kind, GuardFail::Config);
|
||||
// https:// for the same public host is accepted.
|
||||
assert!(validate_keyserver_url("https://8.8.8.8/keys").is_ok());
|
||||
}
|
||||
|
||||
// A non-empty, non-`:port` tail after a bracketed IPv6 authority (e.g.
|
||||
// `[::1]extra`) must be rejected as a Config fault, not silently dropped
|
||||
// with a fall back to the default port.
|
||||
#[test]
|
||||
fn resolve_and_guard_rejects_garbage_after_bracketed_ipv6() {
|
||||
// `[::1]junk` — junk after the closing bracket.
|
||||
let (kind, _) = resolve_and_guard("https://[::1]junk/keys")
|
||||
.expect_err("garbage after ] must be rejected");
|
||||
assert_eq!(kind, GuardFail::Config);
|
||||
// A public v6 literal with a trailing garbage tail is likewise rejected
|
||||
// (so the garbage can't slip a request out on the default port).
|
||||
assert!(resolve_and_guard("https://[2606:4700:4700::1111]extra/keys").is_err());
|
||||
// Sanity: the same public v6 literal WITHOUT the tail is accepted.
|
||||
assert!(resolve_and_guard("https://[2606:4700:4700::1111]/keys").is_ok());
|
||||
// And an explicit :port after ] still parses.
|
||||
let addrs = resolve_and_guard("https://[2606:4700:4700::1111]:8443/keys")
|
||||
.expect("bracketed v6 with :port must parse");
|
||||
assert_eq!(addrs[0].port(), 8443);
|
||||
}
|
||||
|
||||
// ── the reply → verdict mapping (THE defect) ──────────────────────────
|
||||
// Driven through `interpret_reply` directly: a stub HTTP server is NOT
|
||||
// usable here since `resolve_and_guard` blocks loopback by design.
|
||||
@@ -952,6 +1192,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
let _ = interpret_reply(Ok(reply(200, "{}")), &BareCtx, 1);
|
||||
assert_eq!(
|
||||
take_last_decode_reachability(),
|
||||
Some(DecodeReachability::Status(200)),
|
||||
"a 200 answer must record Status(200)"
|
||||
);
|
||||
// take clears the slot — a second take sees nothing.
|
||||
assert_eq!(
|
||||
take_last_decode_reachability(),
|
||||
None,
|
||||
"take must clear the slot"
|
||||
);
|
||||
|
||||
// A definitive 422 ("licensed but unresolved") / 404 is still an ANSWER:
|
||||
// record the status so the caller reads it as reachable (genuine no-key).
|
||||
for status in [404u16, 422] {
|
||||
let _ = interpret_reply(Err(ureq::Error::StatusCode(status)), &BareCtx, 1);
|
||||
assert_eq!(
|
||||
take_last_decode_reachability(),
|
||||
Some(DecodeReachability::Status(status)),
|
||||
"HTTP {status} must record Status({status}) — a reachable answer"
|
||||
);
|
||||
}
|
||||
|
||||
// A 5xx is an answer too — Status(502); the caller maps 5xx to down.
|
||||
let _ = interpret_reply(Err(ureq::Error::StatusCode(502)), &BareCtx, 1);
|
||||
assert_eq!(
|
||||
take_last_decode_reachability(),
|
||||
Some(DecodeReachability::Status(502)),
|
||||
"a 5xx must record its status, not Transport"
|
||||
);
|
||||
|
||||
// A transport failure (refused connection) records `Transport` — no
|
||||
// HTTP answer, so the caller treats it as a transient outage.
|
||||
let config = Config::builder()
|
||||
.timeout_connect(Some(Duration::from_secs(2)))
|
||||
.build();
|
||||
let sent = ureq::Agent::new_with_config(config)
|
||||
.post("http://127.0.0.1:1/")
|
||||
.send("{}");
|
||||
assert!(
|
||||
!matches!(sent, Ok(_) | Err(ureq::Error::StatusCode(_))),
|
||||
"a refused connection must be a transport error"
|
||||
);
|
||||
let _ = interpret_reply(sent, &BareCtx, 1);
|
||||
assert_eq!(
|
||||
take_last_decode_reachability(),
|
||||
Some(DecodeReachability::Transport),
|
||||
"a transport failure must record Transport, never a Status"
|
||||
);
|
||||
}
|
||||
|
||||
/// A reply the client cannot read is the service failing mid-answer, not an
|
||||
/// answer of "no key".
|
||||
#[test]
|
||||
@@ -996,8 +1294,16 @@ mod tests {
|
||||
assert_eq!(keys.len(), 2);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[1].idx, 1);
|
||||
assert_eq!(keys[0].key[0], 0x00);
|
||||
assert_eq!(keys[1].key[0], 0x0f);
|
||||
// Full 16 bytes of each key, not just byte 0 — a byte-transposition bug
|
||||
// would pass a first-byte-only check.
|
||||
assert_eq!(
|
||||
keys[0].key,
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
);
|
||||
assert_eq!(
|
||||
keys[1].key,
|
||||
[15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
|
||||
);
|
||||
}
|
||||
|
||||
/// Backward-compatible form: `"UK"` as a bare hex STRING (not an array)
|
||||
@@ -1012,7 +1318,11 @@ mod tests {
|
||||
.expect("a string UK must still resolve");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[0].key[0], 0x00);
|
||||
// Full 16-byte key, not just byte 0.
|
||||
assert_eq!(
|
||||
keys[0].key,
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
);
|
||||
}
|
||||
|
||||
// A malformed `"UK"` STRING falls through to the genuine-miss path,
|
||||
@@ -1064,6 +1374,12 @@ mod tests {
|
||||
);
|
||||
assert_eq!(keys[0].idx, 0);
|
||||
assert_eq!(keys[1].idx, 1);
|
||||
// Full-byte KAT: the derived keys must equal the local VUK boil over
|
||||
// the disc's encrypted title keys (the same primitive the code calls).
|
||||
let vuk = parse_uk(vuk_hex).unwrap();
|
||||
let expected = crate::uks_from_vuk(&vuk, &[[0x11u8; 16], [0x22u8; 16]]);
|
||||
assert_eq!(keys[0].key, expected[0].key);
|
||||
assert_eq!(keys[1].key, expected[1].key);
|
||||
}
|
||||
|
||||
/// The service answered correctly with a VUK, but the DISC's encrypted
|
||||
@@ -1236,6 +1552,12 @@ mod tests {
|
||||
let keys = interpret_reply(Ok(reply(200, &at_cap)), &BareCtx, 1)
|
||||
.expect("a reply exactly at the cap is legal and must be read");
|
||||
assert_eq!(keys.len(), 1, "the key in an at-cap reply must survive");
|
||||
// The surviving key must be the planted UK bytes, not merely present.
|
||||
assert_eq!(
|
||||
keys[0].key,
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
"the at-cap key must be the exact planted UK"
|
||||
);
|
||||
}
|
||||
|
||||
// ── A bad port is CONFIG, not an outage ────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user