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, 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(event: &OriginalSyncRoomMessageEvent) -> Option<IndexableEvent> {
288 let body = room_message_body(&event.content.msgtype)?;
289 let original_event_id = match &event.content.relates_to {
290 Some(Relation::Replacement(replacement)) => replacement.event_id.clone(),
291 _ => event.event_id.clone(),
292 };
293 Some(IndexableEvent {
294 event_id: event.event_id.clone(),
295 original_event_id,
296 sender: event.sender.clone(),
297 timestamp: event.origin_server_ts,
298 body,
299 })
300}
301
302async fn handle_possible_edit(
306 event: &OriginalSyncRoomMessageEvent,
307 cache: &RoomEventCache,
308) -> Option<RoomIndexOperation> {
309 if let Some(Relation::Replacement(replacement_data)) = &event.content.relates_to {
310 if let Some(recent) = get_most_recent_edit(cache, &replacement_data.event_id).await {
311 return Some(
312 indexable_from_room_message(&recent).map_or(
313 RoomIndexOperation::Noop,
314 |indexable| {
315 RoomIndexOperation::Edit(replacement_data.event_id.clone(), indexable)
316 },
317 ),
318 );
319 } else {
320 return Some(RoomIndexOperation::Noop);
321 }
322 }
323 None
324}
325
326async fn handle_room_message(
329 event: SyncRoomMessageEvent,
330 cache: &RoomEventCache,
331) -> Option<RoomIndexOperation> {
332 if let Some(event) = event.as_original() {
333 return handle_possible_edit(event, cache).await.or(get_most_recent_edit(
334 cache,
335 &event.event_id,
336 )
337 .await
338 .and_then(|recent| indexable_from_room_message(&recent).map(RoomIndexOperation::Add)));
339 }
340 None
341}
342
343async fn handle_room_redaction(
346 event: SyncRoomRedactionEvent,
347 cache: &RoomEventCache,
348 rules: &RedactionRules,
349) -> Option<RoomIndexOperation> {
350 let redacted_event_id = event.redacts(rules)?;
351
352 if let Ok(Some(redacted_event)) = cache.find_event(redacted_event_id).await
355 && let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(
356 redacted_event,
357 ))) = redacted_event.raw().deserialize()
358 && let Some(redacted_event) = redacted_event.as_original()
359 && let Some(operation) = handle_possible_edit(redacted_event, cache).await
360 {
361 return Some(operation);
362 }
363
364 Some(RoomIndexOperation::Remove(redacted_event_id.to_owned()))
367}
368
369fn handle_sticker(event: SyncStickerEvent) -> Option<RoomIndexOperation> {
371 let event = event.as_original()?;
372 Some(RoomIndexOperation::Add(IndexableEvent {
373 event_id: event.event_id.clone(),
374 original_event_id: event.event_id.clone(),
375 sender: event.sender.clone(),
376 timestamp: event.origin_server_ts,
377 body: event.content.body.clone(),
378 }))
379}
380
381fn handle_unstable_poll_start(event: SyncUnstablePollStartEvent) -> Option<RoomIndexOperation> {
388 let event = event.as_original()?;
389
390 let UnstablePollStartEventContent::New(content) = &event.content else {
391 return None;
392 };
393
394 let block = &content.poll_start;
395 let mut body = block.question.text.clone();
396 for answer in block.answers.iter() {
397 body.push(' ');
398 body.push_str(&answer.text);
399 }
400
401 Some(RoomIndexOperation::Add(IndexableEvent {
402 event_id: event.event_id.clone(),
403 original_event_id: event.event_id.clone(),
404 sender: event.sender.clone(),
405 timestamp: event.origin_server_ts,
406 body,
407 }))
408}
409
410fn handle_poll_start(event: SyncPollStartEvent) -> Option<RoomIndexOperation> {
416 let event = event.as_original()?;
417
418 if let Some(Relation::Replacement(_)) = &event.content.relates_to {
420 return None;
421 }
422
423 let block = &event.content.poll;
424 let mut body = block.question.text.find_plain()?.to_owned();
425 for answer in block.answers.iter() {
426 if let Some(text) = answer.text.find_plain() {
427 body.push(' ');
428 body.push_str(text);
429 }
430 }
431
432 Some(RoomIndexOperation::Add(IndexableEvent {
433 event_id: event.event_id.clone(),
434 original_event_id: event.event_id.clone(),
435 sender: event.sender.clone(),
436 timestamp: event.origin_server_ts,
437 body,
438 }))
439}
440
441async fn parse_timeline_event(
444 cache: &RoomEventCache,
445 event: TimelineEvent,
446 redaction_rules: &RedactionRules,
447) -> Option<RoomIndexOperation> {
448 use ruma::events::AnySyncTimelineEvent;
449
450 if event.kind.is_utd() {
451 return None;
452 }
453
454 match event.raw().deserialize() {
455 Ok(event) => match event {
456 AnySyncTimelineEvent::MessageLike(event) => match event {
457 AnySyncMessageLikeEvent::RoomMessage(event) => {
458 handle_room_message(event, cache).await
459 }
460 AnySyncMessageLikeEvent::RoomRedaction(event) => {
461 handle_room_redaction(event, cache, redaction_rules).await
462 }
463 AnySyncMessageLikeEvent::Sticker(event) => handle_sticker(event),
464 AnySyncMessageLikeEvent::PollStart(event) => handle_poll_start(event),
465 AnySyncMessageLikeEvent::UnstablePollStart(event) => {
466 handle_unstable_poll_start(event)
467 }
468 _ => None,
469 },
470 AnySyncTimelineEvent::State(_) => None,
471 },
472
473 Err(e) => {
474 warn!("failed to parse event: {e:?}");
475 None
476 }
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory};
483 use ruma::{
484 event_id, events::room::message::RoomMessageEventContentWithoutRelation, room_id, user_id,
485 };
486
487 use crate::test_utils::mocks::MatrixMockServer;
488
489 #[cfg(feature = "experimental-search")]
490 #[async_test]
491 async fn test_sync_message_is_indexed() {
492 let mock_server = MatrixMockServer::new().await;
493 let client = mock_server.client_builder().build().await;
494
495 client.event_cache().subscribe().unwrap();
496
497 let room_id = room_id!("!room_id:localhost");
498 let event_id = event_id!("$event_id:localost");
499 let user_id = user_id!("@user_id:localost");
500
501 let event_factory = EventFactory::new();
502 let room = mock_server
503 .sync_room(
504 &client,
505 JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
506 event_factory
507 .text_msg("this is a sentence")
508 .event_id(event_id)
509 .sender(user_id)
510 .into_raw_sync(),
511 ]),
512 )
513 .await;
514
515 let response = room.search("this", 5, None).await.expect("search should have 1 result");
516
517 assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}");
518 assert_eq!(response[0].1, event_id, "event id doesn't match: {response:?}");
519 }
520
521 #[cfg(feature = "experimental-search")]
522 #[async_test]
523 async fn test_sync_media_message_is_indexed() {
524 use ruma::owned_mxc_uri;
525
526 let mock_server = MatrixMockServer::new().await;
527 let client = mock_server.client_builder().build().await;
528
529 client.event_cache().subscribe().unwrap();
530
531 let room_id = room_id!("!room_id:localhost");
532 let image_id = event_id!("$image_id:localhost");
533 let file_id = event_id!("$file_id:localhost");
534 let user_id = user_id!("@user_id:localhost");
535
536 let f = EventFactory::new();
537 let room = mock_server
538 .sync_room(
539 &client,
540 JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
541 f.image("holiday_beach.jpg".to_owned(), owned_mxc_uri!("mxc://localhost/1"))
542 .caption(Some("sunset over the ocean".to_owned()), None)
543 .event_id(image_id)
544 .sender(user_id)
545 .into_raw_sync(),
546 f.image("quarterly_report.pdf".to_owned(), owned_mxc_uri!("mxc://localhost/2"))
547 .event_id(file_id)
548 .sender(user_id)
549 .into_raw_sync(),
550 ]),
551 )
552 .await;
553
554 let response = room.search("sunset", 5, None).await.unwrap();
556 assert_eq!(response.len(), 1, "unexpected results for caption search: {response:?}");
557 assert_eq!(response[0].1, image_id, "event id doesn't match: {response:?}");
558
559 let response = room.search("holiday_beach", 5, None).await.unwrap();
561 assert_eq!(response.len(), 1, "unexpected results for filename search: {response:?}");
562 assert_eq!(response[0].1, image_id, "event id doesn't match: {response:?}");
563
564 let response = room.search("quarterly_report", 5, None).await.unwrap();
566 assert_eq!(response.len(), 1, "unexpected results for filename search: {response:?}");
567 assert_eq!(response[0].1, file_id, "event id doesn't match: {response:?}");
568 }
569
570 #[cfg(feature = "experimental-search")]
571 #[async_test]
572 async fn test_sync_sticker_and_poll_are_indexed() {
573 use ruma::{events::room::ImageInfo, owned_mxc_uri};
574
575 let mock_server = MatrixMockServer::new().await;
576 let client = mock_server.client_builder().build().await;
577
578 client.event_cache().subscribe().unwrap();
579
580 let room_id = room_id!("!room_id:localhost");
581 let sticker_id = event_id!("$sticker_id:localhost");
582 let poll_id = event_id!("$poll_id:localhost");
583 let user_id = user_id!("@user_id:localhost");
584
585 let f = EventFactory::new().room(room_id).sender(user_id);
586 let room = mock_server
587 .sync_room(
588 &client,
589 JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![
590 f.sticker(
591 "a waving cat",
592 ImageInfo::new(),
593 owned_mxc_uri!("mxc://localhost/1"),
594 )
595 .event_id(sticker_id)
596 .into_raw_sync(),
597 f.poll_start("fallback", "favourite cheese?", vec!["comté", "gruyère"])
598 .event_id(poll_id)
599 .into_raw_sync(),
600 ]),
601 )
602 .await;
603
604 let response = room.search("waving", 5, None).await.unwrap();
606 assert_eq!(response.len(), 1, "unexpected results for sticker search: {response:?}");
607 assert_eq!(response[0].1, sticker_id, "event id doesn't match: {response:?}");
608
609 let response = room.search("cheese", 5, None).await.unwrap();
611 assert_eq!(response.len(), 1, "unexpected results for poll question search: {response:?}");
612 assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
613
614 let response = room.search("gruyère", 5, None).await.unwrap();
616 assert_eq!(response.len(), 1, "unexpected results for poll answer search: {response:?}");
617 assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
618 }
619
620 #[cfg(feature = "experimental-search")]
621 #[async_test]
622 async fn test_sync_stable_poll_is_indexed() {
623 use ruma::events::{
624 message::TextContentBlock,
625 poll::start::{PollAnswer, PollAnswers, PollContentBlock, PollStartEventContent},
626 };
627
628 let mock_server = MatrixMockServer::new().await;
629 let client = mock_server.client_builder().build().await;
630
631 client.event_cache().subscribe().unwrap();
632
633 let room_id = room_id!("!room_id:localhost");
634 let poll_id = event_id!("$stable_poll_id:localhost");
635 let user_id = user_id!("@user_id:localhost");
636
637 let answers: PollAnswers = vec![
638 PollAnswer::new("0".to_owned(), TextContentBlock::plain("comté")),
639 PollAnswer::new("1".to_owned(), TextContentBlock::plain("gruyère")),
640 ]
641 .try_into()
642 .unwrap();
643 let poll = PollContentBlock::new(TextContentBlock::plain("favourite cheese?"), answers);
644 let content = PollStartEventContent::new(TextContentBlock::plain("fallback"), poll);
645
646 let f = EventFactory::new().room(room_id).sender(user_id);
647 let room = mock_server
648 .sync_room(
649 &client,
650 JoinedRoomBuilder::new(room_id)
651 .add_timeline_bulk(vec![f.event(content).event_id(poll_id).into_raw_sync()]),
652 )
653 .await;
654
655 let response = room.search("cheese", 5, None).await.unwrap();
657 assert_eq!(response.len(), 1, "unexpected results for poll question search: {response:?}");
658 assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
659
660 let response = room.search("gruyère", 5, None).await.unwrap();
662 assert_eq!(response.len(), 1, "unexpected results for poll answer search: {response:?}");
663 assert_eq!(response[0].1, poll_id, "event id doesn't match: {response:?}");
664 }
665
666 #[cfg(feature = "experimental-search")]
667 #[async_test]
668 async fn test_search_index_edit_ordering() {
669 let room_id = room_id!("!room_id:localhost");
670 let dummy_id = event_id!("$dummy");
671 let edit1_id = event_id!("$edit1");
672 let edit2_id = event_id!("$edit2");
673 let edit3_id = event_id!("$edit3");
674 let original_id = event_id!("$original");
675
676 let server = MatrixMockServer::new().await;
677 let client = server.client_builder().build().await;
678
679 let event_cache = client.event_cache();
680 event_cache.subscribe().unwrap();
681
682 let room = server.sync_joined_room(&client, room_id).await;
683
684 let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost"));
685
686 let dummy = f.text_msg("dummy").event_id(dummy_id);
688
689 let original = f.text_msg("This is a message").event_id(original_id);
690
691 let edit1 = f
692 .text_msg("* A new message")
693 .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message"))
694 .event_id(edit1_id);
695
696 let edit2 = f
697 .text_msg("* An even newer message")
698 .edit(
699 original_id,
700 RoomMessageEventContentWithoutRelation::text_plain("An even newer message"),
701 )
702 .event_id(edit2_id);
703
704 let edit3 = f
705 .text_msg("* The newest message")
706 .edit(
707 original_id,
708 RoomMessageEventContentWithoutRelation::text_plain("The newest message"),
709 )
710 .event_id(edit3_id);
711
712 server
713 .sync_room(
714 &client,
715 JoinedRoomBuilder::new(room_id)
716 .add_timeline_event(dummy)
717 .add_timeline_event(edit1)
718 .add_timeline_event(edit2),
719 )
720 .await;
721
722 let results = room.search("message", 3, None).await.unwrap();
723
724 assert_eq!(results.len(), 0, "Search should return 0 results, got {results:?}");
725
726 server
729 .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original))
730 .await;
731
732 let results = room.search("message", 3, None).await.unwrap();
733
734 assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}");
735 assert_eq!(
736 results[0].1, edit2_id,
737 "Search should return latest edit, got {:?}",
738 results[0].1
739 );
740
741 server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await;
744
745 let results = room.search("message", 3, None).await.unwrap();
746
747 assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}");
748 assert_eq!(
749 results[0].1, edit3_id,
750 "Search should return latest edit, got {:?}",
751 results[0].1
752 );
753 }
754
755 #[cfg(feature = "experimental-search")]
756 #[async_test]
757 async fn test_search_index_ignores_cross_sender_edit() {
758 let room_id = room_id!("!room_id:localhost");
759 let original_id = event_id!("$original");
760 let edit_id = event_id!("$edit");
761
762 let server = MatrixMockServer::new().await;
763 let client = server.client_builder().build().await;
764
765 let event_cache = client.event_cache();
766 event_cache.subscribe().unwrap();
767
768 let room = server.sync_joined_room(&client, room_id).await;
769
770 let f = EventFactory::new().room(room_id);
771
772 let original =
773 f.text_msg("original alpha").sender(user_id!("@alice:localhost")).event_id(original_id);
774
775 let malicious_edit = f
778 .text_msg("* malicious beta")
779 .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("malicious beta"))
780 .sender(user_id!("@bob:localhost"))
781 .event_id(edit_id);
782
783 server
784 .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original))
785 .await;
786
787 let results = room.search("alpha", 3, None).await.unwrap();
789 assert_eq!(results.len(), 1, "Original should be indexed, got {results:?}");
790 assert_eq!(results[0].1, original_id, "unexpected event id: {results:?}");
791
792 server
793 .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(malicious_edit))
794 .await;
795
796 let results = room.search("beta", 3, None).await.unwrap();
798 assert_eq!(results.len(), 0, "Cross-sender edit should be ignored, got {results:?}");
799
800 let results = room.search("alpha", 3, None).await.unwrap();
802 assert_eq!(results.len(), 1, "Original should stay indexed, got {results:?}");
803 assert_eq!(results[0].1, original_id, "unexpected event id: {results:?}");
804 }
805}