saorsa_core/bootstrap/
cache.rs1use 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
27const CACHE_FILENAME: &str = "close_group_cache.json";
29
30const MAX_FUTURE_TIMESTAMP_SKEW: Duration = Duration::from_secs(5 * 60);
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CachedCloseGroupPeer {
38 pub peer_id: PeerId,
40 pub addresses: Vec<MultiAddr>,
42 pub trust: TrustRecord,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CloseGroupCache {
55 pub peers: Vec<CachedCloseGroupPeer>,
57 pub saved_at_epoch_secs: u64,
59}
60
61impl CloseGroupCache {
62 #[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 pub async fn save_to_dir(&self, dir: &Path) -> anyhow::Result<()> {
85 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 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 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}