saorsa_core/
address_book.rs

1// Copyright 2025 Saorsa Labs Limited
2//
3// Dual-licensed under AGPL-3.0-or-later and a commercial license.
4
5//! AddressBook: maps UserId <-> FourWordAddress for messaging and chat.
6//!
7//! - Panic-free; uses DHT for persistence when available, else in-memory fallback.
8//! - Public helpers are async and safe to call from other modules/apps.
9
10use crate::identity::four_words::FourWordAddress;
11use sha2::{Digest, Sha256};
12use crate::{error::{IdentityError, P2PError}, fwid, Result};
13use once_cell::sync::OnceCell;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::sync::Arc;
17use tokio::sync::RwLock;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20struct UserToWordsEntry {
21    user_id: String,
22    four_words: String,
23}
24
25#[derive(Clone)]
26pub struct AddressBook {
27    user_to_words: Arc<RwLock<HashMap<String, String>>>,
28    words_to_user: Arc<RwLock<HashMap<String, String>>>,
29}
30
31impl Default for AddressBook {
32    fn default() -> Self {
33        Self {
34            user_to_words: Arc::new(RwLock::new(HashMap::new())),
35            words_to_user: Arc::new(RwLock::new(HashMap::new())),
36        }
37    }
38}
39
40static GLOBAL_BOOK: OnceCell<AddressBook> = OnceCell::new();
41
42pub fn address_book() -> &'static AddressBook {
43    GLOBAL_BOOK.get_or_init(AddressBook::default)
44}
45
46fn key_user_to_words(user_id: &str) -> String {
47    let k = fwid::compute_key("abw:user", user_id.as_bytes());
48    hex::encode(k.as_bytes())
49}
50
51fn key_words_to_user(words: &str) -> String {
52    let k = fwid::compute_key("abw:words", words.as_bytes());
53    hex::encode(k.as_bytes())
54}
55
56impl AddressBook {
57    /// Register mapping (overwrites existing).
58    pub async fn register(&self, user_id: String, four_words: String) -> Result<()> {
59        // Validate format (4 hyphen-separated words)
60        let parts: Vec<String> = four_words
61            .split('-')
62            .map(|s| s.to_string())
63            .collect();
64        if parts.len() != 4 || !fwid::fw_check([
65            parts[0].clone(),
66            parts[1].clone(),
67            parts[2].clone(),
68            parts[3].clone(),
69        ]) {
70            return Err(P2PError::Identity(IdentityError::InvalidFourWordAddress(
71                "invalid four-word address format".into(),
72            )));
73        }
74
75        {
76            let mut u2w = self.user_to_words.write().await;
77            u2w.insert(user_id.clone(), four_words.clone());
78        }
79        {
80            let mut w2u = self.words_to_user.write().await;
81            w2u.insert(four_words.clone(), user_id.clone());
82        }
83
84        // Try to persist to DHT (best-effort)
85        if let Ok(client) = crate::dht::client::DhtClient::new() {
86            let entry = UserToWordsEntry {
87                user_id: user_id.clone(),
88                four_words: four_words.clone(),
89            };
90            let _ = client.put_object(key_user_to_words(&user_id), &entry).await;
91            let _ = client.put_object(key_words_to_user(&four_words), &entry).await;
92        }
93        Ok(())
94    }
95
96    /// Lookup FourWordAddress by user_id.
97    pub async fn get_words(&self, user_id: &str) -> Result<Option<FourWordAddress>> {
98        if let Some(w) = self.user_to_words.read().await.get(user_id).cloned() {
99            return Ok(Some(FourWordAddress(w)));
100        }
101        // Try DHT
102        if let Ok(client) = crate::dht::client::DhtClient::new()
103            && let Ok(Some(entry)) = client
104                .get_object::<UserToWordsEntry>(key_user_to_words(user_id))
105                .await
106        {
107            // cache
108            {
109                let mut u2w = self.user_to_words.write().await;
110                u2w.insert(entry.user_id.clone(), entry.four_words.clone());
111            }
112            {
113                let mut w2u = self.words_to_user.write().await;
114                w2u.insert(entry.four_words.clone(), entry.user_id.clone());
115            }
116            return Ok(Some(FourWordAddress(entry.four_words)));
117        }
118        Ok(None)
119    }
120
121    /// Lookup user_id by FourWordAddress string.
122    pub async fn get_user(&self, words: &str) -> Result<Option<String>> {
123        if let Some(u) = self.words_to_user.read().await.get(words).cloned() {
124            return Ok(Some(u));
125        }
126        if let Ok(client) = crate::dht::client::DhtClient::new()
127            && let Ok(Some(entry)) = client
128                .get_object::<UserToWordsEntry>(key_words_to_user(words))
129                .await
130        {
131            // cache
132            {
133                let mut u2w = self.user_to_words.write().await;
134                u2w.insert(entry.user_id.clone(), entry.four_words.clone());
135            }
136            {
137                let mut w2u = self.words_to_user.write().await;
138                w2u.insert(entry.four_words.clone(), entry.user_id.clone());
139            }
140            return Ok(Some(entry.user_id));
141        }
142
143        // Backfill from identity packet if publicly retrievable via identity key
144        // This path allows discovering user_id from four-words when directory entries are missing.
145        let parts: Vec<String> = words.split('-').map(|s| s.to_string()).collect();
146        if parts.len() == 4
147            && crate::fwid::fw_check([
148                parts[0].clone(),
149                parts[1].clone(),
150                parts[2].clone(),
151                parts[3].clone(),
152            ])
153            && let Ok(key) = crate::fwid::fw_to_key([
154                parts[0].clone(),
155                parts[1].clone(),
156                parts[2].clone(),
157                parts[3].clone(),
158            ])
159            && let Ok(pkt) = crate::identity_fetch(key.clone()).await
160        {
161            let mut hasher = Sha256::new();
162            hasher.update(&pkt.pk);
163            let user_id = hex::encode(hasher.finalize());
164            // Cache in background (ignore errors)
165            let words_owned = words.to_string();
166            let this = self.clone();
167            let user_id_for_cache = user_id.clone();
168            tokio::spawn(async move {
169                let _ = this.register(user_id_for_cache, words_owned).await;
170            });
171            return Ok(Some(user_id));
172        }
173        Ok(None)
174    }
175}
176
177// Convenience top-level helpers
178pub async fn register_user_address(user_id: String, four_words: String) -> Result<()> {
179    address_book().register(user_id, four_words).await
180}
181
182pub async fn get_user_four_words(user_id: &str) -> Result<Option<FourWordAddress>> {
183    address_book().get_words(user_id).await
184}
185
186pub async fn get_user_by_four_words(words: &str) -> Result<Option<String>> {
187    address_book().get_user(words).await
188}