1use std::collections::HashMap;
17use std::fmt::Write as _;
18use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
20use anyhow::{Context as _, Result};
21
22use umsh::core::{PublicKey, RouterHint};
23use umsh::hal::Radio;
24use umsh::mac::CachedRoute;
25
26use crate::connection;
27
28pub const ROUTE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
35
36const HEADER: &str = "# umshctl learned routes v1";
39
40#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct RouteRecord {
43 pub route: CachedRoute,
44 pub learned_at: SystemTime,
45}
46
47impl RouteRecord {
48 pub fn age(&self) -> Duration {
51 SystemTime::now()
52 .duration_since(self.learned_at)
53 .unwrap_or_default()
54 }
55
56 pub fn expired(&self) -> bool {
57 self.age() >= ROUTE_TTL
58 }
59}
60
61#[derive(Debug, Default)]
63pub struct RouteCache {
64 entries: HashMap<[u8; 32], RouteRecord>,
65 forgotten: Vec<[u8; 32]>,
70 dirty: bool,
71}
72
73impl RouteCache {
74 pub fn load() -> Self {
80 let Some(path) = connection::routes_path() else {
81 return Self::default();
82 };
83 let Ok(text) = std::fs::read_to_string(&path) else {
84 return Self::default();
85 };
86 let mut cache = Self::parse(&text);
87 cache.entries.retain(|_, record| !record.expired());
90 cache
91 }
92
93 fn parse(text: &str) -> Self {
94 let mut entries = HashMap::new();
95 for line in text.lines() {
96 let line = line.trim();
97 if line.is_empty() || line.starts_with('#') {
98 continue;
99 }
100 if let Some((key, record)) = parse_line(line) {
102 entries.insert(key, record);
103 }
104 }
105 Self {
106 entries,
107 forgotten: Vec::new(),
108 dirty: false,
109 }
110 }
111
112 pub fn get(&self, peer: &PublicKey) -> Option<&RouteRecord> {
114 self.entries.get(&peer.0).filter(|record| !record.expired())
115 }
116
117 pub fn record(&mut self, peer: &PublicKey, route: CachedRoute) {
123 if let Some(existing) = self.entries.get(&peer.0)
124 && existing.route == route
125 && !existing.expired()
126 {
127 return;
128 }
129 self.forgotten.retain(|key| key != &peer.0);
130 self.entries.insert(
131 peer.0,
132 RouteRecord {
133 route,
134 learned_at: SystemTime::now(),
135 },
136 );
137 self.dirty = true;
138 }
139
140 pub fn remove(&mut self, peer: &PublicKey) -> bool {
142 let removed = self.entries.remove(&peer.0).is_some();
143 if !self.forgotten.contains(&peer.0) {
144 self.forgotten.push(peer.0);
145 }
146 self.dirty |= removed;
147 removed
148 }
149
150 pub fn clear(&mut self) -> usize {
152 let count = self.entries.len();
153 for key in self.entries.keys() {
154 if !self.forgotten.contains(key) {
155 self.forgotten.push(*key);
156 }
157 }
158 self.entries.clear();
159 self.dirty |= count > 0;
160 count
161 }
162
163 pub fn iter(&self) -> Vec<(PublicKey, &RouteRecord)> {
165 let mut all: Vec<(PublicKey, &RouteRecord)> = self
166 .entries
167 .iter()
168 .map(|(key, record)| (PublicKey(*key), record))
169 .collect();
170 all.sort_by_key(|(key, record)| (std::cmp::Reverse(record.learned_at), key.0));
171 all
172 }
173
174 pub fn is_empty(&self) -> bool {
175 self.entries.is_empty()
176 }
177
178 pub async fn harvest<R: Radio>(&mut self, handle: &crate::mesh::CtlHandle<'_, R>) {
183 let mut peers = Vec::new();
184 handle.for_each_peer(&mut |peer| peers.push(peer)).await;
185 for peer in peers {
186 match handle.peer_route(&peer).await {
187 Some(route) => self.record(&peer, route),
188 None => {
191 self.remove(&peer);
192 }
193 }
194 }
195 }
196
197 pub fn store(&mut self) -> Result<()> {
205 if !self.dirty {
206 return Ok(());
207 }
208 let Some(path) = connection::routes_path() else {
209 return Ok(());
212 };
213 if let Some(parent) = path.parent() {
214 std::fs::create_dir_all(parent)
215 .with_context(|| format!("creating {}", parent.display()))?;
216 }
217 let mut merged = Self::load();
218 merged
219 .entries
220 .retain(|key, _| !self.forgotten.contains(key));
221 for (key, record) in &self.entries {
222 let newer = merged
223 .entries
224 .get(key)
225 .is_none_or(|existing| existing.learned_at <= record.learned_at);
226 if newer {
227 merged.entries.insert(*key, record.clone());
228 }
229 }
230 std::fs::write(&path, merged.render())
231 .with_context(|| format!("writing {}", path.display()))?;
232 self.dirty = false;
233 Ok(())
234 }
235
236 fn render(&self) -> String {
237 let mut out = format!("{HEADER}\n");
238 for (key, record) in self.iter() {
239 let seconds = record
240 .learned_at
241 .duration_since(UNIX_EPOCH)
242 .unwrap_or_default()
243 .as_secs();
244 let _ = writeln!(out, "{key} {} {seconds}", render_route(&record.route));
245 }
246 out
247 }
248}
249
250fn render_route(route: &CachedRoute) -> String {
261 match route {
262 CachedRoute::Direct => "direct".to_string(),
263 CachedRoute::Source(hints) => {
264 format!("source {}", render_pairs(hints.iter().map(|hint| hint.0)))
265 }
266 CachedRoute::Flood { hops, regions } => {
267 format!("flood {hops} {}", render_pairs(regions.iter().copied()))
268 }
269 }
270}
271
272fn render_pairs(pairs: impl Iterator<Item = [u8; 2]>) -> String {
273 let rendered: Vec<String> = pairs
274 .map(|pair| format!("{:02x}{:02x}", pair[0], pair[1]))
275 .collect();
276 if rendered.is_empty() {
277 return "-".to_string();
278 }
279 rendered.join(",")
280}
281
282fn parse_pairs(text: &str) -> Option<Vec<[u8; 2]>> {
283 if text == "-" {
284 return Some(Vec::new());
285 }
286 text.split(',')
287 .map(|pair| {
288 let bytes = u16::from_str_radix(pair, 16).ok()?;
289 (pair.len() == 4).then(|| bytes.to_be_bytes())
290 })
291 .collect()
292}
293
294fn parse_line(line: &str) -> Option<([u8; 32], RouteRecord)> {
295 let mut fields = line.split_whitespace();
296 let key = fields.next()?.parse::<PublicKey>().ok()?;
297 let route = match fields.next()? {
298 "direct" => CachedRoute::Direct,
299 "source" => {
300 let hints: Vec<RouterHint> = parse_pairs(fields.next()?)?
301 .into_iter()
302 .map(RouterHint)
303 .collect();
304 CachedRoute::source(&hints)?
307 }
308 "flood" => {
309 let hops = fields.next()?.parse::<u8>().ok()?;
310 CachedRoute::flood(hops, &parse_pairs(fields.next()?)?)?
311 }
312 _ => return None,
313 };
314 let seconds = fields.next()?.parse::<u64>().ok()?;
315 if fields.next().is_some() {
318 return None;
319 }
320 Some((
321 key.0,
322 RouteRecord {
323 route,
324 learned_at: UNIX_EPOCH + Duration::from_secs(seconds),
325 },
326 ))
327}
328
329pub fn describe(route: &CachedRoute) -> String {
331 match route {
332 CachedRoute::Direct => "direct".to_string(),
333 CachedRoute::Source(hints) if hints.is_empty() => "source route, no hops".to_string(),
334 CachedRoute::Source(hints) => format!(
335 "via {}",
336 hints
337 .iter()
338 .map(|hint| format!("{:02x}{:02x}", hint.0[0], hint.0[1]))
339 .collect::<Vec<_>>()
340 .join(" > ")
341 ),
342 CachedRoute::Flood { hops, regions } if regions.is_empty() => {
343 format!("flood, {hops} hops")
344 }
345 CachedRoute::Flood { hops, regions } => format!(
346 "flood, {hops} hops, regions {}",
347 regions
348 .iter()
349 .map(|code| umsh::core::RegionCode::from_bytes(*code).to_string())
350 .collect::<Vec<_>>()
351 .join(" ")
352 ),
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 fn key(byte: u8) -> PublicKey {
361 PublicKey([byte; 32])
362 }
363
364 fn record(route: CachedRoute, age: Duration) -> RouteRecord {
365 RouteRecord {
366 route,
367 learned_at: SystemTime::now() - age,
368 }
369 }
370
371 fn cache_of(entries: &[(PublicKey, RouteRecord)]) -> RouteCache {
372 let mut cache = RouteCache::default();
373 for (key, record) in entries {
374 cache.entries.insert(key.0, record.clone());
375 }
376 cache
377 }
378
379 #[test]
382 fn a_forgotten_route_stays_forgotten_through_a_merge() {
383 let mut cache = cache_of(&[
384 (key(20), record(CachedRoute::Direct, Duration::from_secs(5))),
385 (key(21), record(CachedRoute::Direct, Duration::from_secs(5))),
386 ]);
387 assert!(cache.remove(&key(20)));
388
389 let theirs = cache_of(&[
392 (key(20), record(CachedRoute::Direct, Duration::ZERO)),
393 (key(22), record(CachedRoute::Direct, Duration::ZERO)),
394 ]);
395 let mut merged = RouteCache::parse(&theirs.render());
396 merged
397 .entries
398 .retain(|key, _| !cache.forgotten.contains(key));
399 for (key, record) in &cache.entries {
400 merged.entries.insert(*key, record.clone());
401 }
402
403 assert!(!merged.entries.contains_key(&key(20).0), "forgotten");
404 assert!(merged.entries.contains_key(&key(21).0), "ours");
405 assert!(merged.entries.contains_key(&key(22).0), "theirs");
406 }
407
408 #[test]
411 fn relearning_a_forgotten_route_un_forgets_it() {
412 let mut cache = cache_of(&[(key(23), record(CachedRoute::Direct, Duration::ZERO))]);
413 cache.remove(&key(23));
414 assert!(cache.forgotten.contains(&key(23).0));
415 cache.record(&key(23), CachedRoute::Direct);
416 assert!(!cache.forgotten.contains(&key(23).0));
417 assert!(cache.get(&key(23)).is_some());
418 }
419
420 #[test]
421 fn every_route_shape_survives_the_round_trip() {
422 let source =
423 CachedRoute::source(&[RouterHint([0xA1, 0xB2]), RouterHint([0xC3, 0xD4])]).unwrap();
424 let flood = CachedRoute::flood(5, &[[0x68, 0xAC], [0x9B, 0x21]]).unwrap();
425 let cache = cache_of(&[
426 (key(1), record(CachedRoute::Direct, Duration::from_secs(10))),
427 (key(2), record(source.clone(), Duration::from_secs(20))),
428 (key(3), record(flood.clone(), Duration::from_secs(30))),
429 ]);
430
431 let parsed = RouteCache::parse(&cache.render());
432 assert_eq!(parsed.get(&key(1)).unwrap().route, CachedRoute::Direct);
433 assert_eq!(parsed.get(&key(2)).unwrap().route, source);
434 assert_eq!(parsed.get(&key(3)).unwrap().route, flood);
435 }
436
437 #[test]
440 fn the_empty_forms_round_trip_as_themselves() {
441 let empty_source = CachedRoute::source(&[]).unwrap();
442 let plain_flood = CachedRoute::flood(3, &[]).unwrap();
443 let cache = cache_of(&[
444 (key(4), record(empty_source.clone(), Duration::ZERO)),
445 (key(5), record(plain_flood.clone(), Duration::ZERO)),
446 ]);
447
448 let parsed = RouteCache::parse(&cache.render());
449 assert_eq!(parsed.get(&key(4)).unwrap().route, empty_source);
450 assert_eq!(parsed.get(&key(5)).unwrap().route, plain_flood);
451 }
452
453 #[test]
454 fn an_expired_route_is_not_offered() {
455 let cache = cache_of(&[
456 (key(6), record(CachedRoute::Direct, Duration::from_secs(60))),
457 (key(7), record(CachedRoute::Direct, ROUTE_TTL)),
458 ]);
459 assert!(cache.get(&key(6)).is_some());
460 assert!(cache.get(&key(7)).is_none(), "a route at the TTL is spent");
461 }
462
463 #[test]
466 fn a_malformed_line_costs_only_itself() {
467 let good = format!("{} direct 1000", key(8));
468 let text = format!(
469 "{HEADER}\n\
470 {good}\n\
471 not-a-key direct 1000\n\
472 {} teleport 1000\n\
473 {} source zzzz 1000\n\
474 {} direct 1000 extra\n\
475 \n\
476 # a comment\n",
477 key(9),
478 key(10),
479 key(11),
480 );
481 let parsed = RouteCache::parse(&text);
482 assert_eq!(parsed.entries.len(), 1);
483 assert!(parsed.entries.contains_key(&key(8).0));
484 }
485
486 #[test]
490 fn recording_the_same_route_again_does_not_refresh_its_age() {
491 let mut cache = cache_of(&[(
492 key(12),
493 record(CachedRoute::Direct, Duration::from_secs(600)),
494 )]);
495 let before = cache.get(&key(12)).unwrap().learned_at;
496 cache.record(&key(12), CachedRoute::Direct);
497 assert_eq!(cache.get(&key(12)).unwrap().learned_at, before);
498 assert!(!cache.dirty, "an unchanged route is not a reason to write");
499
500 cache.record(&key(12), CachedRoute::flood(4, &[]).unwrap());
502 assert!(cache.get(&key(12)).unwrap().learned_at > before);
503 assert!(cache.dirty);
504 }
505
506 #[test]
507 fn a_header_only_file_is_an_empty_cache() {
508 assert!(RouteCache::parse(&format!("{HEADER}\n")).is_empty());
509 assert!(RouteCache::parse("").is_empty());
510 }
511}