Stop an uncrackable VTS borrowing another VTS's title key
`resolve_vts_key` called the `Option`-returning `css::crack_key`, which collapses "no scrambled sector was seen" with "scrambled sectors were seen and no key came out", and then fell back to the disc-wide key. A multi-VTS CSS DVD whose second title set resists the Stevenson scan was descrambled under the FIRST set's key: corrupt PES behind an intact header, written out with `complete = true` at exit 0. `CrackOutcome` exists precisely to keep those two apart, and its doc says callers must surface the second as a hard error. Every sibling path in the crate already does — `Disc::decrypt_keys_for_title` and the mux path both map `ScrambledUncracked` to `CssKeyMissing`. This was the one path that did not. The ordering comment 15 lines above describes this exact outcome as the bug it exists to prevent; ordering makes the crack far more likely to succeed, but it cannot make a failed crack safe. Also, three things nothing could catch: - The demux output filename took the stream's language raw while the base beside it was sanitised. A language code is three raw STN bytes through `from_utf8_lossy`, and `00 00 00` is the ordinary "undefined" encoding on real discs — a NUL in a path fails `File::create` with InvalidInput, taking the whole export down before one track file opened. `sanitize` now maps control characters too; it did not. - `css::crack_key_scan`'s short-read handling was dead code under test: every source in the module returned the full request, so reverting `advance` to the requested count, or dropping the `.max(1)`, left the suite green. The `.max(1)` is load-bearing — without it a source returning `Ok(0)` never moves the cursor and never increments the budget, so the scan spins forever. That mutation now HANGS the test rather than failing it, which is the honest demonstration. - `MAX_SUBDIRS`'s const-assert carried `#[cfg(not(test))]` inside a `#[cfg(test)] mod tests`, so it compiled in no configuration and could never fire — the dead gate the test above it was written to replace. Moved to module scope, and verified it now rejects a wrong constant at compile time.
This commit is contained in:
+67
-2
@@ -738,6 +738,11 @@ mod tests {
|
|||||||
/// instead of the uniform `flag_byte` fill. Lets the scan actually
|
/// instead of the uniform `flag_byte` fill. Lets the scan actually
|
||||||
/// reach `CrackOutcome::Cracked` from a synthetic ISO.
|
/// reach `CrackOutcome::Cracked` from a synthetic ISO.
|
||||||
crackable: Option<(u32, Vec<u8>)>,
|
crackable: Option<(u32, Vec<u8>)>,
|
||||||
|
/// Sectors actually filled per batch, however many were asked for —
|
||||||
|
/// the SHORT READ a `recovery: true` source is allowed to return over
|
||||||
|
/// a damaged region. `Some(0)` is the degenerate case that must not
|
||||||
|
/// spin the scan.
|
||||||
|
short_read: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockSource {
|
impl MockSource {
|
||||||
@@ -748,6 +753,7 @@ mod tests {
|
|||||||
fail_all: false,
|
fail_all: false,
|
||||||
lock_all: false,
|
lock_all: false,
|
||||||
crackable: None,
|
crackable: None,
|
||||||
|
short_read: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -798,14 +804,19 @@ mod tests {
|
|||||||
if self.fail_all {
|
if self.fail_all {
|
||||||
return Err(Error::DecryptFailed);
|
return Err(Error::DecryptFailed);
|
||||||
}
|
}
|
||||||
let n = count as usize * 2048;
|
// A short read fills, and reports, fewer sectors than asked.
|
||||||
|
let filled = match self.short_read {
|
||||||
|
Some(k) => (k as u16).min(count),
|
||||||
|
None => count,
|
||||||
|
};
|
||||||
|
let n = filled as usize * 2048;
|
||||||
let end = n.min(buf.len());
|
let end = n.min(buf.len());
|
||||||
for b in buf[..end].iter_mut() {
|
for b in buf[..end].iter_mut() {
|
||||||
*b = 0;
|
*b = 0;
|
||||||
}
|
}
|
||||||
// Fill each sector in the batch with the uniform flag byte, EXCEPT a
|
// Fill each sector in the batch with the uniform flag byte, EXCEPT a
|
||||||
// designated crackable LBA which gets the full synthetic sector.
|
// designated crackable LBA which gets the full synthetic sector.
|
||||||
for s in 0..count as u32 {
|
for s in 0..filled as u32 {
|
||||||
let sect_lba = lba + s;
|
let sect_lba = lba + s;
|
||||||
let base = s as usize * 2048;
|
let base = s as usize * 2048;
|
||||||
if base + 2048 > end {
|
if base + 2048 > end {
|
||||||
@@ -830,6 +841,60 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Short reads: the branch nothing exercised ─────────────────────────
|
||||||
|
//
|
||||||
|
// `crack_key_scan` passes `recovery = true`, which is precisely the mode
|
||||||
|
// where a `SectorSource` may return Ok with fewer bytes than asked. Every
|
||||||
|
// source in this module returned the full request, so `usable`, `advance`
|
||||||
|
// and the `.max(1)` anti-spin guard were dead code under test: reverting
|
||||||
|
// `advance` to `n`, or dropping the `.max(1)`, left the whole suite green.
|
||||||
|
|
||||||
|
/// A short batch is RE-READ from where it stopped, not skipped. Skipping
|
||||||
|
/// would quietly shrink the crack's coverage on exactly the damaged media
|
||||||
|
/// where a key is hardest to find.
|
||||||
|
#[test]
|
||||||
|
fn a_short_read_resumes_from_where_it_stopped() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.short_read = Some(1);
|
||||||
|
let ext = [crate::disc::Extent {
|
||||||
|
start_lba: 100,
|
||||||
|
sector_count: 4,
|
||||||
|
}];
|
||||||
|
let _ = crack_key_scan(&mut src, &ext, 4, None, false);
|
||||||
|
let reads = src.reads.borrow().clone();
|
||||||
|
assert_eq!(
|
||||||
|
reads,
|
||||||
|
vec![100, 101, 102, 103],
|
||||||
|
"a source that filled one sector per batch must be asked for the \
|
||||||
|
next one, not advanced a whole batch past it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source that reads NOTHING must terminate. Without the `.max(1)` the
|
||||||
|
/// cursor never moves and `tried` never increments — the budget cannot end
|
||||||
|
/// the loop, so the scan spins forever inside a library whose whole job is
|
||||||
|
/// surviving hostile input.
|
||||||
|
#[test]
|
||||||
|
fn a_source_that_returns_zero_sectors_terminates() {
|
||||||
|
let mut src = MockSource::new(0x00);
|
||||||
|
src.short_read = Some(0);
|
||||||
|
let ext = [crate::disc::Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 8,
|
||||||
|
}];
|
||||||
|
let outcome = crack_key_scan(&mut src, &ext, 4, None, false);
|
||||||
|
assert!(
|
||||||
|
matches!(outcome, CrackOutcome::Unencrypted),
|
||||||
|
"nothing was read, so nothing scrambled was seen"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
src.reads.borrow().len() <= 8,
|
||||||
|
"the cursor must advance even on an empty read; got {} reads over \
|
||||||
|
an 8-sector extent",
|
||||||
|
src.reads.borrow().len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// crack_key caps total scanned sectors at 50_000 even when extents are
|
/// crack_key caps total scanned sectors at 50_000 even when extents are
|
||||||
/// far larger, and counts EVERY scanned sector (clear ones included)
|
/// far larger, and counts EVERY scanned sector (clear ones included)
|
||||||
/// toward the budget. With one 200_000-sector extent of clear sectors, it
|
/// toward the budget. With one 200_000-sector extent of clear sectors, it
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ const MAX_SUBDIRS: usize = (u16::MAX - 1) as usize;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const MAX_SUBDIRS: usize = 4;
|
const MAX_SUBDIRS: usize = 4;
|
||||||
|
|
||||||
|
/// One entry per child plus the parent's own must still fit the 16-bit field.
|
||||||
|
///
|
||||||
|
/// At MODULE scope, and it has to be. This assertion previously sat inside
|
||||||
|
/// `mod tests`, which is `#[cfg(test)]`, while carrying `#[cfg(not(test))]`
|
||||||
|
/// itself — so it was compiled in NO configuration and could never fire, which
|
||||||
|
/// made it exactly the dead gate the test above it was written to replace.
|
||||||
|
#[cfg(not(test))]
|
||||||
|
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
|
||||||
|
|
||||||
/// Largest image this planner will synthesize, in sectors (128 GiB).
|
/// Largest image this planner will synthesize, in sectors (128 GiB).
|
||||||
///
|
///
|
||||||
/// A DVD title set records where its VOBS begins as an offset in its own IFO,
|
/// A DVD title set records where its VOBS begins as an offset in its own IFO,
|
||||||
@@ -998,11 +1007,6 @@ mod tests {
|
|||||||
"expected DirImageFanout, got {err:?}"
|
"expected DirImageFanout, got {err:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// And the real production value is what ships: one per child plus the
|
|
||||||
// parent's own entry must still fit in the 16-bit field.
|
|
||||||
#[cfg(not(test))]
|
|
||||||
const _: () = assert!(MAX_SUBDIRS + 1 == u16::MAX as usize);
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-7
@@ -254,7 +254,7 @@ impl Disc {
|
|||||||
let key = match vts_keys.get(&vts) {
|
let key = match vts_keys.get(&vts) {
|
||||||
Some(k) => k.clone(),
|
Some(k) => k.clone(),
|
||||||
None => {
|
None => {
|
||||||
let k = self.resolve_vts_key(&vts, &planned, &mut dec, &base_keys);
|
let k = self.resolve_vts_key(&vts, &planned, &mut dec, &base_keys)?;
|
||||||
vts_keys.insert(vts.clone(), k.clone());
|
vts_keys.insert(vts.clone(), k.clone());
|
||||||
k
|
k
|
||||||
}
|
}
|
||||||
@@ -291,13 +291,24 @@ impl Disc {
|
|||||||
/// VOB extents (keyless Stevenson attack). The disc-wide key is reused when
|
/// VOB extents (keyless Stevenson attack). The disc-wide key is reused when
|
||||||
/// it already covers this VTS (single-VTS discs, or this VTS's span). The
|
/// it already covers this VTS (single-VTS discs, or this VTS's span). The
|
||||||
/// reader is borrowed from the decrypting decorator (its inner source).
|
/// reader is borrowed from the decrypting decorator (its inner source).
|
||||||
|
///
|
||||||
|
/// FALLIBLE, because the three outcomes of a crack are not two.
|
||||||
|
/// [`crate::css::CrackOutcome`] exists precisely to separate "no scrambled
|
||||||
|
/// sector was seen, so there is nothing to decrypt" from "scrambled sectors
|
||||||
|
/// were seen and no key came out", and its doc says callers MUST surface
|
||||||
|
/// the second as a hard error. This function used the `Option`-returning
|
||||||
|
/// `crack_key`, which collapses both into `None`, and then fell back to the
|
||||||
|
/// disc-wide key — descrambling this VTS with ANOTHER VTS's title key. The
|
||||||
|
/// hazard is described in the ordering note below, which was written about
|
||||||
|
/// the same fallback; ordering makes the crack far more likely to succeed,
|
||||||
|
/// but it cannot make a failed crack safe.
|
||||||
fn resolve_vts_key<S: SectorSource>(
|
fn resolve_vts_key<S: SectorSource>(
|
||||||
&self,
|
&self,
|
||||||
vts: &str,
|
vts: &str,
|
||||||
planned: &[PlannedFile],
|
planned: &[PlannedFile],
|
||||||
dec: &mut DecryptingSectorSource<S>,
|
dec: &mut DecryptingSectorSource<S>,
|
||||||
base_keys: &DecryptKeys,
|
base_keys: &DecryptKeys,
|
||||||
) -> DecryptKeys {
|
) -> Result<DecryptKeys> {
|
||||||
// Gather the title VOB extents for this VTS (VTS_xx_1.VOB .. _9.VOB;
|
// Gather the title VOB extents for this VTS (VTS_xx_1.VOB .. _9.VOB;
|
||||||
// VTS_xx_0.VOB is the menu and is clear, so excluded from the crack).
|
// VTS_xx_0.VOB is the menu and is clear, so excluded from the crack).
|
||||||
// Gather this VTS's title VOBs BY NAME, ascending.
|
// Gather this VTS's title VOBs BY NAME, ascending.
|
||||||
@@ -336,7 +347,7 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if extents.is_empty() {
|
if extents.is_empty() {
|
||||||
return base_keys.clone();
|
return Ok(base_keys.clone());
|
||||||
}
|
}
|
||||||
// PLAYBACK ORDER — do NOT sort. This is the 1.5.1 garbage bug, and it
|
// PLAYBACK ORDER — do NOT sort. This is the 1.5.1 garbage bug, and it
|
||||||
// grew back here in a new code path: the comment this replaced said it
|
// grew back here in a new code path: the comment this replaced said it
|
||||||
@@ -358,11 +369,20 @@ impl Disc {
|
|||||||
// in file order, which together is playback order for a DVD title set.
|
// in file order, which together is playback order for a DVD title set.
|
||||||
// Crack against the raw (still-scrambled) inner reader, NOT the
|
// Crack against the raw (still-scrambled) inner reader, NOT the
|
||||||
// decrypting view — `crack_key` runs the descrambler itself.
|
// decrypting view — `crack_key` runs the descrambler itself.
|
||||||
match crate::css::crack_key(dec.inner_mut(), &extents, 64) {
|
match crate::css::crack_key_outcome(dec.inner_mut(), &extents, 64, None) {
|
||||||
Some(state) => DecryptKeys::Css {
|
crate::css::CrackOutcome::Cracked(state) => Ok(DecryptKeys::Css {
|
||||||
title_key: state.title_key,
|
title_key: state.title_key,
|
||||||
},
|
}),
|
||||||
None => base_keys.clone(),
|
// No scrambled sector anywhere in this VTS: the content is clear,
|
||||||
|
// and any key descrambles it as a no-op. The disc-wide key is the
|
||||||
|
// right answer, and this is the ONLY case that ever was.
|
||||||
|
crate::css::CrackOutcome::Unencrypted => Ok(base_keys.clone()),
|
||||||
|
// Scrambled sectors WERE seen and no key came out. Reusing the
|
||||||
|
// disc-wide key here writes corrupt PES behind an intact header and
|
||||||
|
// reports a complete extract at exit 0. Skippable per title, which
|
||||||
|
// is why this is the per-title code and not the disc-level one — a
|
||||||
|
// sibling VTS may still crack.
|
||||||
|
crate::css::CrackOutcome::ScrambledUncracked => Err(Error::CssKeyMissing),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1918,6 +1938,87 @@ mod tests {
|
|||||||
assert!(res.complete);
|
assert!(res.complete);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A VTS that IS scrambled but whose key could not be recovered must
|
||||||
|
/// FAIL, not borrow another VTS's key.
|
||||||
|
///
|
||||||
|
/// `crack_key` returns `Option`, which collapses "no scrambled sector was
|
||||||
|
/// seen" with "scrambled sectors were seen and no key came out"; the
|
||||||
|
/// fallback then descrambled this VTS under the disc-wide key and
|
||||||
|
/// `extract_tree` reported `complete = true` at exit 0, because no read had
|
||||||
|
/// failed. `CrackOutcome` exists to keep those two apart, and every sibling
|
||||||
|
/// path in the crate already honours it.
|
||||||
|
///
|
||||||
|
/// The fixture: VTS_01 is crackable, VTS_02 is scrambled with NO periodic
|
||||||
|
/// crib, so its crack genuinely fails.
|
||||||
|
#[test]
|
||||||
|
fn a_scrambled_vts_that_cannot_be_cracked_fails_instead_of_borrowing_a_key() {
|
||||||
|
let key_1 = [0x10u8, 0x20, 0x30, 0x40, 0x50];
|
||||||
|
let (_plain_1, scrambled_1) = {
|
||||||
|
let seed = [0x11u8, 0x22, 0x33, 0x44, 0x01];
|
||||||
|
let mut plain = vec![0u8; 2048];
|
||||||
|
plain[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||||
|
plain[0x14] = 0x10;
|
||||||
|
let pat: Vec<u8> = (0..8)
|
||||||
|
.map(|k| (0xA0u8.wrapping_add(k as u8) ^ 0x01) ^ 0x5A)
|
||||||
|
.collect();
|
||||||
|
for (i, b) in plain.iter_mut().enumerate().skip(0x59) {
|
||||||
|
*b = pat[i % 8];
|
||||||
|
}
|
||||||
|
plain[0x54..0x59].copy_from_slice(&seed);
|
||||||
|
let mut scrambled = plain.clone();
|
||||||
|
lfsr::scramble_sector(&key_1, &mut scrambled);
|
||||||
|
(plain, scrambled)
|
||||||
|
};
|
||||||
|
// Scrambled — the pack header and the 0x14 flag are set, so the scan
|
||||||
|
// SEES ciphertext — but the cleartext region carries no periodic run,
|
||||||
|
// so the Stevenson crib never forms and no key is recoverable.
|
||||||
|
let uncrackable = {
|
||||||
|
let mut sect = vec![0u8; 2048];
|
||||||
|
sect[0x00..0x04].copy_from_slice(&[0x00, 0x00, 0x01, 0xBA]);
|
||||||
|
sect[0x14] = 0x10;
|
||||||
|
for (i, b) in sect.iter_mut().enumerate().skip(0x59) {
|
||||||
|
// Non-repeating, so no run of any period survives to 0x80.
|
||||||
|
*b = (i as u8).wrapping_mul(37).wrapping_add(11);
|
||||||
|
}
|
||||||
|
sect
|
||||||
|
};
|
||||||
|
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "VIDEO_TS".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: vec![
|
||||||
|
file("VTS_01_1.VOB", 30, 5000, scrambled_1, false),
|
||||||
|
file("VTS_02_1.VOB", 32, 6000, uncrackable, false),
|
||||||
|
],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let mut disc = build_disc(root);
|
||||||
|
let out = TmpDir::new("css_uncrackable_vts");
|
||||||
|
let mut d = clear_disc();
|
||||||
|
d.content_format = crate::disc::ContentFormat::MpegPs;
|
||||||
|
d.css = Some(crate::css::CssState {
|
||||||
|
title_key: [0xFFu8; 5],
|
||||||
|
crack_span: None,
|
||||||
|
});
|
||||||
|
let err = d
|
||||||
|
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
|
||||||
|
.expect_err(
|
||||||
|
"a VTS whose key could not be recovered must be a hard error, \
|
||||||
|
not a silent extract under another VTS's key",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(err, Error::CssKeyMissing),
|
||||||
|
"expected CssKeyMissing, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `Borrowed` is a thin forwarding wrapper the decrypting decorator uses
|
/// `Borrowed` is a thin forwarding wrapper the decrypting decorator uses
|
||||||
/// to avoid taking ownership of the caller's reader -- every
|
/// to avoid taking ownership of the caller's reader -- every
|
||||||
/// `SectorSource` method must forward to the wrapped `&mut dyn
|
/// `SectorSource` method must forward to the wrapped `&mut dyn
|
||||||
|
|||||||
+58
-1
@@ -637,10 +637,18 @@ fn xml_escape(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Replace path-hostile characters in a filename component.
|
/// Replace path-hostile characters in a filename component.
|
||||||
|
///
|
||||||
|
/// Control characters included, NUL above all. Every string this touches is
|
||||||
|
/// disc bytes, and a language code is three raw STN bytes run through
|
||||||
|
/// `from_utf8_lossy` with no validation — `00 00 00` is the ordinary
|
||||||
|
/// "undefined" encoding on real Blu-rays. A NUL in a path aborts `File::create`
|
||||||
|
/// with `InvalidInput`, which took the whole demux export down before a single
|
||||||
|
/// track file was opened.
|
||||||
fn sanitize(s: &str) -> String {
|
fn sanitize(s: &str) -> String {
|
||||||
s.chars()
|
s.chars()
|
||||||
.map(|c| match c {
|
.map(|c| match c {
|
||||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
c if c.is_control() => '_',
|
||||||
_ => c,
|
_ => c,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -787,8 +795,11 @@ impl DemuxSink {
|
|||||||
Naming::Pid => format!("{} {:04x}", sanitize(&opts.base), pid),
|
Naming::Pid => format!("{} {:04x}", sanitize(&opts.base), pid),
|
||||||
Naming::Friendly => {
|
Naming::Friendly => {
|
||||||
let mut parts = vec![sanitize(&opts.base), format!("t{idx:02}")];
|
let mut parts = vec![sanitize(&opts.base), format!("t{idx:02}")];
|
||||||
|
// The language is disc bytes too, and got none of the
|
||||||
|
// treatment `opts.base` two lines up already had.
|
||||||
|
let lang = sanitize(lang);
|
||||||
if !lang.is_empty() {
|
if !lang.is_empty() {
|
||||||
parts.push(lang.to_string());
|
parts.push(lang);
|
||||||
}
|
}
|
||||||
parts.push(codec_label(codec).to_string());
|
parts.push(codec_label(codec).to_string());
|
||||||
parts.join(" ")
|
parts.join(" ")
|
||||||
@@ -987,6 +998,52 @@ mod tests {
|
|||||||
Resolution, SampleRate, VideoStream,
|
Resolution, SampleRate, VideoStream,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── The language component is disc bytes ──────────────────────────────
|
||||||
|
//
|
||||||
|
// `opts.base` was sanitised and the language beside it was not, though a
|
||||||
|
// language code is three raw STN bytes run through `from_utf8_lossy` with
|
||||||
|
// no validation. `00 00 00` is the ordinary "undefined" encoding on real
|
||||||
|
// Blu-rays, and a NUL in a path fails `File::create` with `InvalidInput` —
|
||||||
|
// which aborted the whole export before a single track file was opened.
|
||||||
|
|
||||||
|
/// The stem stays one usable filename component whatever the disc says.
|
||||||
|
#[test]
|
||||||
|
fn a_hostile_language_code_cannot_break_the_output_filename() {
|
||||||
|
let opts = DemuxOptions {
|
||||||
|
base: "Movie".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(matches!(opts.naming, Naming::Friendly), "default naming");
|
||||||
|
|
||||||
|
for lang in ["\u{0}\u{0}\u{0}", "a/b", "..", "a\nb", "e:s"] {
|
||||||
|
let stem = DemuxSink::stem_for(&opts, 1, 0x1100, lang, Codec::Ac3);
|
||||||
|
assert!(
|
||||||
|
!stem.chars().any(|c| c.is_control()),
|
||||||
|
"control character survived into the filename for {lang:?}: {stem:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stem.contains('/') && !stem.contains('\\') && !stem.contains(':'),
|
||||||
|
"a path separator survived for {lang:?}: {stem:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
std::path::Path::new(&stem).components().count() == 1,
|
||||||
|
"the stem must stay ONE component for {lang:?}: {stem:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A legitimate language is still carried through untouched — the
|
||||||
|
/// sanitiser must not be so lossy that it stops naming the track.
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_language_code_survives_sanitising() {
|
||||||
|
let opts = DemuxOptions {
|
||||||
|
base: "Movie".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let stem = DemuxSink::stem_for(&opts, 1, 0x1100, "eng", Codec::Ac3);
|
||||||
|
assert!(stem.contains("eng"), "got {stem:?}");
|
||||||
|
}
|
||||||
|
|
||||||
fn video_stream(codec: Codec) -> DiscStream {
|
fn video_stream(codec: Codec) -> DiscStream {
|
||||||
DiscStream::Video(VideoStream {
|
DiscStream::Video(VideoStream {
|
||||||
pid: 0x1011,
|
pid: 0x1011,
|
||||||
|
|||||||
Reference in New Issue
Block a user