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