Files
libfreemkv/tests/legacy_key_api_guard.rs
T
Matthew Jackson 1d78420366 keys: read damage is never a key failure; legacy key API removed
Damaged AACS units are holed, blanked and counted on every path instead
of stopping with E7013; failed batch partners fall back to side reads
and a wrong FMTS key is judged per key. ResolvedKeySet is the only AACS
decrypt door; a raw whole-disc reader never decrypts and refuses titles
with no stream folder (E6003) or a piece no held key opens (E7032).
2026-09-29 17:20:43 -07:00

276 lines
9.8 KiB
Rust

//! The structural key guard (keys-upfront design v3.4 §2.2, LK20, KU-X2).
//!
//! §2.2: "One test per repo; any match outside the allow-path fails." Every Rust file under
//! `src/` (`#[cfg(test)]` included) and `tests/` is scanned, comments skipped, for the
//! libfreemkv banned list. The allow-path is §2.2's "`src/keys/**`, `src/decrypt.rs`,
//! `src/sector/decrypting.rs`, `src/aacs/**` (definitions and internal use)".
//!
//! The sanctioned test helper `test_util::decrypt_unit` (§2.2: "Both move to
//! `libfreemkv::test_util::decrypt_unit`") is not the library door: its definition and its
//! callers are exempt. So is `KeySource::resolve_unit_keys`'s default, the trait op's own
//! forward to `get_unit_keys` (§2.2: the "public trait ops ... Stay").
//! Per spec; do not change without a spec citation proving otherwise.
use std::path::{Path, PathBuf};
/// §2.2, the libfreemkv row of the structural guards.
const BANNED: &[&str] = &[
".get_unit_keys(",
".get_fmts_indexes(",
".resolve_unit_keys(",
"decrypt_with(",
"AacsKeyMap::from_ranges",
"with_key_map(",
"set_key_map(",
"decrypt_unit(",
];
/// §2.2: "`src/keys/**`, `src/decrypt.rs`, `src/sector/decrypting.rs`, `src/aacs/**`".
fn allowed(rel: &str) -> bool {
rel.starts_with("src/keys/")
|| rel.starts_with("src/aacs/")
|| rel == "src/decrypt.rs"
|| rel == "src/sector/decrypting.rs"
}
/// `src` with every comment blanked (newlines kept), and a copy with string and char
/// literal contents blanked too; both keep `src`'s char positions.
fn lex(src: &str) -> (Vec<char>, Vec<char>) {
let s: Vec<char> = src.chars().collect();
let mut code = s.clone();
let mut bare = s.clone();
let blank = |v: &mut Vec<char>, from: usize, to: usize| {
for c in &mut v[from..to] {
if *c != '\n' {
*c = ' ';
}
}
};
let word = |c: char| c.is_alphanumeric() || c == '_';
let (n, mut i) = (s.len(), 0);
while i < n {
let at = |k: usize| s.get(k).copied().unwrap_or('\0');
if at(i) == '/' && at(i + 1) == '/' {
let end = (i..n).find(|&k| s[k] == '\n').unwrap_or(n);
blank(&mut code, i, end);
blank(&mut bare, i, end);
i = end;
} else if at(i) == '/' && at(i + 1) == '*' {
let (mut depth, mut k) = (1, i + 2);
while k < n && depth > 0 {
if at(k) == '/' && at(k + 1) == '*' {
depth += 1;
k += 2;
} else if at(k) == '*' && at(k + 1) == '/' {
depth -= 1;
k += 2;
} else {
k += 1;
}
}
blank(&mut code, i, k);
blank(&mut bare, i, k);
i = k;
} else if at(i) == 'r'
&& (i == 0 || !word(s[i - 1]) || (s[i - 1] == 'b' && (i < 2 || !word(s[i - 2]))))
&& {
let h = (i + 1..n).take_while(|&k| s[k] == '#').count();
at(i + 1 + h) == '"'
}
{
let h = (i + 1..n).take_while(|&k| s[k] == '#').count();
let open = i + 2 + h;
let close: String = std::iter::once('"')
.chain(std::iter::repeat_n('#', h))
.collect();
let close: Vec<char> = close.chars().collect();
let end = (open..n).find(|&k| s[k..].starts_with(&close)).unwrap_or(n);
blank(&mut bare, open, end);
i = (end + close.len()).min(n);
} else if at(i) == '"' {
let mut k = i + 1;
while k < n && s[k] != '"' {
k += if s[k] == '\\' { 2 } else { 1 };
}
blank(&mut bare, i + 1, k.min(n));
i = k + 1;
} else if at(i) == '\'' && (at(i + 1) == '\\' || at(i + 2) == '\'') {
let end = (i + 2..n).find(|&k| s[k] == '\'').unwrap_or(n);
blank(&mut bare, i + 1, end);
i = end + 1;
} else {
i += 1;
}
}
(code, bare)
}
/// Char spans of the bodies of the `fn`s in `bare` whose name satisfies `pick`.
fn fn_spans(bare: &[char], pick: impl Fn(&str) -> bool) -> Vec<(usize, usize)> {
let text: String = bare.iter().collect();
let chars: Vec<(usize, char)> = text.char_indices().collect();
let to_char = |byte: usize| chars.partition_point(|&(b, _)| b < byte);
let mut spans = Vec::new();
for (at, _) in text.match_indices("fn ") {
let name: String = text[at + 3..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if name.is_empty() || !pick(&name) {
continue;
}
let start = to_char(at);
let Some(open) = (start..bare.len()).find(|&k| bare[k] == '{') else {
continue;
};
let mut depth = 0;
for (k, &c) in bare.iter().enumerate().skip(open) {
depth += (c == '{') as i32 - (c == '}') as i32;
if depth == 0 {
spans.push((start, k + 1));
break;
}
}
}
spans
}
/// `(line, token)` of every banned match in `src` (file `rel`) outside the allow-path.
fn hits(rel: &str, src: &str) -> Vec<(usize, &'static str)> {
if allowed(rel) {
return Vec::new();
}
let (code, bare) = lex(src);
let mut exempt = fn_spans(&bare, |n| rel == "src/test_util.rs" && n == "decrypt_unit");
exempt.extend(fn_spans(&bare, |n| {
rel == "src/keysource.rs" && n == "resolve_unit_keys"
}));
let inside = |k: usize| exempt.iter().any(|&(a, b)| a <= k && k < b);
let helper: Vec<char> = "test_util::".chars().collect();
let mut out = Vec::new();
for &tok in BANNED {
let t: Vec<char> = tok.chars().collect();
for k in 0..code.len().saturating_sub(t.len() - 1) {
if code[k..k + t.len()] != t[..] || inside(k) {
continue;
}
let via_helper = k >= helper.len() && code[k - helper.len()..k] == helper[..];
if tok == "decrypt_unit(" && via_helper {
continue;
}
out.push((code[..k].iter().filter(|&&c| c == '\n').count() + 1, tok));
}
}
out.sort();
out
}
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
rs_files(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
/// LK20 (KU §2.2, KU-X2): no AACS decrypt door in `src/` or `tests/` outside the allow-path.
#[test]
fn no_legacy_key_api_outside_the_allow_path() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut files = Vec::new();
rs_files(&root.join("src"), &mut files);
rs_files(&root.join("tests"), &mut files);
assert!(files.len() > 50, "the walk lost files: {}", files.len());
let mut found = Vec::new();
for f in &files {
let rel = f
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if rel == "tests/legacy_key_api_guard.rs" {
continue;
}
let src = std::fs::read_to_string(f).expect("read source");
for (line, tok) in hits(&rel, &src) {
found.push(format!("{rel}:{line}: {tok}"));
}
}
assert!(
found.is_empty(),
"legacy key API outside the allow-path:\n{}",
found.join("\n")
);
}
/// Self-test: comments are skipped, code and strings are not, the allow-path is exactly
/// §2.2's four, and only the sanctioned helper is exempt.
#[test]
fn the_structural_guard_skips_comments_and_keeps_its_allow_path() {
let code = "fn f() { m.set_key_map(v); }\n";
assert_eq!(hits("src/mux/x.rs", code), [(1, "set_key_map(")]);
let commented =
"// m.with_key_map(v)\n/* decrypt_with( /* nested */ */\n/// x.get_unit_keys(\n";
assert!(
hits("src/x.rs", commented).is_empty(),
"comments are skipped"
);
let url = "let u = \"http://x\"; AacsKeyMap::from_ranges_phased(r);\n";
assert_eq!(hits("src/x.rs", url), [(1, "AacsKeyMap::from_ranges")]);
let raw = "let s = r#\"x.get_fmts_indexes( // \"#; s.resolve_unit_keys(c);\n";
assert_eq!(
hits("src/x.rs", raw).len(),
2,
"raw strings are data, not comments"
);
for rel in [
"src/keys/resolve.rs",
"src/aacs/content.rs",
"src/decrypt.rs",
"src/sector/decrypting.rs",
] {
assert!(hits(rel, code).is_empty(), "{rel} is on the allow-path");
}
for rel in [
"src/keysource.rs",
"src/sector/mod.rs",
"src/decrypt_extra.rs",
"tests/x.rs",
] {
assert_eq!(hits(rel, code).len(), 1, "{rel} is not on the allow-path");
}
let helper = "test_util::decrypt_unit(&mut u, k); content::decrypt_unit(&mut u, k);";
assert_eq!(hits("tests/x.rs", helper).len(), 1, "only the library door");
let def = "pub fn decrypt_unit(u: &mut [u8]) { crate::aacs::content::decrypt_unit(u) }\n";
assert!(
hits("src/test_util.rs", def).is_empty(),
"the helper's definition"
);
assert_eq!(
hits("src/other.rs", def).len(),
2,
"only in src/test_util.rs"
);
let forward = "fn resolve_unit_keys(&self, c: &C) -> R { self.get_unit_keys(c) }\n";
assert!(
hits("src/keysource.rs", forward).is_empty(),
"the trait op's own default"
);
let other = "fn other(&self, c: &C) -> R { self.get_unit_keys(c) }\n";
assert_eq!(hits("src/keysource.rs", other).len(), 1, "only that fn");
assert_eq!(
hits("src/session.rs", forward).len(),
1,
"only in src/keysource.rs"
);
}