//! Roster-bound chat identity (chat-hardening plan, Phase 2). //! //! The wire `GossipMessage::Chat` carries a sender-CLAIMED display name, which //! any insider could set to another member's name. This map is the antidote: //! the core event task records each authenticated member's latest sanitized //! presence name here (from `PeerJoined`/`PeerUpdated`, the events that only //! fire for a verified signed `Announce`), and chat renders under THAT name — //! the embedded wire name is never displayed. //! //! Shared (`Arc>`) because eviction happens in two places: the event //! task itself (graceful `PeerLeft`) and the detached reconnect-grace timer //! (terminal eviction). A peer mid-reconnect-grace keeps its entry, so its //! chat stays admitted until the grace actually expires. use iroh::EndpointId; use std::collections::HashMap; use std::sync::{Arc, Mutex}; /// Bound on tracked names. Mirrors the gossip roster cap (`MAX_ACTIVE_PEERS`): /// insertions only follow cap-gated roster admissions, so this is pure defense /// in depth against that invariant breaking. const CHAT_ROSTER_CAP: usize = 32; /// The authoritative id → display-name map for the current room. Cheap to /// clone; all clones share one map. #[derive(Debug, Clone, Default)] pub struct ChatRoster { names: Arc>>, } impl ChatRoster { /// Record (or refresh) a member's display name. The name is re-sanitized /// here (idempotent — gossip ingress already did) and an empty result falls /// back to the short node id so a chat line is never label-less. A NEW id /// is refused past the cap; updates to a present id always land. pub fn upsert(&self, id: EndpointId, name: &str) { let clean = crate::sanitize::sanitize_name(name); let label = if clean.is_empty() { crate::short_id(&id.to_string()) } else { clean }; let mut names = self.names.lock().unwrap(); if names.contains_key(&id) || names.len() < CHAT_ROSTER_CAP { names.insert(id, label); } } /// Drop a member on graceful leave or terminal (grace-expired) eviction. pub fn remove(&self, id: &EndpointId) { self.names.lock().unwrap().remove(id); } /// The roster-bound name for an id, or `None` if the author is not a /// current member — the caller must then drop the chat entirely. pub fn name_of(&self, id: &EndpointId) -> Option { self.names.lock().unwrap().get(id).cloned() } } #[cfg(test)] mod tests { use super::*; use iroh::SecretKey; fn fresh_id() -> EndpointId { SecretKey::generate().public() } #[test] fn upsert_then_lookup_returns_sanitized_name() { let roster = ChatRoster::default(); let a = fresh_id(); roster.upsert(a, "Alice"); assert_eq!(roster.name_of(&a), Some("Alice".to_string())); // Bidi override / zero-width spoofing characters are stripped. roster.upsert(a, "Al\u{202E}ice\u{200B}"); assert_eq!(roster.name_of(&a), Some("Alice".to_string())); } #[test] fn name_update_affects_future_lookups() { let roster = ChatRoster::default(); let a = fresh_id(); roster.upsert(a, "Alice"); roster.upsert(a, "Alice2"); assert_eq!(roster.name_of(&a), Some("Alice2".to_string())); } #[test] fn unknown_author_has_no_name() { let roster = ChatRoster::default(); roster.upsert(fresh_id(), "Alice"); assert_eq!(roster.name_of(&fresh_id()), None); } #[test] fn removed_author_is_no_longer_a_member() { let roster = ChatRoster::default(); let a = fresh_id(); roster.upsert(a, "Alice"); roster.remove(&a); assert_eq!(roster.name_of(&a), None); } #[test] fn empty_sanitized_name_falls_back_to_short_id() { let roster = ChatRoster::default(); let a = fresh_id(); roster.upsert(a, "\u{0}\r\n\t "); let label = roster.name_of(&a).unwrap(); assert!(!label.is_empty()); assert_eq!(label, crate::short_id(&a.to_string())); } #[test] fn new_ids_are_refused_past_the_cap_but_updates_land() { let roster = ChatRoster::default(); let first = fresh_id(); roster.upsert(first, "member"); for _ in 1..CHAT_ROSTER_CAP { roster.upsert(fresh_id(), "member"); } // A brand-new 33rd id is refused... let overflow = fresh_id(); roster.upsert(overflow, "overflow"); assert_eq!(roster.name_of(&overflow), None); // ...but an update to a present id still lands at the cap. roster.upsert(first, "renamed"); assert_eq!(roster.name_of(&first), Some("renamed".to_string())); } }