1use std::{collections::hash_map::HashMap, path::PathBuf, sync::Arc};
20
21use futures_util::future::join_all;
22use matrix_sdk_base::{
23 check_validity_of_replacement_events, deserialized_responses::TimelineEvent,
24};
25use matrix_sdk_search::{
26 error::IndexError,
27 index::{IndexableEvent, RoomIndex, RoomIndexOperation, builder::RoomIndexBuilder},
28};
29use ruma::{
30 EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, RoomId,
31 events::{
32 AnySyncMessageLikeEvent, AnySyncTimelineEvent,
33 poll::{
34 start::SyncPollStartEvent,
35 unstable_start::{SyncUnstablePollStartEvent, UnstablePollStartEventContent},
36 },
37 room::{
38 message::{MessageType, OriginalSyncRoomMessageEvent, Relation, SyncRoomMessageEvent},
39 redaction::SyncRoomRedactionEvent,
40 },
41 sticker::SyncStickerEvent,
42 },
43 room_version_rules::RedactionRules,
44};
45use tokio::sync::{Mutex, MutexGuard};
46use tracing::{debug, warn};
47
48use crate::event_cache::RoomEventCache;
49
50type Password = String;
51
52#[derive(Clone, Debug)]
54pub enum SearchIndexStoreKind {
55 UnencryptedDirectory(PathBuf),
57 EncryptedDirectory(PathBuf, Password),
59 InMemory,
61}
62
63#[derive(Clone, Debug)]
65pub struct SearchIndex {
66 room_indexes: Arc<Mutex<HashMap<OwnedRoomId, RoomIndex>>>,
68
69 search_index_store_kind: SearchIndexStoreKind,
71}
72
73impl SearchIndex {
74 pub fn new(
76 room_indexes: Arc<Mutex<HashMap<OwnedRoomId, RoomIndex>>>,
77 search_index_store_kind: SearchIndexStoreKind,
78 ) -> Self {
79 Self { room_indexes, search_index_store_kind }
80 }
81
82 pub async fn lock(&self) -> SearchIndexGuard<'_> {
84 SearchIndexGuard {
85 index_map: self.room_indexes.lock().await,
86 search_index_store_kind: &self.search_index_store_kind,
87 }
88 }
89}
90
91#[derive(Debug)]
93pub struct SearchIndexGuard<'a> {
94 index_map: MutexGuard<'a, HashMap<OwnedRoomId, RoomIndex>>,
96
97 search_index_store_kind: &'a SearchIndexStoreKind,
99}
100
101impl SearchIndexGuard<'_> {
102 fn create_index(&self, room_id: &RoomId) -> Result<RoomIndex, IndexError> {
103 let index = match self.search_index_store_kind {
104 SearchIndexStoreKind::UnencryptedDirectory(path) => {
105 RoomIndexBuilder::new_on_disk(path.to_path_buf(), room_id).unencrypted().build()?
106 }
107 SearchIndexStoreKind::EncryptedDirectory(path, password) => {
108 RoomIndexBuilder::new_on_disk(path.to_path_buf(), room_id)
109 .encrypted(password)
110 .build()?
111 }
112 SearchIndexStoreKind::InMemory => RoomIndexBuilder::new_in_memory(room_id).build(),
113 };
114 Ok(index)
115 }
116
117 pub(crate) fn execute(
125 &mut self,
126 operation: RoomIndexOperation,
127 room_id: &RoomId,
128 ) -> Result<(), IndexError> {
129 if !self.index_map.contains_key(room_id) {
130 let index = self.create_index(room_id)?;
131 self.index_map.insert(room_id.to_owned(), index);
132 }
133
134 let index = self.index_map.get_mut(room_id).expect("index should exist");
135
136 index.execute(operation)
137 }
138
139 pub(crate) fn bulk_execute(
145 &mut self,
146 operations: Vec<RoomIndexOperation>,
147 room_id: &RoomId,
148 ) -> Result<(), IndexError> {
149 if !self.index_map.contains_key(room_id) {
150 let index = self.create_index(room_id)?;
151 self.index_map.insert(room_id.to_owned(), index);
152 }
153
154 let index = self.index_map.get_mut(room_id).expect("index should exist");
155
156 index.bulk_execute(operations)
157 }
158
159 pub(crate) fn search(
162 &mut self,
163 query: &str,
164 max_number_of_results: usize,
165 pagination_offset: Option<usize>,
166 room_id: &RoomId,
167 ) -> Result<Vec<(f32, OwnedEventId)>, IndexError> {
168 if !self.index_map.contains_key(room_id) {
169 let index = self.create_index(room_id)?;
170 self.index_map.insert(room_id.to_owned(), index);
171 }
172
173 let index = self.index_map.get_mut(room_id).expect("index should exist");
174
175 index.search(query, max_number_of_results, pagination_offset)
176 }
177
178 pub async fn handle_timeline_event(
185 &mut self,
186 event: TimelineEvent,
187 room_cache: &RoomEventCache,
188 room_id: &RoomId,
189 redaction_rules: &RedactionRules,
190 ) -> Result<(), IndexError> {
191 if let Some(index_operation) =
192 parse_timeline_event(room_cache, event, redaction_rules).await
193 {
194 self.execute(index_operation, room_id)
195 } else {
196 Ok(())
197 }
198 }
199
200 pub async fn bulk_handle_timeline_event<T>(
203 &mut self,
204 events: T,
205 room_cache: &RoomEventCache,
206 room_id: &RoomId,
207 redaction_rules: &RedactionRules,
208 ) -> Result<(), IndexError>
209 where
210 T: Iterator<Item = TimelineEvent>,
211 {
212 let futures = events.map(|ev| parse_timeline_event(room_cache, ev, redaction_rules));
213
214 let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect();
215
216 self.bulk_execute(operations, room_id)
217 }
218}
219
220async fn get_most_recent_edit(
223 cache: &RoomEventCache,
224 original: &EventId,
225) -> Option<OriginalSyncRoomMessageEvent> {
226 use ruma::events::{AnySyncTimelineEvent, relation::RelationType};
227
228 let Ok(Some((original_ev, related))) =
229 cache.find_event_with_relations(original, Some(vec![RelationType::Replacement])).await
230 else {
231 debug!("Couldn't find relations for {}", original);
232 return None;
233 };
234
235 let latest = related
239 .iter()
240 .rev()
241 .find(|edit| {
242 check_validity_of_replacement_events(
243 original_ev.raw(),
244 original_ev.encryption_info().map(|info| &**info),
245 edit.raw(),
246 edit.encryption_info().map(|info| &**info),
247 )
248 .is_ok()
249 })
250 .unwrap_or(&original_ev);
251
252 match latest.raw().deserialize() {
253 Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(latest))) => {
254 latest.as_original().cloned()
255 }
256 _ => None,
257 }
258}
259
260fn media_body(filename: &str, caption: Option<&str>) -> String {
262 match caption {
263 Some(caption) => format!("{filename} {caption}"),
264 None => filename.to_owned(),
265 }
266}
267
268fn room_message_body(msgtype: &MessageType) -> Option<String> {
271 match msgtype {
272 MessageType::Text(content) => Some(content.body.clone()),
273 MessageType::Emote(content) => Some(content.body.clone()),
274 MessageType::Notice(content) => Some(content.body.clone()),
275 MessageType::ServerNotice(content) => Some(content.body.clone()),
276 MessageType::Location(content) => Some(content.body.clone()),
277 MessageType::Image(content) => Some(media_body(content.filename(), content.caption())),
278 MessageType::Video(content) => Some(media_body(content.filename(), content.caption())),
279 MessageType::Audio(content) => Some(media_body(content.filename(), content.caption())),
280 MessageType::File(content) => Some(media_body(content.filename(), content.caption())),
281 _ => None,
282 }
283}
284
285fn indexable_from_room_message(
288 event: &OriginalSyncRoomMessageEvent,
289 timestamp: Option<MilliSecondsSinceUnixEpoch>,
290) -> Option<IndexableEvent> {
291 let body = room_message_body(&event.content.msgtype)?;
292 let original_event_id = match &event.content.relates_to {
293 Some(Relation::Replacement(replacement)) => replacement.event_id.clone(),
294 _ => event.event_id.clone(),
295 };
296
297 Some(IndexableEvent::new(
298 event.event_id.clone(),
299 original_event_id,
300 event.sender.clone(),
301 timestamp,
302 body,
303 ))
304}
305
306async fn handle_possible_edit(
310 event: &OriginalSyncRoomMessageEvent,
311 timestamp: Option<MilliSecondsSinceUnixEpoch>,
312 cache: &RoomEventCache,
313) -> Option<RoomIndexOperation> {
314 if let Some(Relation::Replacement(replacement_data)) = &event.content.relates_to {
315 if let Some(recent) = get_most_recent_edit(cache, &replacement_data.event_id).await {
316 return Some(
317 indexable_from_room_message(&recent, timestamp).map_or(
318 RoomIndexOperation::Noop,
319 |indexable| {
320 RoomIndexOperation::Edit(replacement_data.event_id.clone(), indexable)
321 },
322 ),
323 );
324 } else {
325 return Some(RoomIndexOperation::Noop);
326 }
327 }
328 None
329}
330
331async fn handle_room_message(
334 event: SyncRoomMessageEvent,
335 timestamp: Option<MilliSecondsSinceUnixEpoch>,
336 cache: &RoomEventCache,
337) -> Option<RoomIndexOperation> {
338 if let Some(event) = event.as_original() {
339 return handle_possible_edit(event, timestamp, cache).await.or(get_most_recent_edit(
340 cache,
341 &event.event_id,
342 )
343 .await
344 .and_then(|recent| {
345 indexable_from_room_message(&recent, timestamp).map(RoomIndexOperation::Add)
346 }));
347 }
348 None
349}
350
351async fn handle_room_redaction(
354 event: SyncRoomRedactionEvent,
355 timestamp: Option<MilliSecondsSinceUnixEpoch>,
356 cache: &RoomEventCache,
357 rules: &RedactionRules,
358) -> Option<RoomIndexOperation> {
359 let redacted_event_id = event.redacts(rules)?;
360
361 if let Ok(Some(redacted_event)) = cache.find_event(redacted_event_id).await
364 && let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(
365 redacted_event,
366 ))) = redacted_event.raw().deserialize()
367 && let Some(redacted_event) = redacted_event.as_original()
368 && let Some(operation) = handle_possible_edit(redacted_event, timestamp, cache).await
369 {
370 return Some(operation);
371 }
372
373 Some(RoomIndexOperation::Remove(redacted_event_id.to_owned()))
376}
377
378fn handle_sticker(
380 event: SyncStickerEvent,
381 timestamp: Option<MilliSecondsSinceUnixEpoch>,
382) -> Option<RoomIndexOperation> {
383 let event = event.as_original()?;
384
385 Some(RoomIndexOperation::Add(IndexableEvent::new(
386 event.event_id.clone(),
387 event.event_id.clone(),
388 event.sender.clone(),
389 timestamp,
390 event.content.body.clone(),
391 )))
392}
393
394fn handle_unstable_poll_start(
401 event: SyncUnstablePollStartEvent,
402 timestamp: Option<MilliSecondsSinceUnixEpoch>,
403) -> Option<RoomIndexOperation> {
404 let event = event.as_original()?;
405
406 let UnstablePollStartEventContent::New(content) = &event.content else {
407 return None;
408 };
409
410 let block = &content.poll_start;
411 let mut body = block.question.text.clone();
412 for answer in block.answers.iter() {
413 body.push(' ');
414 body.push_str(&answer.text);
415 }
416
417 Some(RoomIndexOperation::Add(IndexableEvent::new(
418 event.event_id.clone(),
419 event.event_id.clone(),
420 event.sender.clone(),
421 timestamp,
422 body,
423 )))
424}
425
426fn handle_poll_start(
432 event: SyncPollStartEvent,
433 timestamp: Option<MilliSecondsSinceUnixEpoch>,
434) -> Option<RoomIndexOperation> {
435 let event = event.as_original()?;
436
437 if let Some(Relation::Replacement(_)) = &event.content.relates_to {
439 return None;
440 }
441
442 let block = &event.content.poll;
443 let mut body = block.question.text.find_plain()?.to_owned();
444 for answer in block.answers.iter() {
445 if let Some(text) = answer.text.find_plain() {
446 body.push(' ');
447 body.push_str(text);
448 }
449 }
450
451 Some(RoomIndexOperation::Add(IndexableEvent::new(
452 event.event_id.clone(),
453 event.event_id.clone(),
454 event.sender.clone(),
455 timestamp,
456 body,
457 )))
458}
459
460async fn parse_timeline_event(
463 cache: &RoomEventCache,
464 event: TimelineEvent,
465 redaction_rules: &RedactionRules,
466) -> Option<RoomIndexOperation> {
467 use ruma::events::AnySyncTimelineEvent;
468
469 if event.kind.is_utd() {
470 return None;
471 }
472
473 let timestamp = event.timestamp();
474
475 match event.raw().deserialize() {
476 Ok(event) => match event {
477 AnySyncTimelineEvent::MessageLike(event) => match event {
478 AnySyncMessageLikeEvent::RoomMessage(event) => {
479 handle_room_message(event, timestamp, cache).await
480 }
481 AnySyncMessageLikeEvent::RoomRedaction(event) => {
482 handle_room_redaction(event, timestamp, cache, redaction_rules).await
483 }
484 AnySyncMessageLikeEvent::Sticker(event) => handle_sticker(event, timestamp),
485 AnySyncMessageLikeEvent::PollStart(event) => handle_poll_start(event, timestamp),
486 AnySyncMessageLikeEvent::UnstablePollStart(event) => {
487 handle_unstable_poll_start(event, timestamp)
488 }
489 _ => None,
490 },
491 AnySyncTimelineEvent::State(_) => None,
492 },
493
494 Err(e) => {
495 warn!("failed to parse event: {e:?}");
496 None
497 }
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory};
504 use ruma::{
505 event_id, events::room::message::RoomMessageEventContentWithoutRelation, room_id, user_id,
506 };
507
508 use crate::test_utils::mocks::MatrixMockServer;
509
510 #[cfg(feature = "experimental-search")]
511 #[async_test]
512 async fn test_sync_message_is_indexed() {
513 let mock_server = MatrixMockServer::new().await;
514 let client = mock_server.client_builder().build().await;
515
516 client.event_cache().subscribe().unwrap();
517
518 let room_id = room_id!("!room_id:localhost");
519 let event_id = event_id!("$event_id:localost");
520 let user_id = user_id!("@user_id:localost");
521
522 let event_factory = EventFactory::new();
523 let room = mock_server
524 .sync_room(
525 &client,
526 JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
527 event_factory
528 .text_msg("this is a sentence")
529 .event_id(event_id)
530 .sender(user_id)
531 .into_raw_sync(),
532 ]),
533 )
534 .await;
535
536 let response = room.search("this", 5, None).await.expect("search should have 1 result");
537
538 assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}");
539 assert_eq!(response[0].1, event_id, "event id doesn't match: {response:?}");
540 }
541
542 #[cfg(feature = "experimental-search")]
543 #[async_test]
544 async fn test_sync_media_message_is_indexed() {
545 use ruma::owned_mxc_uri;
546
547 let mock_server = MatrixMockServer::new().await;
548 let client = mock_server.client_builder().build().await;
549
550 client.event_cache().subscribe().unwrap();
551
552 let room_id = room_id!("!room_id:localhost");
553 let image_id = event_id!("$image_id:localhost");
554 let file_id = event_id!("$file_id:localhost");
555 let user_id = user_id!("@user_id:localhost");
556
557 let f = EventFactory::new();
558 let room = mock_server
559 .sync_room(
560 &client,
561 JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
562 f.image("holiday_beach.jpg".to_owned(), owned_mxc_uri!("mxc://localhost/1"))
563 .caption(Some("sunset over the ocean".to_owned()), None)
564 .event_id(image_id)
565 .sender(user_id)
566 .into_raw_sync(),
567 f.image("quarterly_report.pdf".to_owned(), owned_mxc_uri!("mxc://localhost/2"))
568 .event_id(file_id)
569 .sender(user_id)
570 .into_raw_sync(),
571 ]),
572 )
573 .await;
574
575 let response = room.search("sunset", 5, None).await.unwrap();
577 assert_eq!(response.len(), 1, "unexpected results for caption search: {response:?}");
578 assert_eq!(response[0].1, image_id, "event id doesn't match: {response:?}");
579
580 let response = room.search("holiday_beach", 5, None).await.unwrap();
582 assert_eq!(response.len(), 1, "unexpected results for filename search: {response:?}");
583 assert_eq!(response[0].1, image_id, "event id doesn't match: {response:?}");
584
585 let response = room.search("quarterly_report", 5, None).await.unwrap();
587 assert_eq!(response.len(), 1, "unexpected results for filename search: {response:?}");
588 assert_eq!(response[0].1, file_id, "event id doesn't match: {response:?}");
589 }
590
591 #[cfg(feature = "experimental-search")]
592 #[async_test]
593 async fn test_sync_sticker_and_poll_are_indexed() {
594 use ruma::{events::room::ImageInfo, owned_mxc_uri};
595
596 let mock_server = MatrixMockServer::new().await;
597 let client = mock_server.client_builder().build().await;
598
599 client.event_cache().subscribe().unwrap();
600
601 let room_id = room_id!("!room_id:localhost");
602 let sticker_id = event_id!("$sticker_id:localhost");
603 let poll_id = event_id!("$poll_id:localhost");
604 let user_id = user_id!("@user_id:localhost");
605
606 let f = EventFactory::new().room(room_id).sender(user_id);
607 let room = mock_server
608 .sync_room(
609 &client,
610 JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
611 f.sticker(
612 "a waving cat",
613 ImageInfo::new(),
614 owned_mxc_uri!("mxc://localhost/1"),
615 )
616 .event_id(sticker_id)
617 .into_raw_sync(),
618 f.poll_start("fallback", "favourite cheese?", vec!["comté", "gruyère"])
619 .event_id(poll_id)
620 .into_raw_sync(),
621 ]),
622 )
623 .await;
624
625 let response = room.search("waving", 5, None).await.unwrap();
627 assert_eq!(response.len(), 1, "unexpected results for sticker search: {response:?}");
628 assert_eq!(response[0].1, sticker_id, "event id doesn't match: {response:?}");
629
630 let response = room.search("cheese", 5, None).await.unwrap();
632 assert_eq!(response.len(), 1, "unexpected results for poll question search: {response:?}");
633 assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
634
635 let response = room.search("gruyère", 5, None).await.unwrap();
637 assert_eq!(response.len(), 1, "unexpected results for poll answer search: {response:?}");
638 assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
639 }
640
641 #[cfg(feature = "experimental-search")]
642 #[async_test]
643 async fn test_sync_stable_poll_is_indexed() {
644 use ruma::events::{
645 message::TextContentBlock,
646 poll::start::{PollAnswer, PollAnswers, PollContentBlock, PollStartEventContent},
647 };
648
649 let mock_server = MatrixMockServer::new().await;
650 let client = mock_server.client_builder().build().await;
651
652 client.event_cache().subscribe().unwrap();
653
654 let room_id = room_id!("!room_id:localhost");
655 let poll_id = event_id!("$stable_poll_id:localhost");
656 let user_id = user_id!("@user_id:localhost");
657
658 let answers: PollAnswers = vec![
659 PollAnswer::new("0".to_owned(), TextContentBlock::plain("comté")),
660 PollAnswer::new("1".to_owned(), TextContentBlock::plain("gruyère")),
661 ]
662 .try_into()
663 .unwrap();
664 let poll = PollContentBlock::new(TextContentBlock::plain("favourite cheese?"), answers);
665 let content = PollStartEventContent::new(TextContentBlock::plain("fallback"), poll);
666
667 let f = EventFactory::new().room(room_id).sender(user_id);
668 let room = mock_server
669 .sync_room(
670 &client,
671 JoinedRoomBuilder::new(room_id)
672 .add_timeline_bulk(vec![f.event(content).event_id(poll_id).into_raw_sync()]),
673 )
674 .await;
675
676 let response = room.search("cheese", 5, None).await.unwrap();
678 assert_eq!(response.len(), 1, "unexpected results for poll question search: {response:?}");
679 assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
680
681 let response = room.search("gruyère", 5, None).await.unwrap();
683 assert_eq!(response.len(), 1, "unexpected results for poll answer search: {response:?}");
684 assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
685 }
686
687 #[cfg(feature = "experimental-search")]
688 #[async_test]
689 async fn test_search_index_edit_ordering() {
690 let room_id = room_id!("!room_id:localhost");
691 let dummy_id = event_id!("$dummy");
692 let edit1_id = event_id!("$edit1");
693 let edit2_id = event_id!("$edit2");
694 let edit3_id = event_id!("$edit3");
695 let original_id = event_id!("$original");
696
697 let server = MatrixMockServer::new().await;
698 let client = server.client_builder().build().await;
699
700 let event_cache = client.event_cache();
701 event_cache.subscribe().unwrap();
702
703 let room = server.sync_joined_room(&client, room_id).await;
704
705 let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost"));
706
707 let dummy = f.text_msg("dummy").event_id(dummy_id);
709
710 let original = f.text_msg("This is a message").event_id(original_id);
711
712 let edit1 = f
713 .text_msg("* A new message")
714 .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message"))
715 .event_id(edit1_id);
716
717 let edit2 = f
718 .text_msg("* An even newer message")
719 .edit(
720 original_id,
721 RoomMessageEventContentWithoutRelation::text_plain("An even newer message"),
722 )
723 .event_id(edit2_id);
724
725 let edit3 = f
726 .text_msg("* The newest message")
727 .edit(
728 original_id,
729 RoomMessageEventContentWithoutRelation::text_plain("The newest message"),
730 )
731 .event_id(edit3_id);
732
733 server
734 .sync_room(
735 &client,
736 JoinedRoomBuilder::new(room_id)
737 .add_timeline_event(dummy)
738 .add_timeline_event(edit1)
739 .add_timeline_event(edit2),
740 )
741 .await;
742
743 let results = room.search("message", 3, None).await.unwrap();
744
745 assert_eq!(results.len(), 0, "Search should return 0 results, got {results:?}");
746
747 server
750 .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original))
751 .await;
752
753 let results = room.search("message", 3, None).await.unwrap();
754
755 assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}");
756 assert_eq!(
757 results[0].1, edit2_id,
758 "Search should return latest edit, got {:?}",
759 results[0].1
760 );
761
762 server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await;
765
766 let results = room.search("message", 3, None).await.unwrap();
767
768 assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}");
769 assert_eq!(
770 results[0].1, edit3_id,
771 "Search should return latest edit, got {:?}",
772 results[0].1
773 );
774 }
775
776 #[cfg(feature = "experimental-search")]
777 #[async_test]
778 async fn test_search_index_ignores_cross_sender_edit() {
779 let room_id = room_id!("!room_id:localhost");
780 let original_id = event_id!("$original");
781 let edit_id = event_id!("$edit");
782
783 let server = MatrixMockServer::new().await;
784 let client = server.client_builder().build().await;
785
786 let event_cache = client.event_cache();
787 event_cache.subscribe().unwrap();
788
789 let room = server.sync_joined_room(&client, room_id).await;
790
791 let f = EventFactory::new().room(room_id);
792
793 let original =
794 f.text_msg("original alpha").sender(user_id!("@alice:localhost")).event_id(original_id);
795
796 let malicious_edit = f
799 .text_msg("* malicious beta")
800 .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("malicious beta"))
801 .sender(user_id!("@bob:localhost"))
802 .event_id(edit_id);
803
804 server
805 .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original))
806 .await;
807
808 let results = room.search("alpha", 3, None).await.unwrap();
810 assert_eq!(results.len(), 1, "Original should be indexed, got {results:?}");
811 assert_eq!(results[0].1, original_id, "unexpected event id: {results:?}");
812
813 server
814 .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(malicious_edit))
815 .await;
816
817 let results = room.search("beta", 3, None).await.unwrap();
819 assert_eq!(results.len(), 0, "Cross-sender edit should be ignored, got {results:?}");
820
821 let results = room.search("alpha", 3, None).await.unwrap();
823 assert_eq!(results.len(), 1, "Original should stay indexed, got {results:?}");
824 assert_eq!(results[0].1, original_id, "unexpected event id: {results:?}");
825 }
826}