mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 15:24:42 +00:00
* craft-affinity: bounded reader for native Affinity documents, moved from VectorCraft The reader of .af, .afdesign, .afphoto and .afpub files (archive, object stream and document model) as it is in storytold/vectorcraft at 6e873b80 (crates/affinity), under a neutral name. Its corpus tests read the pinned public documents scripts/fetch-affinity-corpus.sh fetches. * craft-affinity: fold in what only PhotoCraft's copy of the reader had From storytold/photocraft at 63b641b5 (crates/affinity), onto the VectorCraft copy: - Special shapes: clouds, hearts, cogs, callouts, arrows, double stars, tears, crescents, diamonds and segments come in as their own outlines, not their bounding ellipse. - Null slots of object lists are charged to the document's work before a list is scanned, on every visit of a shared object (children, attachments, effects, compound operands, page lists, paint lists, gradient colours, text blocks and runs, table cells, tile lists). - A colour or gradient position record is decoded only as far as its components are read. - 16-bit and 8-bit planes are interleaved through tables, not an f64 for each pixel. - Limits::max_pixel_bytes: the caller sets what the pixel layers of one import may hold. - Pixels::cmyka8_to_rgba8 for a caller without colour management. The crate README and docs describe the model on its own terms. * craft-affinity: a callout with a right-to-left box no longer panics; an oversized shape imports as its box Ported from storytold/photocraft#10473 by DJHoltkamp. A callout rectangle clamped its tail with clamp(x0, x1), which panics when the box is stored right to left. A box with finite corners whose size overflows gave infinite or NaN outline points; it now imports as its box with a warning. Also drops the inflated doc.dat once it is parsed.
431 lines
17 KiB
Rust
431 lines
17 KiB
Rust
//! 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;
|
|
/// Allocation records read over the whole table chain. Each save lists every entry again, so a
|
|
/// document with a few hundred saves of a few thousand entries stays far below; tables whose
|
|
/// record regions overlap (or a table chain of thousands of huge tables) can't multiply the work.
|
|
const MAX_INDEX_RECORDS: usize = 1 << 22;
|
|
/// Bytes of entry names kept by the index (after lossy UTF-8 decoding, which can triple a name).
|
|
/// Real names (`doc.dat`, `d/12`) are a few bytes each.
|
|
const MAX_INDEX_NAME_BYTES: usize = 16 << 20;
|
|
/// 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,
|
|
/// Bytes of decoded pixels the pixel layers of one import may hold together, counted as RGBA
|
|
/// (4 bytes a pixel). The entry limits bound what is extracted, not what the layers made of
|
|
/// it cost: a layer of one flat colour needs no entry at all. A layer that alone is over 64
|
|
/// megapixels is read at a lower resolution; layers past this allowance are left out and said.
|
|
pub max_pixel_bytes: usize,
|
|
}
|
|
|
|
impl Default for Limits {
|
|
fn default() -> Self {
|
|
Self { max_entry: 256 << 20, max_total: 1 << 30, max_pixel_bytes: 2 << 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,
|
|
/// Entries whose extraction failed after their size was charged: asked again, they fail
|
|
/// again without being decompressed again.
|
|
failed: HashMap<String, Error>,
|
|
}
|
|
|
|
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> {
|
|
Self::open_within(bytes, limits, MAX_INDEX_RECORDS, MAX_INDEX_NAME_BYTES)
|
|
}
|
|
|
|
/// [`Archive::open`] reading at most `max_records` allocation records and keeping at most
|
|
/// `max_name_bytes` bytes of entry names, both over the whole table chain.
|
|
pub(crate) fn open_within(bytes: &'a [u8], limits: Limits, max_records: usize, max_name_bytes: usize) -> Result<Self, Error> {
|
|
let header = header(bytes)?;
|
|
let mut records_left = max_records;
|
|
let mut name_bytes_left = max_name_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 {
|
|
records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?;
|
|
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 raw = c.take(len)?;
|
|
// Only the first name of an id is kept: later ones are not even decoded.
|
|
if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) {
|
|
let name = String::from_utf8_lossy(raw);
|
|
name_bytes_left = name_bytes_left.checked_sub(name.len()).ok_or(Error::Limit("archive entry names too long"))?;
|
|
slot.insert(name.into_owned());
|
|
}
|
|
}
|
|
// 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 {
|
|
records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?;
|
|
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.remove(&id)) {
|
|
e.name = name;
|
|
entries.insert(e.name.clone(), e);
|
|
}
|
|
}
|
|
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits, failed: HashMap::new() })
|
|
}
|
|
|
|
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.
|
|
/// What is left of the limits for documents embedded in this one.
|
|
pub(crate) fn remaining(&self) -> Limits {
|
|
Limits { max_total: self.limits.max_total.saturating_sub(self.extracted), ..self.limits }
|
|
}
|
|
|
|
/// Count `n` bytes extracted elsewhere (from embedded documents) against this import.
|
|
pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> {
|
|
let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?;
|
|
if total > self.limits.max_total {
|
|
return Err(Error::Limit("document too large"));
|
|
}
|
|
self.extracted = total;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
|
|
if let Some(e) = self.failed.get(name) {
|
|
return Err(*e);
|
|
}
|
|
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?;
|
|
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"));
|
|
}
|
|
// Charged before the work, and kept when it fails: an entry that fails its checksum
|
|
// can't be decompressed over and over for free.
|
|
self.extracted = total;
|
|
let result = extract(self.bytes, e, size);
|
|
if let Err(err) = result {
|
|
self.failed.insert(name.to_owned(), err);
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
/// Decompress entry `e` of `bytes` (declared `size` bytes), undo its predictor and check its CRC.
|
|
fn extract(bytes: &[u8], e: &Entry, size: usize) -> Result<Vec<u8>, Error> {
|
|
let mut c = Cursor::new(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"));
|
|
}
|
|
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.as_chunks_mut::<2>().0.iter_mut() {
|
|
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.as_chunks_mut::<4>().0.iter_mut().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;
|
|
}
|
|
}
|