matrix_sdk_common/
serde_helpers.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A collection of serde helpers to avoid having to deserialize an entire event
16//! to access some fields.
17
18use ruma::{
19    MilliSecondsSinceUnixEpoch, OwnedEventId,
20    events::{
21        AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
22        relation::BundledThread,
23    },
24    serde::Raw,
25};
26use serde::Deserialize;
27
28use crate::deserialized_responses::{ThreadSummary, ThreadSummaryStatus};
29
30#[derive(Deserialize)]
31enum RelationsType {
32    #[serde(rename = "m.thread")]
33    Thread,
34    #[serde(rename = "m.replace")]
35    Edit,
36}
37
38#[derive(Deserialize)]
39struct RelatesTo {
40    #[serde(rename = "rel_type")]
41    rel_type: RelationsType,
42    #[serde(rename = "event_id")]
43    event_id: Option<OwnedEventId>,
44}
45
46#[allow(missing_debug_implementations)]
47#[derive(Deserialize)]
48struct SimplifiedContent {
49    #[serde(rename = "m.relates_to")]
50    relates_to: Option<RelatesTo>,
51}
52
53/// Try to extract the thread root from an event's content, if provided.
54///
55/// The thread root is the field located at `m.relates_to`.`event_id`,
56/// if the field at `m.relates_to`.`rel_type` is `m.thread`.
57///
58/// Returns `None` if we couldn't find a thread root, or if there was an issue
59/// during deserialization.
60pub fn extract_thread_root_from_content(
61    content: Raw<AnyMessageLikeEventContent>,
62) -> Option<OwnedEventId> {
63    let relates_to = content.deserialize_as_unchecked::<SimplifiedContent>().ok()?.relates_to?;
64    match relates_to.rel_type {
65        RelationsType::Thread => relates_to.event_id,
66        RelationsType::Edit => None,
67    }
68}
69
70/// Try to extract the thread root from a timeline event, if provided.
71///
72/// The thread root is the field located at `content`.`m.relates_to`.`event_id`,
73/// if the field at `content`.`m.relates_to`.`rel_type` is `m.thread`.
74///
75/// Returns `None` if we couldn't find a thread root, or if there was an issue
76/// during deserialization.
77pub fn extract_thread_root(event: &Raw<AnySyncTimelineEvent>) -> Option<OwnedEventId> {
78    extract_thread_root_from_content(event.get_field("content").ok().flatten()?)
79}
80
81/// Try to extract the target of an edit event, from a raw timeline event, if
82/// provided.
83///
84/// The target event is the field located at
85/// `content`.`m.relates_to`.`event_id`, if the field at
86/// `content`.`m.relates_to`.`rel_type` is `m.replace`.
87///
88/// Returns `None` if we couldn't find it, or if there was an issue
89/// during deserialization.
90pub fn extract_edit_target(event: &Raw<AnySyncTimelineEvent>) -> Option<OwnedEventId> {
91    let relates_to = event.get_field::<SimplifiedContent>("content").ok().flatten()?.relates_to?;
92    match relates_to.rel_type {
93        RelationsType::Edit => relates_to.event_id,
94        RelationsType::Thread => None,
95    }
96}
97
98#[allow(missing_debug_implementations)]
99#[derive(Deserialize)]
100struct Relations {
101    #[serde(rename = "m.thread")]
102    thread: Option<Box<BundledThread>>,
103}
104
105#[allow(missing_debug_implementations)]
106#[derive(Deserialize)]
107struct Unsigned {
108    #[serde(rename = "m.relations")]
109    relations: Option<Relations>,
110}
111
112/// Try to extract a bundled thread summary of a timeline event, if available.
113pub fn extract_bundled_thread_summary(
114    event: &Raw<AnySyncTimelineEvent>,
115) -> (ThreadSummaryStatus, Option<Raw<AnySyncMessageLikeEvent>>) {
116    match event.get_field::<Unsigned>("unsigned") {
117        Ok(Some(Unsigned { relations: Some(Relations { thread: Some(bundled_thread) }) })) => {
118            // Take the count from the bundled thread summary, if available. If it can't be
119            // converted to a `u32`, we use `u32::MAX` as a fallback, as this is unlikely
120            // to happen to have that many events in real-world threads.
121            let count = bundled_thread.count.try_into().unwrap_or(u32::MAX);
122
123            let latest_reply =
124                bundled_thread.latest_event.get_field::<OwnedEventId>("event_id").ok().flatten();
125
126            (
127                ThreadSummaryStatus::Some(ThreadSummary { num_replies: count, latest_reply }),
128                Some(bundled_thread.latest_event),
129            )
130        }
131        Ok(_) => (ThreadSummaryStatus::None, None),
132        Err(_) => (ThreadSummaryStatus::Unknown, None),
133    }
134}
135
136/// Try to extract the `origin_server_ts`, if available.
137///
138/// If the value is larger than `max_value`, it becomes `max_value`. This is
139/// necessary to prevent against user-forged value pretending an event is coming
140/// from the future.
141pub fn extract_timestamp(
142    event: &Raw<AnySyncTimelineEvent>,
143    max_value: MilliSecondsSinceUnixEpoch,
144) -> Option<MilliSecondsSinceUnixEpoch> {
145    let mut origin_server_ts = event.get_field("origin_server_ts").ok().flatten()?;
146
147    if origin_server_ts > max_value {
148        origin_server_ts = max_value;
149    }
150
151    Some(origin_server_ts)
152}
153
154#[cfg(test)]
155mod tests {
156    use assert_matches::assert_matches;
157    use ruma::{UInt, event_id};
158    use serde_json::json;
159
160    use super::{
161        MilliSecondsSinceUnixEpoch, Raw, extract_bundled_thread_summary, extract_thread_root,
162        extract_timestamp,
163    };
164    use crate::deserialized_responses::{ThreadSummary, ThreadSummaryStatus};
165
166    #[test]
167    fn test_extract_thread_root() {
168        // No event factory in this crate :( There would be a dependency cycle with the
169        // `matrix-sdk-test` crate if we tried to use it here.
170
171        // We can extract the thread root from a regular message that contains one.
172        let thread_root = event_id!("$thread_root_event_id:example.com");
173        let event = Raw::new(&json!({
174            "event_id": "$eid:example.com",
175            "type": "m.room.message",
176            "sender": "@alice:example.com",
177            "origin_server_ts": 42,
178            "content": {
179                "body": "Hello, world!",
180                "m.relates_to": {
181                    "rel_type": "m.thread",
182                    "event_id": thread_root,
183                }
184            }
185        }))
186        .unwrap()
187        .cast_unchecked();
188
189        let observed_thread_root = extract_thread_root(&event);
190        assert_eq!(observed_thread_root.as_deref(), Some(thread_root));
191
192        // If the event doesn't have a content for some reason (redacted), it returns
193        // None.
194        let event = Raw::new(&json!({
195            "event_id": "$eid:example.com",
196            "type": "m.room.message",
197            "sender": "@alice:example.com",
198            "origin_server_ts": 42,
199        }))
200        .unwrap()
201        .cast_unchecked();
202
203        let observed_thread_root = extract_thread_root(&event);
204        assert_matches!(observed_thread_root, None);
205
206        // If the event has a content but with no `m.relates_to` field, it returns None.
207        let event = Raw::new(&json!({
208            "event_id": "$eid:example.com",
209            "type": "m.room.message",
210            "sender": "@alice:example.com",
211            "origin_server_ts": 42,
212            "content": {
213                "body": "Hello, world!",
214            }
215        }))
216        .unwrap()
217        .cast_unchecked();
218
219        let observed_thread_root = extract_thread_root(&event);
220        assert_matches!(observed_thread_root, None);
221
222        // If the event has a relation, but it's not a thread reply, it returns None.
223        let event = Raw::new(&json!({
224            "event_id": "$eid:example.com",
225            "type": "m.room.message",
226            "sender": "@alice:example.com",
227            "origin_server_ts": 42,
228            "content": {
229                "body": "Hello, world!",
230                "m.relates_to": {
231                    "rel_type": "m.reference",
232                    "event_id": "$referenced_event_id:example.com",
233                }
234            }
235        }))
236        .unwrap()
237        .cast_unchecked();
238
239        let observed_thread_root = extract_thread_root(&event);
240        assert_matches!(observed_thread_root, None);
241    }
242
243    #[test]
244    fn test_extract_bundled_thread_summary() {
245        // When there's a bundled thread summary, we can extract it.
246        let event = Raw::new(&json!({
247            "event_id": "$eid:example.com",
248            "type": "m.room.message",
249            "sender": "@alice:example.com",
250            "origin_server_ts": 42,
251            "content": {
252                "body": "Hello, world!",
253            },
254            "unsigned": {
255                "m.relations": {
256                    "m.thread": {
257                        "latest_event": {
258                            "event_id": "$latest_event:example.com",
259                            "type": "m.room.message",
260                            "sender": "@bob:example.com",
261                            "origin_server_ts": 42,
262                            "content": {
263                                "body": "Hello to you too!",
264                            }
265                        },
266                        "count": 2,
267                        "current_user_participated": true,
268                    }
269                }
270            }
271        }))
272        .unwrap()
273        .cast_unchecked();
274
275        assert_matches!(
276            extract_bundled_thread_summary(&event),
277            (ThreadSummaryStatus::Some(ThreadSummary { .. }), Some(..))
278        );
279
280        // When there's a bundled thread summary, we can assert it with certainty.
281        let event = Raw::new(&json!({
282            "event_id": "$eid:example.com",
283            "type": "m.room.message",
284            "sender": "@alice:example.com",
285            "origin_server_ts": 42,
286        }))
287        .unwrap()
288        .cast_unchecked();
289
290        assert_matches!(extract_bundled_thread_summary(&event), (ThreadSummaryStatus::None, None));
291
292        // When there's a bundled replace, we can assert there's no thread summary.
293        let event = Raw::new(&json!({
294            "event_id": "$eid:example.com",
295            "type": "m.room.message",
296            "sender": "@alice:example.com",
297            "origin_server_ts": 42,
298            "content": {
299                "body": "Bonjour, monde!",
300            },
301            "unsigned": {
302                "m.relations": {
303                    "m.replace":
304                    {
305                        "event_id": "$update:example.com",
306                        "type": "m.room.message",
307                        "sender": "@alice:example.com",
308                        "origin_server_ts": 43,
309                        "content": {
310                            "body": "* Hello, world!",
311                        }
312                    },
313                }
314            }
315        }))
316        .unwrap()
317        .cast_unchecked();
318
319        assert_matches!(extract_bundled_thread_summary(&event), (ThreadSummaryStatus::None, None));
320
321        // When the bundled thread summary is malformed, we return
322        // `ThreadSummaryStatus::Unknown`.
323        let event = Raw::new(&json!({
324            "event_id": "$eid:example.com",
325            "type": "m.room.message",
326            "sender": "@alice:example.com",
327            "origin_server_ts": 42,
328            "unsigned": {
329                "m.relations": {
330                    "m.thread": {
331                        // Missing `latest_event` field.
332                    }
333                }
334            }
335        }))
336        .unwrap()
337        .cast_unchecked();
338
339        assert_matches!(
340            extract_bundled_thread_summary(&event),
341            (ThreadSummaryStatus::Unknown, None)
342        );
343    }
344
345    #[test]
346    fn test_extract_timestamp() {
347        let event = Raw::new(&json!({
348            "event_id": "$ev0",
349            "type": "m.room.message",
350            "sender": "@mnt_io:matrix.org",
351            "origin_server_ts": 42,
352            "content": {
353                "body": "Le gras, c'est la vie",
354            }
355        }))
356        .unwrap()
357        .cast_unchecked();
358
359        let timestamp = extract_timestamp(&event, MilliSecondsSinceUnixEpoch(UInt::from(100u32)));
360
361        assert_eq!(timestamp, Some(MilliSecondsSinceUnixEpoch(UInt::from(42u32))));
362    }
363
364    #[test]
365    fn test_extract_timestamp_no_origin_server_ts() {
366        let event = Raw::new(&json!({
367            "event_id": "$ev0",
368            "type": "m.room.message",
369            "sender": "@mnt_io:matrix.org",
370            "content": {
371                "body": "Le gras, c'est la vie",
372            }
373        }))
374        .unwrap()
375        .cast_unchecked();
376
377        let timestamp = extract_timestamp(&event, MilliSecondsSinceUnixEpoch(UInt::from(100u32)));
378
379        assert!(timestamp.is_none());
380    }
381
382    #[test]
383    fn test_extract_timestamp_invalid_origin_server_ts() {
384        let event = Raw::new(&json!({
385            "event_id": "$ev0",
386            "type": "m.room.message",
387            "sender": "@mnt_io:matrix.org",
388            "origin_server_ts": "saucisse",
389            "content": {
390                "body": "Le gras, c'est la vie",
391            }
392        }))
393        .unwrap()
394        .cast_unchecked();
395
396        let timestamp = extract_timestamp(&event, MilliSecondsSinceUnixEpoch(UInt::from(100u32)));
397
398        assert!(timestamp.is_none());
399    }
400
401    #[test]
402    fn test_extract_timestamp_malicious_origin_server_ts() {
403        let event = Raw::new(&json!({
404            "event_id": "$ev0",
405            "type": "m.room.message",
406            "sender": "@mnt_io:matrix.org",
407            "origin_server_ts": 101,
408            "content": {
409                "body": "Le gras, c'est la vie",
410            }
411        }))
412        .unwrap()
413        .cast_unchecked();
414
415        let timestamp = extract_timestamp(&event, MilliSecondsSinceUnixEpoch(UInt::from(100u32)));
416
417        assert_eq!(timestamp, Some(MilliSecondsSinceUnixEpoch(UInt::from(100u32))));
418    }
419}