Skip to main content

saorsa_core/bootstrap/
cache.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is licensed under the MIT license <LICENSE-MIT or
4// https://opensource.org/licenses/MIT> or the Apache License, Version 2.0
5// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0>, at your
6// option. This file may not be copied, modified, or distributed except
7// according to those terms.
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under these licenses is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
13//! Close group cache for persisting trusted peers across restarts.
14//!
15//! Stores the node's close group peers with their addresses and trust scores
16//! in a single JSON file. Loaded on startup to warm the routing table with
17//! trusted peers, preserving close group consistency across restarts.
18
19use crate::PeerId;
20use crate::adaptive::trust::TrustRecord;
21use crate::address::MultiAddr;
22use serde::{Deserialize, Serialize};
23use std::io::Write as _;
24use std::path::Path;
25use std::time::Duration;
26
27/// Filename used for the close group cache inside the configured directory.
28const CACHE_FILENAME: &str = "close_group_cache.json";
29
30/// Maximum tolerated wall-clock skew for a cache timestamp in the future.
31/// Larger offsets are treated as invalid so a corrupt clock cannot make a
32/// cache appear fresh indefinitely.
33const MAX_FUTURE_TIMESTAMP_SKEW: Duration = Duration::from_secs(5 * 60);
34
35/// A peer in the persisted close group cache.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CachedCloseGroupPeer {
38    /// Peer identity
39    pub peer_id: PeerId,
40    /// Known addresses for this peer
41    pub addresses: Vec<MultiAddr>,
42    /// Trust score at time of save
43    pub trust: TrustRecord,
44}
45
46/// Persisted close group snapshot with trust scores.
47///
48/// Saved periodically during normal operation, after initial bootstrap,
49/// and on shutdown. Loaded on startup to reconnect to the same trusted
50/// close group peers, preserving close group consistency across restarts.
51/// Stale snapshots are skipped as Priority-0 bootstrap material according
52/// to the node's configured maximum cache age.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CloseGroupCache {
55    /// Close group peers with their trust scores
56    pub peers: Vec<CachedCloseGroupPeer>,
57    /// When this snapshot was saved (seconds since UNIX epoch)
58    pub saved_at_epoch_secs: u64,
59}
60
61impl CloseGroupCache {
62    /// Return whether this snapshot is older than `max_age` relative to
63    /// `now_epoch_secs`.
64    ///
65    /// `None` disables the maximum-age check, but timestamps materially in the
66    /// future are always rejected. Small offsets are tolerated for clock skew.
67    #[must_use]
68    pub fn is_stale(&self, now_epoch_secs: u64, max_age: Option<Duration>) -> bool {
69        let future_skew = self.saved_at_epoch_secs.saturating_sub(now_epoch_secs);
70        if future_skew > MAX_FUTURE_TIMESTAMP_SKEW.as_secs() {
71            return true;
72        }
73
74        max_age.is_some_and(|max_age| {
75            now_epoch_secs.saturating_sub(self.saved_at_epoch_secs) > max_age.as_secs()
76        })
77    }
78
79    /// Save the cache to `{dir}/close_group_cache.json`.
80    ///
81    /// Uses [`tempfile::NamedTempFile::persist`] for atomicity: the temp file
82    /// has a unique name (safe under concurrent saves) and `persist` is an
83    /// atomic rename on Unix and a replace-then-rename on Windows.
84    pub async fn save_to_dir(&self, dir: &Path) -> anyhow::Result<()> {
85        // Ensure the directory exists (first run or after cache dir deletion).
86        tokio::fs::create_dir_all(dir).await.map_err(|e| {
87            anyhow::anyhow!(
88                "failed to create close group cache directory {}: {e}",
89                dir.display()
90            )
91        })?;
92
93        let path = dir.join(CACHE_FILENAME);
94        let json = serde_json::to_string_pretty(self)
95            .map_err(|e| anyhow::anyhow!("failed to serialize close group cache: {e}"))?;
96
97        // Spawn blocking because NamedTempFile I/O is synchronous.
98        let dir_owned = dir.to_path_buf();
99        tokio::task::spawn_blocking(move || {
100            let mut tmp = tempfile::NamedTempFile::new_in(&dir_owned).map_err(|e| {
101                anyhow::anyhow!("failed to create temp file in {}: {e}", dir_owned.display())
102            })?;
103            tmp.write_all(json.as_bytes())
104                .map_err(|e| anyhow::anyhow!("failed to write close group cache: {e}"))?;
105            tmp.persist(&path).map_err(|e| {
106                anyhow::anyhow!(
107                    "failed to persist close group cache to {}: {e}",
108                    path.display()
109                )
110            })?;
111            Ok(())
112        })
113        .await
114        .map_err(|e| anyhow::anyhow!("close group cache save task panicked: {e}"))?
115    }
116
117    /// Load the cache from `{dir}/close_group_cache.json`.
118    ///
119    /// Returns `None` if the file doesn't exist (fresh start).
120    pub async fn load_from_dir(dir: &Path) -> anyhow::Result<Option<Self>> {
121        let path = dir.join(CACHE_FILENAME);
122        match tokio::fs::read_to_string(&path).await {
123            Ok(json) => {
124                let cache: Self = serde_json::from_str(&json)
125                    .map_err(|e| anyhow::anyhow!("failed to deserialize close group cache: {e}"))?;
126                Ok(Some(cache))
127            }
128            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
129            Err(e) => Err(anyhow::anyhow!(
130                "failed to read close group cache from {}: {e}",
131                path.display()
132            )),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::adaptive::trust::TrustRecord;
141
142    #[tokio::test]
143    async fn test_save_load_roundtrip() {
144        let cache = CloseGroupCache {
145            peers: vec![
146                CachedCloseGroupPeer {
147                    peer_id: PeerId::random(),
148                    addresses: vec!["/ip4/10.0.1.1/udp/9000/quic".parse().unwrap()],
149                    trust: TrustRecord {
150                        score: 0.8,
151                        last_updated_epoch_secs: 1_234_567_890,
152                    },
153                },
154                CachedCloseGroupPeer {
155                    peer_id: PeerId::random(),
156                    addresses: vec!["/ip4/10.0.2.1/udp/9000/quic".parse().unwrap()],
157                    trust: TrustRecord {
158                        score: 0.6,
159                        last_updated_epoch_secs: 1_234_567_890,
160                    },
161                },
162            ],
163            saved_at_epoch_secs: 1_234_567_890,
164        };
165
166        let dir = tempfile::tempdir().unwrap();
167
168        cache.save_to_dir(dir.path()).await.unwrap();
169        let loaded = CloseGroupCache::load_from_dir(dir.path())
170            .await
171            .unwrap()
172            .unwrap();
173
174        assert_eq!(loaded.peers.len(), 2);
175        assert_eq!(loaded.peers[0].peer_id, cache.peers[0].peer_id);
176        assert!((loaded.peers[0].trust.score - 0.8).abs() < f64::EPSILON);
177        assert_eq!(loaded.saved_at_epoch_secs, 1_234_567_890);
178    }
179
180    #[tokio::test]
181    async fn test_load_nonexistent_returns_none() {
182        let dir = tempfile::tempdir().unwrap();
183        let result = CloseGroupCache::load_from_dir(dir.path()).await.unwrap();
184        assert!(result.is_none());
185    }
186
187    #[tokio::test]
188    async fn test_empty_cache() {
189        let populated = CloseGroupCache {
190            peers: vec![CachedCloseGroupPeer {
191                peer_id: PeerId::random(),
192                addresses: vec!["/ip4/10.0.1.1/udp/9000/quic".parse().unwrap()],
193                trust: TrustRecord {
194                    score: 0.8,
195                    last_updated_epoch_secs: 1,
196                },
197            }],
198            saved_at_epoch_secs: 1,
199        };
200        let empty = CloseGroupCache {
201            peers: vec![],
202            saved_at_epoch_secs: 2,
203        };
204
205        let dir = tempfile::tempdir().unwrap();
206
207        populated.save_to_dir(dir.path()).await.unwrap();
208        empty.save_to_dir(dir.path()).await.unwrap();
209        let loaded = CloseGroupCache::load_from_dir(dir.path())
210            .await
211            .unwrap()
212            .unwrap();
213        assert!(loaded.peers.is_empty());
214        assert_eq!(loaded.saved_at_epoch_secs, 2);
215    }
216
217    #[test]
218    fn cache_staleness_respects_age_limit_and_rejects_future_timestamp() {
219        let now = 10_000;
220        let max_age = Duration::from_secs(3_600);
221        let mut cache = CloseGroupCache {
222            peers: vec![],
223            saved_at_epoch_secs: now,
224        };
225
226        assert!(!cache.is_stale(now, Some(max_age)));
227        cache.saved_at_epoch_secs = now - 3_600;
228        assert!(!cache.is_stale(now, Some(max_age)));
229        cache.saved_at_epoch_secs = now - 3_601;
230        assert!(cache.is_stale(now, Some(max_age)));
231        assert!(!cache.is_stale(now, None));
232
233        cache.saved_at_epoch_secs = now + 60;
234        assert!(!cache.is_stale(now, Some(max_age)));
235        cache.saved_at_epoch_secs = now + MAX_FUTURE_TIMESTAMP_SKEW.as_secs();
236        assert!(!cache.is_stale(now, Some(max_age)));
237        cache.saved_at_epoch_secs = now + MAX_FUTURE_TIMESTAMP_SKEW.as_secs() + 1;
238        assert!(cache.is_stale(now, Some(max_age)));
239        assert!(cache.is_stale(now, None));
240    }
241
242    #[tokio::test]
243    async fn stale_age_survives_save_load_roundtrip() {
244        let now = 10_000;
245        let cache = CloseGroupCache {
246            peers: vec![],
247            saved_at_epoch_secs: now - 7_200,
248        };
249        let dir = tempfile::tempdir().unwrap();
250
251        cache.save_to_dir(dir.path()).await.unwrap();
252        let loaded = CloseGroupCache::load_from_dir(dir.path())
253            .await
254            .unwrap()
255            .unwrap();
256
257        assert!(loaded.is_stale(now, Some(Duration::from_secs(3_600))));
258    }
259}