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