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