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