Skip to main content

brushkit_preview/
procreate.rs

1//! Reading Procreate `.brush` and `.brushset` archives: the guarded zip and
2//! plist readers, the `Shape.png` decoder and the two name/member lookups the
3//! preview API needs.
4//!
5//! Every entry here parses untrusted bytes, so each size, count and dimension
6//! is checked against a ceiling before anything is allocated.
7
8use crate::bitmap::{decode_guarded, tip_plane, GuardedDecodeError, TipSample};
9use crate::GrayscaleBitmap;
10use std::io::{Cursor, Read};
11
12/// Defensive ceilings for untrusted archives: anything above them is treated
13/// as malformed rather than allocated from.
14pub const MAX_ENTRY_BYTES: usize = 256 * 1024 * 1024;
15pub const MAX_PNG_DIMENSION: u32 = 16384;
16/// The plist entry read cap. The largest of 5803 real `Brush.archive` and
17/// `brushset.plist` files is 10164 bytes. The whole entry is held before any
18/// guard runs, and an XML plist's strings are allocated before the stream
19/// guard sees them, so this ceiling alone bounds both.
20pub const MAX_PLIST_BYTES: usize = 1024 * 1024;
21pub const MAX_PLIST_DEPTH: usize = 64;
22/// A binary plist object may be referenced any number of times, and each
23/// reference is expanded into its own value, so the tree can be far larger
24/// than the file. Every value costs a `plist::Value` slot of about 80 bytes,
25/// and a dictionary also carries an `IndexMap` table, so a tree of one-entry
26/// dictionaries costs about 160 bytes per value. A census of 5803 real
27/// `Brush.archive` and `brushset.plist` files found at most 949 values. It
28/// also caps the objects and collection references a binary plist declares.
29pub const MAX_PLIST_VALUES: usize = 10_000;
30/// The expanded string and data bytes of one plist. The same census found at
31/// most 7 KiB. Each accepted string is decoded twice, once per parse, and a
32/// UTF-16 string holds its code units and its UTF-8 copy at once, so in a
33/// binary plist this ceiling bounds what strings cost beyond the entry
34/// itself. An XML plist's string is allocated before the stream guard sees
35/// it, so only [`MAX_PLIST_BYTES`] bounds it.
36pub const MAX_PLIST_PAYLOAD_BYTES: usize = 1024 * 1024;
37
38/// Reads a zip entry of at most [`MAX_ENTRY_BYTES`]. Plist entries go through
39/// [`read_zip_plist`].
40pub fn read_zip_entry(
41    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
42    path: &str,
43) -> Result<Vec<u8>, String> {
44    read_capped(zip, path, MAX_ENTRY_BYTES)
45}
46
47/// [`read_zip_entry`] capped at [`MAX_PLIST_BYTES`], so an oversize plist
48/// fails before more than the plist ceiling is read.
49pub fn read_zip_plist(
50    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
51    path: &str,
52) -> Result<Vec<u8>, String> {
53    read_capped(zip, path, MAX_PLIST_BYTES)
54}
55
56/// The declared uncompressed size is an attacker-controlled zip-header field,
57/// and a small declared size can hide a huge inflate. So the read stops at the
58/// declared size, which fits the buffer allocated for it, and one byte more
59/// rejects the entry. That extra read also reaches the end of the entry, where
60/// the zip reader checks the CRC.
61fn read_capped(
62    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
63    path: &str,
64    limit: usize,
65) -> Result<Vec<u8>, String> {
66    let mut file = zip.by_name(path).map_err(|_| format!("{path} not found"))?;
67    let size = file.size();
68    if size > limit as u64 {
69        return Err(format!("{path}: declared size {size} exceeds limit"));
70    }
71    let read_error = |e: std::io::Error| format!("failed to read {path}: {e}");
72    let mut buf = Vec::with_capacity(size as usize);
73    (&mut file)
74        .take(size)
75        .read_to_end(&mut buf)
76        .map_err(read_error)?;
77    if file.read(&mut [0]).map_err(read_error)? > 0 {
78        return Err(format!("{path}: entry exceeds its declared size"));
79    }
80    Ok(buf)
81}
82
83/// The streaming pre-check builds no tree, so it bails early: without it a
84/// deeply nested plist produces a `Value` whose recursive `Drop` overflows the
85/// call stack, and a small binary plist whose objects reference one another
86/// many times expands into a tree far larger than the file. The stream yields
87/// one event per expanded value, so counting values and payload bytes bounds
88/// the tree before it is built. A table scan runs first, then the bytes are
89/// parsed twice, stream then tree.
90pub fn parse_plist_guarded(bytes: &[u8], label: &str) -> Result<plist::Value, String> {
91    use plist::stream::Event;
92    if bytes.len() > MAX_PLIST_BYTES {
93        return Err(format!("{label}: plist size {} exceeds limit", bytes.len()));
94    }
95    check_binary_tables(bytes, label)?;
96    let mut depth: usize = 0;
97    let mut values: usize = 0;
98    let mut payload: usize = 0;
99    for event in plist::stream::Reader::new(Cursor::new(bytes)) {
100        match event.map_err(|e| format!("failed to parse {label}: {e}"))? {
101            Event::StartArray(_) | Event::StartDictionary(_) => {
102                depth += 1;
103                if depth > MAX_PLIST_DEPTH {
104                    return Err(format!("{label}: plist nesting depth exceeds limit"));
105                }
106            }
107            Event::EndCollection => {
108                depth = depth.saturating_sub(1);
109                continue;
110            }
111            Event::Data(data) => payload = payload.saturating_add(data.len()),
112            Event::String(string) => payload = payload.saturating_add(string.len()),
113            _ => {}
114        }
115        values += 1;
116        if values > MAX_PLIST_VALUES {
117            return Err(format!(
118                "{label}: plist expands to over {MAX_PLIST_VALUES} values"
119            ));
120        }
121        if payload > MAX_PLIST_PAYLOAD_BYTES {
122            return Err(format!(
123                "{label}: plist expands to over {MAX_PLIST_PAYLOAD_BYTES} bytes of strings and data"
124            ));
125        }
126    }
127    plist::Value::from_reader(Cursor::new(bytes))
128        .map_err(|e| format!("failed to parse {label}: {e}"))
129}
130
131/// plist 1.8's binary reader allocates the offset table, each collection's
132/// references and each decoded string before the event the stream guard
133/// counts, and a UTF-16 string holds its code units and the growing UTF-8
134/// string at once. When every object is reachable, the object count and the
135/// declared references are each at most the expanded value count that guard
136/// caps, and the declared string and data bytes are at most the expanded
137/// payload, so a plist it accepts passes here. A bad magic or offset width,
138/// or an offset table that does not fit, is left to plist.
139fn check_binary_tables(bytes: &[u8], label: &str) -> Result<(), String> {
140    let Some(tail) = bytes
141        .strip_prefix(b"bplist00")
142        .and_then(|body| body.last_chunk::<32>())
143    else {
144        return Ok(());
145    };
146    let trailer = bytes.len() - tail.len();
147    let width = usize::from(tail[6]);
148    let count = big_endian(&tail[8..16]);
149    let table = big_endian(&tail[24..32]);
150    if !matches!(width, 1 | 2 | 3 | 4 | 8) {
151        return Ok(());
152    }
153    let Some(entries) = count
154        .checked_mul(width as u64)
155        .and_then(|n| n.checked_add(table))
156        .filter(|&end| end <= trailer as u64)
157        .and_then(|end| bytes.get(table as usize..end as usize))
158    else {
159        return Ok(());
160    };
161    if count > MAX_PLIST_VALUES as u64 {
162        return Err(format!(
163            "{label}: plist declares over {MAX_PLIST_VALUES} objects"
164        ));
165    }
166    let mut references: u64 = 0;
167    let mut payload: u64 = 0;
168    for entry in entries.chunks_exact(width) {
169        let offset = big_endian(entry);
170        if offset >= trailer as u64 {
171            continue;
172        }
173        let (object_references, object_payload) = declared(bytes, trailer, offset as usize);
174        references = references.saturating_add(object_references);
175        payload = payload.saturating_add(object_payload);
176        if references > MAX_PLIST_VALUES as u64 {
177            return Err(format!(
178                "{label}: plist collections declare over {MAX_PLIST_VALUES} references"
179            ));
180        }
181        if payload > MAX_PLIST_PAYLOAD_BYTES as u64 {
182            return Err(format!(
183                "{label}: plist declares over {MAX_PLIST_PAYLOAD_BYTES} bytes of strings and data"
184            ));
185        }
186    }
187    Ok(())
188}
189
190/// The references and the string or data bytes plist's reader allocates
191/// for the object at `at` before it yields the object's event.
192fn declared(bytes: &[u8], trailer: usize, at: usize) -> (u64, u64) {
193    let Some(&token) = bytes.get(at) else {
194        return (0, 0);
195    };
196    let (len, start) = if token & 0xF == 0xF {
197        let Some(&marker) = bytes.get(at.saturating_add(1)) else {
198            return (0, 0);
199        };
200        let start = at.saturating_add(2);
201        let end = start.saturating_add(1 << (marker & 3));
202        let Some(field) = bytes.get(start..end) else {
203            return (0, 0);
204        };
205        (big_endian(field), end)
206    } else {
207        (u64::from(token & 0xF), at + 1)
208    };
209    // plist rejects content that does not fit before the trailer without
210    // allocating it, and measures the fit from the object's offset because
211    // `PosReader::read` never advances `pos`.
212    let content = |unit: u64| {
213        let size = usize::try_from(len.checked_mul(unit)?).ok()?;
214        at.checked_add(size).filter(|&end| end <= trailer)?;
215        bytes.get(start..start.checked_add(size)?)
216    };
217    match token >> 4 {
218        0x4 | 0x5 => (0, content(1).map_or(0, |data| data.len() as u64)),
219        0x6 => (0, content(2).map_or(0, utf8_len)),
220        0xA => (len, 0),
221        0xD => (len.saturating_mul(2), 0),
222        _ => (0, 0),
223    }
224}
225
226/// The UTF-8 length of big-endian UTF-16 `units`, exact for a valid string.
227/// plist allocates for every unit before it finds an unpaired surrogate, so a
228/// lone surrogate counts as half a pair.
229fn utf8_len(units: &[u8]) -> u64 {
230    units
231        .as_chunks()
232        .0
233        .iter()
234        .map(|&unit| match u16::from_be_bytes(unit) {
235            0..=0x7F => 1,
236            0x80..=0x7FF | 0xD800..=0xDFFF => 2,
237            _ => 3,
238        })
239        .sum()
240}
241
242/// `field`, at most eight bytes, as a big-endian unsigned integer.
243fn big_endian(field: &[u8]) -> u64 {
244    field.iter().fold(0, |n, &b| n << 8 | u64::from(b))
245}
246
247/// Why a `Shape.png` did not decode.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum ShapePngError {
250    /// A side over [`MAX_PNG_DIMENSION`], or a decode over [`MAX_ENTRY_BYTES`],
251    /// counted as [`TipImageError::TooLarge`](crate::TipImageError::TooLarge)
252    /// describes.
253    TooLarge { width: u32, height: u32 },
254    /// The bytes did not decode, or a PNG carries an eXIf chunk over 64 KiB
255    /// before the image data.
256    Corrupt(String),
257}
258
259impl std::fmt::Display for ShapePngError {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            ShapePngError::TooLarge { width, height } => write!(
263                f,
264                "Shape.png is {width}x{height}px; a brush tip must be at most {MAX_PNG_DIMENSION}px per side and {} MiB to decode",
265                MAX_ENTRY_BYTES / (1024 * 1024)
266            ),
267            ShapePngError::Corrupt(msg) => f.write_str(msg),
268        }
269    }
270}
271
272impl std::error::Error for ShapePngError {}
273
274/// Decode a Procreate `Shape.png` into a grayscale tip. White is stamp
275/// coverage, so the luminance is taken as-is. Oversize, by either dimension
276/// or decoded size, is decided from the header before any pixels are decoded.
277pub fn decode_tip_png(bytes: &[u8]) -> Result<GrayscaleBitmap, ShapePngError> {
278    let image =
279        decode_guarded(bytes, MAX_PNG_DIMENSION, MAX_ENTRY_BYTES as u64).map_err(|e| match e {
280            GuardedDecodeError::TooLarge { width, height } => {
281                ShapePngError::TooLarge { width, height }
282            }
283            GuardedDecodeError::Decode(e) => {
284                ShapePngError::Corrupt(format!("failed to decode Shape.png: {e}"))
285            }
286        })?;
287    Ok(GrayscaleBitmap {
288        width: image.width,
289        height: image.height,
290        data: tip_plane(image, TipSample::Luma),
291    })
292}
293
294/// The set name and the member uuids a `brushset.plist` declares, in its order.
295pub fn parse_brushset_plist(bytes: &[u8]) -> Result<(Option<String>, Vec<String>), String> {
296    let value = parse_plist_guarded(bytes, "brushset.plist")?;
297    let dict = value
298        .as_dictionary()
299        .ok_or("brushset.plist root is not a dictionary")?;
300    let name = dict
301        .get("name")
302        .and_then(|v| v.as_string())
303        .map(str::to_string);
304    let array = dict
305        .get("brushes")
306        .and_then(|v| v.as_array())
307        .ok_or("brushset.plist missing brushes array")?;
308    let uuids = array
309        .iter()
310        .enumerate()
311        .map(|(i, v)| {
312            v.as_string()
313                .map(|s| s.to_string())
314                .ok_or_else(|| format!("brushset.plist brushes[{i}] is not a string"))
315        })
316        .collect::<Result<Vec<_>, _>>()?;
317    Ok((name, uuids))
318}
319
320/// The NSKeyedArchiver settings dictionary lives at `$objects[1]`, and every
321/// non-scalar field of it is a UID into the same `$objects` array — so a
322/// caller needs the pair, not just the dictionary.
323pub fn archive_objects_and_main(
324    value: &plist::Value,
325) -> Result<(&[plist::Value], &plist::Dictionary), String> {
326    let root = value
327        .as_dictionary()
328        .ok_or("Brush.archive root is not a dictionary")?;
329    let objects = root
330        .get("$objects")
331        .and_then(|v| v.as_array())
332        .ok_or("Brush.archive missing $objects array")?;
333    let main_dict = objects
334        .get(1)
335        .and_then(|v| v.as_dictionary())
336        .ok_or("$objects[1] is not a dictionary")?;
337    Ok((objects, main_dict))
338}
339
340/// Follow a UID-valued field of the settings dictionary to the string it names,
341/// treating NSKeyedArchiver's literal `"$null"` marker as absent.
342pub fn resolve_string(
343    objects: &[plist::Value],
344    main_dict: &plist::Dictionary,
345    key: &str,
346) -> Option<String> {
347    main_dict
348        .get(key)
349        .and_then(|v| v.as_uid())
350        .and_then(|u| objects.get(u.get() as usize))
351        .and_then(|v| v.as_string())
352        .filter(|s| *s != "$null")
353        .map(str::to_string)
354}
355
356/// The display name stored in a `Brush.archive` (`$objects[1].name`), `None`
357/// when the archive has no readable name.
358pub fn brush_name(archive_bytes: &[u8]) -> Result<Option<String>, String> {
359    let value = parse_plist_guarded(archive_bytes, "Brush.archive")?;
360    let (objects, main_dict) = archive_objects_and_main(&value)?;
361    Ok(resolve_string(objects, main_dict, "name"))
362}
363
364/// Members of a set that has no `brushset.plist`: every top-level directory `d`
365/// with an entry named exactly `d/Brush.archive`, in first-appearance zip order.
366/// `d/Reset/Brush.archive` does not make `d/Reset` a member.
367pub fn members_in_zip_order(zip: &mut zip::ZipArchive<Cursor<&[u8]>>) -> Vec<String> {
368    // By index, not `file_names()`: only the index walk is guaranteed to follow
369    // the central directory, and the member order is part of the contract.
370    (0..zip.len())
371        .filter_map(|i| {
372            let dir = zip.name_for_index(i)?.strip_suffix("/Brush.archive")?;
373            (!dir.is_empty() && !dir.contains('/')).then(|| dir.to_string())
374        })
375        .collect()
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    /// `body` after the magic, then a trailer with one-byte references.
383    fn bplist(body: &[u8], offset_width: u8, count: u64, table: u64) -> Vec<u8> {
384        let mut out = b"bplist00".to_vec();
385        out.extend_from_slice(body);
386        out.extend_from_slice(&[0, 0, 0, 0, 0, 0, offset_width, 1]);
387        out.extend_from_slice(&count.to_be_bytes());
388        out.extend_from_slice(&0u64.to_be_bytes());
389        out.extend_from_slice(&table.to_be_bytes());
390        out
391    }
392
393    #[test]
394    fn tables_that_overflow_or_do_not_fit_are_left_to_plist() {
395        let over = MAX_PLIST_VALUES as u64 + 1;
396        for (width, count, table) in [
397            (8, u64::MAX, 8),
398            (1, 1, u64::MAX),
399            (1, over, 8),
400            (5, over, 8),
401        ] {
402            let plist = bplist(&[0x10, 0, 8], width, count, table);
403            let case = format!("{width} {count} {table}");
404            assert_eq!(check_binary_tables(&plist, "t"), Ok(()), "{case}");
405            let err = parse_plist_guarded(&plist, "t").expect_err(&case);
406            assert!(err.starts_with("failed to parse t:"), "{case}: {err}");
407        }
408    }
409
410    #[test]
411    fn extended_lengths_are_read_at_the_marked_width() {
412        let over = (MAX_PLIST_VALUES as u64 + 1).to_be_bytes();
413        let rejected = Err(format!(
414            "t: plist collections declare over {MAX_PLIST_VALUES} references"
415        ));
416        for (marker, expected) in [(0x10, Ok(())), (0x13, rejected.clone()), (0xF3, rejected)] {
417            let mut body = vec![0xAF, marker];
418            body.extend_from_slice(&over);
419            body.push(8);
420            assert_eq!(
421                check_binary_tables(&bplist(&body, 1, 1, 18), "t"),
422                expected,
423                "{marker:#x}"
424            );
425        }
426    }
427
428    #[test]
429    fn utf16_strings_are_counted_as_utf8() {
430        let plist = |ascii: usize| {
431            let string = "\u{5b57}".repeat(MAX_PLIST_PAYLOAD_BYTES / 3) + &"a".repeat(ascii);
432            let mut out = Vec::new();
433            plist::Value::String(string)
434                .to_writer_binary(&mut out)
435                .expect("binary plist");
436            out
437        };
438        let fits = MAX_PLIST_PAYLOAD_BYTES % 3;
439        assert!(parse_plist_guarded(&plist(fits), "t").is_ok());
440        assert_eq!(
441            parse_plist_guarded(&plist(fits + 1), "t"),
442            declared_payload()
443        );
444    }
445
446    fn declared_payload() -> Result<plist::Value, String> {
447        Err(format!(
448            "t: plist declares over {MAX_PLIST_PAYLOAD_BYTES} bytes of strings and data"
449        ))
450    }
451
452    /// One UTF-16 string whose content runs `into_trailer` bytes into the
453    /// trailer, with the offset table's one entry as its last byte before it.
454    fn utf16_string(units: &[u16], into_trailer: usize) -> Vec<u8> {
455        let mut body = vec![0x6F, 0x12];
456        body.extend_from_slice(&(units.len() as u32).to_be_bytes());
457        body.extend(units.iter().flat_map(|unit| unit.to_be_bytes()));
458        body.truncate(body.len() - into_trailer);
459        *body.last_mut().expect("content") = 8;
460        let table = 8 + body.len() as u64 - 1;
461        bplist(&body, 1, 1, table)
462    }
463
464    #[test]
465    fn utf16_strings_that_plist_fails_or_reads_into_the_trailer_are_counted() {
466        let cjk = vec![0x5B57; MAX_PLIST_PAYLOAD_BYTES / 3 + 3];
467        let lone_surrogate = [&[0xDC00][..], &cjk[3..]].concat();
468        for plist in [utf16_string(&lone_surrogate, 0), utf16_string(&cjk, 6)] {
469            assert_eq!(parse_plist_guarded(&plist, "t"), declared_payload());
470        }
471    }
472}