matrix_sdk_base/store/
ambiguity_map.rs

1// Copyright 2021 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 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/// A map of users that use a certain display name.
36#[derive(Debug, Clone)]
37struct DisplayNameUsers {
38    display_name: DisplayName,
39    users: BTreeSet<OwnedUserId>,
40}
41
42impl DisplayNameUsers {
43    /// Remove the given [`UserId`] from the map, marking that the [`UserId`]
44    /// doesn't use the display name anymore.
45    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    /// Add the given [`UserId`] from the map, marking that the [`UserId`]
56    /// is using the display name.
57    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    /// How many users are using this display name.
67    fn user_count(&self) -> usize {
68        self.users.len()
69    }
70
71    /// Is the display name considered to be ambiguous.
72    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    /// Create a new [`AmbiguityCache`] backed by the given state store.
100    pub fn new(store: Arc<DynStateStore>) -> Self {
101        Self { store, cache: BTreeMap::new(), changes: BTreeMap::new() }
102    }
103
104    /// Handle a newly received [`SyncRoomMemberEvent`] for the given room.
105    pub async fn handle_event(
106        &mut self,
107        changes: &StateChanges,
108        room_id: &RoomId,
109        member_event: &SyncRoomMemberEvent,
110    ) -> Result<()> {
111        // Synapse seems to have a bug where it puts the same event into the state and
112        // the timeline sometimes.
113        //
114        // Since our state, e.g. the old display name, already ended up inside the state
115        // changes and we're pulling stuff out of the cache if it's there calculating
116        // this twice for the same event will result in an incorrect AmbiguityChange
117        // overwriting the correct one. In other words, this method is not idempotent so
118        // we make it by ignoring duplicate events.
119        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 the user's display name didn't change, then there's nothing more to
132        // calculate here.
133        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    /// Update the [`AmbiguityCache`] state for the given room with a pair of
163    /// [`DisplayNameUsers`] that got created by a new [`SyncRoomMemberEvent`].
164    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    /// Get the previously used display name, if any, of the member described in
182    /// the given new [`SyncRoomMemberEvent`].
183    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    /// Get the [`DisplayNameUsers`] for the given display name in the given
228    /// room.
229    ///
230    /// This method will get the [`DisplayNameUsers`] from the cache, if the
231    /// cache doesn't contain such an entry, it falls back to the state
232    /// store.
233    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    /// Calculate the change in the users that use a display name a
249    /// [`SyncRoomMemberEvent`] will cause for a given room.
250    ///
251    /// Returns the [`DisplayNameUsers`] before the member event is applied and
252    /// the [`DisplayNameUsers`] after the member event is applied to the
253    /// room state.
254    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            // We don't allow other users to set the display name, so if we have a more
276            // trusted version of the display name use that.
277            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}