nomadnet-rs 0.1.0

Rust library for NomadNet Node Hosting and browsing over Reticulum
use rns_core::destination::destination_hash;
use rns_net::AnnouncedIdentity;
use tracing::debug;

use crate::types::{DirectoryEntry, MAX_DIRECTORY_ENTRIES};

/// Tracks discovered NomadNet nodes from RNS transport announces.
///
/// Feed it [`AnnouncedIdentity`] events from your RNS callbacks. Only announces
/// whose destination hash matches `nomadnetwork.node` for the announcer's
/// identity are kept.
pub struct NomadDirectory {
    entries: Vec<DirectoryEntry>,
}

impl NomadDirectory {
    pub fn new() -> Self {
        Self {
            entries: Vec::new(),
        }
    }

    pub fn handle_announce(&mut self, announced: &AnnouncedIdentity) {
        let expected_dest =
            destination_hash("nomadnetwork", &["node"], Some(&announced.identity_hash.0));

        if announced.dest_hash.0 != expected_dest {
            return;
        }

        let node_name = announced
            .app_data
            .as_ref()
            .and_then(|d| std::str::from_utf8(d).ok())
            .map(|s| s.to_string());

        debug!(
            "NomadNet node announce: dest={} identity={} name={:?} hops={}",
            hex::encode(announced.dest_hash.0),
            hex::encode(announced.identity_hash.0),
            node_name,
            announced.hops
        );

        if let Some(entry) = self
            .entries
            .iter_mut()
            .find(|e| e.dest_hash == announced.dest_hash.0)
        {
            entry.node_name = node_name;
            entry.hops = announced.hops;
            entry.last_seen = announced.received_at;
        } else {
            self.entries.push(DirectoryEntry {
                dest_hash: announced.dest_hash.0,
                identity_hash: announced.identity_hash.0,
                node_name,
                hops: announced.hops,
                last_seen: announced.received_at,
            });

            if self.entries.len() > MAX_DIRECTORY_ENTRIES {
                self.entries
                    .sort_by(|a, b| b.last_seen.total_cmp(&a.last_seen));
                self.entries.truncate(MAX_DIRECTORY_ENTRIES);
            }
        }
    }

    pub fn known_nodes(&self) -> &[DirectoryEntry] {
        &self.entries
    }

    pub fn get_node(&self, dest_hash: &[u8; 16]) -> Option<&DirectoryEntry> {
        self.entries.iter().find(|e| &e.dest_hash == dest_hash)
    }

