mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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:
@@ -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[..]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user