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:
parent
3e782b22a9
commit
1c47480a09
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue