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).
This commit is contained in:
Matthew Jackson
2026-09-29 17:20:43 -07:00
parent 25fd393c04
commit 1d78420366
51 changed files with 2461 additions and 8541 deletions
+3 -3
View File
@@ -147,7 +147,7 @@ fn aacs_decrypt_unit_roundtrip() {
));
// Now decrypt
aacs::content::decrypt_unit(&mut plain, &unit_key);
libfreemkv::test_util::decrypt_unit(&mut plain, &unit_key);
assert!(
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
"decrypted unit should read as clear (TS syncs restored)"
@@ -424,7 +424,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
);
// -- Decrypt with the library --
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
libfreemkv::test_util::decrypt_unit(&mut plaintext, &unit_key);
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(
@@ -465,7 +465,7 @@ fn aacs_cross_validation_alternate_key() {
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
);
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
libfreemkv::test_util::decrypt_unit(&mut plaintext, &unit_key);
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(&plaintext[..], &expected[..]);
-15
View File
@@ -545,21 +545,6 @@ fn batch_count_max_batch_sizes() {
}
}
#[test]
fn scan_encrypted_resolves_no_keys() {
// A UDF image with an /AACS directory: the lookup-free scan detects
// encryption and captures inputs, but resolves NO key on its own — a
// caller applies one later via Disc::decrypt_with.
let mut reader = MockSectorReader::new();
build_udf_with_aacs_dir(&mut reader);
let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap();
// No unit keys without an external key (the mock has no Unit_Key_RO.inf to
// capture, so the keyless state isn't even built) — either way, no keys.
assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None));
}
#[test]
fn aacs_dir_alone_marks_the_disc_encrypted_and_reports_the_capture_error() {
// Detection is an OR over `/AACS` and `/BDMV/AACS`. This fixture carries
-381
View File
@@ -1,381 +0,0 @@
//! End-to-end `fvi://` tests: drive a REAL MPEG-2 Program-Stream image through
//! the public highway (`build_iso_pipeline`, MpegPs → PS demux → `Mpeg2Parser`)
//! and into the `fvi://` sink built by `output()`, then parse the `.fvi` back
//! and assert the per-picture index is correct.
//!
//! These tests deliberately use only the public API and the real parser /
//! pipeline — no stubbed frames that bypass the demuxer or the codec parse.
use libfreemkv::disc::{
Codec, ColorSpace, ContentFormat, DiscTitle, Extent, FrameRate, HdrFormat, Resolution, Stream,
VideoStream,
};
use libfreemkv::pes::Stream as PesStream;
use libfreemkv::{DecryptKeys, Medium, SectorSource, SourceInfo, build_iso_pipeline, output};
use std::path::PathBuf;
/// DVD video PES stream_id (0xE0).
const DVD_VIDEO_STREAM_ID: u8 = 0xE0;
// ── MPEG-2 elementary-stream fixture builders (mirror the in-crate ones) ──────
/// 720x480, 4:3, 29.97 (aspect_ratio_information=2, frame_rate_code=4).
fn m2_seq_header() -> Vec<u8> {
let (w, h, aspect, fr): (u16, u16, u8, u8) = (720, 480, 2, 4);
let mut hdr = vec![0x00, 0x00, 0x01, 0xB3u8];
hdr.push((w >> 4) as u8);
hdr.push((((w & 0x0F) as u8) << 4) | (((h >> 8) & 0x0F) as u8));
hdr.push((h & 0xFF) as u8);
hdr.push((aspect << 4) | (fr & 0x0F));
hdr.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x00]);
hdr
}
/// GOP header (00 00 01 B8) with a zeroed time-code / flags.
fn m2_gop() -> Vec<u8> {
vec![0x00, 0x00, 0x01, 0xB8u8, 0x00, 0x00, 0x00, 0x00]
}
/// One coded picture: picture header (coding_type, temporal_reference) + a
/// picture coding extension carrying tff=1 (00 00 01 B5, ext-id 1000), + slice
/// padding.
fn m2_pic(coding_type: u8, tr: u16) -> Vec<u8> {
let b4 = ((tr >> 2) & 0xFF) as u8;
let b5 = (((tr & 0x03) as u8) << 6) | ((coding_type & 0x07) << 3);
let mut au = vec![0x00, 0x00, 0x01, 0x00u8, b4, b5, 0x00, 0x00];
// Picture coding extension: e0=ext-id 1000, e2=0x03 (frame picture),
// e3 bit7 = top_field_first = 1.
au.extend_from_slice(&[0x00, 0x00, 0x01, 0xB5u8, 0x80, 0x00, 0x03, 0x80, 0x00]);
au.extend_from_slice(&[0xAA; 32]);
au
}
// ── Program-Stream packing ────────────────────────────────────────────────────
/// A 14-byte MPEG-2 PS pack header (00 00 01 BA …) with no stuffing.
fn ps_pack_header() -> Vec<u8> {
let mut p = vec![0x00, 0x00, 0x01, 0xBAu8];
// 9 bytes of SCR/mux-rate fields (content irrelevant to the demuxer's
// framing) + a final byte whose low 3 bits are pack_stuffing_length = 0.
p.extend_from_slice(&[0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3]);
p.push(0xF8); // stuffing_length = 0 (low 3 bits)
p
}
/// A video PES (stream_id 0xE0) carrying `es`, with a 33-bit PTS in 90 kHz
/// ticks and a bounded PES_packet_length. PTS prefix nibble is 0b0010.
fn video_pes(es: &[u8], pts: u64) -> Vec<u8> {
let mut pes = vec![0x00, 0x00, 0x01, DVD_VIDEO_STREAM_ID];
// PES header: flags1=0x80, flags2=0x80 (PTS only), header_data_len=5.
let mut body = vec![0x80u8, 0x80, 0x05];
// 5-byte PTS ('0010' marker + 33-bit value with marker bits).
let p = pts & 0x1_FFFF_FFFF;
body.push(0x21 | (((p >> 30) & 0x07) << 1) as u8);
body.push(((p >> 22) & 0xFF) as u8);
body.push((0x01 | (((p >> 15) & 0x7F) << 1)) as u8);
body.push(((p >> 7) & 0xFF) as u8);
body.push((0x01 | ((p & 0x7F) << 1)) as u8);
body.extend_from_slice(es);
let len = body.len() as u16;
pes.extend_from_slice(&len.to_be_bytes());
pes.extend_from_slice(&body);
pes
}
/// One GOP's worth of ES (seq header + GOP + I + P + B pictures).
fn gop_es() -> Vec<u8> {
let mut es = m2_seq_header();
es.extend_from_slice(&m2_gop());
es.extend_from_slice(&m2_pic(1, 0)); // I (keyframe, GOP opener)
es.extend_from_slice(&m2_pic(2, 2)); // P
es.extend_from_slice(&m2_pic(3, 1)); // B
es
}
/// In-memory sector source serving a fixed byte image.
struct MemSource {
data: Vec<u8>,
}
impl SectorSource for MemSource {
fn capacity_sectors(&self) -> u32 {
(self.data.len() / 2048) as u32
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> libfreemkv::error::Result<usize> {
let start = lba as usize * 2048;
let want = count as usize * 2048;
for (i, b) in buf[..want].iter_mut().enumerate() {
*b = self.data.get(start + i).copied().unwrap_or(0);
}
Ok(want)
}
}
fn mpeg2_dvd_title(extent_sectors: u32) -> DiscTitle {
let mut title = DiscTitle::empty();
title.streams.push(Stream::Video(VideoStream {
pid: 0xE0, // DVD_VIDEO_PID
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Smpte170m,
display_aspect: Some((4, 3)),
secondary: false,
label: String::new(),
measured_cicp: None,
}));
title.content_format = ContentFormat::MpegPs;
title.extents = vec![Extent {
start_lba: 0,
sector_count: extent_sectors,
}];
title
}
/// Tiny unique temp dir helper.
fn tempdir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let p = std::env::temp_dir().join(format!("fmkv_fvi_pipe_{}_{}", std::process::id(), n));
std::fs::create_dir_all(&p).unwrap();
p
}
/// Build a 6-sector PS image: GOP A in sector 0, GOP B in sector 3 — each in
/// its own 3-sector AACS-aligned region so the prefetcher's unit alignment is
/// satisfied and each batch carries a distinct, ascending source offset.
fn two_gop_image() -> Vec<u8> {
let mut data = vec![0u8; 6 * 2048];
let mut a = ps_pack_header();
a.extend_from_slice(&video_pes(&gop_es(), 0));
data[..a.len()].copy_from_slice(&a);
let mut b = ps_pack_header();
b.extend_from_slice(&video_pes(&gop_es(), 3003)); // one frame (~33 ms) later at 29.97 fps (3003/90000 s)
let off = 3 * 2048;
data[off..off + b.len()].copy_from_slice(&b);
data
}
/// Drive the real highway and write every frame into the `fvi://` sink.
fn run_to_fvi(image: Vec<u8>, title: DiscTitle, path: &std::path::Path) {
let mut input = build_iso_pipeline(
MemSource { data: image },
title.clone(),
DecryptKeys::None,
3, // 3-sector (one AACS unit) batches → one source stamp per GOP region
ContentFormat::MpegPs,
false,
None,
None,
None,
)
.expect("pipeline builds");
// Real provenance for the run — the header must describe THIS input, not
// the `.fvi` it is writing.
let source = SourceInfo {
medium: Medium::Iso,
path: "iso://two-gop.iso".into(),
title: 2,
..SourceInfo::default()
};
let url = format!("fvi://{}", path.display());
let mut sink = output(&url, &title, Some(&source)).expect("fvi sink opens");
while let Some(frame) = input.read().expect("read ok") {
sink.write(&frame).expect("sink write ok");
}
sink.finish().expect("sink finish ok");
}
#[test]
fn fvi_sink_indexes_real_mpeg2_pipeline_output() {
let dir = tempdir();
let path = dir.join("movie.fvi");
run_to_fvi(two_gop_image(), mpeg2_dvd_title(6), &path);
let text = std::fs::read_to_string(&path).unwrap();
let mut lines = text.lines();
// ── Header line ─────────────────────────────.
let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
assert_eq!(header["format"], "freemkv/video-index");
assert_eq!(header["fvi_version"], 1);
assert_eq!(header["timescale"], 1_000_000_000u64);
let stream = &header["stream"];
assert_eq!(stream["codec"], "mpeg2video");
assert_eq!(stream["width"], 720);
assert_eq!(stream["height"], 480);
assert_eq!(stream["dar"], serde_json::json!([4, 3])); // anamorphic DVD
assert_eq!(stream["scan"], "interlaced"); // 480i
assert_eq!(stream["frame_rate"], serde_json::json!([30000, 1001]));
// SMPTE 170M → CICP (6,6,6), limited range.
assert_eq!(stream["colour"]["primaries"], 6);
assert_eq!(stream["colour"]["transfer"], 6);
assert_eq!(stream["colour"]["matrix"], 6);
assert_eq!(stream["colour"]["range"], "limited");
// Provenance root describes the INPUT the index was built from — never the
// destination `.fvi` (which is what the sink used to record).
assert_eq!(header["source"]["sector_size"], 2048);
assert_eq!(header["source"]["medium"], "iso");
assert_eq!(header["source"]["path"], "iso://two-gop.iso");
assert_eq!(header["source"]["title"], 2);
assert!(
!header["source"]["path"].as_str().unwrap().contains(".fvi"),
"the destination path must never leak into source.path"
);
// ── Records ───────────────────────────────────────────────────────────────
let records: Vec<serde_json::Value> = lines.map(|l| serde_json::from_str(l).unwrap()).collect();
assert!(
records.len() >= 4,
"two GOPs of I/P/B → at least 4 pictures, got {}",
records.len()
);
// `n` is 0-based and contiguous in coded order.
for (i, r) in records.iter().enumerate() {
assert_eq!(r["n"], i as u64, "record n must be contiguous coded order");
}
// Every picture carries the codec-agnostic coding members from the REAL
// parser, derived through the `PictureInfo` accessors.
for r in &records {
assert!(
["I", "P", "B"].contains(&r["type"].as_str().unwrap()),
"type must be a real coding type, got {}",
r["type"]
);
// tff was set in the picture coding extension fixture, the frame is an
// interlaced (non-progressive) frame picture → field_order "tff",
// progressive false, 2 displayed fields.
assert_eq!(
r["field_order"], "tff",
"top_field_first survives the parse as field_order"
);
assert_eq!(r["progressive"], false);
assert_eq!(r["nb_fields"], 2);
// No GOP-closure signal is carried by the codec-agnostic PictureInfo, so
// the `gop` member is honestly omitted (not fabricated).
assert!(r.get("gop").is_none(), "gop member omitted, never guessed");
}
// Exactly two I-pictures (one per GOP), each `type` I and a random-access
// point (`key` true — the parser-flagged intra/decode-restart point). The
// fixture's only intra pictures are the two GOP-opening I-frames.
let key_pics: Vec<&serde_json::Value> = records
.iter()
.filter(|r| r["key"] == serde_json::Value::Bool(true))
.collect();
assert_eq!(key_pics.len(), 2, "two intra/random-access pictures");
for opener in &key_pics {
assert_eq!(opener["type"], "I", "a random-access point is an I-picture");
}
// The two stamped source sectors (sector 0 region and sector 3 region) reach
// the index, in ascending order — provenance carried, never reconstructed.
let src_sectors: Vec<u64> = records
.iter()
.filter_map(|r| r["src"]["sector"].as_u64())
.collect();
assert_eq!(
src_sectors,
vec![0, 3],
"stamped src sectors must reach the .fvi in arrival order; got {src_sectors:?}"
);
// Per https://freemkv.org/docs/fvi-format/#9-provenance-and-recovery-semantics, `src.byte` is the AU's offset WITHIN its 2048-byte
// `src.sector` (always < 2048). These sources sit 14 bytes into their
// sector (past the pack header), so byte must be exactly 14.
for r in &records {
if let Some(byte) = r["src"]["byte"].as_u64() {
assert!(
byte < 2048,
"src.byte is a within-sector offset (§9), must be < 2048; got {byte}"
);
assert_eq!(
byte, 14,
"stamped src.byte is 14 (just past the pack header)"
);
}
}
let _ = std::fs::remove_dir_all(&dir);
}
// Codec-agnostic path: non-MPEG2 frames (`coding == None`) with real keyframe/
// source/pts must still yield useful `.fvi` records, not degrade to
// `type:"?"`/`src:null` just because `PictureInfo` is MPEG-2-specific.
#[test]
fn fvi_sink_indexes_non_mpeg2_frames_codec_agnostically() {
use libfreemkv::pes::{PesFrame, SourcePos};
let dir = tempdir();
let path = dir.join("nocoding.fvi");
let title = mpeg2_dvd_title(0);
let mk = |pts: i64, keyframe: bool, source: Option<SourcePos>| PesFrame {
discard_padding_ns: 0,
track: 0,
pts,
keyframe,
data: vec![0u8; 8],
duration_ns: None,
source,
coding: None, // non-MPEG2: no PictureInfo
};
let url = format!("fvi://{}", path.display());
let mut sink = output(&url, &title, None).expect("fvi sink opens");
// IDR (keyframe) with real provenance — must NOT be null/"?".
sink.write(&mk(1234, true, Some(SourcePos::at_byte(8192))))
.unwrap();
// Non-key with provenance.
sink.write(&mk(5678, false, Some(SourcePos::at_byte(16384))))
.unwrap();
// Keyframe with NO provenance — src genuinely null, but key/type still set.
sink.write(&mk(9012, true, None)).unwrap();
sink.finish().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let recs: Vec<serde_json::Value> = text
.lines()
.skip(1)
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(recs.len(), 3);
// IDR: key true, type "I" (from keyframe), real src + pts, no mpeg2 fields.
assert_eq!(recs[0]["key"], true, "HEVC IDR → key from frame.keyframe");
assert_eq!(recs[0]["type"], "I");
assert_eq!(recs[0]["pts"], 1234);
assert_eq!(recs[0]["src"]["sector"], 4); // 8192 / 2048
assert_eq!(recs[0]["src"]["byte"], 0); // 8192 is sector-aligned → within-sector offset 0 (§9)
assert!(
recs[0].get("field_order").is_none() && recs[0].get("nb_fields").is_none(),
"coding-absent record omits field_order/nb_fields"
);
assert!(recs[0].get("dts").is_none(), "no DTS on a frame → omitted");
// Non-key with provenance: key false, type "P", src still present.
assert_eq!(recs[1]["key"], false);
assert_eq!(recs[1]["type"], "P");
assert_eq!(recs[1]["src"]["sector"], 8); // 16384 / 2048
// Keyframe without provenance: key/type still set, src genuinely null.
assert_eq!(recs[2]["key"], true);
assert_eq!(recs[2]["type"], "I");
assert_eq!(recs[2]["src"], serde_json::Value::Null);
let _ = std::fs::remove_dir_all(&dir);
}
+275
View File
@@ -0,0 +1,275 @@
//! 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"
);
}
+1 -1
View File
@@ -559,7 +559,7 @@ const ALLOW_BASELINE: &[(&str, &str, usize)] = &[
(
"src/mux/resolve.rs",
r#"allow(clippy::too_many_arguments)"#,
2,
1,
),
(
"src/platform/fs_type/linux.rs",
-4
View File
@@ -245,10 +245,6 @@ fn inserted_uhd_disc_reports_full_aacs2_state() {
aacs.volume_id, [0u8; 16],
"VID must be populated after a successful AACS 2.0 handshake"
);
assert!(
!aacs.unit_keys.is_empty(),
"at least one CPS unit key expected for a scannable UHD disc"
);
}
/// Scan is idempotent: rescanning the same session yields the same disc
-6
View File
@@ -88,12 +88,6 @@ fn decrypt_keys_is_encrypted_variants() {
let none = DecryptKeys::None;
assert!(!none.is_encrypted());
let aacs = DecryptKeys::Aacs {
unit_keys: vec![],
format: libfreemkv::disc::ContentFormat::BdTs,
};
assert!(aacs.is_encrypted());
let css = DecryptKeys::Css {
title_key: [0u8; 5],
};
+6 -4
View File
@@ -8,11 +8,12 @@ src/decrypt.rs apply_aacs_map KS-3
src/decrypt.rs apply_aacs_map KS-5
src/decrypt.rs apply_aacs_map KS-25
src/decrypt.rs apply_aacs_map KS-26
src/mux/resolve.rs probe_fmts_index_keys KS-25
src/mux/resolve.rs probe_fmts_index_keys KS-26
src/decrypt.rs blank_damaged_units KS-2
src/decrypt.rs blank_damaged_units KS-3
src/decrypt.rs blank_damaged_units KS-4
src/decrypt.rs blank_damaged_units KS-22
src/disc/mod.rs declared_cps_units KS-14
src/whole_disc.rs single_cps_key_slot KS-14
src/whole_disc.rs single_cps_key_slot KS-27
src/disc/mod.rs declared_cps_units KS-27
src/sector/bus_removal.rs debus_read KS-18
src/scsi/mod.rs parse_sense_progress SS-1
src/scsi/mod.rs parse_sense_progress SS-2
@@ -37,6 +38,7 @@ src/keys/arrival.rs decrypt_proven KS-5
src/keys/arrival.rs side_partners KS-1
src/keys/arrival.rs side_partners KS-2
src/keys/arrival.rs side_partners KS-7
src/keys/arrival.rs is_partner KS-4
src/keys/fmts.rs anchor KS-26
src/keys/fmts.rs phases KS-25
src/keys/fmts.rs phases KS-26