Skip to main content

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