mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
ruzstd 0.9.0 let a block decompress to more than its frame's window allows (RUSTSEC-2026-0344). The test swaps a hand-built Zstandard frame with a 1 KiB window and one 100,000-byte RLE block into an Affinity container entry and checks that reading the entry fails. With ruzstd pinned back to 0.9.0 the block is decompressed and the read succeeds.
381 lines
17 KiB
Rust
381 lines
17 KiB
Rust
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
|
|
|
|
mod preview {
|
|
use crate::container::header;
|
|
use crate::*;
|
|
use proptest::prelude::*;
|
|
|
|
fn fixture() -> Vec<u8> {
|
|
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
|
png.extend(13u32.to_be_bytes());
|
|
png.extend(b"IHDR");
|
|
png.extend(2u32.to_be_bytes());
|
|
png.extend(1u32.to_be_bytes());
|
|
png.extend([8, 6, 0, 0, 0]);
|
|
png.extend(crc32fast::hash(&png[12..]).to_be_bytes());
|
|
png.extend([0; 4]);
|
|
png.extend(b"IDAT");
|
|
png.extend(crc32fast::hash(b"IDAT").to_be_bytes());
|
|
png.extend([0; 4]);
|
|
png.extend(b"IEND");
|
|
png.extend(crc32fast::hash(b"IEND").to_be_bytes());
|
|
let mut b = vec![0; 72];
|
|
b[..4].copy_from_slice(MAGIC);
|
|
b[4..6].copy_from_slice(&12u16.to_le_bytes());
|
|
b[8..12].copy_from_slice(b"nsrP");
|
|
b[12..16].copy_from_slice(b"#Inf");
|
|
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
|
b[64..68].copy_from_slice(b"Prot");
|
|
b.extend(b"\xff\xff\xff\xffThmb");
|
|
b.extend(1u32.to_le_bytes());
|
|
b.extend((png.len() as u32 + 13).to_le_bytes());
|
|
b.extend(29u32.to_le_bytes());
|
|
b.extend(0u32.to_le_bytes());
|
|
b.extend((png.len() as u32).to_le_bytes());
|
|
b.push(1);
|
|
b.extend(png);
|
|
b
|
|
}
|
|
|
|
#[test]
|
|
fn indexed_preview_and_header() {
|
|
let b = fixture();
|
|
assert_eq!(header(&b).unwrap().version, 12);
|
|
let p = preview(&b).unwrap();
|
|
assert_eq!((p.width, p.height), (2, 1));
|
|
assert_eq!(p.png, &b[101..]);
|
|
}
|
|
|
|
#[test]
|
|
fn every_truncation_is_rejected() {
|
|
let b = fixture();
|
|
for end in 0..b.len() {
|
|
assert!(preview(&b[..end]).is_err(), "{end}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_assets_versions_offsets_lengths_and_bombs() {
|
|
let b = fixture();
|
|
for (offset, value) in [
|
|
(4, 13u64.to_le_bytes().to_vec()),
|
|
(8, b"urBR".to_vec()),
|
|
(24, u64::MAX.to_le_bytes().to_vec()),
|
|
(24, 1u64.to_le_bytes().to_vec()),
|
|
(24, 0u64.to_le_bytes().to_vec()),
|
|
(84, u32::MAX.to_le_bytes().to_vec()),
|
|
(88, 30u32.to_le_bytes().to_vec()),
|
|
(96, u32::MAX.to_le_bytes().to_vec()),
|
|
(117, 5000u32.to_be_bytes().to_vec()),
|
|
] {
|
|
let mut broken = b.clone();
|
|
broken[offset..offset + value.len()].copy_from_slice(&value);
|
|
assert!(preview(&broken).is_err(), "offset {offset}");
|
|
}
|
|
// Versions 8 to 11 store the same record (checked on public Affinity 1 and 2 files).
|
|
for version in 8..12 {
|
|
let mut legacy = b.clone();
|
|
legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes());
|
|
assert!(preview(&legacy).is_ok());
|
|
}
|
|
// Flags 4 and 8 occur in real files; the low two bits mark other container variants.
|
|
for (flags, ok) in [(4u16, true), (8, true), (1, false), (2, false)] {
|
|
let mut variant = b.clone();
|
|
variant[6..8].copy_from_slice(&flags.to_le_bytes());
|
|
assert_eq!(preview(&variant).is_ok(), ok, "flags {flags}");
|
|
}
|
|
let mut old = b.clone();
|
|
old[4..6].copy_from_slice(&7u16.to_le_bytes());
|
|
assert!(matches!(preview(&old), Err(Error::Unsupported(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unindexed_png_or_resource_is_never_used() {
|
|
let mut b = fixture();
|
|
b[24..32].copy_from_slice(&0u64.to_le_bytes());
|
|
assert!(preview(&b).is_err());
|
|
b[24..32].copy_from_slice(&72u64.to_le_bytes());
|
|
b[76..80].copy_from_slice(b"Meta");
|
|
assert!(preview(&b).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn every_chunk_checksum_including_the_end_is_checked() {
|
|
let b = fixture();
|
|
for at in [130, b.len() - 1] {
|
|
let mut damaged = b.clone();
|
|
damaged[at] ^= 1;
|
|
assert_eq!(preview(&damaged).unwrap_err(), Error::Malformed("PNG chunk checksum"));
|
|
}
|
|
let mut ancillary = vec![0; 4];
|
|
ancillary.extend(b"tEXt");
|
|
ancillary.extend(crc32fast::hash(b"tEXt").to_be_bytes());
|
|
let mut with_ancillary = b.clone();
|
|
let at = b.len() - 12;
|
|
with_ancillary.splice(at..at, ancillary);
|
|
let png_length = (with_ancillary.len() - 101) as u32;
|
|
with_ancillary[84..88].copy_from_slice(&(png_length + 13).to_le_bytes());
|
|
with_ancillary[96..100].copy_from_slice(&png_length.to_le_bytes());
|
|
assert!(preview(&with_ancillary).is_ok());
|
|
with_ancillary[at + 8] ^= 1;
|
|
assert_eq!(preview(&with_ancillary).unwrap_err(), Error::Malformed("PNG chunk checksum"));
|
|
}
|
|
|
|
#[test]
|
|
fn compressed_metadata_and_animation_are_not_preview_support() {
|
|
for kind in [b"zTXt", b"iTXt", b"iCCP", b"acTL", b"fcTL", b"fdAT"] {
|
|
let mut extra = vec![0; 4];
|
|
extra.extend(kind);
|
|
extra.extend(crc32fast::hash(kind).to_be_bytes());
|
|
let mut b = fixture();
|
|
let at = b.len() - 12;
|
|
b.splice(at..at, extra);
|
|
let length = (b.len() - 101) as u32;
|
|
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
|
|
b[96..100].copy_from_slice(&length.to_le_bytes());
|
|
assert!(matches!(preview(&b), Err(Error::Unsupported(_))));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn critical_chunks_follow_a_single_complete_png_layout() {
|
|
fn with_chunks(kinds: &[&[u8; 4]]) -> Vec<u8> {
|
|
let original = fixture();
|
|
let mut b = original[..134].to_vec(); // File, record and complete first IHDR.
|
|
for kind in kinds {
|
|
b.extend([0; 4]);
|
|
b.extend(*kind);
|
|
b.extend(crc32fast::hash(*kind).to_be_bytes());
|
|
}
|
|
let length = (b.len() - 101) as u32;
|
|
b[84..88].copy_from_slice(&(length + 13).to_le_bytes());
|
|
b[96..100].copy_from_slice(&length.to_le_bytes());
|
|
b
|
|
}
|
|
assert!(preview(&with_chunks(&[b"IDAT", b"IDAT", b"IEND"])).is_ok());
|
|
// PNG readers must not reject an otherwise valid unknown ancillary chunk just
|
|
// because its reserved (third-letter) bit is set; future versions can define it.
|
|
assert!(preview(&with_chunks(&[b"abct", b"IDAT", b"IEND"])).is_ok());
|
|
for kinds in [
|
|
vec![b"IEND"],
|
|
vec![b"IDAT", b"IHDR", b"IEND"],
|
|
vec![b"PLTE", b"PLTE", b"IDAT", b"IEND"],
|
|
vec![b"IDAT", b"PLTE", b"IEND"],
|
|
vec![b"IDAT", b"tEXt", b"IDAT", b"IEND"],
|
|
vec![b"ABCD", b"IDAT", b"IEND"],
|
|
vec![b"IDAT", b"IEND", b"tEXt"],
|
|
] {
|
|
assert!(preview(&with_chunks(&kinds)).is_err(), "{kinds:?}");
|
|
}
|
|
}
|
|
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig::with_cases(256))]
|
|
#[test]
|
|
fn arbitrary_input_never_panics(b in prop::collection::vec(any::<u8>(), 0..2048)) {
|
|
let _ = header(&b);
|
|
let _ = preview(&b);
|
|
}
|
|
#[test]
|
|
fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::<u8>()), 0..32)) {
|
|
let mut b = fixture();
|
|
for (i, value) in edits { if let Some(v) = b.get_mut(i) { *v = value; } }
|
|
let _ = preview(&b);
|
|
}
|
|
}
|
|
}
|
|
|
|
mod archive {
|
|
use crate::Error;
|
|
use crate::container::{Archive, Limits};
|
|
use crate::stream::{self, Kind, Tag, Value};
|
|
use crate::synth::{self, F, Method, tag};
|
|
use proptest::prelude::*;
|
|
|
|
fn doc() -> Vec<u8> {
|
|
synth::stream(&[
|
|
(tag(b"Desc"), F::Str("Document".into())),
|
|
(tag(b"Visi"), F::Bool(true)),
|
|
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])),
|
|
(tag(b"Colr"), F::Def(7, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0u8; 16].to_vec()))])),
|
|
(tag(b"Same"), F::Ref(7)),
|
|
(tag(b"Null"), F::Null),
|
|
(tag(b"Kids"), F::Shared(vec![F::Def(8, vec![tag(b"Grup"), tag(b"Node")], vec![(tag(b"Desc"), F::Str("g".into()))]), F::Ref(8)])),
|
|
(
|
|
tag(b"Crvs"),
|
|
F::Obj(tag(b"PCvD"), vec![(tag(b"Data"), F::Pos(vec![F::U8(0), F::U32(1), F::Bool(true), F::Records(18, vec![vec![0; 18]; 4])]))]),
|
|
),
|
|
(tag(b"Enum"), F::Enum(2, 1)),
|
|
(tag(b"Tile"), F::Entry("d/3".into())),
|
|
(tag(b"Stat"), F::U8s(vec![4, 0, 2])),
|
|
])
|
|
}
|
|
|
|
#[test]
|
|
fn every_method_round_trips_with_checksums() {
|
|
let d = doc();
|
|
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
|
let file = synth::container(&[("doc.dat", &d, method), ("d/3", b"tile", method)], None);
|
|
let mut a = Archive::open(&file, Limits::default()).unwrap();
|
|
assert_eq!(a.header.version, 12);
|
|
assert_eq!(a.read("doc.dat").unwrap(), d);
|
|
assert_eq!(a.read("d/3").unwrap(), b"tile");
|
|
assert_eq!(a.extracted(), d.len() + 4);
|
|
assert!(a.read("missing").is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_stream_parses_into_shared_objects_and_links() {
|
|
let s = stream::parse(&doc()).unwrap();
|
|
let root = &s.objects[s.root];
|
|
assert_eq!(root.class, Tag::of(b"Pers"));
|
|
assert_eq!(root.get(Tag::of(b"Desc")), Some(&Value::Str("Document".into())));
|
|
assert_eq!(root.get(Tag::of(b"Xfrm")), Some(&Value::Floats(vec![1.0, 0.0, 5.0, 0.0, 1.0, -2.0])));
|
|
let (Some(Value::Obj(Some(a))), Some(Value::Obj(Some(b)))) = (root.get(Tag::of(b"Colr")), root.get(Tag::of(b"Same"))) else { panic!() };
|
|
assert_eq!(a, b);
|
|
assert_eq!(s.objects[*a].class, Tag::of(b"RGBA"));
|
|
assert_eq!(s.objects[*a].kind, Kind::Shared);
|
|
let Some(Value::Array(kids)) = root.get(Tag::of(b"Kids")) else { panic!() };
|
|
assert_eq!(kids[0], kids[1]);
|
|
let Value::Obj(Some(g)) = kids[0] else { panic!() };
|
|
assert_eq!(s.objects[g].chain, vec![Tag::of(b"Grup"), Tag::of(b"Node")]);
|
|
assert_eq!(root.get(Tag::of(b"Enum")), Some(&Value::Enum { id: 2, version: 1 }));
|
|
assert_eq!(root.get(Tag::of(b"Tile")), Some(&Value::Entry("d/3".into())));
|
|
assert_eq!(root.get(Tag::of(b"Null")), Some(&Value::Obj(None)));
|
|
}
|
|
|
|
#[test]
|
|
fn checksums_sizes_and_budgets_are_enforced() {
|
|
let d = doc();
|
|
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
|
|
// Flip a byte inside the compressed payload, past the "#Fil" tag at offset 72.
|
|
let mut broken = file.clone();
|
|
broken[90] ^= 0x55;
|
|
let mut a = Archive::open(&broken, Limits::default()).unwrap();
|
|
assert!(a.read("doc.dat").is_err());
|
|
let mut a = Archive::open(&file, Limits { max_entry: d.len() - 1, max_total: usize::MAX }).unwrap();
|
|
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
|
let mut a = Archive::open(&file, Limits { max_entry: usize::MAX, max_total: d.len() + 1 }).unwrap();
|
|
a.read("doc.dat").unwrap();
|
|
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
|
}
|
|
|
|
// Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's
|
|
// window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than
|
|
// the format permits for a block. 0.9.1 refuses it.
|
|
#[test]
|
|
fn a_zstd_block_larger_than_its_frames_window_is_refused() {
|
|
const LEN: u32 = 100_000;
|
|
let plain = vec![0x61u8; LEN as usize];
|
|
let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None);
|
|
// A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE
|
|
// block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3.
|
|
let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00];
|
|
frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap());
|
|
frame.push(0x61);
|
|
// Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72).
|
|
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
|
|
let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize;
|
|
let mut swapped = file[..76].to_vec();
|
|
swapped.extend(&frame);
|
|
swapped.extend(&file[76 + old_len..]);
|
|
let new_fat = fat - old_len + frame.len();
|
|
swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes());
|
|
swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes());
|
|
let mut a = Archive::open(&swapped, Limits::default()).unwrap();
|
|
assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window");
|
|
}
|
|
|
|
#[test]
|
|
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
|
|
let big = vec![7u8; 1 << 20];
|
|
let mut file = synth::container(&[("doc.dat", &big, Method::Zlib)], None);
|
|
// Rewrite the declared size in the table to 1 KiB: decoding must stop and fail.
|
|
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
|
|
let size_at = fat + 59 + 5 + 8;
|
|
file[size_at..size_at + 8].copy_from_slice(&1024u64.to_le_bytes());
|
|
let mut a = Archive::open(&file, Limits::default()).unwrap();
|
|
assert_eq!(a.read("doc.dat").unwrap_err(), Error::Malformed("archive entry size mismatch"));
|
|
}
|
|
|
|
#[test]
|
|
fn libraries_and_cyclic_tables_are_rejected() {
|
|
let d = doc();
|
|
let mut file = synth::container(&[("doc.dat", &d, Method::Stored)], None);
|
|
let mut library = file.clone();
|
|
library[8..12].copy_from_slice(b"rAsA");
|
|
assert!(matches!(Archive::open(&library, Limits::default()), Err(Error::Unsupported(_))));
|
|
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap());
|
|
let at = fat as usize + 4;
|
|
file[at..at + 8].copy_from_slice(&fat.to_le_bytes());
|
|
assert_eq!(Archive::open(&file, Limits::default()).unwrap_err(), Error::Malformed("allocation tables form a cycle"));
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_streams_fail_cleanly() {
|
|
let deep = (0..2000).fold(Vec::new(), |inner, _| vec![(tag(b"Chld"), F::Obj(tag(b"Grup"), inner))]);
|
|
assert_eq!(stream::parse(&synth::stream(&deep)).unwrap_err(), Error::Limit("objects nested too deeply"));
|
|
// An array claiming four billion elements in a few bytes.
|
|
let huge = synth::stream(&[(tag(b"Big_"), F::Raw(vec![0x81, b'_', b'g', b'i', b'B', 0xff, 0xff, 0xff, 0xff, 0]))]);
|
|
assert_eq!(stream::parse(&huge).unwrap_err(), Error::Malformed("array longer than its data"));
|
|
let forward = synth::stream(&[(tag(b"Link"), F::Ref(99))]);
|
|
assert!(stream::parse(&forward).is_err());
|
|
let twice = synth::stream(&[(tag(b"A___"), F::Def(1, vec![tag(b"RGBA")], vec![])), (tag(b"B___"), F::Def(1, vec![tag(b"RGBA")], vec![]))]);
|
|
assert!(stream::parse(&twice).is_err());
|
|
let unknown = synth::stream(&[(tag(b"X___"), F::Raw(vec![0x0b, 1, 2, 3, 4]))]);
|
|
assert!(matches!(stream::parse(&unknown), Err(Error::Unsupported(_))));
|
|
let d = doc();
|
|
for end in 0..d.len() {
|
|
assert!(stream::parse(&d[..end]).is_err(), "{end}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
|
|
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
|
|
// of zero bits, a few KiB once compressed, would ask for 2^27 values (about 4 GiB). An
|
|
// array of one-byte values just past the budget fails the same way, before allocating.
|
|
let field = |code: u8, n: u32, data: usize| {
|
|
let mut f = vec![0x80 | code];
|
|
f.extend(tag(b"Big_").0.to_le_bytes());
|
|
f.extend(n.to_le_bytes());
|
|
f.resize(f.len() + data, 0);
|
|
F::Raw(f)
|
|
};
|
|
let bools = 1u32 << 27;
|
|
let bytes = u32::try_from(stream::MAX_VALUES).unwrap() + 1;
|
|
for f in [field(0x29, bools, (bools / 8) as usize), field(0x01, bytes, bytes as usize)] {
|
|
let d = synth::stream(&[(tag(b"Big_"), f)]);
|
|
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
|
|
assert!(file.len() < d.len() / 100, "{} of {}", file.len(), d.len());
|
|
let entry = Archive::open(&file, Limits::default()).unwrap().read("doc.dat").unwrap();
|
|
assert_eq!(stream::parse(&entry).unwrap_err(), Error::Limit("too many values in the document stream"));
|
|
}
|
|
}
|
|
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig::with_cases(512))]
|
|
#[test]
|
|
fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::<u8>()), 1..24)) {
|
|
let d = doc();
|
|
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
|
|
let mut file = synth::container(&[("doc.dat", &d, method)], None);
|
|
for (i, v) in &edits {
|
|
let n = file.len();
|
|
file[i % n] = *v;
|
|
}
|
|
if let Ok(bytes) = Archive::open(&file, Limits::default()).and_then(|mut a| a.read("doc.dat")) {
|
|
let _ = stream::parse(&bytes);
|
|
}
|
|
}
|
|
let mut s = d.clone();
|
|
for (i, v) in &edits {
|
|
let n = s.len();
|
|
s[i % n] = *v;
|
|
}
|
|
let _ = stream::parse(&s);
|
|
}
|
|
}
|
|
}
|