Skip to main content

ant_quic/bootstrap_cache/
cache.rs

1// Copyright 2024 Saorsa Labs Ltd.
2//
3// This Saorsa Network Software is licensed under the General Public License (GPL), version 3.
4// Please see the file LICENSE-GPL, or visit <http://www.gnu.org/licenses/> for the full text.
5//
6// Full details available at https://saorsalabs.com/licenses
7
8//! Main bootstrap cache implementation.
9
10use super::config::BootstrapCacheConfig;
11use super::entry::{CachedPeer, ConnectionOutcome, PeerCapabilities, PeerSource};
12use super::persistence::{CacheData, CachePersistence};
13use super::selection::select_epsilon_greedy;
14use crate::nat_traversal_api::PeerId;
15use std::net::SocketAddr;
16use std::sync::Arc;
17use std::time::{Instant, SystemTime};
18use tokio::sync::{RwLock, broadcast};
19use tracing::{debug, info, warn};
20
21/// Bootstrap cache event for notifications
22#[derive(Debug, Clone)]
23pub enum CacheEvent {
24    /// Cache was updated (peers added/removed/modified)
25    Updated {
26        /// Current peer count
27        peer_count: usize,
28    },
29    /// Cache was saved to disk
30    Saved,
31    /// Cache was merged from another source
32    Merged {
33        /// Number of peers added from merge
34        added: usize,
35    },
36    /// Stale peers were cleaned up
37    Cleaned {
38        /// Number of peers removed
39        removed: usize,
40    },
41}
42
43/// Cache statistics
44#[derive(Debug, Clone, Default)]
45pub struct CacheStats {
46    /// Total number of cached peers
47    pub total_peers: usize,
48    /// Peers that support relay
49    pub relay_peers: usize,
50    /// Peers that support NAT coordination
51    pub coordinator_peers: usize,
52    /// Peers that support dual-stack (IPv4 + IPv6) bridging
53    pub dual_stack_relay_peers: usize,
54    /// Average quality score across all peers
55    pub average_quality: f64,
56    /// Number of untested peers
57    pub untested_peers: usize,
58}
59
60/// Greedy bootstrap cache with quality-based peer selection.
61///
62/// This cache stores peer information with quality metrics and provides
63/// epsilon-greedy selection to balance exploitation (using known-good peers)
64/// with exploration (trying new peers to discover potentially better ones).
65#[derive(Debug)]
66pub struct BootstrapCache {
67    config: BootstrapCacheConfig,
68    data: Arc<RwLock<CacheData>>,
69    /// `None` when the cache is in-memory only (`config.persist == false`).
70    persistence: Option<CachePersistence>,
71    event_tx: broadcast::Sender<CacheEvent>,
72    last_save: Arc<RwLock<Instant>>,
73    last_cleanup: Arc<RwLock<Instant>>,
74}
75
76impl BootstrapCache {
77    // ... (existing open/subscribe methods)
78    /// Open or create a bootstrap cache.
79    ///
80    /// Loads existing cache data from disk if available, otherwise starts fresh.
81    /// With `config.persist == false` the cache is in-memory only: nothing is
82    /// loaded, nothing is ever written, and no cache directory is created.
83    pub async fn open(config: BootstrapCacheConfig) -> std::io::Result<Self> {
84        let persistence = if config.persist {
85            Some(CachePersistence::new(
86                &config.cache_dir,
87                config.enable_file_locking,
88            )?)
89        } else {
90            None
91        };
92        let data = match &persistence {
93            Some(p) => p.load()?,
94            None => CacheData::new(super::persistence::generate_instance_id()),
95        };
96        let (event_tx, _) = broadcast::channel(256);
97        let now = Instant::now();
98
99        info!(
100            "Opened bootstrap cache with {} peers (persist: {})",
101            data.peers.len(),
102            config.persist
103        );
104
105        Ok(Self {
106            config,
107            data: Arc::new(RwLock::new(data)),
108            persistence,
109            event_tx,
110            last_save: Arc::new(RwLock::new(now)),
111            last_cleanup: Arc::new(RwLock::new(now)),
112        })
113    }
114
115    /// Subscribe to cache events
116    pub fn subscribe(&self) -> broadcast::Receiver<CacheEvent> {
117        self.event_tx.subscribe()
118    }
119
120    /// Get the number of cached peers
121    pub async fn peer_count(&self) -> usize {
122        self.data.read().await.peers.len()
123    }
124
125    /// Get a specific peer from the cache
126    pub async fn get_peer(&self, peer_id: &PeerId) -> Option<CachedPeer> {
127        let mut data = self.data.write().await;
128        let peer = data.peers.get_mut(&peer_id.0)?;
129        peer.capabilities
130            .refresh_direct_capabilities(self.config.reachability_ttl, SystemTime::now());
131        peer.calculate_quality(&self.config.weights);
132        Some(peer.clone())
133    }
134
135    fn refresh_cached_peer(&self, peer: &mut CachedPeer, now: SystemTime) {
136        peer.capabilities
137            .refresh_direct_capabilities(self.config.reachability_ttl, now);
138        peer.calculate_quality(&self.config.weights);
139    }
140
141    /// Select peers for bootstrap using epsilon-greedy strategy.
142    ///
143    /// Returns up to `count` peers, balancing exploitation of known-good peers
144    /// with exploration of untested peers based on the configured epsilon.
145    pub async fn select_peers(&self, count: usize) -> Vec<CachedPeer> {
146        let mut data = self.data.write().await;
147        let now = SystemTime::now();
148        for peer in data.peers.values_mut() {
149            self.refresh_cached_peer(peer, now);
150        }
151        let peers: Vec<CachedPeer> = data.peers.values().cloned().collect();
152        drop(data);
153
154        select_epsilon_greedy(&peers, count, self.config.epsilon)
155            .into_iter()
156            .cloned()
157            .collect()
158    }
159
160    /// Select peers that support relay functionality.
161    ///
162    /// Returns peers sorted by quality score, preferring observed relay capability.
163    pub async fn select_relay_peers(&self, count: usize) -> Vec<CachedPeer> {
164        let mut data = self.data.write().await;
165        let now = SystemTime::now();
166        for peer in data.peers.values_mut() {
167            self.refresh_cached_peer(peer, now);
168        }
169        let peers: Vec<CachedPeer> = data.peers.values().cloned().collect();
170        drop(data);
171
172        super::selection::select_with_capabilities(&peers, count, true, false)
173            .into_iter()
174            .cloned()
175            .collect()
176    }
177
178    /// Select peers that support NAT coordination.
179    ///
180    /// Returns peers sorted by quality score, preferring observed coordination capability.
181    pub async fn select_coordinators(&self, count: usize) -> Vec<CachedPeer> {
182        let mut data = self.data.write().await;
183        let now = SystemTime::now();
184        for peer in data.peers.values_mut() {
185            self.refresh_cached_peer(peer, now);
186        }
187        let peers: Vec<CachedPeer> = data.peers.values().cloned().collect();
188        drop(data);
189
190        super::selection::select_with_capabilities(&peers, count, false, true)
191            .into_iter()
192            .cloned()
193            .collect()
194    }
195
196    /// Select relay peers that can reach a target IP version.
197    ///
198    /// Returns relays sorted by quality that can bridge traffic to the target.
199    /// Dual-stack relays are preferred as they can reach any target.
200    ///
201    /// # Arguments
202    /// * `count` - Maximum number of relays to return
203    /// * `target` - The target address to reach
204    /// * `prefer_dual_stack` - If true, prioritize dual-stack relays
205    pub async fn select_relays_for_target(
206        &self,
207        count: usize,
208        target: &std::net::SocketAddr,
209        prefer_dual_stack: bool,
210    ) -> Vec<CachedPeer> {
211        use super::selection::select_relays_for_target;
212
213        let mut data = self.data.write().await;
214        let now = SystemTime::now();
215        for peer in data.peers.values_mut() {
216            self.refresh_cached_peer(peer, now);
217        }
218        let peers: Vec<CachedPeer> = data.peers.values().cloned().collect();
219        drop(data);
220
221        select_relays_for_target(&peers, count, *target, prefer_dual_stack)
222            .into_iter()
223            .cloned()
224            .collect()
225    }
226
227    /// Select relay peers that support dual-stack (IPv4 + IPv6) bridging.
228    ///
229    /// These peers are valuable for bridging between IPv4-only and IPv6-only networks.
230    pub async fn select_dual_stack_relays(&self, count: usize) -> Vec<CachedPeer> {
231        use super::selection::select_dual_stack_relays;
232
233        let mut data = self.data.write().await;
234        let now = SystemTime::now();
235        for peer in data.peers.values_mut() {
236            self.refresh_cached_peer(peer, now);
237        }
238        let peers: Vec<CachedPeer> = data.peers.values().cloned().collect();
239        drop(data);
240
241        select_dual_stack_relays(&peers, count)
242            .into_iter()
243            .cloned()
244            .collect()
245    }
246
247    /// Add or update a peer in the cache.
248    ///
249    /// If the cache is at capacity, evicts the lowest quality peers.
250    pub async fn upsert(&self, peer: CachedPeer) {
251        let mut data = self.data.write().await;
252
253        // Evict lowest quality if at capacity
254        if data.peers.len() >= self.config.max_peers && !data.peers.contains_key(&peer.peer_id.0) {
255            self.evict_lowest_quality(&mut data);
256        }
257
258        data.peers.insert(peer.peer_id.0, peer);
259
260        let count = data.peers.len();
261        drop(data);
262
263        let _ = self
264            .event_tx
265            .send(CacheEvent::Updated { peer_count: count });
266    }
267
268    /// Add a seed peer (user-provided bootstrap node).
269    pub async fn add_seed(&self, peer_id: PeerId, addresses: Vec<SocketAddr>) {
270        let peer = CachedPeer::new(peer_id, addresses, PeerSource::Seed);
271        self.upsert(peer).await;
272    }
273
274    /// Add a peer discovered from an active connection.
275    pub async fn add_from_connection(
276        &self,
277        peer_id: PeerId,
278        addresses: Vec<SocketAddr>,
279        caps: Option<PeerCapabilities>,
280    ) {
281        let mut peer = CachedPeer::new(peer_id, addresses, PeerSource::Connection);
282        if let Some(caps) = caps {
283            peer.capabilities = caps;
284        }
285        self.upsert(peer).await;
286    }
287
288    /// Record a connection attempt result.
289    pub async fn record_outcome(&self, peer_id: &PeerId, outcome: ConnectionOutcome) {
290        let mut data = self.data.write().await;
291
292        if let Some(peer) = data.peers.get_mut(&peer_id.0) {
293            if outcome.success {
294                peer.record_success(
295                    outcome.rtt_ms.unwrap_or(100),
296                    outcome.capabilities_discovered,
297                );
298            } else {
299                peer.record_failure();
300            }
301
302            // Recalculate quality score
303            peer.calculate_quality(&self.config.weights);
304        }
305    }
306
307    /// Record successful connection.
308    pub async fn record_success(&self, peer_id: &PeerId, rtt_ms: u32) {
309        self.record_outcome(
310            peer_id,
311            ConnectionOutcome {
312                success: true,
313                rtt_ms: Some(rtt_ms),
314                capabilities_discovered: None,
315            },
316        )
317        .await;
318    }
319
320    /// Record failed connection.
321    pub async fn record_failure(&self, peer_id: &PeerId) {
322        self.record_outcome(
323            peer_id,
324            ConnectionOutcome {
325                success: false,
326                rtt_ms: None,
327                capabilities_discovered: None,
328            },
329        )
330        .await;
331    }
332
333    /// Update peer capabilities.
334    pub async fn update_capabilities(&self, peer_id: &PeerId, caps: PeerCapabilities) {
335        let mut data = self.data.write().await;
336
337        if let Some(peer) = data.peers.get_mut(&peer_id.0) {
338            peer.capabilities = caps;
339            peer.calculate_quality(&self.config.weights);
340        }
341    }
342
343    /// Record that a peer was directly reachable from this node.
344    ///
345    /// This is observer-scoped evidence. A peer is considered suitable for
346    /// relay/bootstrap/coordinator selection only after a direct connection to
347    /// one of its addresses succeeds without coordinator or relay assistance.
348    pub async fn observe_direct_reachability(&self, peer_id: PeerId, address: SocketAddr) {
349        let mut data = self.data.write().await;
350        let now = SystemTime::now();
351
352        let peer = data
353            .peers
354            .entry(peer_id.0)
355            .or_insert_with(|| CachedPeer::new(peer_id, vec![address], PeerSource::Connection));
356
357        if !peer.addresses.contains(&address) {
358            peer.addresses.push(address);
359        }
360
361        peer.last_seen = now;
362        peer.last_attempt = Some(now);
363        peer.stats.success_count = peer.stats.success_count.saturating_add(1);
364        peer.capabilities.record_direct_observation(address, now);
365        self.refresh_cached_peer(peer, now);
366
367        let count = data.peers.len();
368        drop(data);
369
370        let _ = self
371            .event_tx
372            .send(CacheEvent::Updated { peer_count: count });
373    }
374
375    /// Get a specific peer.
376    pub async fn get(&self, peer_id: &PeerId) -> Option<CachedPeer> {
377        let mut data = self.data.write().await;
378        let peer = data.peers.get_mut(&peer_id.0)?;
379        self.refresh_cached_peer(peer, SystemTime::now());
380        Some(peer.clone())
381    }
382
383    /// Update the address validation token for a peer
384    pub async fn update_token(&self, peer_id: PeerId, token: Vec<u8>) {
385        let mut data = self.data.write().await;
386        if let Some(peer) = data.peers.get_mut(&peer_id.0) {
387            peer.token = Some(token);
388        }
389    }
390
391    /// Get all tokens from cached peers (for initializing TokenStore)
392    pub async fn get_all_tokens(&self) -> std::collections::HashMap<PeerId, Vec<u8>> {
393        self.data
394            .read()
395            .await
396            .peers
397            .values()
398            .filter_map(|p| p.token.clone().map(|t| (p.peer_id, t)))
399            .collect()
400    }
401
402    /// Check if peer exists in cache.
403    pub async fn contains(&self, peer_id: &PeerId) -> bool {
404        self.data.read().await.peers.contains_key(&peer_id.0)
405    }
406
407    /// Remove a peer from cache.
408    pub async fn remove(&self, peer_id: &PeerId) -> Option<CachedPeer> {
409        self.data.write().await.peers.remove(&peer_id.0)
410    }
411
412    /// Save cache to disk. No-op for in-memory caches (`persist: false`).
413    pub async fn save(&self) -> std::io::Result<()> {
414        let Some(persistence) = &self.persistence else {
415            return Ok(());
416        };
417        let mut data = self.data.write().await;
418
419        if data.peers.len() < self.config.min_peers_to_save {
420            debug!(
421                "Skipping save: only {} peers (min: {})",
422                data.peers.len(),
423                self.config.min_peers_to_save
424            );
425            return Ok(());
426        }
427
428        persistence.save(&mut data)?;
429
430        drop(data);
431        *self.last_save.write().await = Instant::now();
432        let _ = self.event_tx.send(CacheEvent::Saved);
433
434        Ok(())
435    }
436
437    /// Cleanup stale peers.
438    ///
439    /// Removes peers that haven't been seen within the stale threshold.
440    /// Returns the number of peers removed.
441    pub async fn cleanup_stale(&self) -> usize {
442        let mut data = self.data.write().await;
443        let initial_count = data.peers.len();
444
445        data.peers
446            .retain(|_, peer| !peer.is_stale(self.config.stale_threshold));
447
448        let removed = initial_count - data.peers.len();
449
450        if removed > 0 {
451            info!("Cleaned up {} stale peers", removed);
452            let _ = self.event_tx.send(CacheEvent::Cleaned { removed });
453        }
454
455        drop(data);
456        *self.last_cleanup.write().await = Instant::now();
457
458        removed
459    }
460
461    /// Recalculate quality scores for all peers.
462    pub async fn recalculate_quality(&self) {
463        let mut data = self.data.write().await;
464
465        for peer in data.peers.values_mut() {
466            peer.calculate_quality(&self.config.weights);
467        }
468
469        let count = data.peers.len();
470        let _ = self
471            .event_tx
472            .send(CacheEvent::Updated { peer_count: count });
473    }
474
475    /// Get cache statistics.
476    pub async fn stats(&self) -> CacheStats {
477        let mut data = self.data.write().await;
478        let now = SystemTime::now();
479        for peer in data.peers.values_mut() {
480            self.refresh_cached_peer(peer, now);
481        }
482
483        let relay_count = data
484            .peers
485            .values()
486            .filter(|p| p.capabilities.supports_relay)
487            .count();
488        let coord_count = data
489            .peers
490            .values()
491            .filter(|p| p.capabilities.supports_coordination)
492            .count();
493        let dual_stack_count = data
494            .peers
495            .values()
496            .filter(|p| p.capabilities.supports_relay && p.capabilities.supports_dual_stack())
497            .count();
498        let untested = data
499            .peers
500            .values()
501            .filter(|p| p.stats.success_count + p.stats.failure_count == 0)
502            .count();
503        let avg_quality = if data.peers.is_empty() {
504            0.0
505        } else {
506            data.peers.values().map(|p| p.quality_score).sum::<f64>() / data.peers.len() as f64
507        };
508
509        CacheStats {
510            total_peers: data.peers.len(),
511            relay_peers: relay_count,
512            coordinator_peers: coord_count,
513            dual_stack_relay_peers: dual_stack_count,
514            average_quality: avg_quality,
515            untested_peers: untested,
516        }
517    }
518
519    /// Start background maintenance tasks.
520    ///
521    /// Spawns a task that periodically:
522    /// - Saves the cache to disk (no-op for in-memory caches)
523    /// - Cleans up stale peers
524    /// - Recalculates quality scores
525    ///
526    /// Returns a handle that can be used to cancel the task.
527    ///
528    /// Note: [`crate::p2p_endpoint::P2pEndpoint`] starts maintenance
529    /// automatically for its own cache — embedders sharing the endpoint's
530    /// cache (via [`crate::Node::bootstrap_cache`]) must not call this again.
531    pub fn start_maintenance(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
532        let cache = self;
533
534        tokio::spawn(async move {
535            let mut save_interval = tokio::time::interval(cache.config.save_interval);
536            let mut cleanup_interval = tokio::time::interval(cache.config.cleanup_interval);
537            let mut quality_interval = tokio::time::interval(cache.config.quality_update_interval);
538
539            loop {
540                tokio::select! {
541                    _ = save_interval.tick() => {
542                        if let Err(e) = cache.save().await {
543                            warn!("Failed to save cache: {}", e);
544                        }
545                    }
546                    _ = cleanup_interval.tick() => {
547                        cache.cleanup_stale().await;
548                    }
549                    _ = quality_interval.tick() => {
550                        cache.recalculate_quality().await;
551                    }
552                }
553            }
554        })
555    }
556
557    /// Get all cached peers (for export/debug).
558    pub async fn all_peers(&self) -> Vec<CachedPeer> {
559        let mut data = self.data.write().await;
560        let now = SystemTime::now();
561        for peer in data.peers.values_mut() {
562            self.refresh_cached_peer(peer, now);
563        }
564        data.peers.values().cloned().collect()
565    }
566
567    /// Get the configuration.
568    pub fn config(&self) -> &BootstrapCacheConfig {
569        &self.config
570    }
571
572    fn evict_lowest_quality(&self, data: &mut CacheData) {
573        let evict_count = (self.config.max_peers / 20).max(1); // Evict ~5%
574
575        let mut sorted: Vec<_> = data.peers.iter().collect();
576        sorted.sort_by(|a, b| {
577            a.1.quality_score
578                .partial_cmp(&b.1.quality_score)
579                .unwrap_or(std::cmp::Ordering::Equal)
580        });
581
582        let to_remove: Vec<[u8; 32]> = sorted
583            .into_iter()
584            .take(evict_count)
585            .map(|(id, _)| *id)
586            .collect();
587
588        for id in to_remove {
589            data.peers.remove(&id);
590        }
591
592        debug!("Evicted {} lowest quality peers", evict_count);
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use tempfile::TempDir;
600
601    async fn create_test_cache(temp_dir: &TempDir) -> BootstrapCache {
602        let config = BootstrapCacheConfig::builder()
603            .cache_dir(temp_dir.path())
604            .max_peers(100)
605            .epsilon(0.0) // Pure exploitation for predictable tests
606            .min_peers_to_save(1)
607            .build();
608
609        BootstrapCache::open(config).await.unwrap()
610    }
611
612    /// `persist: false` must make the cache purely in-memory: opening in a
613    /// directory that does not exist succeeds, and adding peers + saving
614    /// never creates the directory, cache file, or lock file. This is what
615    /// lets multiple node instances on one host opt out of the shared
616    /// default cache dir without cross-instance pollution.
617    #[tokio::test]
618    async fn in_memory_cache_never_touches_disk() {
619        let temp_dir = TempDir::new().unwrap();
620        let cache_dir = temp_dir.path().join("does-not-exist");
621        let config = BootstrapCacheConfig::builder()
622            .cache_dir(&cache_dir)
623            .min_peers_to_save(1)
624            .persist(false)
625            .build();
626
627        let cache = BootstrapCache::open(config).await.unwrap();
628        cache
629            .add_seed(PeerId([7u8; 32]), vec!["127.0.0.1:9000".parse().unwrap()])
630            .await;
631        cache.save().await.unwrap();
632
633        assert!(
634            !cache_dir.exists(),
635            "in-memory cache must not create its cache directory"
636        );
637        // Runtime behaviour is unchanged: the peer is still selectable.
638        assert_eq!(cache.peer_count().await, 1);
639    }
640
641    #[tokio::test]
642    async fn test_cache_creation() {
643        let temp_dir = TempDir::new().unwrap();
644        let cache = create_test_cache(&temp_dir).await;
645        assert_eq!(cache.peer_count().await, 0);
646    }
647
648    #[tokio::test]
649    async fn test_add_and_get() {
650        let temp_dir = TempDir::new().unwrap();
651        let cache = create_test_cache(&temp_dir).await;
652
653        let peer_id = PeerId([1u8; 32]);
654        cache
655            .add_seed(peer_id, vec!["127.0.0.1:9000".parse().unwrap()])
656            .await;
657
658        assert_eq!(cache.peer_count().await, 1);
659        assert!(cache.contains(&peer_id).await);
660
661        let peer = cache.get(&peer_id).await.unwrap();
662        assert_eq!(peer.addresses.len(), 1);
663    }
664
665    #[tokio::test]
666    async fn test_select_peers() {
667        let temp_dir = TempDir::new().unwrap();
668        let cache = create_test_cache(&temp_dir).await;
669
670        // Add peers with different quality
671        for i in 0..10usize {
672            let peer_id = PeerId([i as u8; 32]);
673            let mut peer = CachedPeer::new(
674                peer_id,
675                vec![format!("127.0.0.1:{}", 9000 + i).parse().unwrap()],
676                PeerSource::Seed,
677            );
678            peer.quality_score = i as f64 / 10.0;
679            cache.upsert(peer).await;
680        }
681
682        // Select should return highest quality first (epsilon=0)
683        let selected = cache.select_peers(5).await;
684        assert_eq!(selected.len(), 5);
685        assert!(selected[0].quality_score >= selected[4].quality_score);
686    }
687
688    #[tokio::test]
689    async fn test_persistence() {
690        let temp_dir = TempDir::new().unwrap();
691
692        // Create and populate cache
693        {
694            let cache = create_test_cache(&temp_dir).await;
695            cache
696                .add_seed(PeerId([1; 32]), vec!["127.0.0.1:9000".parse().unwrap()])
697                .await;
698            cache.save().await.unwrap();
699        }
700
701        // Reopen and verify
702        {
703            let cache = create_test_cache(&temp_dir).await;
704            assert_eq!(cache.peer_count().await, 1);
705            assert!(cache.contains(&PeerId([1; 32])).await);
706        }
707    }
708
709    #[tokio::test]
710    async fn test_persisted_explicit_assist_hints_survive_reopen() {
711        let temp_dir = TempDir::new().unwrap();
712        let peer_id = PeerId([9; 32]);
713        let peer_addr: SocketAddr = "198.51.100.9:9000".parse().unwrap();
714
715        {
716            let cache = create_test_cache(&temp_dir).await;
717            let mut peer = CachedPeer::new(peer_id, vec![peer_addr], PeerSource::Merge);
718            peer.capabilities.record_assist_hints(true, true);
719            cache.upsert(peer).await;
720            cache.save().await.unwrap();
721        }
722
723        {
724            let cache = create_test_cache(&temp_dir).await;
725            let peer = cache.get(&peer_id).await.expect("peer should reload");
726            assert!(peer.capabilities.hinted_supports_relay);
727            assert!(peer.capabilities.hinted_supports_coordination);
728            assert!(peer.capabilities.supports_relay);
729            assert!(peer.capabilities.supports_coordination);
730            assert!(peer.addresses.contains(&peer_addr));
731        }
732    }
733
734    #[tokio::test]
735    async fn test_quality_scoring() {
736        let temp_dir = TempDir::new().unwrap();
737        let cache = create_test_cache(&temp_dir).await;
738
739        let peer_id = PeerId([1; 32]);
740        cache
741            .add_seed(peer_id, vec!["127.0.0.1:9000".parse().unwrap()])
742            .await;
743
744        // Initial quality should be neutral
745        let peer = cache.get(&peer_id).await.unwrap();
746        let initial_quality = peer.quality_score;
747
748        // Record successes - quality should improve
749        for _ in 0..5 {
750            cache.record_success(&peer_id, 50).await;
751        }
752
753        let peer = cache.get(&peer_id).await.unwrap();
754        assert!(peer.quality_score > initial_quality);
755        assert!(peer.success_rate() > 0.9);
756    }
757
758    #[tokio::test]
759    async fn test_eviction() {
760        let temp_dir = TempDir::new().unwrap();
761        let config = BootstrapCacheConfig::builder()
762            .cache_dir(temp_dir.path())
763            .max_peers(10)
764            .build();
765
766        let cache = BootstrapCache::open(config).await.unwrap();
767
768        // Add 15 peers
769        for i in 0..15u8 {
770            let peer_id = PeerId([i; 32]);
771            let mut peer = CachedPeer::new(
772                peer_id,
773                vec![format!("127.0.0.1:{}", 9000 + i as u16).parse().unwrap()],
774                PeerSource::Seed,
775            );
776            peer.quality_score = i as f64 / 15.0;
777            cache.upsert(peer).await;
778        }
779
780        // Should have evicted some
781        assert!(cache.peer_count().await <= 10);
782    }
783
784    #[tokio::test]
785    async fn test_stats() {
786        let temp_dir = TempDir::new().unwrap();
787        let cache = create_test_cache(&temp_dir).await;
788
789        // Add some peers with capabilities
790        let mut peer1 = CachedPeer::new(
791            PeerId([1; 32]),
792            vec!["203.0.113.1:9001".parse().unwrap()],
793            PeerSource::Seed,
794        );
795        peer1
796            .capabilities
797            .record_direct_observation("203.0.113.1:9001".parse().unwrap(), SystemTime::now());
798        cache.upsert(peer1).await;
799
800        let mut peer2 = CachedPeer::new(
801            PeerId([2; 32]),
802            vec!["198.51.100.2:9002".parse().unwrap()],
803            PeerSource::Seed,
804        );
805        peer2
806            .capabilities
807            .record_direct_observation("198.51.100.2:9002".parse().unwrap(), SystemTime::now());
808        cache.upsert(peer2).await;
809
810        cache
811            .add_seed(PeerId([3; 32]), vec!["127.0.0.1:9003".parse().unwrap()])
812            .await;
813
814        let stats = cache.stats().await;
815        assert_eq!(stats.total_peers, 3);
816        assert_eq!(stats.relay_peers, 2);
817        assert_eq!(stats.coordinator_peers, 2);
818        assert_eq!(stats.untested_peers, 3);
819    }
820
821    #[tokio::test]
822    async fn test_select_relay_peers() {
823        let temp_dir = TempDir::new().unwrap();
824        let cache = create_test_cache(&temp_dir).await;
825
826        // Add mix of relay and non-relay peers
827        for i in 0..10u8 {
828            let addr: SocketAddr = format!("127.0.0.1:{}", 9000 + i as u16).parse().unwrap();
829            let mut peer = CachedPeer::new(PeerId([i; 32]), vec![addr], PeerSource::Seed);
830            if i % 2 == 0 {
831                peer.capabilities
832                    .record_direct_observation(addr, SystemTime::now());
833            }
834            peer.quality_score = i as f64 / 10.0;
835            cache.upsert(peer).await;
836        }
837
838        // v0.13.0+: Measure, don't trust - returns all peers but prefers
839        // those with observed relay capability.
840        let relays = cache.select_relay_peers(10).await;
841        assert_eq!(relays.len(), 10); // All peers are candidates
842
843        // First 5 should have direct reachability evidence (prioritized)
844        let relay_capable = relays
845            .iter()
846            .take(5)
847            .filter(|p| p.capabilities.direct_reachability_scope.is_some())
848            .count();
849        assert_eq!(
850            relay_capable, 5,
851            "Scoped direct-evidence peers should be first"
852        );
853    }
854
855    #[tokio::test]
856    async fn test_observe_direct_reachability_preserves_local_scope_without_global_promotion() {
857        let temp_dir = TempDir::new().unwrap();
858        let cache = create_test_cache(&temp_dir).await;
859        let peer_id = PeerId([9; 32]);
860        let addr: SocketAddr = "192.168.1.50:9000".parse().unwrap();
861
862        cache.observe_direct_reachability(peer_id, addr).await;
863
864        let peer = cache.get(&peer_id).await.expect("peer inserted");
865        assert!(!peer.capabilities.supports_relay);
866        assert!(!peer.capabilities.supports_coordination);
867        assert_eq!(
868            peer.capabilities.direct_reachability_scope,
869            Some(crate::reachability::ReachabilityScope::LocalNetwork)
870        );
871        assert!(peer.addresses.contains(&addr));
872        assert!(
873            peer.capabilities
874                .reachable_addresses
875                .iter()
876                .any(|entry| entry.address == addr)
877        );
878        assert!(peer.success_rate() > 0.0);
879    }
880}