Skip to main content

imessage_database/message_types/
polls.rs

1/*!
2 Poll payloads generated by the Messages Polls app.
3*/
4
5use std::collections::{HashMap, HashSet};
6
7use base64::{Engine as _, engine::general_purpose};
8use jzon::{self, JsonValue};
9use plist::Value;
10
11use crate::{
12    error::plist::PlistParseError,
13    util::plist::{get_string_from_nested_dict, parse_ns_keyed_archiver},
14};
15
16/// Poll option identifier.
17pub type PollOptionID = String;
18
19/// Poll option and its counted votes.
20#[derive(Debug, PartialEq, Eq)]
21pub struct PollOption {
22    /// Option text.
23    pub text: String,
24    /// Handle that created the option.
25    pub creator: String,
26    /// Votes cast for this option.
27    pub votes: Vec<PollVote>,
28}
29
30impl PollOption {
31    fn from_json(json_data: &JsonValue) -> (PollOptionID, Self) {
32        let poll_id = json_data["optionIdentifier"]
33            .as_str()
34            .unwrap_or_default()
35            .to_string();
36        let creator = json_data["creatorHandle"]
37            .as_str()
38            .unwrap_or_default()
39            .to_string();
40        let text = json_data["text"].as_str().unwrap_or_default().to_string();
41
42        (
43            poll_id,
44            PollOption {
45                text,
46                creator,
47                votes: Vec::new(),
48            },
49        )
50    }
51}
52
53/// One vote in a poll update payload.
54#[derive(Debug, PartialEq, Eq)]
55pub struct PollVote {
56    /// Handle that cast the vote.
57    pub voter: String,
58    /// Option identifier that received the vote.
59    pub option_id: PollOptionID,
60}
61
62impl PollVote {
63    fn from_json(payload: &JsonValue) -> Result<Self, PlistParseError> {
64        let voter = payload["participantHandle"]
65            .as_str()
66            .ok_or(PlistParseError::MissingKey("participantHandle".to_string()))?
67            .to_string();
68
69        let option_id = payload["voteOptionIdentifier"]
70            .as_str()
71            .ok_or(PlistParseError::MissingKey(
72                "voteOptionIdentifier".to_string(),
73            ))?
74            .to_string();
75
76        Ok(PollVote { voter, option_id })
77    }
78}
79
80/// Poll state reconstructed from poll and vote payloads.
81#[derive(Debug, PartialEq, Eq)]
82pub struct Poll {
83    /// Options keyed by poll option identifier.
84    pub options: HashMap<PollOptionID, PollOption>,
85    /// Option identifiers in display order.
86    pub order: Vec<PollOptionID>,
87}
88
89impl Poll {
90    /// Parse the initial poll payload.
91    pub fn from_payload(payload: &Value) -> Result<Self, PlistParseError> {
92        let parsed = parse_ns_keyed_archiver(payload)?;
93
94        let url = get_string_from_nested_dict(&parsed, "URL")
95            .ok_or(PlistParseError::MissingKey("URL".to_string()))?;
96
97        // Parse the JSON
98        let parsed_json = base64_url_to_json(url)?;
99
100        let mut options = HashMap::new();
101        let mut order = Vec::new();
102        let ordered_options = parsed_json["item"]["orderedPollOptions"].as_array().ok_or(
103            PlistParseError::MissingKey("orderedPollOptions".to_string()),
104        )?;
105
106        for option in ordered_options {
107            let (id, poll_option) = PollOption::from_json(option);
108            order.push(id.clone());
109            options.insert(id, poll_option);
110        }
111
112        Ok(Poll { options, order })
113    }
114
115    /// Apply a vote payload to the poll options.
116    pub fn count_votes(&mut self, payload: &Value) -> Result<(), PlistParseError> {
117        let parsed = parse_ns_keyed_archiver(payload)?;
118
119        let url = get_string_from_nested_dict(&parsed, "URL")
120            .ok_or(PlistParseError::MissingKey("URL".to_string()))?;
121
122        // Parse the JSON
123        let parsed_json = base64_url_to_json(url)?;
124
125        let votes = parsed_json["item"]["votes"]
126            .as_array()
127            .ok_or(PlistParseError::MissingKey("votes".to_string()))?
128            .iter()
129            .map(PollVote::from_json)
130            .collect::<Result<Vec<PollVote>, PlistParseError>>()?;
131
132        self.apply_votes(votes);
133
134        Ok(())
135    }
136
137    /// Apply one payload's votes as a snapshot.
138    pub fn apply_votes(&mut self, votes: Vec<PollVote>) {
139        let voters: HashSet<&str> = votes.iter().map(|vote| vote.voter.as_str()).collect();
140        for option in self.options.values_mut() {
141            option
142                .votes
143                .retain(|vote| !voters.contains(vote.voter.as_str()));
144        }
145
146        // Payloads are small, so a linear scan is cheaper than hashing owned pairs.
147        let mut distinct: Vec<PollVote> = Vec::with_capacity(votes.len());
148        for vote in votes {
149            if !distinct.contains(&vote) {
150                distinct.push(vote);
151            }
152        }
153
154        for vote in distinct {
155            if let Some(option) = self.options.get_mut(&vote.option_id) {
156                option.votes.push(vote);
157            }
158        }
159    }
160}
161
162fn base64_url_to_json(data: &str) -> Result<JsonValue, PlistParseError> {
163    // Strip the fixed prefix
164    let after_prefix = data
165        .strip_prefix("data:,")
166        .ok_or(PlistParseError::WrongMessageType)?;
167
168    // Extract the base64 part before the first "?" (if present)
169    let base64_part = after_prefix
170        .split_once('?')
171        .map_or(after_prefix, |(before, _)| before);
172
173    // Decode the base64 part
174    let bytes = String::from_utf8(
175        general_purpose::URL_SAFE
176            .decode(base64_part)
177            .map_err(|_| PlistParseError::WrongMessageType)?,
178    )
179    .map_err(|_| PlistParseError::WrongMessageType)?;
180
181    // Parse the JSON
182    jzon::parse(&bytes).map_err(|_| PlistParseError::WrongMessageType)
183}
184
185#[cfg(test)]
186mod tests {
187    use std::{env::current_dir, fs::File};
188
189    use plist::Value;
190
191    use crate::message_types::polls::{Poll, PollVote};
192
193    /// Option identifiers from `PollCreate.plist`, in display order.
194    const OPTION_1: &str = "630CD0F8-9502-40D4-B2E1-C53E02FD435E";
195    const OPTION_2: &str = "58B59D74-EF73-4BA7-AA8E-5A30FBAF3F44";
196    const OPTION_3: &str = "D493DDC8-6EE2-408F-888C-3986838A1FBB";
197    const OPTION_4: &str = "541DCA6D-686A-4FEB-9092-00BA4A45E2A4";
198
199    fn archive(filename: &str) -> Value {
200        let path = current_dir()
201            .unwrap()
202            .join("test_data/app_message")
203            .join(filename);
204        Value::from_reader(File::open(path).unwrap()).unwrap()
205    }
206
207    fn vote(voter: &str, option_id: &str) -> PollVote {
208        PollVote {
209            voter: voter.to_string(),
210            option_id: option_id.to_string(),
211        }
212    }
213
214    #[test]
215    fn test_parse_poll_creation() {
216        let poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
217
218        assert_eq!(poll.order, vec![OPTION_1, OPTION_2, OPTION_3, OPTION_4]);
219        let options: Vec<(&str, &str)> = poll
220            .order
221            .iter()
222            .map(|id| {
223                (
224                    poll.options[id].text.as_str(),
225                    poll.options[id].creator.as_str(),
226                )
227            })
228            .collect();
229        assert_eq!(
230            options,
231            vec![
232                ("Option 1", "steven@applenet.com"),
233                ("Option 2", "steven@applenet.com"),
234                ("Option 3", "steven@applenet.com"),
235                ("Option 4", "+12223334444"),
236            ]
237        );
238        assert!(poll.options.values().all(|option| option.votes.is_empty()));
239    }
240
241    #[test]
242    fn test_parse_poll_votes() {
243        let mut poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
244        poll.count_votes(&archive("PollVote.plist")).unwrap();
245        poll.count_votes(&archive("PollVote2.plist")).unwrap();
246
247        // Votes accumulate across payloads in arrival order.
248        assert_eq!(
249            poll.options[OPTION_1].votes,
250            vec![vote("steven@applenet.com", OPTION_1)]
251        );
252        assert_eq!(
253            poll.options[OPTION_2].votes,
254            vec![
255                vote("+12223334444", OPTION_2),
256                vote("steven@applenet.com", OPTION_2),
257            ]
258        );
259        assert!(poll.options[OPTION_3].votes.is_empty());
260        assert!(poll.options[OPTION_4].votes.is_empty());
261    }
262
263    #[test]
264    fn test_parse_poll_vote_unknown_option_dropped() {
265        let mut poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
266        poll.count_votes(&archive("PollRemovedVote.plist")).unwrap();
267
268        // The fixture carries one vote for option
269        // 89913F11-D12C-4A06-BB4B-C26C6C4D1A1F, which `PollCreate.plist` does
270        // not define: it was captured from a different poll. It proves only
271        // that a vote for an unknown option is dropped; a vote-then-retract
272        // capture from one poll is still missing (GitHub issue #823).
273        assert!(poll.options.values().all(|option| option.votes.is_empty()));
274    }
275
276    #[test]
277    fn test_parse_poll_votes_reapplied_snapshot_does_not_double_count() {
278        let mut once = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
279        once.count_votes(&archive("PollVote2.plist")).unwrap();
280
281        let mut twice = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
282        twice.count_votes(&archive("PollVote2.plist")).unwrap();
283        twice.count_votes(&archive("PollVote2.plist")).unwrap();
284
285        assert_eq!(twice, once);
286        assert_eq!(
287            once.options[OPTION_1].votes,
288            vec![vote("steven@applenet.com", OPTION_1)]
289        );
290        assert_eq!(
291            once.options[OPTION_2].votes,
292            vec![vote("steven@applenet.com", OPTION_2)]
293        );
294    }
295
296    #[test]
297    fn test_apply_votes_snapshot_replaces_across_options() {
298        let mut poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
299        poll.apply_votes(vec![vote("a", OPTION_1)]);
300        poll.apply_votes(vec![vote("a", OPTION_2)]);
301
302        assert!(poll.options[OPTION_1].votes.is_empty());
303        assert_eq!(poll.options[OPTION_2].votes, vec![vote("a", OPTION_2)]);
304    }
305
306    #[test]
307    fn test_apply_votes_same_snapshot_twice_counts_once() {
308        let mut poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
309        poll.apply_votes(vec![vote("a", OPTION_1), vote("a", OPTION_2)]);
310        poll.apply_votes(vec![vote("a", OPTION_1), vote("a", OPTION_2)]);
311
312        assert_eq!(poll.options[OPTION_1].votes, vec![vote("a", OPTION_1)]);
313        assert_eq!(poll.options[OPTION_2].votes, vec![vote("a", OPTION_2)]);
314    }
315
316    #[test]
317    fn test_apply_votes_leaves_other_participants_in_order() {
318        let mut poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
319        poll.apply_votes(vec![vote("a", OPTION_1)]);
320        poll.apply_votes(vec![vote("b", OPTION_1)]);
321        poll.apply_votes(vec![vote("c", OPTION_1)]);
322        // `b` re-votes: `a` and `c` keep their order, `b` moves to the end.
323        poll.apply_votes(vec![vote("b", OPTION_1), vote("b", OPTION_2)]);
324
325        assert_eq!(
326            poll.options[OPTION_1].votes,
327            vec![
328                vote("a", OPTION_1),
329                vote("c", OPTION_1),
330                vote("b", OPTION_1)
331            ]
332        );
333        assert_eq!(poll.options[OPTION_2].votes, vec![vote("b", OPTION_2)]);
334    }
335
336    #[test]
337    fn test_apply_votes_partial_retraction_removes_absent_option() {
338        let mut poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
339        poll.apply_votes(vec![vote("a", OPTION_1), vote("a", OPTION_3)]);
340        poll.apply_votes(vec![vote("a", OPTION_3)]);
341
342        assert!(poll.options[OPTION_1].votes.is_empty());
343        assert!(poll.options[OPTION_2].votes.is_empty());
344        assert_eq!(poll.options[OPTION_3].votes, vec![vote("a", OPTION_3)]);
345        assert!(poll.options[OPTION_4].votes.is_empty());
346    }
347
348    #[test]
349    fn test_apply_votes_duplicate_pair_collapses() {
350        let mut poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
351        poll.apply_votes(vec![
352            vote("a", OPTION_1),
353            vote("a", OPTION_1),
354            vote("a", OPTION_2),
355        ]);
356
357        assert_eq!(poll.options[OPTION_1].votes, vec![vote("a", OPTION_1)]);
358        assert_eq!(poll.options[OPTION_2].votes, vec![vote("a", OPTION_2)]);
359    }
360
361    #[test]
362    fn test_apply_votes_empty_snapshot_is_no_op() {
363        let mut poll = Poll::from_payload(&archive("PollCreate.plist")).unwrap();
364        poll.apply_votes(vec![vote("a", OPTION_1)]);
365        poll.apply_votes(Vec::new());
366
367        // Names no participant, so it cannot retract (GitHub issue #823).
368        assert_eq!(poll.options[OPTION_1].votes, vec![vote("a", OPTION_1)]);
369    }
370}