    pub fn get_node_by_identity(&self, identity_hash: &[u8; 16]) -> Option<&DirectoryEntry> {
        self.entries
            .iter()
            .find(|e| &e.identity_hash == identity_hash)
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

impl Default for NomadDirectory {
    fn default() -> Self {
        Self::new()
    }
}

pub fn is_nomadnet_announce(announced: &AnnouncedIdentity) -> bool {
    let expected_dest =
        destination_hash("nomadnetwork", &["node"], Some(&announced.identity_hash.0));
    announced.dest_hash.0 == expected_dest
}

pub fn associated_lxmf_dest_hash(identity_hash: &[u8; 16]) -> [u8; 16] {
    destination_hash("lxmf", &["delivery"], Some(identity_hash))
}

#[cfg(test)]
mod tests {
    use rns_net::{DestHash, IdentityHash, InterfaceId};

    use super::*;

    fn make_announced(identity_hash: [u8; 16], app_data: Option<Vec<u8>>) -> AnnouncedIdentity {
        let dest = destination_hash("nomadnetwork", &["node"], Some(&identity_hash));
        AnnouncedIdentity {
            dest_hash: DestHash(dest),
            identity_hash: IdentityHash(identity_hash),
            public_key: [0u8; 64],
            app_data,
            hops: 3,
            received_at: 1000.0,
            receiving_interface: InterfaceId(0),
        }
    }

    #[test]
    fn test_nomadnet_announce_accepted() {
        let mut dir = NomadDirectory::new();
        let identity = [0xAA; 16];
        let announced = make_announced(identity, Some(b"TestNode".to_vec()));
        dir.handle_announce(&announced);
        assert_eq!(dir.len(), 1);
        assert_eq!(dir.known_nodes()[0].node_name.as_deref(), Some("TestNode"));
        assert_eq!(dir.known_nodes()[0].hops, 3);
    }

    #[test]
    fn test_non_nomadnet_announce_rejected() {
        let mut dir = NomadDirectory::new();
        let identity = [0xBB; 16];
        let lxmf_dest = destination_hash("lxmf", &["delivery"], Some(&identity));
        let announced = AnnouncedIdentity {
            dest_hash: DestHash(lxmf_dest),
            identity_hash: IdentityHash(identity),
            public_key: [0u8; 64],
            app_data: Some(b"NotANode".to_vec()),
            hops: 1,
            received_at: 1000.0,
            receiving_interface: InterfaceId(0),
        };
        dir.handle_announce(&announced);
        assert_eq!(dir.len(), 0);
    }

    #[test]
    fn test_announce_updates_existing() {
        let mut dir = NomadDirectory::new();
        let identity = [0xCC; 16];

        dir.handle_announce(&make_announced(identity, Some(b"NodeV1".to_vec())));
        assert_eq!(dir.known_nodes()[0].node_name.as_deref(), Some("NodeV1"));

        dir.handle_announce(&make_announced(identity, Some(b"NodeV2".to_vec())));
        assert_eq!(dir.len(), 1);
        assert_eq!(dir.known_nodes()[0].node_name.as_deref(), Some("NodeV2"));
    }

    #[test]
    fn test_max_entries_eviction() {
        let mut dir = NomadDirectory::new();
        for i in 0..=MAX_DIRECTORY_ENTRIES {
            let mut identity = [0u8; 16];
            identity[0..2].copy_from_slice(&(i as u16).to_le_bytes());
            let announced = make_announced(identity, Some(format!("Node{i}").into_bytes()));
            dir.handle_announce(&announced);
        }
        assert!(dir.len() <= MAX_DIRECTORY_ENTRIES);
    }

    #[test]
    fn test_eviction_keeps_most_recent_entries() {
        let mut dir = NomadDirectory::new();

        for i in 0..(MAX_DIRECTORY_ENTRIES + 1) {
            let mut identity = [0u8; 16];
            identity[0..2].copy_from_slice(&(i as u16).to_le_bytes());
            let mut announced = make_announced(identity, Some(format!("Node{i}").into_bytes()));
            announced.received_at = i as f64;
            dir.handle_announce(&announced);
        }

        let oldest_identity = {
            let mut id = [0u8; 16];
            id[0..2].copy_from_slice(&0u16.to_le_bytes());
            id
        };
        let newest_identity = {
            let mut id = [0u8; 16];
            id[0..2].copy_from_slice(&(MAX_DIRECTORY_ENTRIES as u16).to_le_bytes());
            id
        };

        assert!(dir.get_node_by_identity(&oldest_identity).is_none());
        assert!(dir.get_node_by_identity(&newest_identity).is_some());
    }

    #[test]
    fn test_get_node() {
        let mut dir = NomadDirectory::new();
        let identity = [0xDD; 16];
        let dest = destination_hash("nomadnetwork", &["node"], Some(&identity));
        dir.handle_announce(&make_announced(identity, Some(b"Target".to_vec())));
        assert!(dir.get_node(&dest).is_some());
        assert_eq!(
            dir.get_node(&dest).unwrap().node_name.as_deref(),
            Some("Target")
        );
    }

    #[test]
    fn test_get_node_by_identity() {
        let mut dir = NomadDirectory::new();
        let identity = [0xEE; 16];
        dir.handle_announce(&make_announced(identity, Some(b"ByIdentity".to_vec())));
        assert!(dir.get_node_by_identity(&identity).is_some());
        assert_eq!(
            dir.get_node_by_identity(&identity)
                .unwrap()
                .node_name
                .as_deref(),
            Some("ByIdentity")
        );
    }

    #[test]
    fn test_is_nomadnet_announce() {
        let identity = [0xFF; 16];
        let nn_dest = destination_hash("nomadnetwork", &["node"], Some(&identity));
        let lxmf_dest = destination_hash("lxmf", &["delivery"], Some(&identity));

        let nn_announce = AnnouncedIdentity {
            dest_hash: DestHash(nn_dest),
            identity_hash: IdentityHash(identity),
            public_key: [0u8; 64],
            app_data: None,
            hops: 1,
            received_at: 0.0,
            receiving_interface: InterfaceId(0),
        };
        assert!(is_nomadnet_announce(&nn_announce));

        let lxmf_announce = AnnouncedIdentity {
            dest_hash: DestHash(lxmf_dest),
            identity_hash: IdentityHash(identity),
            public_key: [0u8; 64],
            app_data: None,
            hops: 1,
            received_at: 0.0,
            receiving_interface: InterfaceId(0),
        };
        assert!(!is_nomadnet_announce(&lxmf_announce));
    }

    #[test]
    fn test_associated_lxmf_dest_hash() {
        let identity = [0x42; 16];
        let expected = destination_hash("lxmf", &["delivery"], Some(&identity));
        assert_eq!(associated_lxmf_dest_hash(&identity), expected);
    }
}