test: constrain the DiscStream loss surface and the empty-title guards
Second mutation pass over src/mux/. 26 survivors killed, no production change. Verified on HEAD before landing: each mutation below passes all 1,237 mux tests unmutated-suite. The priority item was the honest-loss-reporting surface. Both DiscStream::errors and DiscStream::lost_bytes could return a constant with nothing failing — a rip that lost sectors would report zero loss to the caller. This project has already shipped one defect of that shape (a total decryption failure reported as an empty title, exit 0). Driven now through two short-read fills so both land on values that are neither 0 nor 1 and differ from each other; no constant and no field swap survives. MkvStream::finish -> Ok(()) also survived. MkvMuxer::finish has the zero-frame MkvInvalid guard and two tests cover it, but the Stream wrapper above it could return Ok unconditionally and bypass the guard entirely — the empty-title defence was one layer thinner than it looked. au_assembly: pinned au_opener_from behaviourally to the normative byte values for all four modes, with negative cases for codes that are explicitly not openers (MPEG-2 slice 0x01..0xAF, user data 0xB2, extension 0xB5, sequence end 0xB7 per 13818-2 Table 6-1; VC-1 0x0A/0x0B/0x0C; H.264 SPS/PPS/IDR-slice). au_assembly and codec/ hold independent copies of these constants; they agree today, and comparing constants would not catch logic drifting apart, so both sides are now pinned to the spec instead of to each other. demux_sink::sanitize: every filename component demux:// writes comes from disc-controlled text, so the path-separator arm is a traversal guard. Deleting it now fails, including an end-to-end case where base = "../evil/Title" must produce exactly one file inside the chosen directory. stts_and_ctts_expand renamed to stts_expands_runs_to_per_sample_deltas_in_order and given runs with distinct deltas AND distinct lengths. Its old name claimed ctts coverage it never had, which is why the composition-time chain went unconstrained for eight rounds; the doc comment now points at the tests that do cover ctts. Correction to the previous pass: codec/truehd.rs flush -> vec![] IS equivalent. Applied it, full mux suite green. TrueHD buffers across PES but parse emits every complete unit immediately, so a residual buffer at EOF is a truncated access unit and is correctly discarded. The vec![Default::default()] variants are genuinely different and are killed. Deliberately not constrained: mkv::set_opening_capture (diagnostics behind a process-global tracing check, flaky under the parallel runner), and the three stdio.rs header paths (StdioStream holds concrete io::Stdin/Stdout and cannot be driven without a production refactor to injectable Read/Write).
This commit is contained in:
@@ -1628,4 +1628,165 @@ mod tests {
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// `demux://` exports the title's chapters as side files at `finish()`.
|
||||
/// A `write_chapters` that returned `Ok(())` without writing produces a
|
||||
/// demux run that reports complete success while the chapter files simply
|
||||
/// do not exist — the caller has no way to tell an intentionally
|
||||
/// chapterless title from a lost export.
|
||||
///
|
||||
/// Both formats are requested at once and BOTH files are checked, with
|
||||
/// distinct chapter names and a non-zero timestamp, so a writer that emitted
|
||||
/// one format, an empty file, or the wrong chapter list cannot pass.
|
||||
#[test]
|
||||
fn finish_exports_both_chapter_formats_with_real_content() {
|
||||
let dir = tempdir();
|
||||
let mut title = title_with(vec![video_stream(Codec::Mpeg2)], vec![None]);
|
||||
title.chapters = vec![
|
||||
crate::disc::Chapter {
|
||||
time_secs: 0.0,
|
||||
name: "Opening".into(),
|
||||
},
|
||||
crate::disc::Chapter {
|
||||
time_secs: 62.5,
|
||||
name: "Second".into(),
|
||||
},
|
||||
];
|
||||
let opts = DemuxOptions {
|
||||
base: "ChapTitle".into(),
|
||||
export_chapters: true,
|
||||
chapters_fmt: ChaptersFmt::Both,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||||
sink.finish().unwrap();
|
||||
|
||||
let xml = std::fs::read_to_string(dir.join("ChapTitle chapters.xml"))
|
||||
.expect("chapters.xml must exist after finish");
|
||||
let ogm = std::fs::read_to_string(dir.join("ChapTitle chapters.txt"))
|
||||
.expect("chapters.txt must exist after finish");
|
||||
|
||||
// Content, not merely existence: both chapters, both names, and the
|
||||
// 62.5 s timestamp formatted per its format.
|
||||
assert!(
|
||||
xml.contains("Opening") && xml.contains("Second"),
|
||||
"xml: {xml}"
|
||||
);
|
||||
assert!(
|
||||
xml.contains("00:01:02.500"),
|
||||
"xml must carry the real chapter time: {xml}"
|
||||
);
|
||||
assert!(
|
||||
ogm.contains("CHAPTER01=") && ogm.contains("CHAPTER02="),
|
||||
"ogm: {ogm}"
|
||||
);
|
||||
assert!(
|
||||
ogm.contains("Opening") && ogm.contains("Second"),
|
||||
"ogm names: {ogm}"
|
||||
);
|
||||
assert!(
|
||||
ogm.contains("00:01:02.500"),
|
||||
"ogm must carry the real chapter time: {ogm}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The other side of the gate: with the export switched off, no chapter
|
||||
/// file is written at all. Without this the test above would also pass for
|
||||
/// a `write_chapters` that ignored `opts.export_chapters`.
|
||||
#[test]
|
||||
fn chapters_are_not_exported_when_the_option_is_off() {
|
||||
let dir = tempdir();
|
||||
let mut title = title_with(vec![video_stream(Codec::Mpeg2)], vec![None]);
|
||||
title.chapters = vec![crate::disc::Chapter {
|
||||
time_secs: 0.0,
|
||||
name: "Opening".into(),
|
||||
}];
|
||||
let opts = DemuxOptions {
|
||||
base: "ChapTitle".into(),
|
||||
export_chapters: false,
|
||||
chapters_fmt: ChaptersFmt::Both,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||||
sink.finish().unwrap();
|
||||
assert!(!dir.join("ChapTitle chapters.xml").exists());
|
||||
assert!(!dir.join("ChapTitle chapters.txt").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Every filename component `demux://` writes is built from DISC-CONTROLLED
|
||||
/// text — the volume label / playlist name (`opts.base`) and the track label.
|
||||
/// `sanitize` is the only thing standing between that text and the
|
||||
/// filesystem: a surviving `/` (or `\` on Windows) makes the sink write
|
||||
/// OUTSIDE the output directory the caller chose, and `:` / `?` / `*` / `"` /
|
||||
/// `<` / `>` / `|` make the create fail outright on Windows and on SMB/exFAT
|
||||
/// shares, which are the normal targets for a rip.
|
||||
#[test]
|
||||
fn sanitize_neutralises_every_path_hostile_character() {
|
||||
for c in ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] {
|
||||
let got = sanitize(&format!("a{c}b"));
|
||||
assert_eq!(got, "a_b", "{c:?} must be replaced, got {got:?}");
|
||||
}
|
||||
// A traversal attempt in a disc label cannot escape the output directory:
|
||||
// no separator survives, so the whole thing stays ONE component.
|
||||
let escaped = sanitize("../../etc/passwd");
|
||||
assert_eq!(escaped, ".._.._etc_passwd");
|
||||
assert!(
|
||||
!std::path::Path::new(&escaped)
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir)),
|
||||
"the sanitized name must not decompose into a parent-directory hop"
|
||||
);
|
||||
// Ordinary characters — including spaces, dots, unicode and other
|
||||
// punctuation — are preserved, so the replacement is targeted, not a
|
||||
// blanket scrub that would mangle real titles.
|
||||
assert_eq!(
|
||||
sanitize("Amélie (2001) - Chapter 1.5 [Director's Cut]"),
|
||||
"Amélie (2001) - Chapter 1.5 [Director's Cut]"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end witness that `sanitize` is actually applied on the write path:
|
||||
/// a disc label containing a separator must produce ONE file inside the
|
||||
/// chosen directory, never a write into a sibling/parent path.
|
||||
#[test]
|
||||
fn a_disc_label_with_a_separator_cannot_write_outside_the_output_directory() {
|
||||
let dir = tempdir();
|
||||
let title = title_with(vec![video_stream(Codec::Mpeg2)], vec![None]);
|
||||
let opts = DemuxOptions {
|
||||
base: "../evil/Title".into(),
|
||||
export_chapters: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||||
sink.write(&PesFrame {
|
||||
coding: None,
|
||||
source: None,
|
||||
track: 0,
|
||||
pts: 0,
|
||||
keyframe: true,
|
||||
data: vec![0x00, 0x00, 0x01, 0xB3, 0xAA],
|
||||
duration_ns: None,
|
||||
})
|
||||
.unwrap();
|
||||
sink.finish().unwrap();
|
||||
|
||||
let names: Vec<String> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(names.len(), 1, "exactly one output file; got {names:?}");
|
||||
assert!(
|
||||
names[0].starts_with(".._evil_Title"),
|
||||
"the separators must be neutralised in the real filename; got {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!dir.parent().unwrap().join("evil").exists(),
|
||||
"nothing may be created outside the output directory"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user