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
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 type and target of a relation, from a raw timeline event,
74/// if provided.
75pub fn extract_relation(event: &Raw<AnySyncTimelineEvent>) -> Option<(RelationType, OwnedEventId)> {
76    let relates_to = event.get_field::<SimplifiedContent>("content").ok().flatten()?.relates_to?;
77    Some((relates_to.rel_type, relates_to.event_id?))
78}
79
80/// Try to extract the event ID of the event targeted by `event` if it is of
81/// type `m.room.redaction`.
82pub fn extract_redaction_target(
83    event: &Raw<AnySyncTimelineEvent>,
84    redaction_rules: &RedactionRules,
85) -> Option<OwnedEventId> {
86    // Check if it's a `m.room.redaction`.
87    let Ok(Some(MessageLikeEventType::RoomRedaction)) =
88        event.get_field::<MessageLikeEventType>("type")
89    else {
90        // Not the expected event. Early return.
91        return None;
92    };
93
94    // It is a `m.room.redaction`! We can deserialize it entirely.
95
96    let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomRedaction(redaction))) =
97        event.deserialize()
98    else {
99        // Failed to deserialized. Early return.
100        return None;
101    };
102
103    redaction.redacts(redaction_rules).map(ToOwned::to_owned)
104}
105
106/// Try to extract a bundled thread of a timeline event, if available.
107pub fn extract_bundled_thread(event: &Raw<AnySyncTimelineEvent>) -> Option<BundledThread> {
108    #[derive(Deserialize)]
109    struct Unsigned {
110        #[serde(rename = "m.relations")]
111        relations: Option<Relations>,
112    }
113
114    #[derive(Deserialize)]
115    struct Relations {
116        #[serde(rename = "m.thread")]
117        thread: Option<BundledThread>,
118    }
119
120    match event.get_field::<Unsigned>("unsigned") {
121        Ok(Some(Unsigned { relations: Some(Relations { thread: Some(bundled_thread) }) })) => {
122            Some(bundled_thread)
123        }
124        Ok(_) | Err(_) => None,
125    }
126}
127
128/// Try to extract the `origin_server_ts`, if available.
129///
130/// If the value is larger than `max_value`, it becomes `max_value`. This is
131/// necessary to prevent against user-forged value pretending an event is coming
132/// from the future.
133pub fn extract_timestamp(
134    event: &Raw<AnySyncTimelineEvent>,
135    max_value: MilliSecondsSinceUnixEpoch,
136) -> Option<MilliSecondsSinceUnixEpoch> {
137    let mut origin_server_ts = event.get_field("origin_server_ts").ok().flatten()?;
138
139    if origin_server_ts > max_value {
140        origin_server_ts = max_value;
141    }
142
143    Some(origin_server_ts)
144}
145
146#[cfg(test)]
147mod tests {
148    use assert_matches::assert_matches;
149    use ruma::{UInt, event_id, owned_event_id};
150    use serde_json::json;
151
152    use super::{
153        MilliSecondsSinceUnixEpoch, Raw, RelationType, extract_bundled_thread, extract_relation,
154        extract_thread_root, extract_timestamp,
155    };
156
157    #[test]
158    fn test_extract_thread_root() {
159        // No event factory in this crate :( There would be a dependency cycle with the
160        // `matrix-sdk-test` crate if we tried to use it here.
161
162        // We can extract the thread root from a regular message that contains one.
163        let thread_root = event_id!("$thread_root_event_id:example.com");
164        let event = Raw::new(&json!({
165            "event_id": "$eid:example.com",
166            "type": "m.room.message",
167            "sender": "@alice:example.com",
168            "origin_server_ts": 42,
169            "content": {
170                "body": "Hello, world!",
171                "m.relates_to": {
172                    "rel_type": "m.thread",
173                    "event_id": thread_root,
174                }
175            }
176        }))
177        .unwrap()
178        .cast_unchecked();
179
180        let observed_thread_root = extract_thread_root(&event);
181        assert_eq!(observed_thread_root.as_deref(), Some(thread_root));
182        let observed_relation = extract_relation(&event).unwrap();
183        assert_eq!(observed_relation, (RelationType::Thread, thread_root.to_owned()));
184
185        // If the event doesn't have a content for some reason (redacted), it returns
186        // None.
187        let event = Raw::new(&json!({
188            "event_id": "$eid:example.com",
189            "type": "m.room.message",
190            "sender": "@alice:example.com",
191            "origin_server_ts": 42,
192        }))
193        .unwrap()
194        .cast_unchecked();
195
196        let observed_thread_root = extract_thread_root(&event);
197        assert_matches!(observed_thread_root, None);
198        assert_matches!(extract_relation(&event), None);
199
200        // If the event has a content but with no `m.relates_to` field, it returns None.
201        let event = Raw::new(&json!({
202            "event_id": "$eid:example.com",
203            "type": "m.room.message",
204            "sender": "@alice:example.com",
205            "origin_server_ts": 42,
206            "content": {
207                "body": "Hello, world!",
208            }
209        }))
210        .unwrap()
211        .cast_unchecked();
212
213        let observed_thread_root = extract_thread_root(&event);
214        assert_matches!(observed_thread_root, None);
215        assert_matches!(extract_relation(&event), None);
216
217        // If the event has a relation, but it's not a thread reply, it returns None.
218        let event = Raw::new(&json!({
219            "event_id": "$eid:example.com",
220            "type": "m.room.message",
221            "sender": "@alice:example.com",
222            "origin_server_ts": 42,
223            "content": {
224                "body": "Hello, world!",
225                "m.relates_to": {
226                    "rel_type": "m.reference",
227                    "event_id": "$referenced_event_id:example.com",
228                }
229            }
230        }))
231        .unwrap()
232        .cast_unchecked();
233
234        let observed_thread_root = extract_thread_root(&event);
235        assert_matches!(observed_thread_root, None);
236        let observed_relation = extract_relation(&event).unwrap();
237        assert_eq!(
238            observed_relation,
239            (RelationType::Reference, owned_event_id!("$referenced_event_id:example.com"))
240        );
241    }
242
243    #[test]
244    fn test_extract_bundled_thread() {
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!(extract_bundled_thread(&event).is_some());
276
277        // When there's not a bundled thread summary, we can assert it with certainty.
278        let event = Raw::new(&json!({
279            "event_id": "$eid:example.com",
280            "type": "m.room.message",
281            "sender": "@alice:example.com",
282            "origin_server_ts": 42,
283        }))
284        .unwrap()
285        .cast_unchecked();
286
287        assert!(extract_bundled_thread(&event).is_none());
288
289        // When there's a bundled replace, we can assert there's no thread summary.
290        let event = Raw::new(&json!({
291            "event_id": "$eid:example.com",
292            "type": "m.room.message",
293            "sender": "@alice:example.com",
294            "origin_server_ts": 42,
295            "content": {
296                "body": "Bonjour, monde!",
297            },
298            "unsigned": {
299                "m.relations": {
300                    "m.replace":
301                    {
302                        "event_id": "$update:example.com",
303                        "type": "m.room.message",
304                        "sender": "@alice:example.com",
305                        "origin_server_ts": 43,
306                        "content": {
307                            "body": "* Hello, world!",
308                        }
309                    },
310                }
311            }
312        }))
313        .unwrap()
314        .cast_unchecked();
315
316        assert!(extract_bundled_thread(&event).is_none());
317
318        // When the bundled thread summary is malformed, we return `None`.
319        let event = Raw::new(&json!({
320            "event_id": "$eid:example.com",
321            "type": "m.room.message",
322            "sender": "@alice:example.com",
323            "origin_server_ts": 42,
324            "unsigned": {
325                "m.relations": {
326                    "m.thread": {
327                        // Missing `latest_event` field.
328                    }
329                }
330            }
331        }))
332        .unwrap()
333        .cast_unchecked();
334
335        assert!(extract_bundled_thread(&event).is_none());
336    }
337
338    #[test]
339    fn test_extract_timestamp() {
340        let event = Raw::new(&json!({
341            "event_id": "$ev0",
342            "type": "m.room.message",
343            "sender": "@mnt_io:matrix.org",
344            "origin_server_ts": 42,
345            "content": {
346                "body": "Le gras, c'est la vie",
347            }
348        }))
349        .unwrap()
350        .cast_unchecked();
351
352        let timestamp = extract_timestamp(&event, MilliSecondsSinceUnixEpoch(UInt::from(100u32)));
353
354        assert_eq!(timestamp, Some(MilliSecondsSinceUnixEpoch(UInt::from(42u32))));
355    }
356
357    #[test]
358    fn test_extract_timestamp_no_origin_server_ts() {
359        let event = Raw::new(&json!({
360            "event_id": "$ev0",
361            "type": "m.room.message",
362            "sender": "@mnt_io:matrix.org",
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!(timestamp.is_none());
373    }
374
375    #[test]
376    fn test_extract_timestamp_invalid_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            "origin_server_ts": "saucisse",
382            "content": {
383                "body": "Le gras, c'est la vie",
384            }
385        }))
386        .unwrap()
387        .cast_unchecked();
388
389        let timestamp = extract_timestamp(&event, MilliSecondsSinceUnixEpoch(UInt::from(100u32)));
390
391        assert!(timestamp.is_none());
392    }
393
394    #[test]
395    fn test_extract_timestamp_malicious_origin_server_ts() {
396        let event = Raw::new(&json!({
397            "event_id": "$ev0",
398            "type": "m.room.message",
399            "sender": "@mnt_io:matrix.org",
400            "origin_server_ts": 101,
401            "content": {
402                "body": "Le gras, c'est la vie",
403            }
404        }))
405        .unwrap()
406        .cast_unchecked();
407
408        let timestamp = extract_timestamp(&event, MilliSecondsSinceUnixEpoch(UInt::from(100u32)));
409
410        assert_eq!(timestamp, Some(MilliSecondsSinceUnixEpoch(UInt::from(100u32))));
411    }
412}