Skip to main content

imessage_database/util/
streamtyped.rs

1/*!
2 Legacy text parser for `streamtyped` body blobs.
3
4 This parser extracts plain text from `attributedBody` when the full
5 typedstream deserializer cannot reconstruct the attributed string.
6*/
7
8use crate::error::streamtyped::StreamTypedError;
9
10/// Literals: `[<Start of Heading> (SOH), +]`
11/// - <https://www.compart.com/en/unicode/U+0001>
12/// - <https://www.compart.com/en/unicode/U+002b>
13const START_PATTERN: [u8; 2] = [0x0001, 0x002b];
14
15/// Literals: `[<Start of Selected Area> (SSA), <Index> (IND)]`
16/// - <https://www.compart.com/en/unicode/U+0086>
17/// - <https://www.compart.com/en/unicode/U+0084>
18const END_PATTERN: [u8; 2] = [0x0086, 0x0084];
19
20/// Parse plain body [text](crate::tables::messages::Message::text) from a `streamtyped` `attributedBody` blob.
21///
22/// `attributedBody` `typedstream` data looks like:
23///
24/// ```txt
25/// streamtyped���@���NSAttributedString�NSObject����NSString��+Example message  ��iI���� NSDictionary��i����__kIMMessagePartAttributeName����NSNumber��NSValue��*������
26/// ```
27///
28/// In that example, the returned body text would be `"Example message"`.
29///
30/// ## Parser scope
31///
32/// This string parser only supports unstyled text.
33///
34/// If the message has attachments, there will be one [`U+FFFC`](https://www.compart.com/en/unicode/U+FFFC) character
35/// for each attachment and one [`U+FFFD`](https://www.compart.com/en/unicode/U+FFFD) for app messages that we need
36/// to format.
37///
38/// ## Sample
39///
40/// An iMessage that contains body text like:
41///
42/// ```
43/// let message_text = "\u{FFFC}Check out this photo!";
44/// ```
45///
46/// produces fallback body components like:
47///
48/// ```
49/// use imessage_database::message_types::text_effects::text_effect::TextEffect;
50/// use imessage_database::tables::messages::{models::{AttributedRange, BubbleComponent, AttachmentMeta}};
51///
52/// let result = vec![
53///     BubbleComponent::Run(vec![AttributedRange::attachment(0, 3, AttachmentMeta::default())]),
54///     BubbleComponent::Run(vec![AttributedRange::text(3, 24, vec![TextEffect::Default])]),
55/// ];
56/// ```
57pub fn parse(mut stream: Vec<u8>) -> Result<String, StreamTypedError> {
58    // Find the start index and drain
59    for idx in 0..stream.len() {
60        if idx + 2 > stream.len() {
61            return Err(StreamTypedError::NoStartPattern);
62        }
63        let part = &stream[idx..idx + 2];
64
65        if part == START_PATTERN {
66            // Remove the start pattern from the string
67            stream.drain(..idx + 2);
68            break;
69        }
70    }
71
72    // Find the end index and truncate
73    for idx in 1..stream.len() {
74        if idx >= stream.len() - 2 {
75            return Err(StreamTypedError::NoEndPattern);
76        }
77        let part = &stream[idx..idx + 2];
78
79        if part == END_PATTERN {
80            // Remove the end pattern from the string
81            stream.truncate(idx);
82            break;
83        }
84    }
85
86    // `from_utf8` doesn't allocate, but `from_utf8_lossy` does, so we try the allocation-free
87    // version first and only allocate if it fails
88    match String::from_utf8(stream)
89        .map_err(|non_utf8| String::from_utf8_lossy(non_utf8.as_bytes()).into_owned())
90    {
91        // Valid UTF-8 bodies carry one non-text prefix character.
92        // ['\u{6}', 'T', ...] where `T` is the first real char
93        Ok(string) => drop_chars(1, string),
94        // Lossy-decoded bodies carry three replacement/prefix characters.
95        // ['�', '�', '\0', 'T', ...] where `T` is the first real char
96        Err(string) => drop_chars(3, string),
97    }
98}
99
100/// Drop `offset` chars from the front of a string.
101fn drop_chars(offset: usize, mut string: String) -> Result<String, StreamTypedError> {
102    // Find the index of the specified character offset
103    let (position, _) = string
104        .char_indices()
105        .nth(offset)
106        .ok_or(StreamTypedError::InvalidPrefix)?;
107
108    // Remove the prefix and give the String back
109    string.drain(..position);
110    Ok(string)
111}
112
113#[cfg(test)]
114mod tests {
115    use std::env::current_dir;
116    use std::fs::File;
117    use std::io::Read;
118    use std::vec;
119
120    use crate::util::streamtyped::{drop_chars, parse};
121
122    #[test]
123    fn test_parse_text_clean() {
124        let cases = [
125            ("plain text", "AttributedBodyTextOnly", "Noter test"),
126            ("text with a space", "AttributedBodyTextOnly2", "Test 3"),
127            ("url", "URL", "https://github.com/ReagentX/Logria"),
128        ];
129        for (label, fixture, expected) in cases {
130            let plist_path = current_dir()
131                .unwrap()
132                .as_path()
133                .join("test_data/typedstream")
134                .join(fixture);
135            let mut file = File::open(plist_path).unwrap();
136            let mut bytes = vec![];
137            file.read_to_end(&mut bytes).unwrap();
138            let parsed = parse(bytes).unwrap();
139
140            assert_eq!(parsed, expected, "{label}");
141        }
142    }
143
144    #[test]
145    fn test_parse_text_weird_font() {
146        let plist_path = current_dir()
147            .unwrap()
148            .as_path()
149            .join("test_data/typedstream/WeirdText");
150        let mut file = File::open(plist_path).unwrap();
151        let mut bytes = vec![];
152        file.read_to_end(&mut bytes).unwrap();
153        let parsed = parse(bytes).unwrap();
154
155        let expected = "𝖍𝖊𝖑𝖑𝖔 𝖜𝖔𝖗𝖑𝖉".to_string();
156
157        assert_eq!(parsed, expected);
158    }
159
160    #[test]
161    fn test_parse_text_multi_part() {
162        let plist_path = current_dir()
163            .unwrap()
164            .as_path()
165            .join("test_data/typedstream/MultiPart");
166        let mut file = File::open(plist_path).unwrap();
167        let mut bytes = vec![];
168        file.read_to_end(&mut bytes).unwrap();
169        let parsed = parse(bytes).unwrap();
170
171        let expected = "\u{FFFC}test 1\u{FFFC}test 2 \u{FFFC}test 3".to_string();
172
173        assert_eq!(parsed, expected);
174    }
175
176    #[test]
177    fn test_parse_text_app() {
178        // This test removed a block of text so the pointers become misaligned during parsing
179        let plist_path = current_dir()
180            .unwrap()
181            .as_path()
182            .join("test_data/typedstream/ExtraData");
183        let mut file = File::open(plist_path).unwrap();
184        let mut bytes = vec![];
185        file.read_to_end(&mut bytes).unwrap();
186        let parsed = parse(bytes).unwrap();
187
188        let expected = "This is parsing";
189
190        assert_eq!(&parsed[..expected.len()], expected);
191    }
192
193    #[test]
194    fn test_parse_text_long() {
195        let plist_path = current_dir()
196            .unwrap()
197            .as_path()
198            .join("test_data/typedstream/LongMessage");
199        let mut file = File::open(plist_path).unwrap();
200        let mut bytes = vec![];
201        file.read_to_end(&mut bytes).unwrap();
202        let parsed = parse(bytes).unwrap();
203
204        let expected = "Sed nibh velit,";
205
206        assert_eq!(&parsed[..expected.len()], expected);
207        assert_eq!(parsed.len(), 2359);
208    }
209
210    #[test]
211    fn test_parse_text_blank() {
212        let plist_path = current_dir()
213            .unwrap()
214            .as_path()
215            .join("test_data/typedstream/Blank");
216        let mut file = File::open(plist_path).unwrap();
217        let mut bytes = vec![];
218        file.read_to_end(&mut bytes).unwrap();
219        let parsed = parse(bytes);
220
221        assert!(&parsed.is_err());
222    }
223
224    #[test]
225    fn test_parse_text_multi_part_deleted() {
226        let plist_path = current_dir()
227            .unwrap()
228            .as_path()
229            .join("test_data/typedstream/MultiPartWithDeleted");
230        let mut file = File::open(plist_path).unwrap();
231        let mut bytes = vec![];
232        file.read_to_end(&mut bytes).unwrap();
233        let parsed = parse(bytes).unwrap();
234        println!("{parsed:?}");
235
236        let expected = "From arbitrary byte stream:\r\u{FFFC}To native Rust data structures:\r";
237
238        assert_eq!(parsed, expected);
239    }
240
241    #[test]
242    fn test_parse_text_attachment() {
243        let plist_path = current_dir()
244            .unwrap()
245            .as_path()
246            .join("test_data/typedstream/Attachment");
247        let mut file = File::open(plist_path).unwrap();
248        let mut bytes = vec![];
249        file.read_to_end(&mut bytes).unwrap();
250        let parsed = parse(bytes).unwrap();
251        println!("{parsed:?}");
252
253        let expected =
254            "\u{FFFC}This is how the notes look to me fyi, in case it helps make sense of anything";
255
256        assert_eq!(parsed, expected);
257    }
258
259    #[test]
260    fn test_parse_text_array() {
261        let plist_path = current_dir()
262            .unwrap()
263            .as_path()
264            .join("test_data/typedstream/Array");
265        let mut file = File::open(plist_path).unwrap();
266        let mut bytes = vec![];
267        file.read_to_end(&mut bytes).unwrap();
268        let parsed = parse(bytes).unwrap();
269        println!("{parsed:?}");
270
271        let expected = "A single ChatGPT instance takes 5MW of power to run";
272
273        assert_eq!(parsed, expected);
274    }
275
276    #[test]
277    fn test_cant_drop_all() {
278        assert!(drop_chars(1000, String::from("Hello world")).is_err());
279    }
280}