Skip to main content

zenmeta/
bplist.rs

1//! Minimal Apple binary property list (`bplist00`) reader.
2//!
3//! Several Apple MakerNote tags (e.g. `RunTime`, auto-exposure state) store an
4//! embedded `bplist00` blob rather than a plain TIFF value. This reader decodes
5//! the common object types well enough to inspect those structures. It is a
6//! reader only — there is no writer — and it is deliberately small.
7//!
8//! Format reference: CoreFoundation `CFBinaryPList` (`bplist00`):
9//! 8-byte `"bplist00"` header, packed objects, an offset table, and a 32-byte
10//! trailer giving the integer widths, object count, top object index, and the
11//! offset-table location.
12//!
13//! `no_std` + `alloc`, `#![forbid(unsafe_code)]`. Recursion is depth-bounded so
14//! a maliciously cyclic ref graph cannot blow the stack.
15
16use alloc::string::String;
17use alloc::vec::Vec;
18
19/// A decoded property-list value.
20#[derive(Clone, Debug, PartialEq)]
21pub enum PlistValue {
22    /// Boolean (`0x08` false / `0x09` true).
23    Bool(bool),
24    /// Signed integer (1/2/4/8-byte; wider ints are truncated to the low 64 bits).
25    Integer(i64),
26    /// IEEE-754 real (`f32` widened to `f64`, or native `f64`).
27    Real(f64),
28    /// Date: seconds since the Apple epoch (2001-01-01T00:00:00Z).
29    Date(f64),
30    /// Raw data blob.
31    Data(Vec<u8>),
32    /// UTF-8 (from ASCII) or UTF-16BE string.
33    String(String),
34    /// CoreFoundation keyed-archiver UID.
35    Uid(u64),
36    /// Ordered array.
37    Array(Vec<PlistValue>),
38    /// Ordered key/value dictionary (insertion order preserved).
39    Dict(Vec<(String, PlistValue)>),
40}
41
42/// Maximum container nesting depth before parsing bails (cycle/DoS guard).
43const MAX_DEPTH: usize = 32;
44const MAX_NODES: usize = 65_536;
45const MAX_BYTES: usize = 16 * 1024 * 1024;
46
47struct Budget {
48    nodes: usize,
49    bytes: usize,
50}
51
52impl Budget {
53    fn charge(&mut self, bytes: usize) -> Option<()> {
54        self.bytes = self.bytes.checked_sub(bytes)?;
55        Some(())
56    }
57}
58
59/// Parse a `bplist00` blob and return the top-level object, or `None` if the
60/// data is not a well-formed binary plist within our supported subset.
61/// Input and requested output allocations are each limited to 16 MiB;
62/// expansion is limited to 65,536 visited values and 32 nested containers.
63pub fn parse_bplist(data: &[u8]) -> Option<PlistValue> {
64    if data.len() < 8 + 32 || data.len() > MAX_BYTES || &data[..8] != b"bplist00" {
65        return None;
66    }
67    let trailer = &data[data.len() - 32..];
68    let offset_size = *trailer.get(6)? as usize;
69    let ref_size = *trailer.get(7)? as usize;
70    if offset_size == 0 || offset_size > 8 || ref_size == 0 || ref_size > 8 {
71        return None;
72    }
73    let num_objects = usize::try_from(read_uint_be(trailer, 8, 8)?).ok()?;
74    let top_object = usize::try_from(read_uint_be(trailer, 16, 8)?).ok()?;
75    let offset_table_off = usize::try_from(read_uint_be(trailer, 24, 8)?).ok()?;
76    if top_object >= num_objects || offset_table_off < 8 {
77        return None;
78    }
79    // The offset table must fit before the trailer.
80    let table_end = offset_table_off.checked_add(num_objects.checked_mul(offset_size)?)?;
81    if table_end > data.len() - 32 {
82        return None;
83    }
84    let ctx = Ctx {
85        data,
86        num_objects,
87        offset_size,
88        ref_size,
89        offset_table_off,
90    };
91    ctx.read_object(
92        top_object,
93        0,
94        &mut Budget {
95            nodes: MAX_NODES,
96            bytes: MAX_BYTES,
97        },
98    )
99}
100
101/// Immutable parsing context shared across the recursive descent.
102struct Ctx<'a> {
103    data: &'a [u8],
104    num_objects: usize,
105    offset_size: usize,
106    ref_size: usize,
107    offset_table_off: usize,
108}
109
110impl Ctx<'_> {
111    /// Byte offset of object `index` from the offset table.
112    fn object_offset(&self, index: usize) -> Option<usize> {
113        if index >= self.num_objects {
114            return None;
115        }
116        let pos = self.offset_table_off + index * self.offset_size;
117        let offset = usize::try_from(read_uint_be(self.data, pos, self.offset_size)?).ok()?;
118        (offset >= 8 && offset < self.offset_table_off).then_some(offset)
119    }
120
121    /// Read an object reference (used inside arrays/dicts) at byte `pos`.
122    fn read_ref(&self, pos: usize) -> Option<usize> {
123        usize::try_from(read_uint_be(self.payload(), pos, self.ref_size)?).ok()
124    }
125
126    fn payload(&self) -> &[u8] {
127        &self.data[..self.offset_table_off]
128    }
129
130    fn read_object(&self, index: usize, depth: usize, budget: &mut Budget) -> Option<PlistValue> {
131        if depth > MAX_DEPTH {
132            return None;
133        }
134        budget.nodes = budget.nodes.checked_sub(1)?;
135        let off = self.object_offset(index)?;
136        let data = self.payload();
137        let marker = *data.get(off)?;
138        let hi = marker >> 4;
139        let lo = (marker & 0x0F) as usize;
140        match hi {
141            0x0 => match marker {
142                0x08 => Some(PlistValue::Bool(false)),
143                0x09 => Some(PlistValue::Bool(true)),
144                _ => None, // null / fill / unsupported singletons
145            },
146            0x1 => {
147                // Integer: 2^lo bytes, big-endian. bplist 1/2/4-byte ints are
148                // unsigned and 8-byte ints are signed; `raw as i64` is correct
149                // for all (the 8-byte bit pattern reinterprets as signed).
150                let n = 1usize << lo;
151                let bytes = data.get(off + 1..off.checked_add(1)?.checked_add(n)?)?;
152                let raw = read_uint_be(bytes, n.saturating_sub(8), n.min(8))?;
153                Some(PlistValue::Integer(raw as i64))
154            }
155            0x2 => {
156                // Real: 2^lo bytes (4 = f32, 8 = f64).
157                let n = 1usize << lo;
158                let bytes = data.get(off + 1..off.checked_add(1)?.checked_add(n)?)?;
159                let v = match n {
160                    4 => f32::from_be_bytes(bytes.try_into().ok()?) as f64,
161                    8 => f64::from_be_bytes(bytes.try_into().ok()?),
162                    _ => return None,
163                };
164                Some(PlistValue::Real(v))
165            }
166            0x3 => {
167                // Date: always an 8-byte f64.
168                if marker != 0x33 {
169                    return None;
170                }
171                let bytes = data.get(off + 1..off.checked_add(9)?)?;
172                Some(PlistValue::Date(f64::from_be_bytes(bytes.try_into().ok()?)))
173            }
174            0x4 => {
175                let (count, base) = self.read_count(off, lo)?;
176                let bytes = data.get(base..base.checked_add(count)?)?;
177                budget.charge(count)?;
178                let mut out = Vec::new();
179                out.try_reserve_exact(count).ok()?;
180                out.extend_from_slice(bytes);
181                Some(PlistValue::Data(out))
182            }
183            0x5 => {
184                // ASCII string: `count` bytes, one byte per char.
185                let (count, base) = self.read_count(off, lo)?;
186                let bytes = data.get(base..base.checked_add(count)?)?;
187                let text = core::str::from_utf8(bytes).ok()?;
188                budget.charge(count)?;
189                let mut out = String::new();
190                out.try_reserve_exact(count).ok()?;
191                out.push_str(text);
192                Some(PlistValue::String(out))
193            }
194            0x6 => {
195                // UTF-16BE string: `count` UTF-16 code units (2 bytes each).
196                let (count, base) = self.read_count(off, lo)?;
197                // `count` is attacker-controlled; checked arithmetic so a huge
198                // count cannot overflow usize and wrap past the data.get bound
199                // (which then bounds the Vec reservation to `count <= len/2`).
200                let end = count.checked_mul(2).and_then(|n| base.checked_add(n))?;
201                let bytes = data.get(base..end)?;
202                let capacity = count.checked_mul(3)?;
203                budget.charge(capacity)?;
204                let mut s = String::new();
205                s.try_reserve_exact(capacity).ok()?;
206                let units = bytes
207                    .as_chunks::<2>()
208                    .0
209                    .iter()
210                    .map(|c| u16::from_be_bytes(*c));
211                for ch in char::decode_utf16(units) {
212                    s.push(ch.unwrap_or('\u{FFFD}'));
213                }
214                Some(PlistValue::String(s))
215            }
216            0x8 => {
217                // UID: lo+1 bytes.
218                let n = lo + 1;
219                let bytes = data.get(off + 1..off.checked_add(1)?.checked_add(n)?)?;
220                Some(PlistValue::Uid(read_uint_be(
221                    bytes,
222                    n.saturating_sub(8),
223                    n.min(8),
224                )?))
225            }
226            0xA | 0xC => {
227                // Array (0xA) or set (0xC): `count` object refs.
228                let (count, base) = self.read_count(off, lo)?;
229                let end = base.checked_add(count.checked_mul(self.ref_size)?)?;
230                data.get(base..end)?;
231                if count > budget.nodes {
232                    return None;
233                }
234                budget.charge(count.checked_mul(core::mem::size_of::<PlistValue>())?)?;
235                let mut out = Vec::new();
236                out.try_reserve_exact(count).ok()?;
237                for i in 0..count {
238                    let r = self.read_ref(base + i * self.ref_size)?;
239                    out.push(self.read_object(r, depth + 1, budget)?);
240                }
241                Some(PlistValue::Array(out))
242            }
243            0xD => {
244                // Dict: `count` key refs followed by `count` value refs.
245                let (count, base) = self.read_count(off, lo)?;
246                let keys_base = base;
247                let vals_base = count
248                    .checked_mul(self.ref_size)
249                    .and_then(|n| base.checked_add(n))?;
250                let end = vals_base.checked_add(count.checked_mul(self.ref_size)?)?;
251                data.get(base..end)?;
252                if count.checked_mul(2)? > budget.nodes {
253                    return None;
254                }
255                budget.charge(count.checked_mul(core::mem::size_of::<(String, PlistValue)>())?)?;
256                let mut out = Vec::new();
257                out.try_reserve_exact(count).ok()?;
258                for i in 0..count {
259                    let k = self.read_ref(keys_base + i * self.ref_size)?;
260                    let v = self.read_ref(vals_base + i * self.ref_size)?;
261                    let key = match self.read_object(k, depth + 1, budget)? {
262                        PlistValue::String(s) => s,
263                        _ => return None, // non-string keys are unsupported
264                    };
265                    out.push((key, self.read_object(v, depth + 1, budget)?));
266                }
267                Some(PlistValue::Dict(out))
268            }
269            _ => None,
270        }
271    }
272
273    /// Decode a container/data/string length. When the marker's low nibble is
274    /// `0xF`, the count is an inline integer object that follows. Returns
275    /// `(count, base)` where `base` is the byte offset of the first element.
276    fn read_count(&self, off: usize, lo: usize) -> Option<(usize, usize)> {
277        if lo != 0x0F {
278            return Some((lo, off + 1));
279        }
280        let size_marker = *self.payload().get(off.checked_add(1)?)?;
281        if size_marker >> 4 != 0x1 {
282            return None;
283        }
284        let n = 1usize << (size_marker & 0x0F);
285        // Lengths are unsigned machine-sized counts, never truncated integers.
286        if n > 8 {
287            return None;
288        }
289        let count = usize::try_from(read_uint_be(self.payload(), off + 2, n)?).ok()?;
290        Some((count, off.checked_add(2)?.checked_add(n)?))
291    }
292}
293
294/// Read a big-endian unsigned integer of `size` bytes (1..=8) at `off`.
295fn read_uint_be(data: &[u8], off: usize, size: usize) -> Option<u64> {
296    if size == 0 || size > 8 {
297        return None;
298    }
299    let bytes = data.get(off..off + size)?;
300    let mut v = 0u64;
301    for &b in bytes {
302        v = (v << 8) | b as u64;
303    }
304    Some(v)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn plist(objects: &[&[u8]]) -> Vec<u8> {
312        let mut data = b"bplist00".to_vec();
313        let mut offsets = Vec::new();
314        for object in objects {
315            offsets.push(u8::try_from(data.len()).unwrap());
316            data.extend_from_slice(object);
317        }
318        let table = data.len();
319        data.extend_from_slice(&offsets);
320        data.extend_from_slice(&[0; 6]);
321        data.extend_from_slice(&[1, 1]);
322        data.extend_from_slice(&(objects.len() as u64).to_be_bytes());
323        data.extend_from_slice(&0u64.to_be_bytes());
324        data.extend_from_slice(&(table as u64).to_be_bytes());
325        data
326    }
327
328    #[test]
329    fn rejects_exponential_shared_reference_expansion() {
330        let mut objects: Vec<Vec<u8>> = (1..=17).map(|n| alloc::vec![0xa2, n, n]).collect();
331        objects.push(alloc::vec![0x09]);
332        let refs: Vec<_> = objects.iter().map(Vec::as_slice).collect();
333        assert!(parse_bplist(&plist(&refs)).is_none());
334    }
335
336    #[test]
337    fn oversized_data_length_is_rejected_without_overflow() {
338        let mut object = alloc::vec![0x4f, 0x13];
339        object.extend_from_slice(&u64::MAX.to_be_bytes());
340        assert!(parse_bplist(&plist(&[&object])).is_none());
341    }
342
343    #[test]
344    fn wide_integer_uses_documented_low_bits_and_requires_complete_value() {
345        let mut object = alloc::vec![0x14];
346        object.extend_from_slice(&1u64.to_be_bytes());
347        object.extend_from_slice(&7u64.to_be_bytes());
348        assert_eq!(
349            parse_bplist(&plist(&[&object])),
350            Some(PlistValue::Integer(7))
351        );
352        // An object's payload cannot spill into the offset table/trailer.
353        assert!(parse_bplist(&plist(&[&object[..9]])).is_none());
354    }
355
356    /// `{"a": true, "b": 5}` encoded as a real bplist00 (built by Apple's
357    /// `plutil`). Verifies header, trailer, dict, bool, and integer decode.
358    #[test]
359    fn parses_simple_dict() {
360        // Hand-assembled bplist00:
361        //  obj0 dict(2): keys [obj1,obj2] vals [obj3,obj4]
362        //  obj1 "a" obj2 "b" obj3 true obj4 int(5)
363        let mut d = Vec::new();
364        d.extend_from_slice(b"bplist00");
365        let o0 = d.len();
366        d.push(0xD2); // dict, count 2
367        d.extend_from_slice(&[1, 2]); // key refs
368        d.extend_from_slice(&[3, 4]); // val refs
369        let o1 = d.len();
370        d.extend_from_slice(&[0x51, b'a']); // ASCII "a"
371        let o2 = d.len();
372        d.extend_from_slice(&[0x51, b'b']); // ASCII "b"
373        let o3 = d.len();
374        d.push(0x09); // true
375        let o4 = d.len();
376        d.extend_from_slice(&[0x10, 5]); // int 5 (1 byte)
377        let table = d.len();
378        for off in [o0, o1, o2, o3, o4] {
379            d.push(off as u8); // offset_size = 1
380        }
381        // trailer (32 bytes)
382        d.extend_from_slice(&[0; 6]); // unused + sortVersion
383        d.push(1); // offset int size
384        d.push(1); // object ref size
385        d.extend_from_slice(&(5u64).to_be_bytes()); // num objects
386        d.extend_from_slice(&(0u64).to_be_bytes()); // top object
387        d.extend_from_slice(&(table as u64).to_be_bytes()); // offset table off
388
389        let v = parse_bplist(&d).unwrap();
390        let PlistValue::Dict(entries) = v else {
391            panic!("expected dict, got {v:?}");
392        };
393        assert_eq!(entries.len(), 2);
394        assert_eq!(entries[0].0, "a");
395        assert_eq!(entries[0].1, PlistValue::Bool(true));
396        assert_eq!(entries[1].0, "b");
397        assert_eq!(entries[1].1, PlistValue::Integer(5));
398    }
399
400    #[test]
401    fn rejects_non_bplist() {
402        assert_eq!(parse_bplist(b"not a plist at all..............."), None);
403        assert_eq!(parse_bplist(b"bplist00"), None); // no trailer
404        assert_eq!(parse_bplist(&[0u8; 4]), None);
405    }
406}