1use std::{
16 collections::{BTreeMap, BTreeSet, HashMap},
17 sync::Arc,
18};
19
20use ruma::{
21 events::{
22 room::member::{MembershipState, SyncRoomMemberEvent},
23 StateEventType,
24 },
25 OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId,
26};
27use tracing::{instrument, trace};
28
29use super::{DynStateStore, Result, StateChanges};
30use crate::{
31 deserialized_responses::{AmbiguityChange, DisplayName, RawMemberEvent},
32 store::StateStoreExt,
33};
34
35#[derive(Debug, Clone)]
37struct DisplayNameUsers {
38 display_name: DisplayName,
39 users: BTreeSet<OwnedUserId>,
40}
41
42impl DisplayNameUsers {
43 fn remove(&mut self, user_id: &UserId) -> Option<OwnedUserId> {
46 self.users.remove(user_id);
47
48 if self.user_count() == 1 {
49 self.users.iter().next().cloned()
50 } else {
51 None
52 }
53 }
54
55 fn add(&mut self, user_id: OwnedUserId) -> Option<OwnedUserId> {
58 let ambiguous_user =
59 if self.user_count() == 1 { self.users.iter().next().cloned() } else { None };
60
61 self.users.insert(user_id);
62
63 ambiguous_user
64 }
65
66 fn user_count(&self) -> usize {
68 self.users.len()
69 }
70
71 fn is_ambiguous(&self) -> bool {
73 is_display_name_ambiguous(&self.display_name, &self.users)
74 }
75}
76
77fn is_member_active(membership: &MembershipState) -> bool {
78 use MembershipState::*;
79 matches!(membership, Join | Invite | Knock)
80}
81
82#[derive(Debug)]
83pub(crate) struct AmbiguityCache {
84 pub store: Arc<DynStateStore>,
85 pub cache: BTreeMap<OwnedRoomId, HashMap<DisplayName, BTreeSet<OwnedUserId>>>,
86 pub changes: BTreeMap<OwnedRoomId, BTreeMap<OwnedEventId, AmbiguityChange>>,
87}
88
89#[instrument(ret)]
90pub(crate) fn is_display_name_ambiguous(
91 display_name: &DisplayName,
92 users_with_display_name: &BTreeSet<OwnedUserId>,
93) -> bool {
94 trace!("Checking if a display name is ambiguous");
95 display_name.is_inherently_ambiguous() || users_with_display_name.len() > 1
96}
97
98impl AmbiguityCache {
99 pub fn new(store: Arc<DynStateStore>) -> Self {
101 Self { store, cache: BTreeMap::new(), changes: BTreeMap::new() }
102 }
103
104 pub async fn handle_event(
106 &mut self,
107 changes: &StateChanges,
108 room_id: &RoomId,
109 member_event: &SyncRoomMemberEvent,
110 ) -> Result<()> {
111 if self.changes.get(room_id).is_some_and(|c| c.contains_key(member_event.event_id())) {
120 return Ok(());
121 }
122
123 let (mut old_map, mut new_map) =
124 self.calculate_changes(changes, room_id, member_event).await?;
125
126 let display_names_same = match (&old_map, &new_map) {
127 (Some(a), Some(b)) => a.display_name == b.display_name,
128 _ => false,
129 };
130
131 if display_names_same {
134 return Ok(());
135 }
136
137 let disambiguated_member =
138 old_map.as_mut().and_then(|o| o.remove(member_event.state_key()));
139 let ambiguated_member =
140 new_map.as_mut().and_then(|n| n.add(member_event.state_key().clone()));
141 let ambiguous = new_map.as_ref().is_some_and(|n| n.is_ambiguous());
142
143 self.update(room_id, old_map, new_map);
144
145 let change = AmbiguityChange {
146 member_id: member_event.state_key().clone(),
147 disambiguated_member,
148 ambiguated_member,
149 member_ambiguous: ambiguous,
150 };
151
152 trace!(user_id = ?member_event.state_key(), "Handling display name ambiguity: {change:#?}");
153
154 self.changes
155 .entry(room_id.to_owned())
156 .or_default()
157 .insert(member_event.event_id().to_owned(), change);
158
159 Ok(())
160 }
161
162 fn update(
165 &mut self,
166 room_id: &RoomId,
167 old_map: Option<DisplayNameUsers>,
168 new_map: Option<DisplayNameUsers>,
169 ) {
170 let entry = self.cache.entry(room_id.to_owned()).or_default();
171
172 if let Some(old) = old_map {
173 entry.insert(old.display_name, old.users);
174 }
175
176 if let Some(new) = new_map {
177 entry.insert(new.display_name, new.users);
178 }
179 }
180
181 async fn get_old_display_name(
184 &self,
185 changes: &StateChanges,
186 room_id: &RoomId,
187 new_event: &SyncRoomMemberEvent,
188 ) -> Result<Option<String>> {
189 let user_id = new_event.state_key();
190
191 let old_event = if let Some(m) = changes
192 .state
193 .get(room_id)
194 .and_then(|events| events.get(&StateEventType::RoomMember)?.get(user_id.as_str()))
195 {
196 Some(RawMemberEvent::Sync(m.clone().cast()))
197 } else {
198 self.store.get_member_event(room_id, user_id).await?
199 };
200
201 let Some(Ok(old_event)) = old_event.map(|r| r.deserialize()) else { return Ok(None) };
202
203 if is_member_active(old_event.membership()) {
204 let display_name = if let Some(d) = changes
205 .profiles
206 .get(room_id)
207 .and_then(|p| p.get(user_id)?.as_original()?.content.displayname.as_deref())
208 {
209 Some(d.to_owned())
210 } else if let Some(d) = self
211 .store
212 .get_profile(room_id, user_id)
213 .await?
214 .and_then(|p| p.into_original()?.content.displayname)
215 {
216 Some(d)
217 } else {
218 old_event.original_content().and_then(|c| c.displayname.clone())
219 };
220
221 Ok(Some(display_name.unwrap_or_else(|| user_id.localpart().to_owned())))
222 } else {
223 Ok(None)
224 }
225 }
226
227 async fn get_users_with_display_name(
234 &mut self,
235 room_id: &RoomId,
236 display_name: &DisplayName,
237 ) -> Result<DisplayNameUsers> {
238 Ok(if let Some(u) = self.cache.entry(room_id.to_owned()).or_default().get(display_name) {
239 DisplayNameUsers { display_name: display_name.clone(), users: u.clone() }
240 } else {
241 let users_with_display_name =
242 self.store.get_users_with_display_name(room_id, display_name).await?;
243
244 DisplayNameUsers { display_name: display_name.clone(), users: users_with_display_name }
245 })
246 }
247
248 async fn calculate_changes(
255 &mut self,
256 changes: &StateChanges,
257 room_id: &RoomId,
258 member_event: &SyncRoomMemberEvent,
259 ) -> Result<(Option<DisplayNameUsers>, Option<DisplayNameUsers>)> {
260 let old_display_name = self.get_old_display_name(changes, room_id, member_event).await?;
261
262 let old_map = if let Some(old_name) = old_display_name.as_deref() {
263 let old_display_name = DisplayName::new(old_name);
264 Some(self.get_users_with_display_name(room_id, &old_display_name).await?)
265 } else {
266 None
267 };
268
269 let new_map = if is_member_active(member_event.membership()) {
270 let new = member_event
271 .as_original()
272 .and_then(|ev| ev.content.displayname.as_deref())
273 .unwrap_or_else(|| member_event.state_key().localpart());
274
275 let new_display_name = if member_event.sender().as_str() == member_event.state_key() {
278 new
279 } else if let Some(old) = old_display_name.as_deref() {
280 old
281 } else {
282 new
283 };
284
285 let new_display_name = DisplayName::new(new_display_name);
286
287 Some(self.get_users_with_display_name(room_id, &new_display_name).await?)
288 } else {
289 None
290 };
291
292 Ok((old_map, new_map))
293 }
294
295 #[cfg(test)]
296 fn check(&self, room_id: &RoomId, display_name: &DisplayName) -> bool {
297 self.cache
298 .get(room_id)
299 .and_then(|display_names| {
300 display_names
301 .get(display_name)
302 .map(|user_ids| is_display_name_ambiguous(display_name, user_ids))
303 })
304 .unwrap_or_else(|| {
305 panic!(
306 "The display name {:?} should be part of the cache {:?}",
307 display_name, self.cache
308 )
309 })
310 }
311}
312
313#[cfg(test)]
314mod test {
315 use matrix_sdk_test::async_test;
316 use ruma::{room_id, server_name, user_id, EventId};
317 use serde_json::json;
318
319 use super::*;
320 use crate::store::{IntoStateStore, MemoryStore};
321
322 fn generate_event(user_id: &UserId, display_name: &str) -> SyncRoomMemberEvent {
323 let server_name = server_name!("localhost");
324 serde_json::from_value(json!({
325 "content": {
326 "displayname": display_name,
327 "membership": "join"
328 },
329 "event_id": EventId::new(server_name),
330 "origin_server_ts": 152037280,
331 "sender": user_id,
332 "state_key": user_id,
333 "type": "m.room.member",
334
335 }))
336 .expect("We should be able to deserialize the static member event")
337 }
338
339 macro_rules! assert_ambiguity {
340 (
341 [ $( ($user:literal, $display_name:literal) ),* ],
342 [ $( ($check_display_name:literal, $ambiguous:expr) ),* ] $(,)?
343 ) => {
344 assert_ambiguity!(
345 [ $( ($user, $display_name) ),* ],
346 [ $( ($check_display_name, $ambiguous) ),* ],
347 "The test failed the ambiguity assertions"
348 )
349 };
350
351 (
352 [ $( ($user:literal, $display_name:literal) ),* ],
353 [ $( ($check_display_name:literal, $ambiguous:expr) ),* ],
354 $description:literal $(,)?
355 ) => {
356 let store = MemoryStore::new();
357 let mut ambiguity_cache = AmbiguityCache::new(store.into_state_store());
358
359 let changes = Default::default();
360 let room_id = room_id!("!foo:bar");
361
362 macro_rules! add_display_name {
363 ($u:literal, $n:literal) => {
364 let event = generate_event(user_id!($u), $n);
365
366 ambiguity_cache
367 .handle_event(&changes, room_id, &event)
368 .await
369 .expect("We should be able to handle a member event to calculate the ambiguity.");
370 };
371 }
372
373 macro_rules! assert_display_name_ambiguity {
374 ($n:literal, $a:expr) => {
375 let display_name = DisplayName::new($n);
376
377 if ambiguity_cache.check(room_id, &display_name) != $a {
378 let foo = if $a { "be" } else { "not be" };
379 panic!("{}: the display name {} should {} ambiguous", $description, $n, foo);
380 }
381 };
382 }
383
384 $(
385 add_display_name!($user, $display_name);
386 )*
387
388 $(
389 assert_display_name_ambiguity!($check_display_name, $ambiguous);
390 )*
391 };
392 }
393
394 #[async_test]
395 async fn test_disambiguation() {
396 assert_ambiguity!(
397 [("@alice:localhost", "alice")],
398 [("alice", false)],
399 "Alice is alone in the room"
400 );
401
402 assert_ambiguity!(
403 [("@alice:localhost", "alice")],
404 [("Alice", false)],
405 "Alice is alone in the room and has a capitalized display name"
406 );
407
408 assert_ambiguity!(
409 [("@alice:localhost", "alice"), ("@bob:localhost", "alice")],
410 [("alice", true)],
411 "Alice and bob share a display name"
412 );
413
414 assert_ambiguity!(
415 [
416 ("@alice:localhost", "alice"),
417 ("@bob:localhost", "alice"),
418 ("@carol:localhost", "carol")
419 ],
420 [("alice", true), ("carol", false)],
421 "Alice and Bob share a display name, while Carol is unique"
422 );
423
424 assert_ambiguity!(
425 [("@alice:localhost", "alice"), ("@bob:localhost", "ALICE")],
426 [("alice", true)],
427 "Alice and Bob share a display name that is differently capitalized"
428 );
429
430 assert_ambiguity!(
431 [("@alice:localhost", "alice"), ("@bob:localhost", "ะฐlice")],
432 [("alice", true)],
433 "Bob tries to impersonate Alice using a cyrillic ะฐ"
434 );
435
436 assert_ambiguity!(
437 [("@alice:localhost", "@bob:localhost"), ("@bob:localhost", "ะฐlice")],
438 [("@bob:localhost", true)],
439 "Alice tries to impersonate bob using an mxid"
440 );
441
442 assert_ambiguity!(
443 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "๐ฎ๐ถ๐ฝ๐ถ๐๐๐ถ๐ฝ๐๐ถ")],
444 [("Sahasrahla", true)],
445 "Bob tries to impersonate Alice using scripture symbols"
446 );
447
448 assert_ambiguity!(
449 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "๐๐๐ฅ๐๐ฐ๐ฏ๐๐ฅ๐ฉ๐")],
450 [("Sahasrahla", true)],
451 "Bob tries to impersonate Alice using fraktur symbols"
452 );
453
454 assert_ambiguity!(
455 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "โโโโโขโกโโโโ")],
456 [("Sahasrahla", true)],
457 "Bob tries to impersonate Alice using circled symbols"
458 );
459
460 assert_ambiguity!(
461 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "๐
๐ฐ๐ท๐ฐ๐
๐
๐ฐ๐ท๐ป๐ฐ")],
462 [("Sahasrahla", true)],
463 "Bob tries to impersonate Alice using squared symbols"
464 );
465
466 assert_ambiguity!(
467 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "๏ผณ๏ฝ๏ฝ๏ฝ๏ฝ๏ฝ๏ฝ๏ฝ๏ฝ๏ฝ")],
468 [("Sahasrahla", true)],
469 "Bob tries to impersonate Alice using big unicode letters"
470 );
471
472 assert_ambiguity!(
473 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "\u{202e}alharsahas")],
474 [("Sahasrahla", true)],
475 "Bob tries to impersonate Alice using left to right shenanigans"
476 );
477
478 assert_ambiguity!(
479 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "Saฬดhasrahla")],
480 [("Sahasrahla", true)],
481 "Bob tries to impersonate Alice using a diacritical mark"
482 );
483
484 assert_ambiguity!(
485 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "Sahas\u{200B}rahla")],
486 [("Sahasrahla", true)],
487 "Bob tries to impersonate Alice using a zero-width space"
488 );
489
490 assert_ambiguity!(
491 [("@alice:localhost", "Sahasrahla"), ("@bob:localhost", "Sahas\u{200D}rahla")],
492 [("Sahasrahla", true)],
493 "Bob tries to impersonate Alice using a zero-width space"
494 );
495
496 assert_ambiguity!(
497 [("@alice:localhost", "ff"), ("@bob:localhost", "\u{FB00}")],
498 [("ff", true)],
499 "Bob tries to impersonate Alice using a ligature"
500 );
501 }
502}