diff --git a/Cargo.lock b/Cargo.lock index 542cf474..2b31eb3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2777,7 +2777,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3461,6 +3461,15 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "ruzstd" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.23" @@ -4124,6 +4133,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + [[package]] name = "type-map" version = "0.5.1" @@ -4321,10 +4336,12 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.5.0" +version = "0.6.0" dependencies = [ "crc32fast", + "flate2", "proptest", + "ruzstd", ] [[package]] diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml index 8a8eba49..158b8187 100644 --- a/crates/affinity/Cargo.toml +++ b/crates/affinity/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vectorcraft-affinity" -description = "Bounded Affinity container inspection and embedded PNG preview extraction" +description = "Bounded reader for native Affinity documents: archive, object stream and document model" version.workspace = true edition.workspace = true license.workspace = true @@ -9,8 +9,14 @@ rust-version.workspace = true [lints] workspace = true +[features] +# Synthetic-container builders for tests and fuzz seeds; not an Affinity exporter. +synth = [] + [dependencies] crc32fast = { workspace = true } +flate2 = { workspace = true } +ruzstd = "0.9" [dev-dependencies] proptest = { workspace = true } diff --git a/crates/affinity/src/access.rs b/crates/affinity/src/access.rs new file mode 100644 index 00000000..0b60ed7d --- /dev/null +++ b/crates/affinity/src/access.rs @@ -0,0 +1,105 @@ +//! Typed reads of [`Stream`] objects. Every accessor returns `None` when a field is missing or has +//! another type, so the document mapping treats odd data as absent rather than failing. + +use crate::stream::{ObjId, Object, Stream, Tag, Value}; + +impl Stream { + pub fn object(&self, id: ObjId) -> Option<&Object> { + self.objects.get(id) + } + + pub fn field(&self, id: ObjId, tag: &[u8; 4]) -> Option<&Value> { + self.object(id)?.get(Tag::of(tag)) + } + + pub fn class(&self, id: ObjId) -> Option { + self.object(id).map(|o| o.class) + } + + pub fn is(&self, id: ObjId, class: &[u8; 4]) -> bool { + self.class(id) == Some(Tag::of(class)) + } + + /// A child object (shared, linked or inline); `None` for null or missing. + pub fn obj(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Obj(o) => *o, + _ => None, + } + } + + /// An array of objects (nulls skipped). + pub fn objs(&self, id: ObjId, tag: &[u8; 4]) -> Vec { + match self.field(id, tag) { + Some(Value::Array(a)) => a.iter().filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None }).collect(), + _ => Vec::new(), + } + } + + pub fn f64(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Float(f) if f.is_finite() => Some(*f), + _ => None, + } + } + + pub fn int(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Int(i) => Some(*i), + Value::UInt(u) => i64::try_from(*u).ok(), + _ => None, + } + } + + pub fn bool(&self, id: ObjId, tag: &[u8; 4]) -> Option { + match self.field(id, tag)? { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn str(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> { + match self.field(id, tag)? { + Value::Str(s) => Some(s), + _ => None, + } + } + + pub fn enumeration(&self, id: ObjId, tag: &[u8; 4]) -> Option<(u16, u16)> { + match self.field(id, tag)? { + Value::Enum { id, version } => Some((*id, *version)), + _ => None, + } + } + + /// A fixed vector of `N` finite floats (points, rectangles, transforms). + pub fn floats(&self, id: ObjId, tag: &[u8; 4]) -> Option<[f64; N]> { + match self.field(id, tag)? { + Value::Floats(v) if v.len() == N && v.iter().all(|f| f.is_finite()) => { + let mut a = [0.0; N]; + a.copy_from_slice(v); + Some(a) + } + _ => None, + } + } + + pub fn bytes(&self, id: ObjId, tag: &[u8; 4]) -> Option<&[u8]> { + match self.field(id, tag)? { + Value::Bytes(b) | Value::Blob(b) => Some(b), + _ => None, + } + } + + pub fn entry(&self, id: ObjId, tag: &[u8; 4]) -> Option<&str> { + match self.field(id, tag)? { + Value::Entry(s) => Some(s), + _ => None, + } + } + + /// Untagged fields of a positional object, in order. + pub fn positional(&self, id: ObjId) -> impl Iterator { + self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v)) + } +} diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs new file mode 100644 index 00000000..0829f858 --- /dev/null +++ b/crates/affinity/src/container.rs @@ -0,0 +1,371 @@ +//! The Affinity archive: header, the chain of file allocation tables (one per save) and +//! bounded, CRC-checked extraction of named entries such as `doc.dat` and raster tiles. + +use std::collections::{HashMap, HashSet}; +use std::io::Read; + +use crate::Error; + +pub const MAGIC: &[u8; 4] = b"\x00\xffKA"; +/// Oldest and newest container versions seen in real documents (Affinity 1.x writes 8, +/// Affinity 3 writes 12). Version 7 is described by afread but no sample exists. +pub const VERSIONS: std::ops::RangeInclusive = 8..=12; +/// `Prsn` (stored byte-reversed): documents. Asset, brush and style libraries share the magic. +const DOCUMENT_KIND: &[u8; 4] = b"nsrP"; + +/// A save appends one table; real files have one to a few hundred. +const MAX_TABLES: usize = 4096; +/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes. +const MAX_ZSTD_WINDOW: u64 = 64 << 20; + +/// Decompression budget for one import. Every entry declares its size, which is checked against +/// these limits before anything is allocated, and the decoded length must match it exactly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Largest single entry (`doc.dat` or one tile). + pub max_entry: usize, + /// Total bytes extracted over the whole import. + pub max_total: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_entry: 256 << 20, max_total: 1 << 30 } + } +} + +/// The container header, all integers little-endian. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Header { + pub version: u16, + /// Seen as 0, 4 or 8 in real files; bits 0 and 1 mark container variants that are rejected. + pub flags: u16, + pub(crate) fat_offset: u64, + pub(crate) thumbnail_offset: u64, + /// Save time in Unix seconds, as written by the application. + pub saved: u32, +} + +/// One FAT record, as of the newest table that mentions its id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub id: u32, + pub name: String, + offset: u64, + size: u64, + compressed: u64, + crc: u32, + compression: u8, +} + +impl Entry { + /// Decompressed size declared by the table. + pub fn size(&self) -> u64 { + self.size + } +} + +#[derive(Debug)] +pub struct Archive<'a> { + bytes: &'a [u8], + pub header: Header, + /// Head revision of every named, non-deleted entry. + entries: HashMap, + /// Number of tables (saves) in the chain. + pub revisions: usize, + extracted: usize, + limits: Limits, +} + +pub(crate) struct Cursor<'a> { + bytes: &'a [u8], + pub(crate) pos: usize, +} + +impl<'a> Cursor<'a> { + pub(crate) fn new(bytes: &'a [u8], pos: usize) -> Self { + Self { bytes, pos } + } + pub(crate) fn remaining(&self) -> usize { + self.bytes.len().saturating_sub(self.pos) + } + pub(crate) fn take(&mut self, n: usize) -> Result<&'a [u8], Error> { + let end = self.pos.checked_add(n).ok_or(Error::Malformed("length overflow"))?; + let s = self.bytes.get(self.pos..end).ok_or(Error::Malformed("truncated data"))?; + self.pos = end; + Ok(s) + } + pub(crate) fn array(&mut self) -> Result<[u8; N], Error> { + let s = self.take(N)?; + let mut a = [0u8; N]; + a.copy_from_slice(s); + Ok(a) + } + pub(crate) fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + pub(crate) fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes(self.array()?)) + } + pub(crate) fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.array()?)) + } + pub(crate) fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes(self.array()?)) + } +} + +fn offset(v: u64) -> Result { + usize::try_from(v).map_err(|_| Error::Malformed("offset beyond addressable memory")) +} + +/// True when `bytes` start with the Affinity container signature (documents and libraries). +pub fn is_affinity(bytes: &[u8]) -> bool { + bytes.starts_with(MAGIC) +} + +/// Check the signature, version and document kind. +pub fn header(bytes: &[u8]) -> Result { + if !is_affinity(bytes) { + return Err(Error::Malformed("missing container signature")); + } + let mut c = Cursor::new(bytes, 4); + let version = c.u16()?; + let flags = c.u16()?; + if !VERSIONS.contains(&version) { + return Err(Error::Unsupported("unknown container version")); + } + if flags & 3 != 0 { + return Err(Error::Unsupported("container variant")); + } + if c.take(4)? != DOCUMENT_KIND { + return Err(Error::Unsupported("this Affinity file is a library (assets, brushes or styles), not a document")); + } + if c.take(4)? != b"#Inf" { + return Err(Error::Malformed("missing information header")); + } + let fat_offset = c.u64()?; + let thumbnail_offset = c.u64()?; + c.take(16)?; + let saved = u32::try_from(c.u64()?).unwrap_or(0); + c.take(8)?; + if c.take(4)? != b"Prot" { + return Err(Error::Malformed("missing protocol header")); + } + Ok(Header { version, flags, fat_offset, thumbnail_offset, saved }) +} + +impl<'a> Archive<'a> { + pub fn open(bytes: &'a [u8], limits: Limits) -> Result { + let header = header(bytes)?; + // Newest record per id: (table date, table index, record). + let mut latest: HashMap)> = HashMap::new(); + let mut names: HashMap = HashMap::new(); + let mut seen = HashSet::new(); + let mut next = header.fat_offset; + let mut tables = 0usize; + while next != 0 { + if !seen.insert(next) { + return Err(Error::Malformed("allocation tables form a cycle")); + } + tables += 1; + if tables > MAX_TABLES { + return Err(Error::Limit("too many saved revisions")); + } + let mut c = Cursor::new(bytes, offset(next)?); + let tag = c.array::<4>()?; + let level = match &tag { + b"#FAT" => 1, + b"#FT2" => 2, + b"#FT3" => 3, + b"#FT4" => 4, + _ => return Err(Error::Malformed("missing allocation table")), + }; + next = c.u64()?; + let date = c.u64()?; + c.take(24)?; + let files = c.u32()?; + c.take(8)?; + let dirs = c.u16()?; + c.take(1)?; + for _ in 0..files { + let id = c.u32()?; + let flag = c.u8()?; + let mut record = None; + match flag { + 0 | 1 => { + let offset = c.u64()?; + let size = c.u64()?; + let compressed = c.u64()?; + let crc = c.u32()?; + let stored = c.u8()?; + if level >= 2 { + c.take(4)?; + } + if level >= 4 { + c.take(4)?; + } + // Old tables number the methods; newer ones store the method byte itself. + let compression = if level <= 2 { + match stored { + 1 => 0x01, + 2 => 0x41, + 3 => 0x81, + 4 => 0xC1, + _ => 0, + } + } else { + stored + }; + record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression }); + } + 2 => {} + _ => return Err(Error::Malformed("unknown allocation record")), + } + if flag == 0 { + let len = usize::from(c.u16()?); + let name = String::from_utf8_lossy(c.take(len)?).into_owned(); + names.entry(id).or_insert(name); + } + // Equal dates: the first table in the chain (the newest) wins, as in afread. + let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d); + if replace { + latest.insert(id, (date, tables, record)); + } + } + for _ in 0..dirs { + let len = usize::from(c.u16()?); + c.take(10)?; + c.take(len)?; + } + } + let mut entries = HashMap::new(); + for (id, (_, _, record)) in latest { + if let (Some(mut e), Some(name)) = (record, names.get(&id)) { + e.name = name.clone(); + entries.insert(e.name.clone(), e); + } + } + Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits }) + } + + pub fn entry(&self, name: &str) -> Option<&Entry> { + self.entries.get(name) + } + + pub fn entry_names(&self) -> impl Iterator { + self.entries.keys().map(String::as_str) + } + + /// Bytes extracted so far, counted against [`Limits::max_total`]. + pub fn extracted(&self) -> usize { + self.extracted + } + + /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC. + pub fn read(&mut self, name: &str) -> Result, Error> { + let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone(); + let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; + if size > self.limits.max_entry { + return Err(Error::Limit("archive entry too large")); + } + let total = self.extracted.checked_add(size).ok_or(Error::Limit("document too large"))?; + if total > self.limits.max_total { + return Err(Error::Limit("document too large")); + } + let mut c = Cursor::new(self.bytes, offset(e.offset)?); + if c.take(4)? != b"#Fil" { + return Err(Error::Malformed("archive entry has no data block")); + } + let method = e.compression & 3; + let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size }; + let data = c.take(offset(stored_len)?)?; + let mut out = match method { + 1 => inflate(data, size)?, + 2 => unzstd(data, size)?, + _ => data.to_vec(), + }; + if out.len() != size { + return Err(Error::Malformed("archive entry size mismatch")); + } + unpredict(&mut out, e.compression)?; + if crc32fast::hash(&out) != e.crc { + return Err(Error::Malformed("archive entry checksum mismatch")); + } + self.extracted = total; + Ok(out) + } +} + +/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught +/// without unbounded allocation. +fn read_bounded(mut r: impl Read, size: usize) -> Result, Error> { + let mut out = Vec::with_capacity(size.min(64 << 20)); + let cap = u64::try_from(size).unwrap_or(u64::MAX).saturating_add(1); + r.by_ref().take(cap).read_to_end(&mut out).map_err(|_| Error::Malformed("corrupt compressed entry"))?; + Ok(out) +} + +fn inflate(data: &[u8], size: usize) -> Result, Error> { + read_bounded(flate2::read::ZlibDecoder::new(data), size) +} + +fn unzstd(data: &[u8], size: usize) -> Result, Error> { + let mut src = data; + let mut out = Vec::new(); + // Entries may hold several concatenated frames. + while !src.is_empty() { + let left = size.checked_sub(out.len()).ok_or(Error::Malformed("archive entry size mismatch"))?; + let decoder = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(&mut src, MAX_ZSTD_WINDOW) + .map_err(|_| Error::Malformed("corrupt compressed entry"))?; + let frame = read_bounded(decoder, left)?; + if frame.is_empty() { + return Err(Error::Malformed("corrupt compressed entry")); + } + out.extend_from_slice(&frame); + if out.len() > size { + return Err(Error::Malformed("archive entry size mismatch")); + } + } + Ok(out) +} + +/// Undo the byte predictor selected by the high bits of the compression byte. +fn unpredict(b: &mut [u8], compression: u8) -> Result<(), Error> { + match (compression & 0xC0, compression & 0x20 != 0) { + (0x00, _) => {} + (0x40, _) => byte_delta(b), + (0x80, false) => { + let mut prev = 0u16; + for pair in b.chunks_exact_mut(2) { + let v = u16::from_le_bytes([pair[0], pair[1]]).wrapping_add(prev); + pair.copy_from_slice(&v.to_le_bytes()); + prev = v; + } + } + (0x80, true) => { + byte_delta(b); + // A 64 KiB tile of 16-bit samples is stored as all high bytes, then all low bytes. + if b.len() == 0x10000 { + let planes = b.to_vec(); + let (hi, lo) = planes.split_at(0x8000); + for (i, out) in b.chunks_exact_mut(4).enumerate() { + let (Some(h), Some(l)) = (hi.get(2 * i..2 * i + 2), lo.get(2 * i..2 * i + 2)) else { + return Err(Error::Malformed("tile size")); + }; + out.copy_from_slice(&[l[0], h[0], l[1], h[1]]); + } + } + } + _ => return Err(Error::Unsupported("archive entry predictor")), + } + Ok(()) +} + +fn byte_delta(b: &mut [u8]) { + let mut prev = 0u8; + for v in b { + *v = v.wrapping_add(prev); + prev = *v; + } +} diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs index 0824d072..7736690c 100644 --- a/crates/affinity/src/lib.rs +++ b/crates/affinity/src/lib.rs @@ -1,18 +1,26 @@ -//! Affinity container inspection, without interpreting the compressed document graph. +//! Reader for native Affinity documents (`.af`, and `.afdesign`/`.afphoto`/`.afpub` from +//! Affinity 1 and 2): the archive container, the tagged object stream in `doc.dat`, and the +//! subset of the document model VectorCraft imports. //! -//! The header layout was researched with MIT-licensed afread (see README). The version-12 -//! thumbnail record was observed in synthetic documents saved by Affinity 3.3. Do not scan for -//! PNG signatures: an unrelated resource or an obsolete revision is not a document preview. +//! Layouts follow the MIT-licensed afread reference and were checked against public documents +//! saved by Affinity 1.x to 3.x (container versions 8 to 12); see the crate README for provenance +//! and the exact supported subset. Nothing here writes Affinity files. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::fmt; -pub const MAGIC: &[u8; 4] = b"\x00\xffKA"; -pub const MAX_PREVIEW_BYTES: usize = 16 << 20; -pub const MAX_PREVIEW_DIMENSION: u32 = 4096; +mod access; +pub mod container; +pub mod preview; +pub mod stream; +#[cfg(any(test, feature = "synth"))] +pub mod synth; -/// Container failure. The compressed graph is deliberately never decompressed here. +pub use container::{Archive, Limits, MAGIC, is_affinity}; +pub use preview::{MAX_PREVIEW_BYTES, MAX_PREVIEW_DIMENSION, Preview, preview}; + +/// Why a file could not be read. Messages are short reasons; callers add advice. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Error { Malformed(&'static str), @@ -22,359 +30,15 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let (kind, reason) = match self { - Self::Malformed(s) => ("damaged Affinity file", s), - Self::Unsupported(s) => ("unsupported Affinity file", s), - Self::Limit(s) => ("Affinity preview limit exceeded", s), - }; - write!(f, "{kind}: {reason}; export PNG, PSD or SVG from Affinity for full-resolution artwork") + match self { + Self::Malformed(s) => write!(f, "damaged Affinity file: {s}"), + Self::Unsupported(s) => write!(f, "unsupported Affinity file: {s}"), + Self::Limit(s) => write!(f, "Affinity file exceeds a safety limit: {s}"), + } } } impl std::error::Error for Error {} -/// Header identity; offsets remain private so callers cannot bypass validation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Header { - pub version: u16, - thumbnail_offset: u64, -} - -/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions. -#[derive(Debug)] -pub struct Preview<'a> { - pub png: &'a [u8], - pub width: u32, - pub height: u32, -} - -pub fn is_affinity(bytes: &[u8]) -> bool { - bytes.starts_with(MAGIC) -} - -fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> { - let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?; - bytes.get(offset..end).ok_or(Error::Malformed("truncated record")) -} - -fn le16(bytes: &[u8], offset: usize) -> Result { - let b = bytes_at(bytes, offset, 2)?; - Ok(u16::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?)) -} - -fn le32(bytes: &[u8], offset: usize) -> Result { - let b = bytes_at(bytes, offset, 4)?; - Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?)) -} - -fn le64(bytes: &[u8], offset: usize) -> Result { - let b = bytes_at(bytes, offset, 8)?; - Ok(u64::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?)) -} - -fn be32(bytes: &[u8], offset: usize) -> Result { - let b = bytes_at(bytes, offset, 4)?; - Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?)) -} - -/// Recognize documents, rejecting add-ons with the same magic and unknown container versions. -pub fn inspect(bytes: &[u8]) -> Result { - if !is_affinity(bytes) { - return Err(Error::Malformed("missing container signature")); - } - let version = le16(bytes, 4)?; - if !(7..=12).contains(&version) { - return Err(Error::Unsupported("unverified container version")); - } - if le16(bytes, 6)? != 0 { - return Err(Error::Unsupported("container flags")); - } - // afread's Prsn document class, stored little-endian. Brushes, macros and palettes - // share the magic but must not be opened as documents. - if bytes_at(bytes, 8, 4)? != b"nsrP" { - return Err(Error::Unsupported("this container is not an Affinity document")); - } - if bytes_at(bytes, 12, 4)? != b"#Inf" { - return Err(Error::Malformed("missing information header")); - } - bytes_at(bytes, 0, if version == 7 { 64 } else { 72 })?; - if version > 7 && bytes_at(bytes, 64, 4)? != b"Prot" { - return Err(Error::Malformed("missing protocol header")); - } - Ok(Header { version, thumbnail_offset: le64(bytes, 24)? }) -} - -/// Read only the indexed version-12 `Thmb` record, checking every PNG chunk CRC. Compressed -/// pixels must still be validated by the consuming image decoder, with allocation limits. -pub fn preview(bytes: &[u8]) -> Result, Error> { - let h = inspect(bytes)?; - if h.version != 12 { - return Err(Error::Unsupported("legacy preview records have not been verified")); - } - if h.thumbnail_offset == 0 { - return Err(Error::Unsupported("no embedded preview; native layer decoding is not implemented")); - } - let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?; - if offset < 72 { - return Err(Error::Malformed("thumbnail overlaps the file header")); - } - let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?; - if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" { - return Err(Error::Malformed("missing indexed thumbnail")); - } - if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] { - return Err(Error::Unsupported("thumbnail record version or encoding")); - } - let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?; - if size > MAX_PREVIEW_BYTES { - return Err(Error::Limit("encoded preview exceeds 16 MiB")); - } - if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 { - return Err(Error::Malformed("inconsistent thumbnail record lengths")); - } - let png = bytes_at(record, 29, size)?; - if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" { - return Err(Error::Malformed("thumbnail is not a PNG")); - } - let width = be32(png, 16)?; - let height = be32(png, 20)?; - if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION { - return Err(Error::Limit("preview dimensions must be 1..4096")); - } - // Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG. - let mut pos = 8usize; - let (mut palette, mut image_data, mut image_data_ended) = (false, false, false); - loop { - let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?; - let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?; - let chunk = bytes_at(png, pos, chunk_size)?; - let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?; - if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? { - return Err(Error::Malformed("PNG chunk checksum")); - } - let kind = bytes_at(chunk, 4, 4)?; - if !kind.iter().all(u8::is_ascii_alphabetic) { - return Err(Error::Malformed("PNG chunk type")); - } - match kind { - b"IHDR" if pos == 8 => {} - b"IHDR" => return Err(Error::Malformed("duplicate PNG header")), - b"PLTE" => { - if palette || image_data { - return Err(Error::Malformed("PNG palette order")); - } - palette = true; - } - b"IDAT" => { - if image_data_ended { - return Err(Error::Malformed("non-contiguous PNG image data")); - } - image_data = true; - } - b"IEND" => { - if !image_data { - return Err(Error::Malformed("PNG has no image data")); - } - } - _ if kind.first().is_some_and(u8::is_ascii_uppercase) => { - return Err(Error::Unsupported("unknown critical PNG chunk")); - } - _ => {} - } - if image_data && kind != b"IDAT" { - image_data_ended = true; - } - // Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not - // necessarily bound aggregate compressed metadata; leave these layouts unsupported. - if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") { - return Err(Error::Unsupported("compressed PNG metadata in the preview")); - } - if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") { - return Err(Error::Unsupported("animated PNG preview")); - } - pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?; - if kind == b"IEND" { - if length != 0 || pos != png.len() { - return Err(Error::Malformed("PNG end or trailing data")); - } - break; - } - } - Ok(Preview { png, width, height }) -} - #[cfg(test)] -mod tests { - use super::*; - use proptest::prelude::*; - - fn fixture() -> Vec { - 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!(inspect(&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}"); - } - for version in 7..12 { - let mut legacy = b.clone(); - legacy[4..6].copy_from_slice(&(version as u16).to_le_bytes()); - assert!(inspect(&legacy).is_ok()); - assert!(matches!(preview(&legacy), 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 { - 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::(), 0..2048)) { - let _ = inspect(&b); - let _ = preview(&b); - } - #[test] - fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 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 tests; diff --git a/crates/affinity/src/preview.rs b/crates/affinity/src/preview.rs new file mode 100644 index 00000000..f50cb8c3 --- /dev/null +++ b/crates/affinity/src/preview.rs @@ -0,0 +1,127 @@ +//! The indexed PNG thumbnail Affinity stores next to the archive: Affinity's own render of the +//! document, at most 512 pixels on the long side in real files, never the document itself. + +use crate::Error; +use crate::container::header; + +pub const MAX_PREVIEW_BYTES: usize = 16 << 20; +pub const MAX_PREVIEW_DIMENSION: u32 = 4096; + +/// Borrowed embedded thumbnail, at its own dimensions, never the native document dimensions. +#[derive(Debug)] +pub struct Preview<'a> { + pub png: &'a [u8], + pub width: u32, + pub height: u32, +} + +fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> Result<&[u8], Error> { + let end = offset.checked_add(length).ok_or(Error::Malformed("offset overflow"))?; + bytes.get(offset..end).ok_or(Error::Malformed("truncated record")) +} + +fn le32(bytes: &[u8], offset: usize) -> Result { + let b = bytes_at(bytes, offset, 4)?; + Ok(u32::from_le_bytes(b.try_into().map_err(|_| Error::Malformed("integer"))?)) +} + +fn be32(bytes: &[u8], offset: usize) -> Result { + let b = bytes_at(bytes, offset, 4)?; + Ok(u32::from_be_bytes(b.try_into().map_err(|_| Error::Malformed("PNG integer"))?)) +} + +/// Read only the indexed `Thmb` record (identical in container versions 8 to 12), checking every PNG chunk CRC. Compressed +/// pixels must still be validated by the consuming image decoder, with allocation limits. +pub fn preview(bytes: &[u8]) -> Result, Error> { + let h = header(bytes)?; + if h.thumbnail_offset == 0 { + return Err(Error::Unsupported("no embedded preview")); + } + let offset = usize::try_from(h.thumbnail_offset).map_err(|_| Error::Malformed("thumbnail offset overflow"))?; + if offset < 72 { + return Err(Error::Malformed("thumbnail overlaps the file header")); + } + let record = bytes.get(offset..).ok_or(Error::Malformed("thumbnail offset outside file"))?; + if bytes_at(record, 0, 8)? != b"\xff\xff\xff\xffThmb" { + return Err(Error::Malformed("missing indexed thumbnail")); + } + if le32(record, 8)? != 1 || bytes_at(record, 28, 1)? != [1] { + return Err(Error::Unsupported("thumbnail record version or encoding")); + } + let size = usize::try_from(le32(record, 24)?).map_err(|_| Error::Limit("encoded size"))?; + if size > MAX_PREVIEW_BYTES { + return Err(Error::Limit("encoded preview exceeds 16 MiB")); + } + if le32(record, 16)? != 29 || le32(record, 20)? != 0 || u64::from(le32(record, 12)?) != size as u64 + 13 { + return Err(Error::Malformed("inconsistent thumbnail record lengths")); + } + let png = bytes_at(record, 29, size)?; + if bytes_at(png, 0, 8)? != b"\x89PNG\r\n\x1a\n" || be32(png, 8)? != 13 || bytes_at(png, 12, 4)? != b"IHDR" { + return Err(Error::Malformed("thumbnail is not a PNG")); + } + let width = be32(png, 16)?; + let height = be32(png, 20)?; + if width == 0 || height == 0 || width > MAX_PREVIEW_DIMENSION || height > MAX_PREVIEW_DIMENSION { + return Err(Error::Limit("preview dimensions must be 1..4096")); + } + // Bound work by bytes, not a file-supplied chunk count, and require exactly one complete PNG. + let mut pos = 8usize; + let (mut palette, mut image_data, mut image_data_ended) = (false, false, false); + loop { + let length = usize::try_from(be32(png, pos)?).map_err(|_| Error::Limit("PNG chunk length"))?; + let chunk_size = length.checked_add(12).ok_or(Error::Malformed("PNG chunk overflow"))?; + let chunk = bytes_at(png, pos, chunk_size)?; + let checksum_input = bytes_at(chunk, 4, chunk_size - 8)?; + if crc32fast::hash(checksum_input) != be32(chunk, chunk_size - 4)? { + return Err(Error::Malformed("PNG chunk checksum")); + } + let kind = bytes_at(chunk, 4, 4)?; + if !kind.iter().all(u8::is_ascii_alphabetic) { + return Err(Error::Malformed("PNG chunk type")); + } + match kind { + b"IHDR" if pos == 8 => {} + b"IHDR" => return Err(Error::Malformed("duplicate PNG header")), + b"PLTE" => { + if palette || image_data { + return Err(Error::Malformed("PNG palette order")); + } + palette = true; + } + b"IDAT" => { + if image_data_ended { + return Err(Error::Malformed("non-contiguous PNG image data")); + } + image_data = true; + } + b"IEND" => { + if !image_data { + return Err(Error::Malformed("PNG has no image data")); + } + } + _ if kind.first().is_some_and(u8::is_ascii_uppercase) => { + return Err(Error::Unsupported("unknown critical PNG chunk")); + } + _ => {} + } + if image_data && kind != b"IDAT" { + image_data_ended = true; + } + // Native sample previews are plain, single-frame PNGs. Pixel allocation limits do not + // necessarily bound aggregate compressed metadata; leave these layouts unsupported. + if matches!(kind, b"zTXt" | b"iTXt" | b"iCCP") { + return Err(Error::Unsupported("compressed PNG metadata in the preview")); + } + if matches!(kind, b"acTL" | b"fcTL" | b"fdAT") { + return Err(Error::Unsupported("animated PNG preview")); + } + pos = pos.checked_add(chunk_size).ok_or(Error::Malformed("PNG offset overflow"))?; + if kind == b"IEND" { + if length != 0 || pos != png.len() { + return Err(Error::Malformed("PNG end or trailing data")); + } + break; + } + } + Ok(Preview { png, width, height }) +} diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs new file mode 100644 index 00000000..516776b2 --- /dev/null +++ b/crates/affinity/src/stream.rs @@ -0,0 +1,376 @@ +//! The tagged object stream inside `doc.dat`: a self-describing tree of objects whose fields carry +//! a type code and a four-character tag. Parsing needs no schema; unknown fields are kept, so the +//! document mapping can name what it could not import. + +use crate::Error; +use crate::container::Cursor; + +/// A four-character tag in its readable order (`Desc`, `Chld`). +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Tag(pub u32); + +impl Tag { + pub const fn of(s: &[u8; 4]) -> Self { + Self(u32::from_be_bytes(*s)) + } +} + +impl std::fmt::Debug for Tag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", String::from_utf8_lossy(&self.0.to_be_bytes())) + } +} + +impl std::fmt::Display for Tag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +/// Index of an object in [`Stream::objects`]. +pub type ObjId = usize; + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Int(i64), + UInt(u64), + Float(f64), + Bool(bool), + Enum { + id: u16, + version: u16, + }, + Str(String), + /// Fixed-length vectors of i32/f32/f64 (points, rectangles, transforms). + Ints(Vec), + Floats(Vec), + /// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components). + Bytes(Vec), + Blob(Vec), + /// Name of another archive entry (raster tiles, embedded files). + Entry(String), + Flags(u64), + /// `None` is a null object. + Obj(Option), + Array(Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Root, + /// Defined once and referenced by id elsewhere. + Shared, + Inline, + /// Untagged fields identified by position (curve data). + Positional, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Object { + pub class: Tag, + pub kind: Kind, + /// Class chain, most derived first (`ShpN`, `VNod`, `Node`). + pub chain: Vec, + /// Fields in stream order; positional fields have tag 0. + pub fields: Vec<(Tag, Value)>, +} + +impl Object { + pub fn get(&self, tag: Tag) -> Option<&Value> { + self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v) + } +} + +#[derive(Debug)] +pub struct Stream { + pub root: ObjId, + pub objects: Vec, +} + +/// Deepest object nesting accepted; real documents stay well below (each layer level adds three). +pub const MAX_DEPTH: usize = 384; + +const MAX_OBJECTS: usize = 8_000_000; + +struct Parser<'a> { + c: Cursor<'a>, + objects: Vec, + /// Shared ids, defined at first use and referenced backwards. + shared: std::collections::HashMap, + depth: usize, +} + +/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored. +pub fn parse(bytes: &[u8]) -> Result { + let mut c = Cursor::new(bytes, 0); + if c.take(4)? != b"\x00\xffKS" { + return Err(Error::Malformed("document stream signature")); + } + let version = c.u16()?; + if version > 2 { + return Err(Error::Unsupported("document stream version")); + } + let class = Tag(c.u32()?); + c.u16()?; + if version == 2 { + c.u32()?; + } + let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0 }; + let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?; + let fields = p.fields(true, class)?; + if let Some(o) = p.objects.get_mut(root) { + o.fields = fields; + } + Ok(Stream { root, objects: p.objects }) +} + +impl Parser<'_> { + fn push(&mut self, o: Object) -> Result { + if self.objects.len() >= MAX_OBJECTS { + return Err(Error::Limit("too many objects")); + } + self.objects.push(o); + Ok(self.objects.len() - 1) + } + + /// Element count of an array: each element takes at least `min` bytes, so a count the + /// remaining input cannot hold is rejected before anything is allocated. + fn count(&mut self, min: usize) -> Result { + let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; + if n.checked_mul(min.max(1)).is_none_or(|bytes| bytes > self.c.remaining()) { + return Err(Error::Malformed("array longer than its data")); + } + Ok(n) + } + + fn fields(&mut self, tagged: bool, class: Tag) -> Result, Error> { + self.depth += 1; + if self.depth > MAX_DEPTH { + return Err(Error::Limit("objects nested too deeply")); + } + let mut out = Vec::new(); + loop { + let t = self.c.u8()?; + let code = t & 0x7F; + if code == 0 { + break; + } + let tag = if tagged { Tag(self.c.u32()?) } else { Tag(0) }; + let v = if t & 0x80 != 0 { self.array(code, class)? } else { self.scalar(code, tagged, class)? }; + out.push((tag, v)); + } + self.depth -= 1; + Ok(out) + } + + fn string(&mut self) -> Result { + let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("string length"))?; + Ok(String::from_utf8_lossy(self.c.take(n)?).into_owned()) + } + + fn floats(&mut self, n: usize, wide: bool) -> Result { + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(if wide { f64::from_le_bytes(self.c.array()?) } else { f64::from(f32::from_le_bytes(self.c.array()?)) }); + } + Ok(Value::Floats(v)) + } + + fn scalar(&mut self, code: u8, tagged: bool, class: Tag) -> Result { + let c = &mut self.c; + Ok(match code { + 0x01 => Value::UInt(c.u8()?.into()), + 0x02 => Value::UInt(c.u16()?.into()), + 0x03 | 0x2f | 0x34 => Value::UInt(c.u32()?.into()), + 0x04 => Value::UInt(c.u64()?), + 0x05 => Value::Int(i8::from_le_bytes(c.array()?).into()), + 0x06 => Value::Int(i16::from_le_bytes(c.array()?).into()), + 0x07 => Value::Int(i32::from_le_bytes(c.array()?).into()), + 0x08 => Value::Int(i64::from_le_bytes(c.array()?)), + 0x09 => Value::Float(f32::from_le_bytes(c.array()?).into()), + 0x0a => Value::Float(f64::from_le_bytes(c.array()?)), + 0x15..=0x19 => { + let n = usize::from(code - 0x13); + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(i32::from_le_bytes(c.array()?).into()); + } + Value::Ints(v) + } + 0x1f..=0x23 => return self.floats(usize::from(code - 0x1d), false), + 0x24..=0x28 => return self.floats(usize::from(code - 0x22), true), + 0x29 => Value::Bool(c.u8()? != 0), + 0x2a => Value::Enum { id: c.u16()?, version: c.u16()? }, + 0x2b | 0x2e => Value::Str(self.string()?), + 0x2c => { + let n = usize::from(c.u16()?); + Value::Bytes(c.take(n)?.to_vec()) + } + 0x35..=0x74 => Value::Bytes(c.take(usize::from(code - 0x34))?.to_vec()), + 0x2d => { + let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("blob length"))?; + Value::Blob(c.take(n)?.to_vec()) + } + 0x33 => { + c.u32()?; + let n = usize::try_from(c.u32()?).map_err(|_| Error::Malformed("entry name length"))?; + Value::Entry(String::from_utf8_lossy(c.take(n)?).into_owned()) + } + 0x75 => { + c.u16()?; + let n = usize::from(c.u8()?); + if n > 8 { + return Err(Error::Malformed("flags wider than 64 bits")); + } + let mut b = [0u8; 8]; + b.get_mut(..n).ok_or(Error::Malformed("flags"))?.copy_from_slice(c.take(n)?); + Value::Flags(u64::from_le_bytes(b)) + } + 0x30 => { + let fields = self.fields(false, class)?; + let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?; + Value::Obj(Some(id)) + } + 0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")), + 0x31 => self.shared()?, + 0x32 => self.inline(None)?, + _ => return Err(Error::Unsupported("unknown field type in document stream")), + }) + } + + fn array(&mut self, code: u8, class: Tag) -> Result { + let min = match code { + 0x01 | 0x05 | 0x29 => 1, + 0x02 | 0x06 => 2, + 0x03 | 0x07 | 0x09 | 0x2f | 0x34 => 4, + 0x04 | 0x08 | 0x0a => 8, + 0x15..=0x19 => 4 * usize::from(code - 0x13), + 0x1f..=0x23 => 4 * usize::from(code - 0x1d), + 0x24..=0x28 => 8 * usize::from(code - 0x22), + 0x35..=0x74 => usize::from(code - 0x34), + _ => 1, + }; + match code { + 0x29 => { + let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; + let bits = self.c.take(n.div_ceil(8))?; + Ok(Value::Array((0..n).map(|i| Value::Bool(bits.get(i / 8).is_some_and(|b| b >> (i % 8) & 1 != 0))).collect())) + } + 0x2a => { + let n = self.count(2)?; + let version = self.c.u16()?; + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(Value::Enum { id: self.c.u16()?, version }); + } + Ok(Value::Array(v)) + } + 0x2b | 0x2e => { + self.c.u32()?; + let n = self.count(4)?; + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(Value::Str(self.string()?)); + } + Ok(Value::Array(v)) + } + 0x2c => { + let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; + let size = usize::from(self.c.u16()?); + if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { + return Err(Error::Malformed("array longer than its data")); + } + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(Value::Bytes(self.c.take(size)?.to_vec())); + } + Ok(Value::Array(v)) + } + 0x32 => { + let n = self.count(1)?; + let class = Tag(self.c.u32()?); + self.c.u16()?; + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(self.inline(Some(class))?); + } + Ok(Value::Array(v)) + } + 0x2d | 0x33 | 0x75 => Err(Error::Malformed("array of an unarrayable type")), + _ => { + let n = self.count(min)?; + let mut v = Vec::with_capacity(n); + for _ in 0..n { + v.push(self.scalar(code, true, class)?); + } + Ok(Value::Array(v)) + } + } + } + + /// Inline object; `class` is given once for a whole array of them. + fn inline(&mut self, class: Option) -> Result { + match self.c.u8()? { + 0 => return Ok(Value::Obj(None)), + 1 => {} + _ => return Err(Error::Malformed("object marker")), + } + let class = match class { + Some(c) => c, + None => { + let c = Tag(self.c.u32()?); + self.c.u16()?; + c + } + }; + let fields = self.fields(true, class)?; + let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?; + Ok(Value::Obj(Some(id))) + } + + fn shared(&mut self) -> Result { + let flag = self.c.u8()?; + if flag == 0 { + return Ok(Value::Obj(None)); + } + let key = self.c.u32()?; + match flag { + 2 => return self.shared.get(&key).map(|id| Value::Obj(Some(*id))).ok_or(Error::Malformed("reference to an undefined object")), + 1 => {} + _ => return Err(Error::Malformed("object marker")), + } + if self.shared.contains_key(&key) { + return Err(Error::Malformed("object defined twice")); + } + // Register before the fields: an object's own fields may refer back to it. + let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?; + self.shared.insert(key, id); + let mut chain = Vec::new(); + let mut fields = Vec::new(); + loop { + match self.c.u8()? { + 0 => { + let tag = Tag(self.c.u32()?); + self.c.u16()?; + chain.push(tag); + fields.extend(self.fields(true, tag)?); + } + 1 => { + chain.push(Tag(self.c.u32()?)); + break; + } + 2 => break, + _ => return Err(Error::Malformed("object class chain")), + } + if chain.len() > 64 { + return Err(Error::Limit("object class chain too long")); + } + } + let class = chain.first().copied().unwrap_or(Tag(0)); + fields.extend(self.fields(true, class)?); + if let Some(o) = self.objects.get_mut(id) { + *o = Object { class, kind: Kind::Shared, chain, fields }; + } + Ok(Value::Obj(Some(id))) + } +} diff --git a/crates/affinity/src/synth.rs b/crates/affinity/src/synth.rs new file mode 100644 index 00000000..5b75ca0b --- /dev/null +++ b/crates/affinity/src/synth.rs @@ -0,0 +1,259 @@ +//! Synthetic containers for tests and fuzz seeds (cargo feature `synth`). +//! +//! These builders write the layout the reader accepts, so tests can describe documents in a few +//! lines. They are **not** an Affinity exporter: nothing written here has been opened in Affinity. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use crate::stream::Tag; + +/// A field value to encode. +#[derive(Debug, Clone)] +pub enum F { + U8(u8), + U32(u32), + I32(i32), + F32(f32), + F64(f64), + Bool(bool), + Enum(u16, u16), + Str(String), + /// f64 vector of 2..6 values (points, rectangles, transforms). + F64s(Vec), + /// Fixed-size struct (colour components), 1..64 bytes. + Struct(Vec), + /// Array of fixed-size records, all `size` bytes. + Records(u16, Vec>), + U8s(Vec), + Entry(String), + /// Shared object: `Def` defines id with class chain and fields, `Ref` links back. + Def(u32, Vec, Vec<(Tag, F)>), + Ref(u32), + Null, + /// Inline object. + Obj(Tag, Vec<(Tag, F)>), + /// Positional (untagged) object. + Pos(Vec), + /// Array of shared objects / links. + Shared(Vec), + /// Array of inline objects of one class. + Objs(Tag, Vec>), + /// Raw bytes appended verbatim (malformed-input tests). + Raw(Vec), +} + +pub fn tag(s: &[u8; 4]) -> Tag { + Tag::of(s) +} + +fn put_tag(out: &mut Vec, t: Tag) { + out.extend(t.0.to_le_bytes()); +} + +fn code(f: &F) -> u8 { + match f { + F::U8(_) => 0x01, + F::U32(_) => 0x03, + F::I32(_) => 0x07, + F::F32(_) => 0x09, + F::F64(_) => 0x0a, + F::Bool(_) => 0x29, + F::Enum(..) => 0x2a, + F::Str(_) => 0x2b, + F::F64s(v) => 0x22 + v.len() as u8, + F::Struct(b) => 0x34 + b.len() as u8, + F::Records(..) => 0x80 | 0x2c, + F::U8s(_) => 0x80 | 0x01, + F::Entry(_) => 0x33, + F::Def(..) | F::Ref(_) => 0x31, + F::Null => 0x31, + F::Obj(..) => 0x32, + F::Pos(_) => 0x30, + F::Shared(_) => 0x80 | 0x31, + F::Objs(..) => 0x80 | 0x32, + F::Raw(_) => 0, + } +} + +fn payload(out: &mut Vec, f: &F) { + match f { + F::U8(v) => out.push(*v), + F::U32(v) => out.extend(v.to_le_bytes()), + F::I32(v) => out.extend(v.to_le_bytes()), + F::F32(v) => out.extend(v.to_le_bytes()), + F::F64(v) => out.extend(v.to_le_bytes()), + F::Bool(v) => out.push(u8::from(*v)), + F::Enum(id, ver) => { + out.extend(id.to_le_bytes()); + out.extend(ver.to_le_bytes()); + } + F::Str(s) => { + out.extend((s.len() as u32).to_le_bytes()); + out.extend(s.as_bytes()); + } + F::F64s(v) => v.iter().for_each(|x| out.extend(x.to_le_bytes())), + F::Struct(b) => out.extend(b), + F::Records(size, recs) => { + out.extend((recs.len() as u32).to_le_bytes()); + out.extend(size.to_le_bytes()); + recs.iter().for_each(|r| out.extend(r)); + } + F::U8s(v) => { + out.extend((v.len() as u32).to_le_bytes()); + out.extend(v); + } + F::Entry(name) => { + put_tag(out, tag(b"Data")); + out.extend((name.len() as u32).to_le_bytes()); + out.extend(name.as_bytes()); + } + F::Def(id, chain, fields) => { + out.push(1); + out.extend(id.to_le_bytes()); + // Class chain: each level but the last carries no fields here; the last ends the chain. + let (last, rest) = chain.split_last().expect("class chain"); + for t in rest { + out.push(0); + put_tag(out, *t); + out.extend(0u16.to_le_bytes()); + out.push(0); + } + out.push(1); + put_tag(out, *last); + fields_into(out, fields, true); + } + F::Ref(id) => { + out.push(2); + out.extend(id.to_le_bytes()); + } + F::Null => out.push(0), + F::Obj(class, fields) => { + out.push(1); + put_tag(out, *class); + out.extend(0u16.to_le_bytes()); + fields_into(out, fields, true); + } + F::Pos(items) => { + for f in items { + out.push(code(f)); + payload(out, f); + } + out.push(0); + } + F::Shared(items) => { + out.extend((items.len() as u32).to_le_bytes()); + items.iter().for_each(|f| payload(out, f)); + } + F::Objs(class, items) => { + out.extend((items.len() as u32).to_le_bytes()); + put_tag(out, *class); + out.extend(0u16.to_le_bytes()); + for fields in items { + out.push(1); + fields_into(out, fields, true); + } + } + F::Raw(b) => out.extend(b), + } +} + +fn fields_into(out: &mut Vec, fields: &[(Tag, F)], tagged: bool) { + for (t, f) in fields { + if let F::Raw(b) = f { + out.extend(b); + continue; + } + out.push(code(f)); + if tagged { + put_tag(out, *t); + } + payload(out, f); + } + out.push(0); +} + +/// A `doc.dat` stream with root class `Pers`. +pub fn stream(root: &[(Tag, F)]) -> Vec { + let mut out = b"\x00\xffKS".to_vec(); + out.extend(2u16.to_le_bytes()); + put_tag(&mut out, tag(b"Pers")); + out.extend(0u16.to_le_bytes()); + out.extend(30u32.to_le_bytes()); + fields_into(&mut out, root, true); + out +} + +/// How an entry is stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Method { + Stored, + Zlib, + Zstd, +} + +/// A version-12 container with one `#FT4` table holding `entries`, then an optional thumbnail record. +pub fn container(entries: &[(&str, &[u8], Method)], thumbnail_png: Option<&[u8]>) -> Vec { + let mut out = vec![0u8; 72]; + out[..4].copy_from_slice(crate::container::MAGIC); + out[4..6].copy_from_slice(&12u16.to_le_bytes()); + out[6..8].copy_from_slice(&4u16.to_le_bytes()); + out[8..12].copy_from_slice(b"nsrP"); + out[12..16].copy_from_slice(b"#Inf"); + out[64..68].copy_from_slice(b"Prot"); + let mut records = Vec::new(); + for (i, (name, data, method)) in entries.iter().enumerate() { + let packed = match method { + Method::Stored => data.to_vec(), + Method::Zlib => { + use std::io::Write; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + Method::Zstd => ruzstd::encoding::compress_to_vec(*data, ruzstd::encoding::CompressionLevel::Fastest), + }; + let offset = out.len() as u64; + out.extend(b"#Fil"); + out.extend(&packed); + let mut r = Vec::new(); + r.extend((i as u32 + 1).to_le_bytes()); + r.push(0); + r.extend(offset.to_le_bytes()); + r.extend((data.len() as u64).to_le_bytes()); + r.extend((packed.len() as u64).to_le_bytes()); + r.extend(crc32fast::hash(data).to_le_bytes()); + r.push(match method { + Method::Stored => 0, + Method::Zlib => 1, + Method::Zstd => 2, + }); + r.extend(0u32.to_le_bytes()); + r.extend(0u32.to_le_bytes()); + r.extend((name.len() as u16).to_le_bytes()); + r.extend(name.as_bytes()); + records.push(r); + } + let fat = out.len() as u64; + out.extend(b"#FT4"); + out.extend(0u64.to_le_bytes()); + out.extend(1_700_000_000u64.to_le_bytes()); + out.extend([0u8; 24]); + out.extend((records.len() as u32).to_le_bytes()); + out.extend([0u8; 8]); + out.extend(0u16.to_le_bytes()); + out.push(0); + records.iter().for_each(|r| out.extend(r)); + let thumb = out.len() as u64; + out[16..24].copy_from_slice(&fat.to_le_bytes()); + if let Some(png) = thumbnail_png { + out[24..32].copy_from_slice(&thumb.to_le_bytes()); + out.extend(b"\xff\xff\xff\xffThmb"); + out.extend(1u32.to_le_bytes()); + out.extend((png.len() as u32 + 13).to_le_bytes()); + out.extend(29u32.to_le_bytes()); + out.extend(0u32.to_le_bytes()); + out.extend((png.len() as u32).to_le_bytes()); + out.push(1); + out.extend(png); + } + out +} diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs new file mode 100644 index 00000000..4c299dc3 --- /dev/null +++ b/crates/affinity/src/tests.rs @@ -0,0 +1,331 @@ +//! 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 { + 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 { + 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::(), 0..2048)) { + let _ = header(&b); + let _ = preview(&b); + } + #[test] + fn mutations_never_panic(edits in prop::collection::vec((0usize..200, any::()), 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 { + 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(_)))); + } + + #[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}"); + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + #[test] + fn mutated_containers_and_streams_never_panic(edits in prop::collection::vec((0usize..4096, any::()), 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); + } + } +} diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/real_files.rs new file mode 100644 index 00000000..a49498d6 --- /dev/null +++ b/crates/affinity/tests/real_files.rs @@ -0,0 +1,41 @@ +//! Public Affinity documents, when present: `AFFINITY_SAMPLES=` points at a folder of +//! `.af`/`.afdesign`/`.afphoto`/`.afpub` files (searched recursively). Without it this test does +//! nothing; `cargo xtask corpus` fetches the pinned public set (see the crate README). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use vectorcraft_affinity::{Archive, Limits, stream}; + +fn files(dir: &Path, out: &mut Vec) { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + files(&p, out); + } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["af", "afdesign", "afphoto", "afpub", "aftemplate"].contains(&e)) { + out.push(p); + } + } +} + +#[test] +fn public_documents_parse_completely() { + let Some(dir) = std::env::var_os("AFFINITY_SAMPLES") else { return }; + let mut paths = Vec::new(); + files(Path::new(&dir), &mut paths); + assert!(!paths.is_empty(), "no Affinity documents under {dir:?}"); + let mut failures = Vec::new(); + for p in &paths { + let bytes = std::fs::read(p).unwrap(); + let result = Archive::open(&bytes, Limits::default()).and_then(|mut a| { + let doc = a.read("doc.dat")?; + stream::parse(&doc).map(|s| s.objects.len()) + }); + match result { + Ok(n) => assert!(n > 0), + Err(e) => failures.push(format!("{}: {e}", p.display())), + } + } + assert!(failures.is_empty(), "{} of {} failed:\n{}", failures.len(), paths.len(), failures.join("\n")); + eprintln!("{} Affinity documents parsed", paths.len()); +} diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs index d6281d4e..268c1de7 100644 --- a/crates/engine/src/cmd/fileio/tests_affinity.rs +++ b/crates/engine/src/cmd/fileio/tests_affinity.rs @@ -115,9 +115,13 @@ fn malformed_crc_and_every_truncation_fail() { let mut crc = b.clone(); crc[130] ^= 1; assert!(load("bad.af", &crc).is_err()); + // Affinity 1 and 2 (container versions 8 to 11) store the same preview record. let mut legacy = b.clone(); legacy[4..6].copy_from_slice(&10u16.to_le_bytes()); - assert!(load("old.af", &legacy).err().unwrap().to_string().contains("legacy preview")); + assert!(load("old.afdesign", &legacy).is_ok()); + let mut unknown = b.clone(); + unknown[4..6].copy_from_slice(&13u16.to_le_bytes()); + assert!(load("new.af", &unknown).err().unwrap().to_string().contains("unknown container version")); } #[test]