Skip to main content

agent_first_data/document/format/
plist.rs

1//! Property list reader (via the `plist` crate). Read-only.
2//!
3//! A plist is read into the same value tree every other format produces, with
4//! one rule per plist type:
5//!
6//! | plist | value |
7//! |---|---|
8//! | `dict` | object |
9//! | `array` | array |
10//! | `string` | string |
11//! | `integer` | integer |
12//! | `real` | float |
13//! | `true` / `false` | bool |
14//! | `date` | RFC 3339 string in UTC, e.g. `2026-11-08T03:00:00Z` |
15//! | `data` | standard base64 string |
16//!
17//! XML and binary property lists (`bplist` magic) map identically. A binary one
18//! is recognised from its raw bytes by [`is_binary`] before any UTF-8 decoding,
19//! parsed by [`load_binary`], and carries no source text because the format is
20//! read-only.
21
22use std::collections::BTreeMap;
23
24use crate::document::{DocumentError, DocumentResult, Value};
25
26const BINARY_MAGIC: &[u8] = b"bplist";
27
28/// The text of the refusal for a binary property list read as another format.
29/// It is afdata's own sentence and quotes nothing from the file, so
30/// `DocumentError::redacted_message` keeps it whole instead of reducing the
31/// parse failure to `failed to parse JSON`.
32pub(crate) const WRONG_FORMAT_DETAIL: &str =
33    "the input is a binary property list; read it with --input-format plist";
34
35/// The same refusal for text that opens like an XML property list.
36pub(crate) const WRONG_FORMAT_XML_DETAIL: &str =
37    "the input is an XML property list; read it with --input-format plist";
38
39/// Whether the raw bytes open like an XML property list: an XML declaration, a
40/// `<plist` root, or a plist DOCTYPE, after an optional BOM and whitespace. Only
41/// the opening bytes are looked at; this is a hint for a parse that already
42/// failed on another format, not format sniffing.
43pub(crate) fn looks_like_xml(bytes: &[u8]) -> bool {
44    let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
45    let start = bytes
46        .iter()
47        .position(|byte| !byte.is_ascii_whitespace())
48        .unwrap_or(bytes.len());
49    let head = &bytes[start..];
50    head.starts_with(b"<?xml")
51        || head.starts_with(b"<plist")
52        || head.starts_with(b"<!DOCTYPE plist")
53}
54
55/// Whether the raw bytes start with the binary property list magic.
56pub(crate) fn is_binary(bytes: &[u8]) -> bool {
57    bytes.starts_with(BINARY_MAGIC)
58}
59
60/// The error for a binary property list read as `format` instead of plist.
61pub(crate) fn wrong_format(format: &str) -> DocumentError {
62    DocumentError::ParseError {
63        format: format.to_string(),
64        detail: WRONG_FORMAT_DETAIL.to_string(),
65    }
66}
67
68/// The error for text that opens like an XML property list, read as `format`.
69pub(crate) fn wrong_format_xml(format: &str) -> DocumentError {
70    DocumentError::ParseError {
71        format: format.to_string(),
72        detail: WRONG_FORMAT_XML_DETAIL.to_string(),
73    }
74}
75
76/// Parse a binary property list into a [`Value`].
77pub(crate) fn load_binary(bytes: &[u8]) -> DocumentResult<Value> {
78    let parsed = ::plist::Value::from_reader(std::io::Cursor::new(bytes)).map_err(|error| {
79        DocumentError::ParseError {
80            format: "plist".to_string(),
81            detail: error.to_string(),
82        }
83    })?;
84    convert(parsed)
85}
86
87/// Parse an XML property list into a [`Value`].
88pub fn load(content: &str) -> DocumentResult<Value> {
89    let parsed = ::plist::Value::from_reader_xml(content.as_bytes()).map_err(|error| {
90        DocumentError::ParseError {
91            format: "plist".to_string(),
92            detail: error.to_string(),
93        }
94    })?;
95    convert(parsed)
96}
97
98fn convert(value: ::plist::Value) -> DocumentResult<Value> {
99    Ok(match value {
100        ::plist::Value::Array(items) => Value::Array(
101            items
102                .into_iter()
103                .map(convert)
104                .collect::<DocumentResult<_>>()?,
105        ),
106        ::plist::Value::Dictionary(map) => Value::Object(
107            map.into_iter()
108                .map(|(key, value)| Ok((key, convert(value)?)))
109                .collect::<DocumentResult<BTreeMap<_, _>>>()?,
110        ),
111        ::plist::Value::Boolean(value) => Value::Bool(value),
112        ::plist::Value::Data(bytes) => Value::String(base64(&bytes)),
113        ::plist::Value::Date(date) => Value::String(date.to_xml_format()),
114        ::plist::Value::Real(value) => Value::Float(value),
115        ::plist::Value::Integer(value) => match (value.as_signed(), value.as_unsigned()) {
116            (Some(signed), _) => Value::Integer(signed),
117            (None, Some(unsigned)) => Value::Unsigned(unsigned),
118            (None, None) => return Err(unmapped("integer")),
119        },
120        ::plist::Value::String(value) => Value::String(value),
121        ::plist::Value::Uid(uid) => Value::Unsigned(uid.get()),
122        // `plist::Value` is non-exhaustive; a type added later has no mapping,
123        // and reading it as null would hide that.
124        _ => return Err(unmapped("value")),
125    })
126}
127
128fn unmapped(kind: &str) -> DocumentError {
129    DocumentError::ParseError {
130        format: "plist".to_string(),
131        detail: format!("plist {kind} has no value mapping"),
132    }
133}
134
135fn base64(bytes: &[u8]) -> String {
136    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
137    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
138    for chunk in bytes.chunks(3) {
139        let b0 = u32::from(chunk[0]);
140        let b1 = u32::from(chunk.get(1).copied().unwrap_or(0));
141        let b2 = u32::from(chunk.get(2).copied().unwrap_or(0));
142        let n = (b0 << 16) | (b1 << 8) | b2;
143        let sextet = |shift: u32| char::from(ALPHABET[((n >> shift) & 63) as usize]);
144        out.push(sextet(18));
145        out.push(sextet(12));
146        out.push(if chunk.len() > 1 { sextet(6) } else { '=' });
147        out.push(if chunk.len() > 2 { sextet(0) } else { '=' });
148    }
149    out
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn base64_pads_like_the_standard_alphabet() {
158        assert_eq!(base64(b""), "");
159        assert_eq!(base64(b"f"), "Zg==");
160        assert_eq!(base64(b"fo"), "Zm8=");
161        assert_eq!(base64(b"foo"), "Zm9v");
162        assert_eq!(base64(b"foob"), "Zm9vYg==");
163    }
164}