Skip to main content

rns_core/transport/
pathfinder.rs

1use super::tables::{PathEntry, PathSet};
2use crate::constants;
3
4/// Extract emission timestamp from bytes [5:10] of a random_blob (big-endian u64).
5pub fn timebase_from_random_blob(blob: &[u8; 10]) -> u64 {
6    let mut bytes = [0u8; 8];
7    bytes[3..8].copy_from_slice(&blob[5..10]);
8    u64::from_be_bytes(bytes)
9}
10
11/// Maximum emission timestamp across all random blobs.
12pub fn timebase_from_random_blobs(blobs: &[[u8; 10]]) -> u64 {
13    let mut timebase: u64 = 0;
14    for blob in blobs {
15        let emitted = timebase_from_random_blob(blob);
16        if emitted > timebase {
17            timebase = emitted;
18        }
19    }
20    timebase
21}
22
23/// Extract the random_blob from announce packet data.
24///
25/// Located at offset `KEYSIZE/8 + NAME_HASH_LENGTH/8` = 64 + 10 = 74,
26/// length 10 bytes.
27pub fn extract_random_blob(packet_data: &[u8]) -> Option<[u8; 10]> {
28    let offset = constants::KEYSIZE / 8 + constants::NAME_HASH_LENGTH / 8;
29    if packet_data.len() < offset + 10 {
30        return None;
31    }
32    let mut blob = [0u8; 10];
33    blob.copy_from_slice(&packet_data[offset..offset + 10]);
34    Some(blob)
35}
36
37#[derive(Debug, PartialEq, Eq)]
38pub enum PathDecision {
39    Add,
40    Reject,
41}
42
43/// Full path update decision tree from Transport.py:1604-1686.
44///
45/// Determines whether an incoming announce should update the path table.
46pub fn should_update_path(
47    existing: Option<&PathEntry>,
48    announce_hops: u8,
49    announce_emitted_ts: u64,
50    random_blob: &[u8; 10],
51    path_is_unresponsive: bool,
52    now: f64,
53    prefer_shorter_path: bool,
54) -> PathDecision {
55    // Hop limit
56    if announce_hops > constants::PATHFINDER_M {
57        return PathDecision::Reject;
58    }
59
60    let existing = match existing {
61        None => return PathDecision::Add,
62        Some(e) => e,
63    };
64
65    let path_timebase = timebase_from_random_blobs(&existing.random_blobs);
66    let blob_is_new = !existing.random_blobs.contains(random_blob);
67
68    if announce_hops <= existing.hops {
69        // Accept strictly shorter path even with duplicate blob
70        if prefer_shorter_path && announce_hops < existing.hops {
71            return PathDecision::Add;
72        }
73        // Equal or fewer hops: accept if new blob AND newer emission
74        if blob_is_new && announce_emitted_ts > path_timebase {
75            return PathDecision::Add;
76        }
77        // Same emission + unresponsive path: accept for path recovery
78        if announce_emitted_ts == path_timebase && path_is_unresponsive {
79            return PathDecision::Add;
80        }
81        PathDecision::Reject
82    } else {
83        // More hops than existing path
84        let path_expired = now >= existing.expires;
85
86        if path_expired && blob_is_new {
87            return PathDecision::Add;
88        }
89
90        if announce_emitted_ts > path_timebase && blob_is_new {
91            return PathDecision::Add;
92        }
93
94        if announce_emitted_ts == path_timebase && path_is_unresponsive {
95            return PathDecision::Add;
96        }
97
98        PathDecision::Reject
99    }
100}
101
102/// Decision for multi-path announce processing.
103#[derive(Debug, PartialEq, Eq)]
104pub enum MultiPathDecision {
105    /// Replace/update the primary path (or the path with the same next_hop).
106    ReplacePrimary,
107    /// Accept as an alternative path via a new next_hop.
108    AddAlternative,
109    /// Reject this announce.
110    Reject,
111}
112
113/// Multi-path aware announce decision.
114///
115/// Determines whether an incoming announce should update the primary path,
116/// be stored as an alternative, or be rejected.
117///
118/// - No existing `PathSet` → `ReplacePrimary` (first path for this dest)
119/// - Same `next_hop` exists in the set → delegate to `should_update_path`
120///   against **that** specific path entry
121/// - New `next_hop` → accept as `AddAlternative` if the blob is genuinely
122///   new (not in any stored path's blobs) and emission timestamp is valid
123#[allow(clippy::too_many_arguments)]
124pub fn decide_announce_multipath(
125    existing_set: Option<&PathSet>,
126    announce_hops: u8,
127    announce_emitted_ts: u64,
128    random_blob: &[u8; 10],
129    next_hop: &[u8; 16],
130    path_is_unresponsive: bool,
131    now: f64,
132    prefer_shorter_path: bool,
133) -> MultiPathDecision {
134    decide_announce_multipath_with_gravity(
135        existing_set,
136        announce_hops,
137        announce_emitted_ts,
138        random_blob,
139        next_hop,
140        path_is_unresponsive,
141        now,
142        prefer_shorter_path,
143        None,
144        None,
145    )
146}
147
148/// Multi-path announce decision with interface gravity context.
149#[allow(clippy::too_many_arguments)]
150pub fn decide_announce_multipath_with_gravity(
151    existing_set: Option<&PathSet>,
152    announce_hops: u8,
153    announce_emitted_ts: u64,
154    random_blob: &[u8; 10],
155    next_hop: &[u8; 16],
156    path_is_unresponsive: bool,
157    now: f64,
158    prefer_shorter_path: bool,
159    current_gravity: Option<i64>,
160    announce_gravity: Option<i64>,
161) -> MultiPathDecision {
162    // Hop limit
163    if announce_hops > constants::PATHFINDER_M {
164        return MultiPathDecision::Reject;
165    }
166
167    let path_set = match existing_set {
168        None => return MultiPathDecision::ReplacePrimary,
169        Some(ps) if ps.is_empty() => return MultiPathDecision::ReplacePrimary,
170        Some(ps) => ps,
171    };
172
173    if is_higher_gravity_replacement(
174        path_set,
175        announce_hops,
176        announce_emitted_ts,
177        current_gravity,
178        announce_gravity,
179    ) {
180        return MultiPathDecision::ReplacePrimary;
181    }
182
183    // Check if there's already a path with the same next_hop
184    if let Some(existing_path) = path_set.find_by_next_hop(next_hop) {
185        let decision = should_update_path(
186            Some(existing_path),
187            announce_hops,
188            announce_emitted_ts,
189            random_blob,
190            path_is_unresponsive,
191            now,
192            prefer_shorter_path,
193        );
194        match decision {
195            PathDecision::Add => MultiPathDecision::ReplacePrimary,
196            PathDecision::Reject => MultiPathDecision::Reject,
197        }
198    } else {
199        // New next_hop — check if blob is genuinely new across all paths
200        let all_blobs = path_set.all_random_blobs();
201        let blob_is_new = !all_blobs.contains(random_blob);
202
203        if !blob_is_new {
204            return MultiPathDecision::Reject;
205        }
206
207        let max_timebase = timebase_from_random_blobs(&all_blobs);
208        if announce_emitted_ts >= max_timebase {
209            MultiPathDecision::AddAlternative
210        } else {
211            MultiPathDecision::Reject
212        }
213    }
214}
215
216/// Whether a same-emission announce should replace the primary path solely
217/// because it arrived on an interface with higher gravity.
218pub fn is_higher_gravity_replacement(
219    path_set: &PathSet,
220    announce_hops: u8,
221    announce_emitted_ts: u64,
222    current_gravity: Option<i64>,
223    announce_gravity: Option<i64>,
224) -> bool {
225    let (Some(primary), Some(current_gravity), Some(announce_gravity)) =
226        (path_set.primary(), current_gravity, announce_gravity)
227    else {
228        return false;
229    };
230    announce_hops <= primary.hops
231        && announce_emitted_ts == timebase_from_random_blobs(&primary.random_blobs)
232        && announce_gravity > current_gravity
233}
234
235#[cfg(test)]
236mod tests {
237    use super::super::types::InterfaceId;
238    use super::*;
239
240    fn make_blob(timebase: u64) -> [u8; 10] {
241        let mut blob = [0u8; 10];
242        let bytes = timebase.to_be_bytes();
243        // timebase is stored in blob[5..10] = last 5 bytes of u64
244        blob[5..10].copy_from_slice(&bytes[3..8]);
245        blob
246    }
247
248    fn make_path_entry(hops: u8, blobs: &[[u8; 10]], expires: f64) -> PathEntry {
249        PathEntry {
250            timestamp: 1000.0,
251            next_hop: [0xAA; 16],
252            hops,
253            expires,
254            random_blobs: blobs.to_vec(),
255            receiving_interface: InterfaceId(1),
256            packet_hash: [0xBB; 32],
257            announce_raw: None,
258        }
259    }
260
261    #[test]
262    fn test_timebase_extraction() {
263        let blob = make_blob(12345);
264        assert_eq!(timebase_from_random_blob(&blob), 12345);
265    }
266
267    #[test]
268    fn test_timebase_from_multiple_blobs() {
269        let b1 = make_blob(100);
270        let b2 = make_blob(200);
271        let b3 = make_blob(50);
272        assert_eq!(timebase_from_random_blobs(&[b1, b2, b3]), 200);
273    }
274
275    #[test]
276    fn test_timebase_empty_blobs() {
277        assert_eq!(timebase_from_random_blobs(&[]), 0);
278    }
279
280    #[test]
281    fn test_extract_random_blob() {
282        // Need at least 74 + 10 = 84 bytes
283        let mut data = [0u8; 100];
284        // Put a known blob at offset 74
285        data[74..84].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
286        let blob = extract_random_blob(&data).unwrap();
287        assert_eq!(blob, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
288    }
289
290    #[test]
291    fn test_extract_random_blob_too_short() {
292        let data = [0u8; 80]; // too short
293        assert!(extract_random_blob(&data).is_none());
294    }
295
296    // --- Decision tree tests ---
297
298    #[test]
299    fn test_no_existing_path_always_add() {
300        let blob = make_blob(100);
301        assert_eq!(
302            should_update_path(None, 3, 100, &blob, false, 1000.0, false),
303            PathDecision::Add
304        );
305    }
306
307    #[test]
308    fn test_hop_limit_reject() {
309        let blob = make_blob(100);
310        assert_eq!(
311            should_update_path(None, 129, 100, &blob, false, 1000.0, false),
312            PathDecision::Reject
313        );
314    }
315
316    #[test]
317    fn test_fewer_hops_new_blob_newer_emission_add() {
318        let old_blob = make_blob(100);
319        let new_blob = make_blob(200);
320        let entry = make_path_entry(5, &[old_blob], 9999.0);
321        assert_eq!(
322            should_update_path(Some(&entry), 3, 200, &new_blob, false, 1000.0, false),
323            PathDecision::Add
324        );
325    }
326
327    #[test]
328    fn test_fewer_hops_duplicate_blob_reject() {
329        let blob = make_blob(100);
330        let entry = make_path_entry(5, &[blob], 9999.0);
331        assert_eq!(
332            should_update_path(Some(&entry), 3, 200, &blob, false, 1000.0, false),
333            PathDecision::Reject
334        );
335    }
336
337    #[test]
338    fn test_fewer_hops_same_emission_unresponsive_add() {
339        let old_blob = make_blob(100);
340        let mut different_blob = [0u8; 10];
341        different_blob[0] = 0xFF;
342        different_blob[5..10].copy_from_slice(&100u64.to_be_bytes()[3..8]);
343
344        let entry = make_path_entry(5, &[old_blob], 9999.0);
345        assert_eq!(
346            should_update_path(Some(&entry), 3, 100, &different_blob, true, 1000.0, false),
347            PathDecision::Add
348        );
349    }
350
351    #[test]
352    fn test_fewer_hops_same_emission_responsive_reject() {
353        let old_blob = make_blob(100);
354        let mut different_blob = [0u8; 10];
355        different_blob[0] = 0xFF;
356        different_blob[5..10].copy_from_slice(&100u64.to_be_bytes()[3..8]);
357
358        let entry = make_path_entry(5, &[old_blob], 9999.0);
359        assert_eq!(
360            should_update_path(Some(&entry), 3, 100, &different_blob, false, 1000.0, false),
361            PathDecision::Reject
362        );
363    }
364
365    #[test]
366    fn test_more_hops_expired_path_new_blob_add() {
367        let old_blob = make_blob(100);
368        let new_blob = make_blob(50); // older emission but path expired
369        let entry = make_path_entry(2, &[old_blob], 500.0); // expires at 500
370
371        assert_eq!(
372            should_update_path(Some(&entry), 5, 50, &new_blob, false, 600.0, false), // now > expires
373            PathDecision::Add
374        );
375    }
376
377    #[test]
378    fn test_more_hops_not_expired_older_emission_reject() {
379        let old_blob = make_blob(200);
380        let new_blob = make_blob(100);
381        let entry = make_path_entry(2, &[old_blob], 9999.0);
382
383        assert_eq!(
384            should_update_path(Some(&entry), 5, 100, &new_blob, false, 1000.0, false),
385            PathDecision::Reject
386        );
387    }
388
389    #[test]
390    fn test_more_hops_newer_emission_new_blob_add() {
391        let old_blob = make_blob(100);
392        let new_blob = make_blob(200);
393        let entry = make_path_entry(2, &[old_blob], 9999.0);
394
395        assert_eq!(
396            should_update_path(Some(&entry), 5, 200, &new_blob, false, 1000.0, false),
397            PathDecision::Add
398        );
399    }
400
401    #[test]
402    fn test_more_hops_same_emission_unresponsive_add() {
403        let old_blob = make_blob(100);
404        let mut different_blob = [0u8; 10];
405        different_blob[0] = 0xFF;
406        different_blob[5..10].copy_from_slice(&100u64.to_be_bytes()[3..8]);
407
408        let entry = make_path_entry(2, &[old_blob], 9999.0);
409
410        assert_eq!(
411            should_update_path(Some(&entry), 5, 100, &different_blob, true, 1000.0, false),
412            PathDecision::Add
413        );
414    }
415
416    #[test]
417    fn test_more_hops_same_emission_responsive_reject() {
418        let old_blob = make_blob(100);
419        let mut different_blob = [0u8; 10];
420        different_blob[0] = 0xFF;
421        different_blob[5..10].copy_from_slice(&100u64.to_be_bytes()[3..8]);
422
423        let entry = make_path_entry(2, &[old_blob], 9999.0);
424
425        assert_eq!(
426            should_update_path(Some(&entry), 5, 100, &different_blob, false, 1000.0, false),
427            PathDecision::Reject
428        );
429    }
430
431    #[test]
432    fn test_more_hops_duplicate_blob_reject() {
433        let blob = make_blob(200);
434        let entry = make_path_entry(2, &[blob], 9999.0);
435
436        assert_eq!(
437            should_update_path(Some(&entry), 5, 200, &blob, false, 1000.0, false),
438            PathDecision::Reject
439        );
440    }
441
442    #[test]
443    fn test_equal_hops_new_blob_newer_emission_add() {
444        let old_blob = make_blob(100);
445        let new_blob = make_blob(200);
446        let entry = make_path_entry(3, &[old_blob], 9999.0);
447
448        assert_eq!(
449            should_update_path(Some(&entry), 3, 200, &new_blob, false, 1000.0, false),
450            PathDecision::Add
451        );
452    }
453
454    // --- prefer_shorter_path tests ---
455
456    #[test]
457    fn test_prefer_shorter_path_strictly_fewer_hops_duplicate_blob_add() {
458        let blob = make_blob(100);
459        let entry = make_path_entry(5, &[blob], 9999.0);
460        assert_eq!(
461            should_update_path(Some(&entry), 3, 100, &blob, false, 1000.0, true),
462            PathDecision::Add
463        );
464    }
465
466    #[test]
467    fn test_prefer_shorter_path_equal_hops_duplicate_blob_reject() {
468        // Equal hops with same blob: no benefit, still rejected
469        let blob = make_blob(100);
470        let entry = make_path_entry(3, &[blob], 9999.0);
471        assert_eq!(
472            should_update_path(Some(&entry), 3, 100, &blob, false, 1000.0, true),
473            PathDecision::Reject
474        );
475    }
476
477    #[test]
478    fn test_prefer_shorter_path_more_hops_duplicate_blob_reject() {
479        // More hops: prefer_shorter_path does not help
480        let blob = make_blob(100);
481        let entry = make_path_entry(2, &[blob], 9999.0);
482        assert_eq!(
483            should_update_path(Some(&entry), 5, 100, &blob, false, 1000.0, true),
484            PathDecision::Reject
485        );
486    }
487
488    // --- MultiPathDecision tests ---
489
490    #[test]
491    fn test_multipath_no_existing_set_replace_primary() {
492        let blob = make_blob(100);
493        assert_eq!(
494            decide_announce_multipath(None, 3, 100, &blob, &[0xBB; 16], false, 1000.0, false),
495            MultiPathDecision::ReplacePrimary
496        );
497    }
498
499    #[test]
500    fn test_multipath_same_nexthop_update() {
501        let blob_old = make_blob(100);
502        let blob_new = make_blob(200);
503        let entry = make_path_entry(3, &[blob_old], 9999.0);
504        let ps = PathSet::from_single(entry, 3);
505
506        // Same next_hop, newer blob → ReplacePrimary
507        assert_eq!(
508            decide_announce_multipath(
509                Some(&ps),
510                2,
511                200,
512                &blob_new,
513                &[0xAA; 16],
514                false,
515                1000.0,
516                false
517            ),
518            MultiPathDecision::ReplacePrimary
519        );
520    }
521
522    #[test]
523    fn test_multipath_same_nexthop_reject() {
524        let blob = make_blob(100);
525        let entry = make_path_entry(3, &[blob], 9999.0);
526        let ps = PathSet::from_single(entry, 3);
527
528        // Same next_hop, same blob → Reject
529        assert_eq!(
530            decide_announce_multipath(Some(&ps), 3, 100, &blob, &[0xAA; 16], false, 1000.0, false),
531            MultiPathDecision::Reject
532        );
533    }
534
535    #[test]
536    fn test_multipath_new_nexthop_novel_blob_add_alternative() {
537        let blob_existing = make_blob(100);
538        let blob_new = make_blob(200);
539        let entry = make_path_entry(3, &[blob_existing], 9999.0);
540        let ps = PathSet::from_single(entry, 3);
541
542        // Different next_hop, novel blob, newer emission → AddAlternative
543        assert_eq!(
544            decide_announce_multipath(
545                Some(&ps),
546                4,
547                200,
548                &blob_new,
549                &[0xCC; 16],
550                false,
551                1000.0,
552                false
553            ),
554            MultiPathDecision::AddAlternative
555        );
556    }
557
558    #[test]
559    fn test_multipath_new_nexthop_known_blob_reject() {
560        let blob = make_blob(100);
561        let entry = make_path_entry(3, &[blob], 9999.0);
562        let ps = PathSet::from_single(entry, 3);
563
564        // Different next_hop but blob already known → Reject
565        assert_eq!(
566            decide_announce_multipath(Some(&ps), 4, 100, &blob, &[0xCC; 16], false, 1000.0, false),
567            MultiPathDecision::Reject
568        );
569    }
570
571    #[test]
572    fn test_multipath_new_nexthop_older_emission_reject() {
573        let blob_existing = make_blob(200);
574        let blob_new = make_blob(100); // older emission
575        let entry = make_path_entry(3, &[blob_existing], 9999.0);
576        let ps = PathSet::from_single(entry, 3);
577
578        // Novel blob but older emission timestamp → Reject
579        assert_eq!(
580            decide_announce_multipath(
581                Some(&ps),
582                4,
583                100,
584                &blob_new,
585                &[0xCC; 16],
586                false,
587                1000.0,
588                false
589            ),
590            MultiPathDecision::Reject
591        );
592    }
593
594    #[test]
595    fn test_multipath_hop_limit_reject() {
596        let blob = make_blob(100);
597        assert_eq!(
598            decide_announce_multipath(None, 129, 100, &blob, &[0xBB; 16], false, 1000.0, false),
599            MultiPathDecision::Reject
600        );
601    }
602
603    #[test]
604    fn same_emission_prefers_strictly_higher_gravity() {
605        let blob = make_blob(100);
606        let entry = make_path_entry(3, &[blob], 9999.0);
607        let ps = PathSet::from_single(entry, 3);
608
609        let decide = |current, incoming, hops| {
610            decide_announce_multipath_with_gravity(
611                Some(&ps),
612                hops,
613                100,
614                &blob,
615                &[0xCC; 16],
616                false,
617                1000.0,
618                false,
619                Some(current),
620                Some(incoming),
621            )
622        };
623
624        assert_eq!(decide(0, 1, 3), MultiPathDecision::ReplacePrimary);
625        assert_eq!(decide(-5, -2, 3), MultiPathDecision::ReplacePrimary);
626        assert_eq!(decide(1, 1, 3), MultiPathDecision::Reject);
627        assert_eq!(decide(2, 1, 3), MultiPathDecision::Reject);
628        assert_eq!(decide(0, 1, 4), MultiPathDecision::Reject);
629
630        assert!(is_higher_gravity_replacement(&ps, 3, 100, Some(0), Some(1)));
631        assert!(!is_higher_gravity_replacement(
632            &ps,
633            3,
634            100,
635            Some(1),
636            Some(1)
637        ));
638        assert_eq!(crate::logging::GRAVITY_UPDATE_LOG_LEVEL, log::Level::Trace);
639        assert_eq!(crate::logging::PATHING_LOG_TARGET, "rns::pathing");
640    }
641}