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
19/// The declared uncompressed size is an attacker-controlled zip-header field,
20/// so it is only a clamped pre-allocation hint and the read is capped
21/// independently — a small declared size can hide a huge inflate.
22pub fn read_zip_entry(
23    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
24    path: &str,
25) -> Result<Vec<u8>, String> {
26    let file = zip.by_name(path).map_err(|_| format!("{path} not found"))?;
27    if file.size() > MAX_ENTRY_BYTES as u64 {
28        return Err(format!(
29            "{path}: declared size {} exceeds limit",
30            file.size()
31        ));
32    }
33    let mut buf = Vec::with_capacity((file.size() as usize).min(MAX_ENTRY_BYTES));
34    file.take(MAX_ENTRY_BYTES as u64 + 1)
35        .read_to_end(&mut buf)
36        .map_err(|e| format!("failed to read {path}: {e}"))?;
37    if buf.len() > MAX_ENTRY_BYTES {
38        return Err(format!("{path}: entry exceeds size limit"));
39    }
40    Ok(buf)
41}
42
43/// The streaming depth pre-check builds no tree, so it bails early: without it
44/// a deeply nested plist produces a `Value` whose recursive `Drop` overflows
45/// the call stack. The bytes are parsed twice, stream then tree.
46pub fn parse_plist_guarded(bytes: &[u8], label: &str) -> Result<plist::Value, String> {
47    if bytes.len() > MAX_PLIST_BYTES {
48        return Err(format!("{label}: plist size {} exceeds limit", bytes.len()));
49    }
50    let mut depth: usize = 0;
51    for event in plist::stream::Reader::new(Cursor::new(bytes)) {
52        match event.map_err(|e| format!("failed to parse {label}: {e}"))? {
53            plist::stream::Event::StartArray(_) | plist::stream::Event::StartDictionary(_) => {
54                depth += 1;
55                if depth > MAX_PLIST_DEPTH {
56                    return Err(format!("{label}: plist nesting depth exceeds limit"));
57                }
58            }
59            plist::stream::Event::EndCollection => depth = depth.saturating_sub(1),
60            _ => {}
61        }
62    }
63    plist::Value::from_reader(Cursor::new(bytes))
64        .map_err(|e| format!("failed to parse {label}: {e}"))
65}
66
67/// Why a `Shape.png` did not decode.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ShapePngError {
70    /// A side over [`MAX_PNG_DIMENSION`], or a decode over [`MAX_ENTRY_BYTES`],
71    /// counted as [`TipImageError::TooLarge`](crate::TipImageError::TooLarge)
72    /// describes.
73    TooLarge { width: u32, height: u32 },
74    /// The bytes did not decode, or a PNG carries an eXIf chunk over 64 KiB
75    /// before the image data.
76    Corrupt(String),
77}
78
79impl std::fmt::Display for ShapePngError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            ShapePngError::TooLarge { width, height } => write!(
83                f,
84                "Shape.png is {width}x{height}px; a brush tip must be at most {MAX_PNG_DIMENSION}px per side and {} MiB to decode",
85                MAX_ENTRY_BYTES / (1024 * 1024)
86            ),
87            ShapePngError::Corrupt(msg) => f.write_str(msg),
88        }
89    }
90}
91
92impl std::error::Error for ShapePngError {}
93
94/// Decode a Procreate `Shape.png` into a grayscale tip. White is stamp
95/// coverage, so the luminance is taken as-is. Oversize, by either dimension
96/// or decoded size, is decided from the header before any pixels are decoded.
97pub fn decode_tip_png(bytes: &[u8]) -> Result<GrayscaleBitmap, ShapePngError> {
98    let image =
99        decode_guarded(bytes, MAX_PNG_DIMENSION, MAX_ENTRY_BYTES as u64).map_err(|e| match e {
100            GuardedDecodeError::TooLarge { width, height } => {
101                ShapePngError::TooLarge { width, height }
102            }
103            GuardedDecodeError::Decode(e) => {
104                ShapePngError::Corrupt(format!("failed to decode Shape.png: {e}"))
105            }
106        })?;
107    Ok(GrayscaleBitmap {
108        width: image.width,
109        height: image.height,
110        data: tip_plane(image, TipSample::Luma),
111    })
112}
113
114/// The set name and the member uuids a `brushset.plist` declares, in its order.
115pub fn parse_brushset_plist(bytes: &[u8]) -> Result<(Option<String>, Vec<String>), String> {
116    let value = parse_plist_guarded(bytes, "brushset.plist")?;
117    let dict = value
118        .as_dictionary()
119        .ok_or("brushset.plist root is not a dictionary")?;
120    let name = dict
121        .get("name")
122        .and_then(|v| v.as_string())
123        .map(str::to_string);
124    let array = dict
125        .get("brushes")
126        .and_then(|v| v.as_array())
127        .ok_or("brushset.plist missing brushes array")?;
128    let uuids = array
129        .iter()
130        .enumerate()
131        .map(|(i, v)| {
132            v.as_string()
133                .map(|s| s.to_string())
134                .ok_or_else(|| format!("brushset.plist brushes[{i}] is not a string"))
135        })
136        .collect::<Result<Vec<_>, _>>()?;
137    Ok((name, uuids))
138}
139
140/// The NSKeyedArchiver settings dictionary lives at `$objects[1]`, and every
141/// non-scalar field of it is a UID into the same `$objects` array — so a
142/// caller needs the pair, not just the dictionary.
143pub fn archive_objects_and_main(
144    value: &plist::Value,
145) -> Result<(&[plist::Value], &plist::Dictionary), String> {
146    let root = value
147        .as_dictionary()
148        .ok_or("Brush.archive root is not a dictionary")?;
149    let objects = root
150        .get("$objects")
151        .and_then(|v| v.as_array())
152        .ok_or("Brush.archive missing $objects array")?;
153    let main_dict = objects
154        .get(1)
155        .and_then(|v| v.as_dictionary())
156        .ok_or("$objects[1] is not a dictionary")?;
157    Ok((objects, main_dict))
158}
159
160/// Follow a UID-valued field of the settings dictionary to the string it names,
161/// treating NSKeyedArchiver's literal `"$null"` marker as absent.
162pub fn resolve_string(
163    objects: &[plist::Value],
164    main_dict: &plist::Dictionary,
165    key: &str,
166) -> Option<String> {
167    main_dict
168        .get(key)
169        .and_then(|v| v.as_uid())
170        .and_then(|u| objects.get(u.get() as usize))
171        .and_then(|v| v.as_string())
172        .filter(|s| *s != "$null")
173        .map(str::to_string)
174}
175
176/// The display name stored in a `Brush.archive` (`$objects[1].name`), `None`
177/// when the archive has no readable name.
178pub fn brush_name(archive_bytes: &[u8]) -> Result<Option<String>, String> {
179    let value = parse_plist_guarded(archive_bytes, "Brush.archive")?;
180    let (objects, main_dict) = archive_objects_and_main(&value)?;
181    Ok(resolve_string(objects, main_dict, "name"))
182}
183
184/// Members of a set that has no `brushset.plist`: every top-level directory `d`
185/// with an entry named exactly `d/Brush.archive`, in first-appearance zip order.
186/// `d/Reset/Brush.archive` does not make `d/Reset` a member.
187pub fn members_in_zip_order(zip: &mut zip::ZipArchive<Cursor<&[u8]>>) -> Vec<String> {
188    // By index, not `file_names()`: only the index walk is guaranteed to follow
189    // the central directory, and the member order is part of the contract.
190    (0..zip.len())
191        .filter_map(|i| {
192            let dir = zip.name_for_index(i)?.strip_suffix("/Brush.archive")?;
193            (!dir.is_empty() && !dir.contains('/')).then(|| dir.to_string())
194        })
195        .collect()
196}