Skip to main content

imessage_database/util/
plist.rs

1/*!
2 Helpers for reading message property-list payloads.
3
4 The main entry point is [`parse_ns_keyed_archiver()`]. For normal property lists, use [`plist_as_dictionary()`].
5
6 ## Overview
7
8 The `NSKeyedArchiver` format is a property list-based serialization protocol used by Apple's Foundation framework.
9 It stores object graphs in a keyed format, allowing for more flexible deserialization and better handling of
10 object references compared to the older typedstream format.
11
12 ## Origin
13
14 Introduced in Mac OS X 10.2 as part of the Foundation framework, `NSKeyedArchiver` replaced `NSArchiver`
15 ([`typedstream`](https://github.com/ReagentX/crabstep)) system as Apple's primary object serialization mechanism.
16
17 ## Features
18
19 - Pure Rust implementation for efficient and safe deserialization
20 - Support for both XML and binary property list formats
21 - No dependencies on Apple frameworks
22 - Robust error handling for malformed or invalid archives
23*/
24
25use plist::{Dictionary, Value};
26
27use crate::error::plist::PlistParseError;
28
29/// Maximum depth of UID-reference resolution before bailing out.
30const MAX_UID_DEPTH: usize = 256;
31/// The object-table value NSKeyedArchiver uses for an unset field.
32const NULL_PLACEHOLDER: &str = "$null";
33
34/// Deserialize an `NSKeyedArchiver` property list by resolving UID references.
35///
36/// The archive stores objects in `$objects` and points `$top.root` at the root
37/// object. This walks those references and returns the reconstructed value.
38///
39/// For example, a document with a root pointing to some `XML` like
40///
41/// ```xml
42/// <array>
43///     <dict>
44///         <key>link</key>
45///         <dict>
46///              <key>CF$UID</key>
47///              <integer>2</integer>
48///         </dict>
49///     </dict>
50///     <string>https://chrissardegna.com</string>
51/// </array>
52/// ```
53///
54/// parses into a dictionary that looks like:
55///
56/// ```json
57/// {
58///     link: https://chrissardegna.com
59/// }
60/// ```
61///
62/// Some detail on this format is described [here](https://en.wikipedia.org/wiki/Property_list#Serializing_to_plist):
63///
64/// > Internally, `NSKeyedArchiver` somewhat recapitulates the binary plist format by
65/// > storing an object table array called `$objects` in the dictionary. Everything else,
66/// > including class information, is referenced by a UID pointer. A `$top` entry under
67/// > the dict points to the top-level object the programmer was meaning to encode.
68///
69/// # Data Source
70///
71/// The source plist data generally comes from [`Message::payload_data()`](crate::tables::messages::Message::payload_data).
72pub fn parse_ns_keyed_archiver(plist: &Value) -> Result<Value, PlistParseError> {
73    let body = plist_as_dictionary(plist)?;
74    let objects = extract_array_key(body, "$objects")?;
75
76    // Index of root object
77    let root = extract_uid_key(extract_dictionary(body, "$top")?, "root")?;
78
79    // `encode(nil, forKey: root)` emits `$top.root -> UID 0`: an archive with
80    // no object in it.
81    follow_uid(objects, root, None, None, 0)?.ok_or(PlistParseError::NoPayload)
82}
83
84/// Resolve one archived object and any UID references it contains.
85///
86/// `None` is a reference to the archiver's null placeholder. Nested nulls are
87/// omitted from their containing dictionary or array; a null root is
88/// [`PlistParseError::NoPayload`] in [`parse_ns_keyed_archiver`].
89fn follow_uid<'a>(
90    objects: &'a [Value],
91    root: usize,
92    parent: Option<&'a Value>,
93    item: Option<&'a Value>,
94    depth: usize,
95) -> Result<Option<Value>, PlistParseError> {
96    if depth >= MAX_UID_DEPTH {
97        return Err(PlistParseError::RecursionLimit);
98    }
99    if item.is_none() && is_null_placeholder(objects, root) {
100        return Ok(None);
101    }
102    let item = match item {
103        Some(item) => item,
104        None => objects
105            .get(root)
106            .ok_or(PlistParseError::NoValueAtIndex(root))?,
107    };
108
109    match item {
110        Value::Array(arr) => {
111            let mut array = vec![];
112            for item in arr {
113                if let Some(idx) = item.as_uid()
114                    && let Some(value) =
115                        follow_uid(objects, uid_to_index(idx)?, parent, None, depth + 1)?
116                {
117                    array.push(value);
118                }
119            }
120            Ok(Some(plist::Value::Array(array)))
121        }
122        Value::Dictionary(dict) => {
123            let mut dictionary = Dictionary::new();
124            // Handle where type is a Dictionary that points to another single value
125            if let Some(relative) = dict.get("NS.relative") {
126                if let Some(idx) = relative.as_uid()
127                    && let Some(p) = parent
128                    && let Some(value) =
129                        follow_uid(objects, uid_to_index(idx)?, Some(p), None, depth + 1)?
130                {
131                    dictionary.insert(value_to_key_string(p), value);
132                }
133            }
134            // Handle the NSDictionary and NSMutableDictionary types
135            else if dict.contains_key("NS.keys") && dict.contains_key("NS.objects") {
136                let keys = extract_array_key(dict, "NS.keys")?;
137                // These are the values in the objects list
138                let values = extract_array_key(dict, "NS.objects")?;
139                // Die here if the data is invalid
140                if keys.len() != values.len() {
141                    return Err(PlistParseError::InvalidDictionarySize(
142                        keys.len(),
143                        values.len(),
144                    ));
145                }
146
147                for idx in 0..keys.len() {
148                    let key_index = extract_uid_idx(keys, idx)?;
149                    let value_index = extract_uid_idx(values, idx)?;
150                    let Some(key) = follow_uid(objects, key_index, None, None, depth + 1)? else {
151                        continue;
152                    };
153                    let Some(value) =
154                        follow_uid(objects, value_index, Some(&key), None, depth + 1)?
155                    else {
156                        continue;
157                    };
158
159                    dictionary.insert(value_to_key_string(&key), value);
160                }
161            }
162            // Handle a normal `{key: value}` style dictionary
163            else {
164                for (key, val) in dict {
165                    // Skip class names; we don't need them
166                    if key == "$class" {
167                        continue;
168                    }
169                    // If the value is a pointer, follow it
170                    if let Some(idx) = val.as_uid() {
171                        let key_value = Value::String(key.clone());
172                        if let Some(value) = follow_uid(
173                            objects,
174                            uid_to_index(idx)?,
175                            Some(&key_value),
176                            None,
177                            depth + 1,
178                        )? {
179                            dictionary.insert(key.clone(), value);
180                        }
181                    }
182                    // If the value is not a pointer, try and follow the data itself
183                    else if let Some(p) = parent
184                        && let Some(value) =
185                            follow_uid(objects, root, Some(p), Some(val), depth + 1)?
186                    {
187                        dictionary.insert(value_to_key_string(p), value);
188                    }
189                }
190            }
191            Ok(Some(plist::Value::Dictionary(dictionary)))
192        }
193        Value::Uid(uid) => follow_uid(objects, uid_to_index(uid)?, None, None, depth + 1),
194        _ => Ok(Some(item.to_owned())),
195    }
196}
197
198/// Identify the archiver's null object without conflating it with a literal
199/// string whose contents happen to be `"$null"`.
200fn is_null_placeholder(objects: &[Value], index: usize) -> bool {
201    index == 0
202        && objects
203            .first()
204            .and_then(Value::as_string)
205            .is_some_and(|value| value == NULL_PLACEHOLDER)
206}
207
208/// Convert a plist value into a dictionary key.
209fn value_to_key_string(v: &Value) -> String {
210    match v {
211        Value::String(s) => s.clone(),
212        Value::Integer(i) => i.to_string(),
213        Value::Real(f) => f.to_string(),
214        Value::Boolean(b) => b.to_string(),
215        Value::Date(d) => format!("{d:?}"),
216        Value::Data(_) => "data".to_string(),
217        Value::Array(_) => "array".to_string(),
218        Value::Dictionary(_) => "dict".to_string(),
219        Value::Uid(u) => u.get().to_string(),
220        _ => "unknown".to_string(),
221    }
222}
223
224/// Extract a dictionary from table `plist` data.
225pub fn plist_as_dictionary(plist: &Value) -> Result<&Dictionary, PlistParseError> {
226    plist
227        .as_dictionary()
228        .ok_or_else(|| PlistParseError::InvalidType("body".to_string(), "dictionary".to_string()))
229}
230
231/// Extract the shared `richLinkMetadata` payload and one nested metadata value.
232///
233/// Returns `(richLinkMetadata, nested_value)`.
234pub fn rich_link_metadata_and_nested<'a>(
235    payload: &'a Value,
236    nested_key: &str,
237) -> Result<(&'a Value, &'a Value), PlistParseError> {
238    let base = payload
239        .as_dictionary()
240        .ok_or_else(|| PlistParseError::InvalidType("root".to_string(), "dictionary".to_string()))?
241        .get("richLinkMetadata")
242        .ok_or_else(|| PlistParseError::MissingKey("richLinkMetadata".to_string()))?;
243
244    let nested = base
245        .as_dictionary()
246        .ok_or_else(|| {
247            PlistParseError::InvalidType("richLinkMetadata".to_string(), "dictionary".to_string())
248        })?
249        .get(nested_key)
250        .ok_or_else(|| PlistParseError::MissingKey(nested_key.to_string()))?;
251
252    Ok((base, nested))
253}
254
255/// Extract a dictionary from a collection key.
256pub fn extract_dictionary<'a>(
257    body: &'a Dictionary,
258    key: &str,
259) -> Result<&'a Dictionary, PlistParseError> {
260    body.get(key)
261        .ok_or_else(|| PlistParseError::MissingKey(key.to_string()))?
262        .as_dictionary()
263        .ok_or_else(|| PlistParseError::InvalidType(key.to_string(), "dictionary".to_string()))
264}
265
266/// Extract an array from a collection key.
267pub fn extract_array_key<'a>(
268    body: &'a Dictionary,
269    key: &str,
270) -> Result<&'a Vec<Value>, PlistParseError> {
271    body.get(key)
272        .ok_or_else(|| PlistParseError::MissingKey(key.to_string()))?
273        .as_array()
274        .ok_or_else(|| PlistParseError::InvalidType(key.to_string(), "array".to_string()))
275}
276
277/// Extract a `UID` from a collection key.
278fn extract_uid_key(body: &Dictionary, key: &str) -> Result<usize, PlistParseError> {
279    let uid = body
280        .get(key)
281        .ok_or_else(|| PlistParseError::MissingKey(key.to_string()))?
282        .as_uid()
283        .ok_or_else(|| PlistParseError::InvalidType(key.to_string(), "uid".to_string()))?;
284    uid_to_index(uid)
285}
286
287/// Extract bytes from a collection key.
288pub fn extract_bytes_key<'a>(body: &'a Dictionary, key: &str) -> Result<&'a [u8], PlistParseError> {
289    body.get(key)
290        .ok_or_else(|| PlistParseError::MissingKey(key.to_string()))?
291        .as_data()
292        .ok_or_else(|| PlistParseError::InvalidType(key.to_string(), "data".to_string()))
293}
294
295/// Extract a real value from a collection key and coerce it to `i64`.
296pub fn extract_int_key(body: &Dictionary, key: &str) -> Result<i64, PlistParseError> {
297    Ok(body
298        .get(key)
299        .ok_or_else(|| PlistParseError::MissingKey(key.to_string()))?
300        .as_real()
301        .ok_or_else(|| PlistParseError::InvalidType(key.to_string(), "real".to_string()))?
302        as i64)
303}
304
305/// Extract a string slice from a collection key.
306pub fn extract_string_key<'a>(body: &'a Dictionary, key: &str) -> Result<&'a str, PlistParseError> {
307    body.get(key)
308        .ok_or_else(|| PlistParseError::MissingKey(key.to_string()))?
309        .as_string()
310        .ok_or_else(|| PlistParseError::InvalidType(key.to_string(), "string".to_string()))
311}
312
313/// Extract a UID from a collection index.
314fn extract_uid_idx(body: &[Value], idx: usize) -> Result<usize, PlistParseError> {
315    let uid = body
316        .get(idx)
317        .ok_or(PlistParseError::NoValueAtIndex(idx))?
318        .as_uid()
319        .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "uid".to_string()))?;
320    uid_to_index(uid)
321}
322
323/// Convert a plist UID into an object-table index without narrowing.
324fn uid_to_index(uid: &plist::Uid) -> Result<usize, PlistParseError> {
325    usize::try_from(uid.get()).map_err(|_| PlistParseError::UidOutOfRange(uid.get()))
326}
327
328/// Extract a dictionary from a collection index.
329pub fn extract_dict_idx(body: &[Value], idx: usize) -> Result<&Dictionary, PlistParseError> {
330    body.get(idx)
331        .ok_or(PlistParseError::NoValueAtIndex(idx))?
332        .as_dictionary()
333        .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "dictionary".to_string()))
334}
335
336/// Extract a non-empty string from `{key: String("value")}`.
337#[must_use]
338pub fn get_string_from_dict<'a>(payload: &'a Value, key: &'a str) -> Option<&'a str> {
339    payload
340        .as_dictionary()?
341        .get(key)?
342        .as_string()
343        .filter(|s| !s.is_empty())
344}
345
346/// Extract an owned non-empty string from `{key: String("value")}`.
347#[must_use]
348pub fn get_owned_string_from_dict<'a>(payload: &'a Value, key: &'a str) -> Option<String> {
349    get_string_from_dict(payload, key).map(String::from)
350}
351
352/// Extract a value from `{key: value}`.
353#[must_use]
354pub fn get_value_from_dict<'a>(payload: &'a Value, key: &'a str) -> Option<&'a Value> {
355    payload.as_dictionary()?.get(key)
356}
357
358/// Extract a boolean from `{key: true}`.
359#[must_use]
360pub fn get_bool_from_dict<'a>(payload: &'a Value, key: &'a str) -> Option<bool> {
361    payload.as_dictionary()?.get(key)?.as_boolean()
362}
363
364/// Extract a byte slice from `{key: Data(...)}`.
365#[must_use]
366pub fn get_data_from_dict<'a>(payload: &'a Value, key: &'a str) -> Option<&'a [u8]> {
367    payload.as_dictionary()?.get(key)?.as_data()
368}
369
370/// Extract a non-empty string from `{key: {key: String("value")}}`.
371#[must_use]
372pub fn get_string_from_nested_dict<'a>(payload: &'a Value, key: &'a str) -> Option<&'a str> {
373    payload
374        .as_dictionary()?
375        .get(key)?
376        .as_dictionary()?
377        .get(key)?
378        .as_string()
379        .filter(|s| !s.is_empty())
380}
381
382/// Extract a float from `{key: {key: 1.2}}`.
383#[must_use]
384pub fn get_float_from_nested_dict<'a>(payload: &'a Value, key: &'a str) -> Option<f64> {
385    payload
386        .as_dictionary()?
387        .get(key)?
388        .as_dictionary()?
389        .get(key)?
390        .as_real()
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use plist::Uid;
397
398    /// Build a plist dictionary from key/value pairs.
399    fn dict(pairs: Vec<(&str, Value)>) -> Value {
400        let mut dictionary = Dictionary::new();
401        for (key, value) in pairs {
402            dictionary.insert(key.to_string(), value);
403        }
404        Value::Dictionary(dictionary)
405    }
406
407    #[test]
408    fn resolves_simple_archive() {
409        // `$top.root` -> `$objects[1]` == "hello"
410        let archive = dict(vec![
411            (
412                "$objects",
413                Value::Array(vec![
414                    Value::String("$null".to_string()),
415                    Value::String("hello".to_string()),
416                ]),
417            ),
418            ("$top", dict(vec![("root", Value::Uid(Uid::new(1)))])),
419        ]);
420
421        assert_eq!(
422            parse_ns_keyed_archiver(&archive).unwrap(),
423            Value::String("hello".to_string())
424        );
425    }
426
427    #[test]
428    fn null_root_is_no_payload() {
429        // `NSKeyedArchiver.encode(nil, forKey: NSKeyedArchiveRootObjectKey)`
430        // emits exactly this: `$objects == ["$null"]`, `$top.root -> UID 0`.
431        let archive = dict(vec![
432            (
433                "$objects",
434                Value::Array(vec![Value::String(NULL_PLACEHOLDER.to_string())]),
435            ),
436            ("$top", dict(vec![("root", Value::Uid(Uid::new(0)))])),
437        ]);
438
439        assert!(matches!(
440            parse_ns_keyed_archiver(&archive),
441            Err(PlistParseError::NoPayload)
442        ));
443    }
444
445    #[test]
446    fn omits_null_values_from_normal_dictionaries() {
447        // `$objects[3]` is a bare "$null" string at a non-zero index. Foundation
448        // never emits this shape (a literal "$null" NSString is archived as
449        // `{$class: NSString, NS.string: "$null"}`); it exists to prove the
450        // placeholder is identified by index, not by string contents.
451        let archive = dict(vec![
452            (
453                "$objects",
454                Value::Array(vec![
455                    Value::String(NULL_PLACEHOLDER.to_string()),
456                    dict(vec![
457                        ("missing", Value::Uid(Uid::new(0))),
458                        ("present", Value::Uid(Uid::new(2))),
459                        ("literal", Value::Uid(Uid::new(3))),
460                    ]),
461                    Value::String("kept".to_string()),
462                    Value::String(NULL_PLACEHOLDER.to_string()),
463                ]),
464            ),
465            ("$top", dict(vec![("root", Value::Uid(Uid::new(1)))])),
466        ]);
467
468        assert_eq!(
469            parse_ns_keyed_archiver(&archive).unwrap(),
470            dict(vec![
471                ("present", Value::String("kept".to_string())),
472                ("literal", Value::String(NULL_PLACEHOLDER.to_string())),
473            ])
474        );
475    }
476
477    #[test]
478    fn omits_null_values_from_ns_dictionary_pairs() {
479        let archive = dict(vec![
480            (
481                "$objects",
482                Value::Array(vec![
483                    Value::String(NULL_PLACEHOLDER.to_string()),
484                    dict(vec![
485                        (
486                            "NS.keys",
487                            Value::Array(vec![Value::Uid(Uid::new(2)), Value::Uid(Uid::new(3))]),
488                        ),
489                        (
490                            "NS.objects",
491                            Value::Array(vec![Value::Uid(Uid::new(4)), Value::Uid(Uid::new(0))]),
492                        ),
493                    ]),
494                    Value::String("present".to_string()),
495                    Value::String("missing".to_string()),
496                    Value::String("kept".to_string()),
497                ]),
498            ),
499            ("$top", dict(vec![("root", Value::Uid(Uid::new(1)))])),
500        ]);
501
502        assert_eq!(
503            parse_ns_keyed_archiver(&archive).unwrap(),
504            dict(vec![("present", Value::String("kept".to_string()))])
505        );
506    }
507
508    #[test]
509    fn cyclic_uid_reference_is_rejected_not_overflowed() {
510        // `$objects[1]` points at itself and `$top.root` points at index 1. Without
511        // a depth bound this recurses forever and aborts the process via stack
512        // overflow; the bound converts it into a recoverable error instead.
513        let archive = dict(vec![
514            (
515                "$objects",
516                Value::Array(vec![
517                    Value::String("$null".to_string()),
518                    Value::Uid(Uid::new(1)),
519                ]),
520            ),
521            ("$top", dict(vec![("root", Value::Uid(Uid::new(1)))])),
522        ]);
523
524        assert!(matches!(
525            parse_ns_keyed_archiver(&archive),
526            Err(PlistParseError::RecursionLimit)
527        ));
528    }
529}