AF.2: bounded Affinity archive and object-stream reader

Read the native container beyond its preview: the chain of allocation
tables (#FAT, #FT2, #FT3, #FT4) with newest-revision selection, CRC-checked
extraction of stored, zlib and zstd entries with their byte predictors,
and the self-describing tagged object stream in doc.dat as a generic tree
of shared, inline and positional objects.

Every declared size is checked against per-entry and per-import budgets
before allocation; the zstd window is capped at 64 MiB; table chains are
cycle-checked; arrays are checked against the remaining bytes; object
nesting stops at 384 levels.

The header now accepts container versions 8 to 12 and the flag values 4
and 8 that real Affinity 3 files carry (the previous check rejected the
public CC0 Affinity 3 documents); the thumbnail record is the same in all
those versions. Checked against 176 public documents saved by Affinity 1.x
to 3.x: every doc.dat decompresses with a matching CRC and parses with no
bytes left over.

A test-only `synth` feature builds synthetic containers for unit tests
and fuzz seeds; it is not an Affinity exporter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
This commit is contained in:
callmeBalloch 2026-10-08 17:09:56 +00:00 committed by Claude
parent 3e782b22a9
commit 1c47480a09
No known key found for this signature in database
11 changed files with 1663 additions and 362 deletions

21
Cargo.lock generated
View File

@ -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]]

View File

@ -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 }

View File

@ -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<Tag> {
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<ObjId> {
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<ObjId> {
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<f64> {
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<i64> {
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<bool> {
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<const N: usize>(&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<Item = &Value> {
self.object(id).into_iter().flat_map(|o| o.fields.iter().map(|(_, v)| v))
}
}

View File

@ -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<u16> = 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<String, Entry>,
/// 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<const N: usize>(&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<u8, Error> {
Ok(self.array::<1>()?[0])
}
pub(crate) fn u16(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.array()?))
}
pub(crate) fn u32(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.array()?))
}
pub(crate) fn u64(&mut self) -> Result<u64, Error> {
Ok(u64::from_le_bytes(self.array()?))
}
}
fn offset(v: u64) -> Result<usize, Error> {
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<Header, Error> {
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<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut names: HashMap<u32, String> = 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<Item = &str> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, Error> {
read_bounded(flate2::read::ZlibDecoder::new(data), size)
}
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, 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;
}
}

View File

@ -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<u16, Error> {
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<u32, Error> {
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<u64, Error> {
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<u32, Error> {
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<Header, Error> {
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<Preview<'_>, 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<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!(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<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 _ = inspect(&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 tests;

View File

@ -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<u32, Error> {
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<u32, Error> {
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<Preview<'_>, 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 })
}

View File

@ -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<i64>),
Floats(Vec<f64>),
/// Fixed-size record (curve nodes, ids) or fixed-size struct (colour components).
Bytes(Vec<u8>),
Blob(Vec<u8>),
/// Name of another archive entry (raster tiles, embedded files).
Entry(String),
Flags(u64),
/// `None` is a null object.
Obj(Option<ObjId>),
Array(Vec<Value>),
}
#[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<Tag>,
/// 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<Object>,
}
/// 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<Object>,
/// Shared ids, defined at first use and referenced backwards.
shared: std::collections::HashMap<u32, ObjId>,
depth: usize,
}
/// Parse a stream (`00 FF 4B 53` header). Bytes after the root's terminator are ignored.
pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
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<ObjId, Error> {
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<usize, Error> {
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<Vec<(Tag, Value)>, 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<String, Error> {
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<Value, Error> {
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<Value, Error> {
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<Value, Error> {
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<Tag>) -> Result<Value, Error> {
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<Value, Error> {
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)))
}
}

View File

@ -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<f64>),
/// Fixed-size struct (colour components), 1..64 bytes.
Struct(Vec<u8>),
/// Array of fixed-size records, all `size` bytes.
Records(u16, Vec<Vec<u8>>),
U8s(Vec<u8>),
Entry(String),
/// Shared object: `Def` defines id with class chain and fields, `Ref` links back.
Def(u32, Vec<Tag>, Vec<(Tag, F)>),
Ref(u32),
Null,
/// Inline object.
Obj(Tag, Vec<(Tag, F)>),
/// Positional (untagged) object.
Pos(Vec<F>),
/// Array of shared objects / links.
Shared(Vec<F>),
/// Array of inline objects of one class.
Objs(Tag, Vec<Vec<(Tag, F)>>),
/// Raw bytes appended verbatim (malformed-input tests).
Raw(Vec<u8>),
}
pub fn tag(s: &[u8; 4]) -> Tag {
Tag::of(s)
}
fn put_tag(out: &mut Vec<u8>, 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<u8>, 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<u8>, 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<u8> {
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<u8> {
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
}

View File

@ -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<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(_))));
}
#[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::<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);
}
}
}

View File

@ -0,0 +1,41 @@
//! Public Affinity documents, when present: `AFFINITY_SAMPLES=<dir>` 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<PathBuf>) {
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());
}

View File

@ -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]