Skip to main content

irontide_dht/
storage.rs

1//! Pluggable DHT item storage (BEP 44).
2//!
3//! The `DhtStorage` trait abstracts over the storage backend used for
4//! BEP 44 put/get items. The default `InMemoryDhtStorage` uses LRU
5//! eviction to stay within configured limits.
6
7use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10use irontide_core::Id20;
11
12use crate::bep44::{ImmutableItem, MutableItem, compute_mutable_target};
13
14/// Pluggable storage backend for BEP 44 DHT items.
15pub trait DhtStorage: Send + Sync + 'static {
16    /// Retrieve an immutable item by its SHA-1 target hash.
17    ///
18    /// Touches the entry (updates LRU timestamp) on access.
19    fn get_immutable(&mut self, target: &Id20) -> Option<ImmutableItem>;
20
21    /// Store an immutable item. Returns `true` if newly inserted, `false` if updated.
22    fn put_immutable(&mut self, item: ImmutableItem) -> bool;
23
24    /// Retrieve a mutable item by public key and salt.
25    ///
26    /// Touches the entry (updates LRU timestamp) on access.
27    fn get_mutable(&mut self, public_key: &[u8; 32], salt: &[u8]) -> Option<MutableItem>;
28
29    /// Retrieve a mutable item by its SHA-1 target (i.e. `SHA-1(public_key + salt)`).
30    ///
31    /// This performs a linear scan over stored mutable items, checking whether
32    /// `compute_mutable_target(&item.public_key, &item.salt) == *target`.
33    /// Touches the entry on access.
34    fn get_mutable_by_target(&mut self, target: &Id20) -> Option<MutableItem>;
35
36    /// Store a mutable item. Returns `true` if stored (new or higher seq), `false` if rejected.
37    ///
38    /// Implementations MUST enforce sequence number monotonicity:
39    /// reject if `item.seq <= existing.seq` for the same key+salt.
40    fn put_mutable(&mut self, item: MutableItem) -> bool;
41
42    /// Number of stored items as `(immutable_count, mutable_count)`.
43    fn count(&self) -> (usize, usize);
44
45    /// Remove expired items older than `lifetime`.
46    fn expire(&mut self, lifetime: Duration);
47}
48
49/// Stored entry metadata for LRU tracking.
50struct StoredEntry<T> {
51    item: T,
52    last_touched: Instant,
53}
54
55impl<T> StoredEntry<T> {
56    fn new(item: T) -> Self {
57        Self {
58            item,
59            last_touched: Instant::now(),
60        }
61    }
62
63    fn touch(&mut self) {
64        self.last_touched = Instant::now();
65    }
66}
67
68/// Key for mutable items: (`public_key`, salt).
69#[derive(Debug, Clone, PartialEq, Eq, Hash)]
70struct MutableKey {
71    public_key: [u8; 32],
72    salt: Vec<u8>,
73}
74
75/// In-memory DHT storage with LRU eviction.
76///
77/// When the number of items exceeds `max_items`, the least recently
78/// accessed item is evicted on the next `put`.
79pub struct InMemoryDhtStorage {
80    immutable: HashMap<Id20, StoredEntry<ImmutableItem>>,
81    mutable: HashMap<MutableKey, StoredEntry<MutableItem>>,
82    max_items: usize,
83}
84
85impl InMemoryDhtStorage {
86    /// Create a new in-memory storage with the given capacity limit.
87    #[must_use]
88    pub fn new(max_items: usize) -> Self {
89        Self {
90            immutable: HashMap::new(),
91            mutable: HashMap::new(),
92            max_items,
93        }
94    }
95
96    /// Total number of stored items across both maps.
97    fn total_count(&self) -> usize {
98        self.immutable.len() + self.mutable.len()
99    }
100
101    /// Evict the single least recently touched item across both maps.
102    fn evict_lru(&mut self) {
103        let oldest_immutable = self
104            .immutable
105            .iter()
106            .min_by_key(|(_, e)| e.last_touched)
107            .map(|(k, e)| (*k, e.last_touched));
108
109        let oldest_mutable = self
110            .mutable
111            .iter()
112            .min_by_key(|(_, e)| e.last_touched)
113            .map(|(k, e)| (k.clone(), e.last_touched));
114
115        match (oldest_immutable, oldest_mutable) {
116            (Some((ik, it)), Some((mk, mt))) => {
117                if it <= mt {
118                    self.immutable.remove(&ik);
119                } else {
120                    self.mutable.remove(&mk);
121                }
122            }
123            (Some((ik, _)), None) => {
124                self.immutable.remove(&ik);
125            }
126            (None, Some((mk, _))) => {
127                self.mutable.remove(&mk);
128            }
129            (None, None) => {}
130        }
131    }
132}
133
134impl Default for InMemoryDhtStorage {
135    fn default() -> Self {
136        Self::new(700)
137    }
138}
139
140impl DhtStorage for InMemoryDhtStorage {
141    fn get_immutable(&mut self, target: &Id20) -> Option<ImmutableItem> {
142        self.immutable.get_mut(target).map(|e| {
143            e.touch();
144            e.item.clone()
145        })
146    }
147
148    fn put_immutable(&mut self, item: ImmutableItem) -> bool {
149        let target = item.target;
150
151        if self.immutable.contains_key(&target) {
152            // Already have it, just touch
153            if let Some(entry) = self.immutable.get_mut(&target) {
154                entry.touch();
155            }
156            return false;
157        }
158
159        // Evict if at capacity
160        while self.total_count() >= self.max_items {
161            self.evict_lru();
162        }
163
164        self.immutable.insert(target, StoredEntry::new(item));
165        true
166    }
167
168    fn get_mutable(&mut self, public_key: &[u8; 32], salt: &[u8]) -> Option<MutableItem> {
169        let key = MutableKey {
170            public_key: *public_key,
171            salt: salt.to_vec(),
172        };
173        self.mutable.get_mut(&key).map(|e| {
174            e.touch();
175            e.item.clone()
176        })
177    }
178
179    fn get_mutable_by_target(&mut self, target: &Id20) -> Option<MutableItem> {
180        // Linear scan: check each stored mutable item's computed target
181        for entry in self.mutable.values_mut() {
182            if compute_mutable_target(&entry.item.public_key, &entry.item.salt) == *target {
183                entry.touch();
184                return Some(entry.item.clone());
185            }
186        }
187        None
188    }
189
190    fn put_mutable(&mut self, item: MutableItem) -> bool {
191        let key = MutableKey {
192            public_key: item.public_key,
193            salt: item.salt.clone(),
194        };
195
196        // Enforce sequence number monotonicity
197        if let Some(existing) = self.mutable.get(&key)
198            && item.seq <= existing.item.seq
199        {
200            return false;
201        }
202
203        let is_new = !self.mutable.contains_key(&key);
204
205        if is_new {
206            // Evict if at capacity
207            while self.total_count() >= self.max_items {
208                self.evict_lru();
209            }
210        }
211
212        self.mutable.insert(key, StoredEntry::new(item));
213        true
214    }
215
216    fn count(&self) -> (usize, usize) {
217        (self.immutable.len(), self.mutable.len())
218    }
219
220    /// M175 BUG FIX: previously used `Instant::now() - lifetime`, which panics
221    /// when `lifetime` exceeds process uptime. Compare elapsed time forward
222    /// via `Instant::duration_since` instead.
223    fn expire(&mut self, lifetime: Duration) {
224        let now = Instant::now();
225        self.immutable
226            .retain(|_, e| now.duration_since(e.last_touched) <= lifetime);
227        self.mutable
228            .retain(|_, e| now.duration_since(e.last_touched) <= lifetime);
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::bep44::ImmutableItem;
236    use ed25519_dalek::SigningKey;
237
238    fn make_immutable(data: &[u8]) -> ImmutableItem {
239        ImmutableItem::new(data.to_vec()).unwrap()
240    }
241
242    fn make_mutable(seed: u8, seq: i64, salt: &[u8]) -> MutableItem {
243        let keypair = SigningKey::from_bytes(&[seed; 32]);
244        MutableItem::create(&keypair, b"4:test".to_vec(), seq, salt.to_vec()).unwrap()
245    }
246
247    #[test]
248    fn immutable_put_get_round_trip() {
249        let mut store = InMemoryDhtStorage::new(100);
250        let item = make_immutable(b"5:hello");
251        let target = item.target;
252        assert!(store.put_immutable(item.clone()));
253        assert_eq!(store.get_immutable(&target), Some(item));
254    }
255
256    #[test]
257    fn immutable_duplicate_returns_false() {
258        let mut store = InMemoryDhtStorage::new(100);
259        let item = make_immutable(b"5:hello");
260        assert!(store.put_immutable(item.clone()));
261        assert!(!store.put_immutable(item));
262    }
263
264    #[test]
265    fn immutable_get_missing_returns_none() {
266        let mut store = InMemoryDhtStorage::new(100);
267        assert_eq!(store.get_immutable(&Id20::ZERO), None);
268    }
269
270    #[test]
271    fn mutable_put_get_round_trip() {
272        let mut store = InMemoryDhtStorage::new(100);
273        let item = make_mutable(1, 1, b"");
274        let pk = item.public_key;
275        assert!(store.put_mutable(item.clone()));
276        assert_eq!(store.get_mutable(&pk, b""), Some(item));
277    }
278
279    #[test]
280    fn mutable_sequence_monotonicity() {
281        let mut store = InMemoryDhtStorage::new(100);
282        let item1 = make_mutable(1, 5, b"");
283        let item2_old = make_mutable(1, 3, b"");
284        let item3_same = make_mutable(1, 5, b"");
285        let item4_new = make_mutable(1, 6, b"");
286
287        assert!(store.put_mutable(item1));
288        assert!(!store.put_mutable(item2_old)); // older seq rejected
289        assert!(!store.put_mutable(item3_same)); // same seq rejected
290        assert!(store.put_mutable(item4_new.clone())); // newer seq accepted
291
292        let pk = item4_new.public_key;
293        let stored = store.get_mutable(&pk, b"").unwrap();
294        assert_eq!(stored.seq, 6);
295    }
296
297    #[test]
298    fn mutable_salt_separates_items() {
299        let mut store = InMemoryDhtStorage::new(100);
300        let item_a = make_mutable(1, 1, b"salt-a");
301        let item_b = make_mutable(1, 1, b"salt-b");
302        let pk = item_a.public_key;
303
304        assert!(store.put_mutable(item_a.clone()));
305        assert!(store.put_mutable(item_b.clone()));
306        assert_eq!(store.count(), (0, 2));
307        assert_eq!(store.get_mutable(&pk, b"salt-a"), Some(item_a));
308        assert_eq!(store.get_mutable(&pk, b"salt-b"), Some(item_b));
309    }
310
311    #[test]
312    fn lru_eviction_at_capacity() {
313        let mut store = InMemoryDhtStorage::new(3);
314
315        let item1 = make_immutable(b"1:a");
316        let item2 = make_immutable(b"1:b");
317        let item3 = make_immutable(b"1:c");
318        let target1 = item1.target;
319
320        store.put_immutable(item1);
321        store.put_immutable(item2);
322        store.put_immutable(item3);
323        assert_eq!(store.total_count(), 3);
324
325        // Adding a 4th should evict the oldest (item1)
326        let item4 = make_immutable(b"1:d");
327        store.put_immutable(item4);
328        assert_eq!(store.total_count(), 3);
329        assert!(store.get_immutable(&target1).is_none());
330    }
331
332    #[test]
333    fn expire_removes_old_items() {
334        let mut store = InMemoryDhtStorage::new(100);
335        store.put_immutable(make_immutable(b"1:a"));
336        store.put_mutable(make_mutable(1, 1, b""));
337        assert_eq!(store.count(), (1, 1));
338
339        // Expire with zero lifetime removes everything
340        store.expire(Duration::from_secs(0));
341        assert_eq!(store.count(), (0, 0));
342    }
343
344    #[test]
345    fn count_includes_both_types() {
346        let mut store = InMemoryDhtStorage::new(100);
347        store.put_immutable(make_immutable(b"1:a"));
348        store.put_mutable(make_mutable(1, 1, b""));
349        assert_eq!(store.count(), (1, 1));
350    }
351
352    #[test]
353    fn default_max_items_is_700() {
354        let store = InMemoryDhtStorage::default();
355        assert_eq!(store.max_items, 700);
356    }
357
358    #[test]
359    fn get_mutable_by_target_finds_item() {
360        let mut store = InMemoryDhtStorage::new(100);
361        let item = make_mutable(1, 1, b"my-salt");
362        let target = compute_mutable_target(&item.public_key, &item.salt);
363        store.put_mutable(item.clone());
364
365        let found = store.get_mutable_by_target(&target);
366        assert_eq!(found, Some(item));
367    }
368
369    #[test]
370    fn get_mutable_by_target_returns_none_for_missing() {
371        let mut store = InMemoryDhtStorage::new(100);
372        assert_eq!(store.get_mutable_by_target(&Id20::ZERO), None);
373    }
374
375    #[test]
376    fn get_immutable_touches_entry() {
377        let mut store = InMemoryDhtStorage::new(3);
378
379        let item1 = make_immutable(b"1:a");
380        let item2 = make_immutable(b"1:b");
381        let item3 = make_immutable(b"1:c");
382        let target1 = item1.target;
383        let target2 = item2.target;
384
385        store.put_immutable(item1);
386        store.put_immutable(item2);
387        store.put_immutable(item3);
388
389        // Touch item1 to make it recent
390        store.get_immutable(&target1);
391
392        // Adding a 4th should evict item2 (oldest untouched), not item1
393        let item4 = make_immutable(b"1:d");
394        store.put_immutable(item4);
395        assert_eq!(store.total_count(), 3);
396        assert!(store.get_immutable(&target1).is_some());
397        assert!(store.get_immutable(&target2).is_none());
398    }
399}