matrix_sdk_common/edit_validation.rs
1// Copyright 2026 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
15use ruma::{events::AnySyncTimelineEvent, serde::Raw};
16use serde::Deserialize;
17
18use crate::deserialized_responses::EncryptionInfo;
19
20/// Represents all possible validation errors that can occur when processing
21/// an event edit.
22///
23/// These errors ensure that a replacement event complies with the rules
24/// required to safely and correctly modify an existing event.
25///
26/// [spec]: https://spec.matrix.org/v1.17/client-server-api/#validity-of-replacement-events
27#[derive(Debug, thiserror::Error)]
28pub enum EditValidityError {
29 /// Occurs when the sender of the replacement event does not match
30 /// the sender of the original event.
31 ///
32 /// Only the original sender is allowed to edit their own event.
33 #[error(
34 "the sender of the original event isn't the same as the sender of the replacement event"
35 )]
36 InvalidSender,
37
38 /// Occurs when either the original event or the replacement event contains
39 /// a state key.
40 ///
41 /// State events are not allowed to be edited.
42 #[error("the original event or the replacement event contains a state key")]
43 StateKeyPresent,
44
45 /// Occurs when the content type of the original event differs from
46 /// that of the replacement event.
47 ///
48 /// Edits must not change the event’s content type, as this would
49 /// introduce semantic inconsistencies.
50 #[error(
51 "the content type of the original event is `{content_type}` while the replacement is a `{replacement_type}`"
52 )]
53 MismatchContentType {
54 /// The content type of the original event.
55 content_type: String,
56
57 /// The content type of the replacement event.
58 replacement_type: String,
59 },
60
61 /// Occurs when the original event is itself already a replacement (edit).
62 #[error("the original event is an edit as well")]
63 OriginalEventIsReplacement,
64
65 /// Occurs when the replacement event is not a replacement for the original
66 /// event.
67 #[error("the replacement event is not a replacement for the original event")]
68 NotReplacement,
69
70 /// Occurs when a required field is missing from either the original
71 /// or the replacement event.
72 ///
73 /// The event is considered malformed and cannot be validated.
74 #[error("the event was encrypted, as such it should have an `m.new_content` field")]
75 MissingNewContent,
76
77 #[error(transparent)]
78 InvalidJson(#[from] serde_json::Error),
79
80 /// Occurs when the original event is encrypted but the replacement
81 /// event is not.
82 #[error("the original event was encrypted while the replacement is not")]
83 ReplacementNotEncrypted,
84}
85
86/// This implements the Matrix spec rule set for validity of replacement events
87/// (edits). Invalid replacements must be ignored.
88///
89/// This function implements the steps documented in the [spec] with one
90/// exception, the step to check if the room IDs match isn't done. The JSON of
91/// the event might not contain the room ID if it wasn received over a `/sync`
92/// request.
93///
94/// *Warning*: Callers must ensure that the original event and replacement event
95/// belong to the same room, that is, they have the same room ID.
96///
97/// [spec]: https://spec.matrix.org/v1.17/client-server-api/#validity-of-replacement-events
98pub fn check_validity_of_replacement_events(
99 original_json: &Raw<AnySyncTimelineEvent>,
100 original_encryption_info: Option<&EncryptionInfo>,
101 replacement_json: &Raw<AnySyncTimelineEvent>,
102 replacement_encryption_info: Option<&EncryptionInfo>,
103) -> Result<(), EditValidityError> {
104 const REPLACEMENT_REL_TYPE: &str = "m.replace";
105
106 #[derive(Debug, Deserialize)]
107 struct MinimalEvent<'a> {
108 sender: &'a str,
109 event_id: &'a str,
110 #[serde(rename = "type")]
111 event_type: &'a str,
112 state_key: Option<&'a str>,
113 content: MinimalContent<'a>,
114 }
115
116 #[derive(Debug, Deserialize)]
117 struct MinimalContent<'a> {
118 #[serde(borrow, rename = "m.relates_to")]
119 relates_to: Option<MinimalRelatesTo<'a>>,
120 #[serde(rename = "m.new_content")]
121 new_content: Option<MinimalNewContent>,
122 }
123
124 #[derive(Debug, Deserialize)]
125 struct MinimalNewContent {}
126
127 #[derive(Debug, Deserialize)]
128 struct MinimalRelatesTo<'a> {
129 rel_type: Option<&'a str>,
130 event_id: Option<&'a str>,
131 }
132
133 let original_event = original_json.deserialize_as_unchecked::<MinimalEvent<'_>>()?;
134 let replacement_event = replacement_json.deserialize_as_unchecked::<MinimalEvent<'_>>()?;
135
136 // We don't check the room ID here since this event might have been received
137 // over /sync, in this case the JSON likely won't contain the room ID field.
138
139 // The original event and replacement event must have the same sender (i.e. you
140 // cannot edit someone else’s messages).
141 if original_event.sender != replacement_event.sender {
142 return Err(EditValidityError::InvalidSender);
143 }
144
145 // This check isn't part of the list in the spec, but it makes sense to check if
146 // the replacement event is has the correct rel_type and if it's an edit for the
147 // original event.
148 if let Some(relates_to) = replacement_event.content.relates_to {
149 if relates_to.rel_type != Some(REPLACEMENT_REL_TYPE)
150 || relates_to.event_id != Some(original_event.event_id)
151 {
152 return Err(EditValidityError::NotReplacement);
153 }
154 } else {
155 return Err(EditValidityError::NotReplacement);
156 }
157
158 // The replacement and original events must have the same type (i.e. you cannot
159 // change the original event’s type).
160 if original_event.event_type != replacement_event.event_type {
161 return Err(EditValidityError::MismatchContentType {
162 content_type: original_event.event_type.to_owned(),
163 replacement_type: replacement_event.event_type.to_owned(),
164 });
165 }
166
167 // The replacement and original events must not have a state_key property (i.e.
168 // you cannot edit state events at all).
169 if original_event.state_key.is_some() || replacement_event.state_key.is_some() {
170 return Err(EditValidityError::StateKeyPresent);
171 }
172
173 // The original event must not, itself, have a rel_type of m.replace (i.e. you
174 // cannot edit an edit — though you can send multiple edits for a single
175 // original event).
176 if let Some(relates_to) = original_event.content.relates_to
177 && relates_to.rel_type == Some(REPLACEMENT_REL_TYPE)
178 {
179 return Err(EditValidityError::OriginalEventIsReplacement);
180 }
181
182 // The replacement event (once decrypted, if appropriate) must have an
183 // m.new_content property.
184 if replacement_encryption_info.is_some() && replacement_event.content.new_content.is_none() {
185 return Err(EditValidityError::MissingNewContent);
186 }
187
188 // If the original event was encrypted, the replacement should be too.
189 if original_encryption_info.is_some() && replacement_encryption_info.is_none() {
190 return Err(EditValidityError::ReplacementNotEncrypted);
191 }
192
193 Ok(())
194}