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