Compare commits

...
12 Commits
Author SHA1 Message Date
Matthew Jackson d257d40de9 v1.7.4: bump version (unified release) 2026-09-21 23:03:29 +00:00
Matthew Jackson 56c69e8877 docs: mark 1.7.4 changelog heading UNRELEASED (release workflow dates it) 2026-09-21 15:07:05 -07:00
Matthew Jackson 2c9d76235d docs: 1.7.4 changelog + consumer release notes 2026-09-21 11:47:20 -07:00
Matthew Jackson 1a916f0855 docs/ci: drop stale local-precommit/release.sh references — CI is the only gate 2026-09-21 09:11:34 -07:00
Matthew Jackson 609f3bc0bc ci: adopt central reusable guards + top-level concurrency
Replace comment/leak/identity guard workflows with thin callers into
freemkv/.github reusable guards (guard-comment/leak/identity @v1); fork
PRs still run, same-repo pushes aren't double-run. Delete now-unused
local ci/comment-guard.py and ci/leak-guard.sh. Add top-level
concurrency (cancel-in-progress) to ci.yml and qa.yml and standardize
actions/checkout to @v5.
2026-09-21 08:47:07 -07:00
Matthew Jackson 58c667314c freemkv-unlock 1.7.4: Hrl=off in arm_bypass recipes + version
arm_bypass_bd/arm_bypass_uhd now also Set(Hrl, off) (revocation skip) so a
revoked/absent cert never trips the HRL check on a keyless bypass rip; tests
updated for the new SET/verify order.
2026-09-19 14:00:25 -07:00
Matthew Jackson 98ef26f78c v1.7.3: bump version (unified release) 2026-09-19 05:31:07 +00:00
Matthew Jackson 93d9517964 merge: record v1.7.2 release into dev (-s ours; keep path deps + dev's next-cycle work) 2026-09-18 21:39:15 -07:00
Matthew Jackson 64a53b8349 firmware: mirror fw 0.8.3 RESET ABI — RESET_TO_DEFAULTS (0x01) + reset_to_defaults(), true-OEM/marker-gated docs 2026-09-18 21:23:07 -07:00
Matthew Jackson f434298e0d v1.7.2: bump version (unified release) 2026-09-18 18:37:18 +00:00
Matthew Jackson a18bc90164 freemkv: source the SET data-in buffer from MIN_ALLOC_LEN (single source for the transfer length) 2026-09-18 09:52:27 -07:00
Matthew Jackson 79f3324488 changelog: 1.7.2 — record the SET data-phase unlock fix + READ CAPACITY/empty-ISO hardening 2026-09-18 06:54:58 -07:00
14 changed files with 159 additions and 502 deletions
+17 -14
View File
@@ -10,6 +10,11 @@ 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
# freemkv-unlock is the BASE of the whole ecosystem: libfreemkv path-deps it,
# bdemu path-deps it with the `emulation` feature, and every other crate pulls
# it in transitively. It has no freemkv dependencies of its own, so no sibling
@@ -37,7 +42,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"
@@ -51,7 +56,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: |
@@ -72,7 +77,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
# fmt runs on the pinned MSRV toolchain; clippy runs on stable. Linting on
# stable makes new-lint debt a BLOCKING gate the pinned version would
# otherwise hide. The MSRV pin is installed second so it stays the default
@@ -95,7 +100,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -117,7 +122,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -142,7 +147,7 @@ jobs:
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
@@ -162,14 +167,13 @@ 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
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -188,14 +192,13 @@ 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
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
+7 -12
View File
@@ -1,17 +1,12 @@
name: comment-guard
# Caps explanatory prose by COMMENT INTENT (not visibility):
# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text)
# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded)
# // / /* */ asides — 3 lines (single-blank-separated runs count as one)
# Overflow belongs in the module //! or docs/<topic>.md with a one-line // pointer.
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
comment-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run comment-guard
run: python3 ci/comment-guard.py
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-comment.yml@v1
+7 -34
View File
@@ -1,41 +1,14 @@
name: identity-guard
# Prevention: no commit may carry an unrelated-business or non-canonical author/
# committer email. dev@example.com (a dead corp address) once slipped onto the
# release cascade + a batch of local commits; this fails the build if it recurs.
# Thin caller → the canonical reusable guard in freemkv/.github.
on:
push:
branches: [dev, qa, main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Reject banned author/committer emails
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Unrelated business domains + a personal address normalized to the
# GitHub noreply. Real external contributors are unaffected.
BANNED='@example-a\.invalid|@example-b\.invalid|owner@example\.invalid'
if [ "$EVENT" = "pull_request" ]; then
range="$PR_BASE..$PR_HEAD"
elif [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
range="$BEFORE..$AFTER"
else
range="$AFTER~1..$AFTER"
fi
bad=$(git log --format='%an <%ae>%n%cn <%ce>' "$range" 2>/dev/null | sort -u | grep -iE "$BANNED" || true)
if [ -n "$bad" ]; then
echo "::error::commit(s) carry a banned author/committer identity:"; echo "$bad" | sed 's/^/ /'
echo "::error::Commit as your GitHub noreply (1085847+MattJackson@users.noreply.github.com). Fix: git config user.email <noreply>; git commit --amend --reset-author."
exit 1
fi
echo "identity-guard: clean"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
+6 -30
View File
@@ -1,35 +1,11 @@
name: leak-guard
# Self-contained public-repo leak gate. Public CI cannot reach the private
# tooling, so this encodes only the generic net: internal-infra references,
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
# No project-specific reverse-engineering vocabulary lives here.
# Thin caller → the canonical reusable guard in freemkv/.github.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
leak-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Compute commit range
id: range
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
else
before="${{ github.event.before }}"
after="${{ github.sha }}"
# New branch / first push: github.event.before is all-zeros.
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
echo "range=$after" >> "$GITHUB_OUTPUT"
else
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
fi
fi
- name: Run leak-guard
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
+12 -8
View File
@@ -15,14 +15,18 @@ name: qa
# one being released, which is the exact failure this branch exists to prevent.
#
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
# and no hosted runner has an optical drive or the image hoard. Those run on a
# self-hosted runner (see the media job at the end) and are the one leg that
# stays on hardware.
# 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:
# A re-push to qa supersedes its own in-progress run instead of stacking.
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 +36,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 +60,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Stamp the next rc
@@ -85,7 +89,7 @@ jobs:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
@@ -103,7 +107,7 @@ jobs:
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-unlock
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
@@ -126,7 +130,7 @@ jobs:
needs: resolve-msrv
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v5
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
+15
View File
@@ -1,7 +1,22 @@
# Changelog
## [1.7.4] — 2026-09-21
### Maintenance
- CI consolidated to the central reusable workflows; no functional changes.
## [1.7.3] — 2026-09-19
### Changed
- firmware: mirror the fw 0.8.3 RESET ABI — added `RESET_TO_DEFAULTS` (`0x01`, restore the baked create-time defaults into RAM) and `FirmwareControl::reset_to_defaults()`; `RESET_TO_OEM` (`0xFF`) is now documented as TRUE OEM (RAM passthrough **and** the NV block blanked to all-`0xFF`, so the drive is traceless), and `RESET_TO_FLASH` as marker-gated (a never-saved drive loads the defaults). Wire values unchanged.
## [1.7.2] — UNRELEASED
### Fixed
- `FreemkvUnlocker::set` now issues the vendor SET over the required 64-byte data-in phase (mirroring `FirmwareControl::set`). freemkv firmware 0.8.x aborts a SET sent with no data phase (CHECK CONDITION / Aborted Command), so every drive-feature SET failed — including the load-bearing `Set(Ake, off)` — and the freemkv firmware unlock silently fell back to the AACS/online-key route. Drives on freemkv firmware now unlock via the freemkv route again (hardware-confirmed on BU40N fw 0.8.1).
### Changed
- firmware: mirror ABI v2 — `Save` + `RESET`-to-flash/OEM modes, the region/speed value encodings, and the HRL/AKE/BUS `off = unlock` (`0x00`) polarity; `full_unlock`'s default profile flipped to match. Aligns `FirmwareControl` with freemkv firmware 0.8.0.
Generated
+5 -5
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.1"
version = "1.7.4"
dependencies = [
"aes",
"base64",
@@ -406,16 +406,16 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.1"
version = "1.7.4"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.1"
version = "1.7.4"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.1"
version = "1.7.4"
[[patch.unused]]
name = "libfreemkv"
version = "1.7.1"
version = "1.7.4"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-unlock"
version = "1.7.1"
version = "1.7.4"
edition = "2024"
rust-version = "1.88"
license = "MIT"
-252
View File
@@ -1,252 +0,0 @@
#!/usr/bin/env python3
"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility.
Three tiers, keyed on the syntax the author reached for:
* `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per
file, at the top, documenting the module as a whole, so it can't be used to
smuggle per-item rationale the way item docs can. Long correctness / design
essays (e.g. a memory-ordering proof) legitimately live here.
* `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced
``` code blocks do NOT count, so real examples aren't punished. This holds
whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc
paragraph just like a `pub` one. Capping `///` here (rather than exempting
it, as the old guard did — issue #17) is what stops rationale being hidden
behind a doc sigil, while still leaving room for a genuine paragraph.
* `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the
shape that sprawls into walls, so it's kept tight: stanzas separated by a
single blank line count CUMULATIVELY, so one essay can't be split into runs.
Overflow belongs in the module `//!`, or in docs/<topic>.md with a one-line `//`
pointer — both show up in review.
Scans src/ and tests/ under the given root (default cwd). `--selftest` runs
built-in cases. Exit non-zero if any block exceeds its cap.
"""
import re
import sys
from pathlib import Path
INLINE_CAP = 3
DOC_CAP = 8
_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## …
def _strip_line_comment(s):
"""Return (kind, body) for a whole-line comment, else (None, None).
kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on
a code line is not a whole-line comment and is ignored (single-line, cheap).
"""
if s.startswith("//!"):
return "doc", s[3:]
if s.startswith("///") and not s.startswith("////"):
return "doc", s[3:]
if s.startswith("//"):
return "plain", s[2:]
return None, None
def _fence_toggle(body):
"""True if this doc body line opens/closes a ``` fenced code block."""
return body.strip().startswith("```")
def _classify(lines):
"""Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of
block comments, and code info. Block comments are tracked across lines.
"""
info = []
in_block = False
block_is_doc = False
raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#'
for raw in lines:
s = raw.lstrip()
rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False}
# Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the
# bytes are string data, not Rust comments, so the guard is blind to them
# until the string closes. Prevents `/* */` or `//` lines in an embedded
# page from being counted as comment blocks.
if raw_hashes is not None:
if ('"' + "#" * raw_hashes) in raw:
raw_hashes = None
info.append(rec)
continue
if in_block:
rec["comment"] = "doc" if block_is_doc else "plain"
rec["is_doc"] = block_is_doc
if "*/" in raw:
in_block = False
info.append(rec)
continue
# A block comment only starts a comment-line if it's the first token.
if s.startswith("/*"):
rec["is_doc"] = s.startswith("/**") or s.startswith("/*!")
rec["comment"] = "doc" if rec["is_doc"] else "plain"
if "*/" not in s[2:]:
in_block = True
block_is_doc = rec["is_doc"]
info.append(rec)
continue
kind, _ = _strip_line_comment(s)
if kind is not None:
rec["comment"] = kind
rec["is_doc"] = kind == "doc"
info.append(rec)
continue
# Code (or blank). Blank = empty after strip.
rec["code"] = s != ""
# A code line may OPEN a raw string that spans following lines. Detected
# only on code (comments were handled above), so `// see r"x"` is safe.
m = _RAW_OPEN.search(raw)
if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]:
raw_hashes = len(m.group(1))
info.append(rec)
return info
def violations(path):
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
info = _classify(lines)
n = len(info)
out = []
i = 0
while i < n:
rec = info[i]
if rec["comment"] is None:
i += 1
continue
start = i
if rec["is_doc"]:
# Doc run: contiguous doc lines. `//!` / `/*!` module docs are
# UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at
# DOC_CAP, regardless of the documented item's visibility.
j = i
prose = 0
in_fence = False
module_doc = False
while j < n and info[j]["comment"] == "doc":
s = info[j]["s"]
if s.startswith("//!") or s.startswith("/*!"):
module_doc = True
body = (
s[3:] if (s.startswith("///") or s.startswith("//!")) else s
)
if _fence_toggle(body):
in_fence = not in_fence
elif not in_fence and body.strip() != "":
prose += 1
j += 1
if not module_doc and prose > DOC_CAP:
out.append((start + 1, prose, DOC_CAP, "doc prose"))
i = j
continue
# Internal (plain) run, with single-blank bridging (cumulative).
j = i
count = 0
while j < n:
r = info[j]
if r["comment"] == "plain":
count += 1
j += 1
continue
if r["comment"] == "doc":
break
if r["comment"] is None and not r["code"]:
# blank: bridge only a SINGLE blank, else stop
if j + 1 < n and info[j + 1]["comment"] == "plain":
j += 1
continue
break
break # code
if count > INLINE_CAP:
out.append((start + 1, count, INLINE_CAP, "inline comment"))
i = j
return out
def main(argv):
if "--selftest" in argv:
return _selftest()
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
total = 0
for f in files:
for start, length, cap, kind in violations(f):
rel = f.relative_to(root)
print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})")
total += 1
if total:
print(f"\ncomment-guard: {total} comment block(s) exceed their cap.")
print(
f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at "
f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. "
"Trim, move the rationale into the module //!, or docs/<topic>.md "
"with a // pointer."
)
return 1
return 0
def _selftest():
import tempfile
cases = [
# (rust source, expected number of violations)
# short /// doc -> ok
("/// short doc\npub fn a() {}\n", 0),
# /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine
("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0),
# ...but /// is NOT unlimited: 9 prose lines on a private item fails
("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1),
# 8 prose /// on a pub item -> ok (boundary)
("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0),
# 9 prose /// on a pub item -> violation
("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1),
# /// with a long fenced example -> example excluded, ok
(
"/// summary\n/// # Examples\n/// ```\n"
+ "".join("/// code\n" for _ in range(12))
+ "/// ```\npub fn e() {}\n",
0,
),
# pub(crate) gets the SAME doc cap as pub (no pub/private split anymore)
("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0),
# bare inline 4 lines -> violation
("// a\n// b\n// c\n// d\nlet x = 1;\n", 1),
# blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation
("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1),
# plain /* */ block, 5 lines -> inline cap -> violation
("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1),
# comment-like lines INSIDE a raw string are string data, not comments
(
'const H: &str = r##"<style>\n'
+ "".join("/* css note */\n" for _ in range(8))
+ '</style>"##;\nfn h() {}\n',
0,
),
# //! module doc may run long -> EXEMPT (no cap)
("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0),
# /*! block module doc may run long -> EXEMPT
("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0),
]
ok = True
with tempfile.TemporaryDirectory() as d:
for idx, (src, want) in enumerate(cases):
p = Path(d) / f"case{idx}.rs"
p.write_text(src)
got = len(violations(p))
flag = "ok" if got == want else "FAIL"
if got != want:
ok = False
print(f" selftest {idx}: want {want} got {got} [{flag}]")
print("selftest PASSED" if ok else "selftest FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
-120
View File
@@ -1,120 +0,0 @@
#!/usr/bin/env bash
#
# leak-guard.sh — self-contained public-repo leak gate.
#
# This is the LAST line of defense in CI. It is intentionally self-contained:
# public CI cannot reach the private tooling, so this script encodes ONLY the
# generic net — internal infrastructure references, agent-context files, and
# AI-attribution in commit messages. It deliberately contains NO project-
# specific reverse-engineering vocabulary (those words would themselves be a
# leak). The richer private scanner stays private.
#
# Fails (exit 1) if any of the following appear in the repo:
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
# 2. tracked file content matching the internal-infra net,
# 3. a commit message (in the given range) with AI attribution.
#
# Usage:
# leak-guard.sh [<commit-range>]
# <commit-range> optional git rev-list range to scan commit messages
# (e.g. "abc..def"). If omitted, commit-message scan is
# skipped (path + content checks always run).
set -euo pipefail
# Absolute path to this script, resolved before any cd, so we can exclude it
# from the content scan (it necessarily contains the detection patterns).
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
REPO="$(git rev-parse --show-toplevel)"
cd "$REPO"
fail=0
note() { printf ' ✗ %s\n' "$1"; fail=1; }
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
# the patterns themselves must not name any org-specific identifier (doing so
# would itself leak the infra they guard). We catch the leak *class*:
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
# - docker.internal.
# The full org-specific net (literal hostnames, service names, repo paths,
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
# path). This names NO specific user — it matches the leak *class* (any home
# path), so the pattern itself reveals nothing org- or person-specific. A real
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
# this regardless of whose machine it came from. The username segment is a
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
echo "── leak-guard: tracked agent-context paths ──"
while IFS= read -r f; do
case "$f" in
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
esac
done < <(git ls-files)
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
# an argument (not interpolated into a //) so metacharacters like the "/" in a
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
# don't abort the scan.
pcre_matches() {
perl -e '
my ($file, $re) = @ARGV;
open(my $fh, "<:raw", $file) or exit 0;
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
' "$1" "$2" 2>/dev/null
}
# This script's own source necessarily contains the detection patterns (e.g.
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
echo "── leak-guard: internal-infra references in tracked files ──"
while IFS= read -r f; do
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
[ -f "$f" ] || continue
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "internal-infra reference: $f:$hit"
done < <(pcre_matches "$f" "$INFRA_RE")
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
done < <(pcre_matches "$f" "$HOMEPATH_RE")
done < <(git ls-files)
RANGE="${1:-}"
if [ -n "$RANGE" ]; then
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
while IFS= read -r sha; do
[ -z "$sha" ] && continue
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
# Pass the pattern as an argument (not interpolated into a //) so the
# lookbehind char class and "/" don't break the regex.
hit="$(printf '%s' "$msg" | perl -e '
my $re = $ARGV[0]; my $rx = qr/$re/i;
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
' "$ATTR_RE" | head -1 || true)"
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
done < <(git rev-list "$RANGE" 2>/dev/null || true)
fi
echo
if [ "$fail" -ne 0 ]; then
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
exit 1
fi
echo "✓ leak-guard: clean"
+16 -2
View File
@@ -361,6 +361,7 @@ mod tests {
// A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it.
struct FwDrive {
ake: u8,
hrl: u8,
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for FwDrive {
@@ -382,12 +383,21 @@ mod tests {
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Ake as u8
{
self.ake = cdb[CDB_STATE];
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Hrl as u8
{
self.hrl = cdb[CDB_STATE];
} else if cdb[CDB_VERB] == Verb::Get as u8
&& cdb[CDB_FEATURE] == Feature::Ake as u8
&& !data.is_empty()
{
data[0] = self.ake;
n = 1;
} else if cdb[CDB_VERB] == Verb::Get as u8
&& cdb[CDB_FEATURE] == Feature::Hrl as u8
&& !data.is_empty()
{
data[0] = self.hrl;
n = 1;
}
Ok(crate::scsi::ScsiResult {
status: 0,
@@ -398,15 +408,19 @@ mod tests {
}
let mut t = FwDrive {
ake: STATE_PASSTHROUGH,
hrl: STATE_PASSTHROUGH,
cdbs: Vec::new(),
};
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("armed");
assert_eq!(t.hrl, STATE_OFF, "the recipe skipped revocation (Hrl=off)");
assert_eq!(t.ake, STATE_OFF, "the recipe nulled the AKE (Ake=off)");
// IDENTITY, then Set(Ake,off), then a verifying Get(Ake).
// IDENTITY, then Set(Hrl,off)+verify, then Set(Ake,off)+verify.
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
}
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
+43 -13
View File
@@ -31,7 +31,9 @@
//!
//! Every feature defaults to [`STATE_PASSTHROUGH`] (firmware does not touch the
//! subsystem → byte-identical to OEM); [`Verb::Reset`] with [`RESET_TO_OEM`]
//! returns every feature to passthrough, while [`RESET_TO_FLASH`] reloads the last
//! returns every feature to passthrough (and, firmware ≥0.8.3, blanks the NV block
//! so the drive is traceless), [`RESET_TO_DEFAULTS`] restores the baked create-time
//! defaults, while [`RESET_TO_FLASH`] reloads the last
//! [`Verb::Save`]d config block.
use crate::scsi::{DataDirection, ScsiTransport};
@@ -114,11 +116,21 @@ pub const REGION_FREE: u8 = 0x0F;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): reload the saved flash
/// config block into the RAM feature-state table, discarding un-saved RAM changes
/// — the non-destructive "revert to last SAVE".
/// — the non-destructive "revert to last SAVE". Marker-gated in firmware ≥0.8.3: a
/// never-saved drive loads the baked create-time defaults instead.
pub const RESET_TO_FLASH: u8 = 0x00;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): force every feature to
/// [`STATE_PASSTHROUGH`] (OEM behaviour everywhere), regardless of the saved config.
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): restore the baked
/// per-create **defaults** (the create-time flags, e.g. UHD/BD on) into the RAM
/// feature-state table. RAM-only — does not touch flash. This is what a never-saved
/// drive boots to. Firmware ≥0.8.3.
pub const RESET_TO_DEFAULTS: u8 = 0x01;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): TRUE OEM. Forces every
/// feature to [`STATE_PASSTHROUGH`] in RAM **and** (firmware ≥0.8.3) writes the NV
/// config block all-`0xFF` — marker included — so the drive is byte-for-byte like a
/// never-saved/never-configured drive (no trace it was ever set). Because the marker
/// is then `0xFF`, the NEXT boot loads the baked defaults, exactly like a fresh drive.
pub const RESET_TO_OEM: u8 = 0xFF;
/// The verb selector in `cdb[4]`. These numeric values ARE the wire protocol.
@@ -133,8 +145,10 @@ pub enum Verb {
Get = 0x03,
/// Restore the RAM feature-state table. The mode rides in `cdb[6]` (the state
/// slot): [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config block back
/// into RAM (discarding un-saved changes), [`RESET_TO_OEM`] (`0xFF`) forces every
/// feature to [`STATE_PASSTHROUGH`] (out-of-the-box behaviour). Ignores feature.
/// into RAM (discarding un-saved changes; marker-gated in fw ≥0.8.3),
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to [`STATE_PASSTHROUGH`] AND
/// blanks the NV block (traceless, fw ≥0.8.3). Ignores feature.
Reset = 0x04,
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in
/// `cdb[5..9]` (big-endian).
@@ -267,8 +281,9 @@ pub fn build_get_cdb(feature: Feature) -> [u8; CDB_LEN] {
/// Build a `RESET` CDB. `mode` rides in the state slot (`cdb[6]`):
/// [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config into RAM,
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough. Floors its
/// data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough (and blanks NV).
/// Floors its data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
/// [`build_set_cdb`].
pub fn build_reset_cdb(mode: u8) -> [u8; CDB_LEN] {
build_cdb(Verb::Reset, None, Some(mode), MIN_ALLOC_LEN)
@@ -528,14 +543,24 @@ impl<'a> FirmwareControl<'a> {
self.exec_in(&cdb).map(|_| ())
}
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with
/// [`RESET_TO_OEM`]). Data-in phase for the same reason as [`set`](Self::set) —
/// a no-data RESET is aborted.
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with [`RESET_TO_OEM`])
/// — TRUE OEM: firmware ≥0.8.3 also blanks the NV block so the drive is
/// traceless. Data-in phase for the same reason as [`set`](Self::set) — a no-data
/// RESET is aborted.
pub fn reset(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_OEM);
self.exec_in(&cdb).map(|_| ())
}
/// Restore the baked create-time defaults into the RAM feature-state table
/// (RESET with [`RESET_TO_DEFAULTS`]) — what a never-saved drive boots to; does
/// not touch flash. Firmware ≥0.8.3. Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn reset_to_defaults(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_DEFAULTS);
self.exec_in(&cdb).map(|_| ())
}
/// Reload the saved flash config block back into the RAM feature-state table
/// (RESET with [`RESET_TO_FLASH`]), discarding any un-saved RAM changes — the
/// non-destructive "revert to last SAVE". Data-in phase for the same reason as
@@ -673,17 +698,22 @@ impl<'a> FirmwareControl<'a> {
/// **arm_bypass_bd** — full-bypass Blu-ray rip (NO host cert needed).
///
/// Sets: `Ake = null` (drive acts pre-authenticated, so a bare VID read
/// returns the volume ID with no cert and no AKE). Rips: BD with no cert.
/// returns the volume ID with no cert and no AKE) and `Hrl = skip`
/// (revocation off, so a revoked/absent cert never trips the HRL check).
/// Rips: BD with no cert.
pub fn arm_bypass_bd(&mut self) -> Result<()> {
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Ake, STATE_OFF)
}
/// **arm_bypass_uhd** — full-bypass UHD rip (NO host cert needed).
///
/// Sets: `Uhd = on` (engage the UHD disc), `Ake = null` (skip the
/// handshake), `Bus = off` (content de-bussed). Rips: UHD with no cert.
/// handshake), `Bus = off` (content de-bussed), and `Hrl = skip`
/// (revocation off). Rips: UHD with no cert.
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
self.set_verify(Feature::Uhd, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Ake, STATE_OFF)?;
self.set_verify(Feature::Bus, STATE_OFF)
}
+24 -5
View File
@@ -44,6 +44,7 @@ fn state_and_frame_constants_match_abi() {
assert_eq!(REGION_DVD_BASE, 0x00);
assert_eq!(REGION_FREE, 0x0F);
assert_eq!(RESET_TO_FLASH, 0x00);
assert_eq!(RESET_TO_DEFAULTS, 0x01);
assert_eq!(RESET_TO_OEM, 0xFF);
}
@@ -332,6 +333,16 @@ fn reset_sends_reset_cdb_and_clears_state() {
assert_eq!(m.states, FeatureStates::all_passthrough());
}
#[test]
fn reset_to_defaults_sends_the_defaults_mode_cdb() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.reset_to_defaults().expect("reset to defaults");
}
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_DEFAULTS));
}
#[test]
fn save_then_reset_to_flash_restores_the_saved_value() {
// SET a feature, SAVE it, force all-passthrough with RESET(to-OEM), then
@@ -478,8 +489,12 @@ fn arm_bypass_bd_nulls_ake() {
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_bd().expect("armed");
}
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Ake));
// Hrl=off (revocation skip) is set+verified FIRST, then Ake=null.
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(m.cdbs[3], build_get_cdb(Feature::Ake));
assert_eq!(m.states.hrl, STATE_OFF);
assert_eq!(m.states.ake, STATE_OFF);
}
@@ -490,10 +505,13 @@ fn arm_bypass_uhd_sets_uhd_ake_bus() {
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_uhd().expect("armed");
}
// Recipe order: Uhd=on, Hrl=off, Ake=null, Bus=off (each SET then verifying GET).
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Ake, STATE_OFF));
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Bus, STATE_OFF));
assert_eq!(m.states.uhd, STATE_ON);
assert_eq!(m.states.hrl, STATE_OFF);
assert_eq!(m.states.ake, STATE_OFF);
assert_eq!(m.states.bus, STATE_OFF);
}
@@ -552,10 +570,11 @@ fn set_verify_reports_mismatch() {
let mut d = StuckDrive;
let mut fw = FirmwareControl::new(&mut d);
let err = fw.arm_bypass_bd().unwrap_err();
// arm_bypass_bd now verifies Hrl=off FIRST, so the stuck drive trips on Hrl.
assert_eq!(
err,
FirmwareError::VerifyFailed {
feature: Feature::Ake,
feature: Feature::Hrl,
wanted: STATE_OFF,
got: STATE_PASSTHROUGH,
}
+6 -6
View File
@@ -18,8 +18,8 @@
//! the migrated spec the HRL/AKE/BUS unlock direction is `STATE_OFF`, not `0x01`.
use crate::firmware::{
Feature, MEMREAD_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF, build_identity_cdb,
build_memread_cdb, build_set_cdb,
Feature, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF,
build_identity_cdb, build_memread_cdb, build_set_cdb,
};
use crate::scsi::{DataDirection, ScsiTransport};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
@@ -99,10 +99,10 @@ impl FreemkvUnlocker {
state: u8,
) -> std::result::Result<(), UnlockError> {
let cdb = build_set_cdb(feature, state);
// SET needs the 64-byte data-in phase its CDB advertises — the drive aborts
// a no-data SET (CHECK CONDITION / Aborted Command, HW-confirmed on BU40N fw
// 0.8.1). Mirror FirmwareControl::set (exec_in): read the table back, ignore it.
let mut buf = [0u8; RESP_LEN];
// SET needs the data-in phase its CDB advertises — a no-data SET is aborted
// (CHECK CONDITION, HW-confirmed on BU40N fw 0.8.1). Buffer sized from the SAME
// const the CDB advertises (MIN_ALLOC_LEN) so the transfer length has one source.
let mut buf = [0u8; MIN_ALLOC_LEN as usize];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) if r.status == 0 => Ok(()),
Ok(r) => {