mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
The hammer over two compound files (version 3 and 4, and their first 2 KiB), the synthetic ISO BMFF files of the fuzz test, and a synthetic Matroska file plus four muxed ones (no lacing, Xiph, EBML, fixed-or-EBML), each opened and read through the whole API. No panic found. Ported from storytold/filmcraft#1794 (crates/cfb/src/tests/hostile.rs, crates/isobmff/tests/fuzz.rs, crates/matroska/src/synthetic_tests/hostile.rs). Co-authored-by: Logan Jennings <loganwjennings@gmail.com>
444 lines
17 KiB
Rust
444 lines
17 KiB
Rust
//! Round trips, the writer's layout and the reader's loop bounds. `tests/ported.rs` holds the
|
|
//! tests for the behaviour taken from the other readers this crate replaces.
|
|
|
|
use super::*;
|
|
|
|
mod hostile;
|
|
mod ported;
|
|
|
|
fn pattern(n: usize, seed: u8) -> Vec<u8> {
|
|
(0..n).map(|i| (i as u32).wrapping_mul(31).wrapping_add(seed as u32) as u8).collect()
|
|
}
|
|
|
|
/// A tree with mini and regular streams, nested storages and many siblings.
|
|
fn sample(version: Version, siblings: usize) -> (Vec<u8>, Vec<(String, Vec<u8>)>) {
|
|
let mut w = Writer::new(version);
|
|
w.set_clsid(Writer::ROOT, [7; 16]);
|
|
let mut expect = Vec::new();
|
|
let a = w.storage(Writer::ROOT, "Header-2", [1; 16]).unwrap();
|
|
let b = w.storage(a, "Content-3b03", [2; 16]).unwrap();
|
|
for (i, size) in [0usize, 1, 63, 64, 65, 4095, 4096, 4097, 20_000].iter().enumerate() {
|
|
let name = format!("s{i}");
|
|
let data = pattern(*size, i as u8);
|
|
w.stream(b, &name, data.clone()).unwrap();
|
|
expect.push((format!("Header-2/Content-3b03/{name}"), data));
|
|
}
|
|
for i in 0..siblings {
|
|
let name = format!("Mobs-1901{{{i:x}}}");
|
|
let s = w.storage(Writer::ROOT, &name, [3; 16]).unwrap();
|
|
let data = pattern(10 + i * 7, 99);
|
|
w.stream(s, "properties", data.clone()).unwrap();
|
|
expect.push((format!("{name}/properties"), data));
|
|
}
|
|
(w.finish(), expect)
|
|
}
|
|
|
|
fn check(bytes: &[u8], expect: &[(String, Vec<u8>)]) {
|
|
let cf = CompoundFile::open(bytes).unwrap();
|
|
assert_eq!(cf.root().clsid, [7; 16]);
|
|
for (path, data) in expect {
|
|
assert_eq!(&cf.read_path(path).unwrap(), data, "{path}");
|
|
}
|
|
let h = cf.find("Header-2").unwrap();
|
|
assert_eq!(cf.entries[h].clsid, [1; 16]);
|
|
assert_eq!(cf.entries[h].kind, EntryKind::Storage);
|
|
// children come back sorted in CFB order
|
|
for e in &cf.entries {
|
|
for w in e.children.windows(2) {
|
|
assert!(compare_names(&cf.entries[w[0]].name, &cf.entries[w[1]].name).is_lt());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every path from a node to a nil leaf has the same number of black nodes, and no red node has
|
|
/// a red child.
|
|
fn check_red_black(bytes: &[u8]) {
|
|
let cf = CompoundFile::open(bytes).unwrap();
|
|
let raw = |i: u32| -> (u32, u32, u8) {
|
|
let ss = cf.sector_size();
|
|
let dir_start = u32::from_le_bytes(bytes[48..52].try_into().unwrap());
|
|
// the writer stores the directory contiguously
|
|
let at = (dir_start as usize + 1) * ss + i as usize * 128;
|
|
let e = &bytes[at..at + 128];
|
|
(u32::from_le_bytes(e[68..72].try_into().unwrap()), u32::from_le_bytes(e[72..76].try_into().unwrap()), e[67])
|
|
};
|
|
fn black_height(raw: &dyn Fn(u32) -> (u32, u32, u8), n: u32, parent_red: bool) -> usize {
|
|
if n == NOSTREAM {
|
|
return 1;
|
|
}
|
|
let (l, r, c) = raw(n);
|
|
let red = c == 0;
|
|
assert!(!(red && parent_red), "red node with a red parent");
|
|
let hl = black_height(raw, l, red);
|
|
let hr = black_height(raw, r, red);
|
|
assert_eq!(hl, hr, "unbalanced black height");
|
|
hl + usize::from(!red)
|
|
}
|
|
let dir_start = u32::from_le_bytes(bytes[48..52].try_into().unwrap());
|
|
let ss = cf.sector_size();
|
|
for i in 0..cf.entries.len() {
|
|
let at = (dir_start as usize + 1) * ss + i * 128;
|
|
let child = u32::from_le_bytes(bytes[at + 76..at + 80].try_into().unwrap());
|
|
if child != NOSTREAM {
|
|
let (_, _, c) = raw(child);
|
|
assert_eq!(c, 1, "tree roots are black");
|
|
black_height(&raw, child, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_v3_and_v4() {
|
|
for v in [Version::V3, Version::V4] {
|
|
for n in [0, 1, 2, 3, 7, 8, 40] {
|
|
let (bytes, expect) = sample(v, n);
|
|
assert_eq!(bytes.len() % v_sector(v), 0);
|
|
check(&bytes, &expect);
|
|
check_red_black(&bytes);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn v_sector(v: Version) -> usize {
|
|
if v == Version::V3 { 512 } else { 4096 }
|
|
}
|
|
|
|
#[test]
|
|
fn header_fields() {
|
|
let (b, _) = sample(Version::V4, 3);
|
|
assert!(sniff(&b));
|
|
assert_eq!(&b[24..26], &0x3Eu16.to_le_bytes());
|
|
assert_eq!(&b[26..28], &4u16.to_le_bytes());
|
|
assert_eq!(&b[30..32], &12u16.to_le_bytes());
|
|
let (b, _) = sample(Version::V3, 3);
|
|
assert_eq!(&b[26..28], &3u16.to_le_bytes());
|
|
assert_eq!(&b[30..32], &9u16.to_le_bytes());
|
|
assert_eq!(&b[40..44], &0u32.to_le_bytes(), "version 3 leaves the directory sector count 0");
|
|
assert_eq!(CompoundFile::open(&b).unwrap().version, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn large_file_needs_difat() {
|
|
// > 109 FAT sectors with 512-byte sectors: 109 * 128 sectors * 512 bytes ≈ 7.1 MB
|
|
let mut w = Writer::new(Version::V3);
|
|
let big = pattern(7_500_000, 5);
|
|
w.stream(Writer::ROOT, "big", big.clone()).unwrap();
|
|
w.stream(Writer::ROOT, "small", vec![1, 2, 3]).unwrap();
|
|
let b = w.finish();
|
|
let n_difat = u32::from_le_bytes(b[72..76].try_into().unwrap());
|
|
assert!(n_difat >= 1);
|
|
let cf = CompoundFile::open(&b).unwrap();
|
|
assert_eq!(cf.read_path("big").unwrap(), big);
|
|
assert_eq!(cf.read_path("small").unwrap(), vec![1, 2, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn names() {
|
|
assert!(compare_names("b", "aa").is_lt(), "shorter first");
|
|
assert!(compare_names("abc", "ABD").is_lt());
|
|
assert!(compare_names("Abc", "aBC").is_eq());
|
|
assert!(validate_name("a/b").is_err());
|
|
assert!(validate_name(&"x".repeat(32)).is_err());
|
|
assert!(validate_name(&"x".repeat(31)).is_ok());
|
|
let mut w = Writer::default();
|
|
w.stream(Writer::ROOT, "Name", vec![]).unwrap();
|
|
assert!(matches!(w.stream(Writer::ROOT, "NAME", vec![]), Err(Error::BadName(_))));
|
|
let s = w.stream(Writer::ROOT, "x", vec![]).unwrap();
|
|
assert!(w.storage(s, "y", [0; 16]).is_err(), "streams have no children");
|
|
let cf_bytes = w.finish();
|
|
let cf = CompoundFile::open(&cf_bytes).unwrap();
|
|
assert!(cf.find("name").is_ok(), "lookups are case-insensitive");
|
|
assert!(matches!(cf.find("nope"), Err(Error::NotFound(_))));
|
|
assert!(cf.read(0).is_err(), "the root is not a stream");
|
|
}
|
|
|
|
#[test]
|
|
fn unicode_names() {
|
|
let mut w = Writer::default();
|
|
w.stream(Writer::ROOT, "Ünïcødé ストリーム", vec![9; 10]).unwrap();
|
|
let b = w.finish();
|
|
let cf = CompoundFile::open(&b).unwrap();
|
|
assert_eq!(cf.read_path("üNÏCØDÉ ストリーム").unwrap(), vec![9; 10]);
|
|
}
|
|
|
|
#[test]
|
|
fn not_cfb() {
|
|
assert_eq!(CompoundFile::open(b"hello").unwrap_err(), Error::NotCfb);
|
|
assert_eq!(CompoundFile::open(&[0u8; 600]).unwrap_err(), Error::NotCfb);
|
|
}
|
|
|
|
#[test]
|
|
fn truncation_never_panics() {
|
|
let (b, expect) = sample(Version::V3, 20);
|
|
let mut ok = 0;
|
|
for cut in (0..b.len()).step_by(29) {
|
|
if let Ok(cf) = CompoundFile::open(&b[..cut]) {
|
|
ok += 1;
|
|
for (path, data) in &expect {
|
|
if let Ok(d) = cf.read_path(path) {
|
|
assert_eq!(&d, data, "a stream that reads back is complete");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let _ = ok;
|
|
}
|
|
|
|
#[test]
|
|
fn mutation_never_panics_or_hangs() {
|
|
let (b, _) = sample(Version::V3, 12);
|
|
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
|
for _ in 0..1500 {
|
|
let mut g = b.clone();
|
|
for _ in 0..6 {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 7;
|
|
seed ^= seed << 17;
|
|
// bias towards the header, FAT and directory (the end of the file)
|
|
let at = if seed & 1 == 0 { (seed >> 8) as usize % 512 } else { g.len() - 1 - (seed >> 8) as usize % 2048.min(g.len()) };
|
|
g[at] = (seed >> 40) as u8;
|
|
}
|
|
if let Ok(cf) = CompoundFile::open(&g) {
|
|
for i in 0..cf.entries.len() {
|
|
let _ = cf.read(i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn put_u32(b: &mut [u8], at: usize, v: u32) {
|
|
b[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
|
}
|
|
|
|
/// A 512-byte header (§2.2) with sector shift `shift`: no directory, mini FAT or DIFAT chain, and
|
|
/// all 109 FAT slots free. Tests fill in the fields they attack.
|
|
fn bare_header(shift: u16) -> Vec<u8> {
|
|
let mut h = vec![0u8; 512];
|
|
h[..8].copy_from_slice(&SIGNATURE);
|
|
h[24..26].copy_from_slice(&0x3Eu16.to_le_bytes());
|
|
h[26..28].copy_from_slice(&(if shift == 9 { 3u16 } else { 4 }).to_le_bytes());
|
|
h[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes());
|
|
h[30..32].copy_from_slice(&shift.to_le_bytes());
|
|
h[32..34].copy_from_slice(&6u16.to_le_bytes());
|
|
for at in [48, 60, 68] {
|
|
put_u32(&mut h, at, ENDOFCHAIN);
|
|
}
|
|
put_u32(&mut h, 56, MINI_STREAM_CUTOFF);
|
|
h[76..].fill(0xFF);
|
|
h
|
|
}
|
|
|
|
/// A DIFAT sector that names no FAT sector and points to itself. The loop guard used to be the
|
|
/// header's DIFAT sector count (up to 2^32 - 1), so this 1 KB file kept `open` busy for 14-16 s here (minutes with `u32::MAX`), reading a sector each pass. A DIFAT
|
|
/// chain cannot be longer than the file, so the walk now stops after as many passes as the file
|
|
/// has sectors (1024 / 512 + 1 = 3 here) and fails on the 4th.
|
|
#[test]
|
|
fn difat_self_loop_fails_fast() {
|
|
let mut b = bare_header(9);
|
|
put_u32(&mut b, 44, 1); // one FAT sector, to be found through the DIFAT
|
|
put_u32(&mut b, 68, 0); // first DIFAT sector
|
|
put_u32(&mut b, 72, 100_000_000); // claimed DIFAT sector count (inflates the old loop bound)
|
|
let mut difat = vec![0xFF; 512]; // no FAT sector numbers...
|
|
put_u32(&mut difat, 508, 0); // ...and the next DIFAT sector is this one
|
|
b.extend_from_slice(&difat);
|
|
// The message names the bound the walk stopped at: the file's 3 sectors, not the header's
|
|
// 100,000,000 (the old guard allowed 100,000,003 passes).
|
|
let expect = format!("DIFAT chain loops: more than 3 sectors in a {}-byte file", b.len());
|
|
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect));
|
|
}
|
|
|
|
/// All 109 header slots name the same FAT sector, so the FAT would be 109 copies of it. The
|
|
/// reader used to build that FAT and then follow a looping directory chain through it, appending
|
|
/// a sector per FAT entry before failing: a 457 MB buffer per open of this 450 KB file. No file
|
|
/// lists a FAT sector twice, so the list is refused before any of it is read.
|
|
#[test]
|
|
fn duplicated_fat_sector_is_refused() {
|
|
let ss = 4096;
|
|
let mut b = bare_header(12);
|
|
put_u32(&mut b, 44, 109); // FAT sectors: the 109 header slots...
|
|
for i in 0..109 {
|
|
put_u32(&mut b, 76 + i * 4, 0); // ...all naming sector 0
|
|
}
|
|
put_u32(&mut b, 48, 0); // the directory starts at sector 0
|
|
b.resize(ss, 0);
|
|
let mut fat = vec![0xFF; ss];
|
|
put_u32(&mut fat, 0, 0); // FAT[0] = 0: sector 0 is followed by itself
|
|
b.extend_from_slice(&fat);
|
|
b.resize(110 * ss, 0); // big enough for 109 FAT sectors to pass the size check
|
|
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid("FAT sector 0 is listed twice".into()));
|
|
}
|
|
|
|
/// A directory chain that loops (sector 1 → 1) stops after as many sectors as the file has, not
|
|
/// after as many as the FAT has entries.
|
|
#[test]
|
|
fn looping_directory_chain_fails_fast() {
|
|
let mut b = bare_header(9);
|
|
put_u32(&mut b, 44, 1);
|
|
put_u32(&mut b, 76, 0); // the FAT is sector 0
|
|
put_u32(&mut b, 48, 1); // the directory starts at sector 1
|
|
let mut fat = vec![0xFF; 512];
|
|
put_u32(&mut fat, 0, FATSECT);
|
|
put_u32(&mut fat, 4, 1); // FAT[1] = 1
|
|
b.extend_from_slice(&fat);
|
|
b.resize(3 * 512, 0);
|
|
let expect = format!("sector chain loops: more than 4 sectors in a {}-byte file", b.len());
|
|
assert_eq!(CompoundFile::open(&b).unwrap_err(), Error::Invalid(expect));
|
|
}
|
|
|
|
/// Positive control for the chain bound: the longest chains a file can hold still read back, in
|
|
/// both versions: a directory (read without a size limit) and a stream, each filling every sector
|
|
/// but the header and the FAT (and the directory, for the stream).
|
|
#[test]
|
|
fn chains_filling_the_file_still_read() {
|
|
for v in [Version::V3, Version::V4] {
|
|
let ss = v_sector(v);
|
|
let mut w = Writer::new(v);
|
|
for i in 0..1000 {
|
|
w.stream(Writer::ROOT, &format!("e{i}"), Vec::new()).unwrap();
|
|
}
|
|
let b = w.finish();
|
|
assert!(b.len() / ss <= (1001 * 128usize).div_ceil(ss) + 3, "header, directory, FAT");
|
|
let cf = CompoundFile::open(&b).unwrap();
|
|
assert_eq!(cf.root().children.len(), 1000);
|
|
assert_eq!(cf.read_path("e999").unwrap(), Vec::<u8>::new());
|
|
|
|
let mut w = Writer::new(v);
|
|
let data = pattern(64 * ss, 3);
|
|
w.stream(Writer::ROOT, "big", data.clone()).unwrap();
|
|
let b = w.finish();
|
|
assert_eq!(b.len(), (1 + 64 + 2) * ss, "header, stream, directory, FAT");
|
|
assert_eq!(CompoundFile::open(&b).unwrap().read_path("big").unwrap(), data);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nul_in_a_name_is_rejected() {
|
|
assert!(validate_name("a\0b").is_err());
|
|
assert!(validate_name("\0").is_err());
|
|
let mut w = Writer::default();
|
|
assert!(matches!(w.stream(Writer::ROOT, "a\0b", vec![1]), Err(Error::BadName(_))));
|
|
assert!(matches!(w.storage(Writer::ROOT, "x\0", [0; 16]), Err(Error::BadName(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn span_is_checked() {
|
|
use crate::read::span;
|
|
assert_eq!(span(0, 1, 512), Some((512, 1024)));
|
|
assert_eq!(span(3, 0, 64), Some((192, 256)));
|
|
// On a 32-bit `usize` these are the overflows of `sector` and `read_mini_chain`; the checked
|
|
// arithmetic is the same code on 64-bit, so usize::MAX exercises it here. (A real 32-bit
|
|
// target is not run.)
|
|
assert_eq!(span(usize::MAX, 1, 512), None);
|
|
assert_eq!(span(usize::MAX, 0, 64), None);
|
|
assert_eq!(span(usize::MAX / 64, 0, 64), None, "start fits, end does not");
|
|
assert_eq!(span(usize::MAX - 1, 1, 1), None);
|
|
}
|
|
|
|
/// The allocating comparison compare_names used before the iterator rewrite.
|
|
fn compare_names_old(a: &str, b: &str) -> std::cmp::Ordering {
|
|
let ua: Vec<u16> = a.encode_utf16().collect();
|
|
let ub: Vec<u16> = b.encode_utf16().collect();
|
|
ua.len().cmp(&ub.len()).then_with(|| ua.iter().map(|&u| upper(u)).cmp(ub.iter().map(|&u| upper(u))))
|
|
}
|
|
|
|
#[test]
|
|
fn compare_names_matches_the_allocating_version() {
|
|
let names = [
|
|
"",
|
|
"a",
|
|
"A",
|
|
"b",
|
|
"B",
|
|
"ab",
|
|
"AB",
|
|
"aB",
|
|
"abc",
|
|
"ABD",
|
|
"Abc",
|
|
"aBC",
|
|
"z",
|
|
"Z",
|
|
"{",
|
|
"_",
|
|
"ß",
|
|
"SS",
|
|
"ss",
|
|
"ü",
|
|
"Ü",
|
|
"é",
|
|
"É",
|
|
"dž",
|
|
"Dž",
|
|
"DŽ",
|
|
"ストリーム",
|
|
"st",
|
|
"St",
|
|
"😀",
|
|
"😁",
|
|
"a😀",
|
|
"A😀",
|
|
"\u{ffff}",
|
|
"\u{10000}",
|
|
"Header-2",
|
|
"HEADER-2",
|
|
"header-2",
|
|
"Mobs-1901{a}",
|
|
"mobs-1901{A}",
|
|
];
|
|
for a in names {
|
|
for b in names {
|
|
assert_eq!(compare_names(a, b), compare_names_old(a, b), "{a:?} vs {b:?}");
|
|
}
|
|
}
|
|
// every BMP scalar against a few fixed names
|
|
for c in (0u32..=0x10FFFF).filter_map(char::from_u32) {
|
|
let s = c.to_string();
|
|
for other in ["a", "Z", "ü", "dž"] {
|
|
assert_eq!(compare_names(&s, other), compare_names_old(&s, other));
|
|
assert_eq!(compare_names(other, &s), compare_names_old(other, &s));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Name lookups go through a sorted per-storage index: every child of a wide storage resolves
|
|
/// (in any case), and a hostile file whose sibling tree is out of order still resolves by name,
|
|
/// with the first child in tree order winning a duplicated name.
|
|
#[test]
|
|
fn child_lookup_uses_a_sorted_index() {
|
|
let mut w = Writer::default();
|
|
for i in 0..2000 {
|
|
w.stream(Writer::ROOT, &format!("m{i}"), vec![i as u8]).unwrap();
|
|
}
|
|
let b = w.finish();
|
|
let cf = CompoundFile::open(&b).unwrap();
|
|
for i in (0..2000).step_by(37) {
|
|
let id = cf.child(0, &format!("M{i}")).unwrap();
|
|
assert_eq!(cf.read(id).unwrap(), vec![i as u8]);
|
|
}
|
|
assert_eq!(cf.child(0, "m2000"), None);
|
|
assert_eq!(cf.child(12345, "m1"), None);
|
|
|
|
let mut w = Writer::default();
|
|
for name in ["aa", "bb", "cc"] {
|
|
w.stream(Writer::ROOT, name, name.as_bytes().to_vec()).unwrap();
|
|
}
|
|
let mut b = w.finish();
|
|
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_le_bytes).collect::<Vec<u8>>();
|
|
let at = |b: &[u8], s: &str| b.windows(4).position(|w| w == utf16(s)).unwrap();
|
|
// swap the names "aa" and "cc": the tree's in-order walk is now cc, bb, aa
|
|
let (a, c) = (at(&b, "aa"), at(&b, "cc"));
|
|
b[a..a + 4].copy_from_slice(&utf16("cc"));
|
|
b[c..c + 4].copy_from_slice(&utf16("aa"));
|
|
let cf = CompoundFile::open(&b).unwrap();
|
|
let names: Vec<&str> = cf.root().children.iter().map(|&c| cf.entries[c].name.as_str()).collect();
|
|
assert_eq!(names, ["cc", "bb", "aa"]);
|
|
assert_eq!(cf.read_path("AA").unwrap(), b"cc");
|
|
assert_eq!(cf.read_path("cc").unwrap(), b"aa");
|
|
assert_eq!(cf.read_path("bb").unwrap(), b"bb");
|
|
// a duplicated name resolves to the first child in tree order
|
|
let bb = at(&b, "bb");
|
|
b[bb..bb + 4].copy_from_slice(&utf16("cc"));
|
|
let cf = CompoundFile::open(&b).unwrap();
|
|
assert_eq!(cf.read_path("cc").unwrap(), b"aa");
|
|
}
|