use std::collections::{HashMap, VecDeque};
use rns_core::destination::destination_hash;
use rns_net::AnnouncedIdentity;
use tracing::debug;
use crate::types::{
AnnounceKind, AnnounceStreamEntry, DeliveryMethod, DirectoryEntry, PeerEntry, TrustLevel,
ANNOUNCE_STREAM_MAXLENGTH, MAX_DIRECTORY_ENTRIES,
};
use crate::util::{sanitize_name, strip_modifiers};
pub struct NomadDirectory {
node_entries: Vec<DirectoryEntry>,
peer_entries: HashMap<[u8; 16], PeerEntry>,
node_announces: VecDeque<AnnounceStreamEntry>,
peer_announces: VecDeque<AnnounceStreamEntry>,
pn_announces: VecDeque<AnnounceStreamEntry>,
}
fn compact_stream(stream: &mut VecDeque<AnnounceStreamEntry>, source_hash: &[u8; 16]) {
stream.retain(|e| &e.source_hash != source_hash);
}
fn truncate_stream(stream: &mut VecDeque<AnnounceStreamEntry>) {
while stream.len() > ANNOUNCE_STREAM_MAXLENGTH {
stream.pop_back();
}
}
fn remove_from_stream_by_timestamp(
stream: &mut VecDeque<AnnounceStreamEntry>,
timestamp: f64,
) -> bool {
if let Some(pos) = stream.iter().position(|e| e.timestamp == timestamp) {
stream.remove(pos);
true
} else {
false
}
}
impl NomadDirectory {
pub fn new() -> Self {
Self {
node_entries: Vec::new(),
peer_entries: HashMap::new(),
node_announces: VecDeque::new(),
peer_announces: VecDeque::new(),
pn_announces: VecDeque::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
.node_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 {
let associated_peer = associated_lxmf_dest_hash(&announced.identity_hash.0);
let trust = if self.trust_level(&associated_peer, None) == TrustLevel::Trusted {
TrustLevel::Trusted
} else {
TrustLevel::Unknown
};
self.node_entries.push(DirectoryEntry {
dest_hash: announced.dest_hash.0,
identity_hash: announced.identity_hash.0,
node_name: node_name.clone(),
hops: announced.hops,
last_seen: announced.received_at,
trust_level: trust,
});
if self.node_entries.len() > MAX_DIRECTORY_ENTRIES {
self.node_entries
.sort_by(|a, b| b.last_seen.total_cmp(&a.last_seen));
self.node_entries.truncate(MAX_DIRECTORY_ENTRIES);
}
}
self.add_node_announce(
announced.dest_hash.0,
announced.app_data.clone(),
announced.received_at,
true,
);
}
fn add_node_announce(
&mut self,
source_hash: [u8; 16],
app_data: Option<Vec<u8>>,
timestamp: f64,
compact: bool,
) {
Self::push_announce(
&mut self.node_announces,
AnnounceKind::Node,
source_hash,
app_data,
timestamp,
compact,
);
}
pub fn add_peer_announce(
&mut self,
source_hash: [u8; 16],
app_data: Option<Vec<u8>>,
timestamp: f64,
compact: bool,
) {
Self::push_announce(
&mut self.peer_announces,
AnnounceKind::Peer,
source_hash,
app_data,
timestamp,
compact,
);
}
pub fn add_pn_announce(
&mut self,
source_hash: [u8; 16],
app_data: Option<Vec<u8>>,
timestamp: f64,
compact: bool,
) {
Self::push_announce(
&mut self.pn_announces,
AnnounceKind::Pn,
source_hash,
app_data,
timestamp,
compact,
);
}
fn push_announce(
stream: &mut VecDeque<AnnounceStreamEntry>,
kind: AnnounceKind,
source_hash: [u8; 16],
app_data: Option<Vec<u8>>,
timestamp: f64,
compact: bool,
) {
if compact {
compact_stream(stream, &source_hash);
}
stream.push_front(AnnounceStreamEntry {
timestamp,
source_hash,
app_data,
kind,
});
truncate_stream(stream);
}
pub fn announce_stream(&self) -> Vec<&AnnounceStreamEntry> {
let total = self.node_announces.len() + self.peer_announces.len() + self.pn_announces.len();
let mut result = Vec::with_capacity(total);
result.extend(self.node_announces.iter());
result.extend(self.peer_announces.iter());
result.extend(self.pn_announces.iter());
result
}
pub fn remove_announce_by_timestamp(&mut self, timestamp: f64) -> bool {
if remove_from_stream_by_timestamp(&mut self.node_announces, timestamp) {
return true;
}
if remove_from_stream_by_timestamp(&mut self.peer_announces, timestamp) {
return true;
}
if remove_from_stream_by_timestamp(&mut self.pn_announces, timestamp) {
return true;
}
false
}
pub fn node_announces(&self) -> &VecDeque<AnnounceStreamEntry> {
&self.node_announces
}
pub fn peer_announces(&self) -> &VecDeque<AnnounceStreamEntry> {
&self.peer_announces
}
pub fn pn_announces(&self) -> &VecDeque<AnnounceStreamEntry> {
&self.pn_announces
}
pub fn known_nodes(&self) -> &[DirectoryEntry] {
&self.node_entries
}
pub fn get_node(&self, dest_hash: &[u8; 16]) -> Option<&DirectoryEntry> {
self.node_entries.iter().find(|e| &e.dest_hash == dest_hash)
}
pub fn get_node_by_identity(&self, identity_hash: &[u8; 16]) -> Option<&DirectoryEntry> {
self.node_entries
.iter()
.find(|e| &e.identity_hash == identity_hash)
}
pub fn len(&self) -> usize {
self.node_entries.len()
}
pub fn is_empty(&self) -> bool {
self.node_entries.is_empty()
}
pub fn remember(&mut self, entry: PeerEntry) {
let trust_level = entry.trust_level;
let identity_hash = entry.identity_hash;
let source_hash = entry.source_hash;
self.peer_entries.insert(source_hash, entry);
if let Some(id_hash) = identity_hash {
let expected_source = associated_lxmf_dest_hash(&id_hash);
if expected_source != source_hash {
return;
}
let associated_node = associated_node_dest_hash(&id_hash);
if let Some(node_entry) = self
.node_entries
.iter_mut()
.find(|e| e.dest_hash == associated_node)
{
node_entry.trust_level = trust_level;
}
}
}
pub fn forget(&mut self, source_hash: &[u8; 16]) -> bool {
self.peer_entries.remove(source_hash).is_some()
}
pub fn find(&self, source_hash: &[u8; 16]) -> Option<&PeerEntry> {
self.peer_entries.get(source_hash)
}
pub fn find_mut(&mut self, source_hash: &[u8; 16]) -> Option<&mut PeerEntry> {
self.peer_entries.get_mut(source_hash)
}
pub fn is_known(&self, source_hash: &[u8; 16]) -> bool {
self.peer_entries.contains_key(source_hash)
}
pub fn trust_level(
&self,
source_hash: &[u8; 16],
announced_display_name: Option<&str>,
) -> TrustLevel {
match self.peer_entries.get(source_hash) {
Some(entry) => {
if let Some(name) = announced_display_name {
if entry.trust_level != TrustLevel::Trusted {
for e in self.peer_entries.values() {
if e.display_name.as_deref() == Some(name)
&& e.source_hash != *source_hash
{
return TrustLevel::Warning;
}
}
}
}
entry.trust_level
}
None => TrustLevel::Unknown,
}
}
pub fn display_name(&self, source_hash: &[u8; 16]) -> Option<String> {
self.peer_entries
.get(source_hash)
.and_then(|e| e.display_name.as_ref())
.and_then(|n| strip_modifiers(Some(n)))
}
pub fn simplest_display_str(&self, source_hash: &[u8; 16]) -> String {
let trust = self.trust_level(source_hash, None);
let hex_str = hex::encode(source_hash);
match self.peer_entries.get(source_hash) {
Some(entry) => {
let name = entry
.display_name
.as_deref()
.and_then(|n| sanitize_name(Some(n)))
.unwrap_or_else(|| hex_str.clone());
if matches!(trust, TrustLevel::Warning | TrustLevel::Untrusted) {
format!("{} <{}>", name, hex_str)
} else {
name
}
}
None => format!("<{}>", hex_str),
}
}
pub fn alleged_display_str(&self, source_hash: &[u8; 16]) -> Option<String> {
self.peer_entries
.get(source_hash)
.and_then(|e| e.display_name.as_ref())
.and_then(|n| strip_modifiers(Some(n)))
}
pub fn preferred_delivery(&self, source_hash: &[u8; 16]) -> DeliveryMethod {
self.peer_entries
.get(source_hash)
.map(|e| e.preferred_delivery)
.unwrap_or(DeliveryMethod::Direct)
}
pub fn should_identify_on_connect(&self, source_hash: &[u8; 16]) -> bool {
self.peer_entries
.get(source_hash)
.map(|e| e.identify_on_connect)
.unwrap_or(false)
}
pub fn set_identify_on_connect(&mut self, source_hash: &[u8; 16], state: bool) {
if let Some(entry) = self.peer_entries.get_mut(source_hash) {
entry.identify_on_connect = state;
}
}
pub fn sort_rank(&self, source_hash: &[u8; 16]) -> Option<i32> {
self.peer_entries.get(source_hash).and_then(|e| e.sort_rank)
}
pub fn known_peers(&self) -> Vec<&PeerEntry> {
let mut peers: Vec<&PeerEntry> = self.peer_entries.values().collect();
peers.sort_by(|a, b| {
let rank_a = a.sort_rank.unwrap_or(i32::MAX);
let rank_b = b.sort_rank.unwrap_or(i32::MAX);
rank_a
.cmp(&rank_b)
.then_with(|| {
let trust_ord = |t: TrustLevel| match t {
TrustLevel::Trusted => 0,
TrustLevel::Unknown => 1,
TrustLevel::Untrusted => 2,
TrustLevel::Warning => 3,
};
trust_ord(a.trust_level).cmp(&trust_ord(b.trust_level))
})
.then_with(|| {
a.display_name
.as_deref()
.unwrap_or("_")
.cmp(b.display_name.as_deref().unwrap_or("_"))
})
});
peers
}
pub fn number_of_known_peers(&self, lookback_seconds: Option<f64>) -> usize {
let mut unique_hashes: Vec<[u8; 16]> = Vec::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
let cutoff = lookback_seconds.map(|s| now - s);
for entry in self.announce_stream() {
if !unique_hashes.contains(&entry.source_hash) {
if let Some(c) = cutoff {
if entry.timestamp < c {
continue;
}
}
unique_hashes.push(entry.source_hash);
}
}
unique_hashes.len()
}
pub fn save_to_bytes(&self) -> Vec<u8> {
use ciborium::value::Integer as CborInt;
let mut packed_entries = Vec::new();
for entry in self.peer_entries.values() {
packed_entries.push(ciborium::value::Value::Array(vec![
ciborium::value::Value::Bytes(entry.source_hash.to_vec()),
match &entry.display_name {
Some(n) => ciborium::value::Value::Text(n.clone()),
None => ciborium::value::Value::Null,
},
ciborium::value::Value::Integer(CborInt::from(entry.trust_level as u8)),
ciborium::value::Value::Bool(entry.hosts_node),
ciborium::value::Value::Integer(CborInt::from(entry.preferred_delivery as u8)),
ciborium::value::Value::Bool(entry.identify_on_connect),
match entry.sort_rank {
Some(r) => ciborium::value::Value::Integer(CborInt::from(r)),
None => ciborium::value::Value::Null,
},
ciborium::value::Value::Text(entry.notes.clone()),
match &entry.identity_hash {
Some(h) => ciborium::value::Value::Bytes(h.to_vec()),
None => ciborium::value::Value::Null,
},
]));
}
let announce_stream: Vec<ciborium::value::Value> = self
.announce_stream()
.iter()
.map(|e| {
ciborium::value::Value::Array(vec![
ciborium::value::Value::Float(e.timestamp),
ciborium::value::Value::Bytes(e.source_hash.to_vec()),
match &e.app_data {
Some(d) => ciborium::value::Value::Bytes(d.clone()),
None => ciborium::value::Value::Null,
},
ciborium::value::Value::Text(match e.kind {
AnnounceKind::Node => "node".to_string(),
AnnounceKind::Peer => "peer".to_string(),
AnnounceKind::Pn => "pn".to_string(),
}),
])
})
.collect();
let directory = ciborium::value::Value::Map(vec![
(
ciborium::value::Value::Text("entry_list".to_string()),
ciborium::value::Value::Array(packed_entries),
),
(
ciborium::value::Value::Text("announce_stream".to_string()),
ciborium::value::Value::Array(announce_stream),
),
]);
let mut buf = Vec::new();
let _ = ciborium::ser::into_writer(&directory, &mut buf);
buf
}
pub fn load_from_bytes(&mut self, data: &[u8]) -> Result<(), String> {
let val: ciborium::value::Value =
ciborium::de::from_reader(data).map_err(|e| e.to_string())?;
let root_map = match &val {
ciborium::value::Value::Map(pairs) => pairs,
_ => return Err("expected map".to_string()),
};
let get_map_val = |pairs: &[(ciborium::value::Value, ciborium::value::Value)],
key: &str|
-> Option<ciborium::value::Value> {
pairs
.iter()
.find(|(k, _)| matches!(k, ciborium::value::Value::Text(t) if t == key))
.map(|(_, v)| v.clone())
};
if let Some(ciborium::value::Value::Array(entries)) = get_map_val(root_map, "entry_list") {
for entry_val in entries {
if let ciborium::value::Value::Array(fields) = entry_val {
if fields.len() < 3 {
continue;
}
let source_hash = match &fields[0] {
ciborium::value::Value::Bytes(b) => {
let mut h = [0u8; 16];
if b.len() != 16 {
continue;
}
h.copy_from_slice(b);
h
}
_ => continue,
};
let display_name = match &fields[1] {
ciborium::value::Value::Text(t) => Some(t.clone()),
_ => None,
};
let trust_level = match &fields[2] {
ciborium::value::Value::Integer(i) => {
let v: i128 = (*i).into();
match v as u8 {
0x00 => TrustLevel::Warning,
0x01 => TrustLevel::Untrusted,
0x02 => TrustLevel::Unknown,
0xFF => TrustLevel::Trusted,
_ => TrustLevel::Unknown,
}
}
_ => TrustLevel::Unknown,
};
let hosts_node = fields
.get(3)
.and_then(|v| match v {
ciborium::value::Value::Bool(b) => Some(*b),
_ => None,
})
.unwrap_or(false);
let preferred_delivery = fields
.get(4)
.and_then(|v| match v {
ciborium::value::Value::Integer(i) => {
let v: i128 = (*i).into();
match v as u8 {
0x02 => Some(DeliveryMethod::Propagated),
_ => Some(DeliveryMethod::Direct),
}
}
_ => None,
})
.unwrap_or(DeliveryMethod::Direct);
let identify = fields
.get(5)
.and_then(|v| match v {
ciborium::value::Value::Bool(b) => Some(*b),
_ => None,
})
.unwrap_or(false);
let sort_rank = fields.get(6).and_then(|v| match v {
ciborium::value::Value::Integer(i) => {
let v: i128 = (*i).into();
Some(v as i32)
}
_ => None,
});
let (notes, legacy_identity_hash) = match fields.get(7) {
Some(ciborium::value::Value::Text(notes)) => (notes.clone(), None),
Some(ciborium::value::Value::Bytes(bytes)) if bytes.len() == 16 => {
let mut hash = [0u8; 16];
hash.copy_from_slice(bytes);
(String::new(), Some(hash))
}
_ => (String::new(), None),
};
let identity_hash = fields
.get(8)
.and_then(|value| match value {
ciborium::value::Value::Bytes(bytes) if bytes.len() == 16 => {
let mut hash = [0u8; 16];
hash.copy_from_slice(bytes);
Some(hash)
}
_ => None,
})
.or(legacy_identity_hash);
self.peer_entries.insert(
source_hash,
PeerEntry {
source_hash,
display_name,
trust_level,
hosts_node,
preferred_delivery,
identify_on_connect: identify,
sort_rank,
notes,
identity_hash,
},
);
}
}
}
if let Some(ciborium::value::Value::Array(items)) = get_map_val(root_map, "announce_stream")
{
self.node_announces.clear();
self.peer_announces.clear();
self.pn_announces.clear();
for item in items {
if let ciborium::value::Value::Array(fields) = item {
if fields.len() < 4 {
continue;
}
let timestamp = match &fields[0] {
ciborium::value::Value::Float(f) => *f,
ciborium::value::Value::Integer(i) => {
let v: i128 = (*i).into();
v as f64
}
_ => continue,
};
let source_hash = match &fields[1] {
ciborium::value::Value::Bytes(b) if b.len() == 16 => {
let mut h = [0u8; 16];
h.copy_from_slice(b);
h
}
_ => continue,
};
let app_data = match &fields[2] {
ciborium::value::Value::Bytes(b) => Some(b.clone()),
_ => None,
};
let kind = match &fields[3] {
ciborium::value::Value::Text(t) => match t.as_str() {
"node" => AnnounceKind::Node,
"peer" => AnnounceKind::Peer,
"pn" => AnnounceKind::Pn,
_ => continue,
},
_ => continue,
};
let entry = AnnounceStreamEntry {
timestamp,
source_hash,
app_data,
kind,
};
match kind {
AnnounceKind::Node => self.node_announces.push_back(entry),
AnnounceKind::Peer => self.peer_announces.push_back(entry),
AnnounceKind::Pn => self.pn_announces.push_back(entry),
}
}
}
}
Ok(())
}
}
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))
}
pub fn associated_node_dest_hash(identity_hash: &[u8; 16]) -> [u8; 16] {
destination_hash("nomadnetwork", &["node"], 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);
}
#[test]
fn test_handle_announce_adds_to_node_stream() {
let mut dir = NomadDirectory::new();
let identity = [0xA1; 16];
let announced = make_announced(identity, Some(b"StreamNode".to_vec()));
let expected_dest = announced.dest_hash.0;
dir.handle_announce(&announced);
assert_eq!(dir.node_announces().len(), 1);
let entry = &dir.node_announces()[0];
assert_eq!(entry.kind, AnnounceKind::Node);
assert_eq!(entry.source_hash, expected_dest);
assert_eq!(entry.app_data.as_deref(), Some(b"StreamNode".as_slice()));
}
#[test]
fn test_add_peer_announce() {
let mut dir = NomadDirectory::new();
let hash = [0xB1; 16];
dir.add_peer_announce(hash, Some(b"PeerData".to_vec()), 100.0, false);
assert_eq!(dir.peer_announces().len(), 1);
assert_eq!(dir.peer_announces()[0].kind, AnnounceKind::Peer);
assert_eq!(dir.peer_announces()[0].source_hash, hash);
}
#[test]
fn test_add_pn_announce() {
let mut dir = NomadDirectory::new();
let hash = [0xC1; 16];
dir.add_pn_announce(hash, Some(b"PnData".to_vec()), 200.0, false);
assert_eq!(dir.pn_announces().len(), 1);
assert_eq!(dir.pn_announces()[0].kind, AnnounceKind::Pn);
assert_eq!(dir.pn_announces()[0].source_hash, hash);
}
#[test]
fn test_announce_stream_combines_all() {
let mut dir = NomadDirectory::new();
dir.add_node_announce([0xA1; 16], None, 300.0, false);
dir.add_peer_announce([0xB1; 16], None, 200.0, false);
dir.add_pn_announce([0xC1; 16], None, 100.0, false);
let stream = dir.announce_stream();
assert_eq!(stream.len(), 3);
assert_eq!(stream[0].kind, AnnounceKind::Node);
assert_eq!(stream[1].kind, AnnounceKind::Peer);
assert_eq!(stream[2].kind, AnnounceKind::Pn);
}
#[test]
fn test_per_type_truncation() {
let mut dir = NomadDirectory::new();
for i in 0..=(ANNOUNCE_STREAM_MAXLENGTH + 5) {
let mut hash = [0u8; 16];
hash[0..2].copy_from_slice(&(i as u16).to_le_bytes());
dir.add_peer_announce(hash, None, i as f64, false);
}
assert_eq!(dir.peer_announces().len(), ANNOUNCE_STREAM_MAXLENGTH);
assert!(dir.node_announces().is_empty());
assert!(dir.pn_announces().is_empty());
}
#[test]
fn test_compact_stream_dedup() {
let mut dir = NomadDirectory::new();
let hash = [0xD1; 16];
dir.add_peer_announce(hash, Some(b"v1".to_vec()), 100.0, true);
dir.add_peer_announce(hash, Some(b"v2".to_vec()), 200.0, true);
assert_eq!(dir.peer_announces().len(), 1);
assert_eq!(
dir.peer_announces()[0].app_data.as_deref(),
Some(b"v2".as_slice())
);
}
#[test]
fn test_compact_only_within_same_type() {
let mut dir = NomadDirectory::new();
let hash = [0xE1; 16];
dir.add_node_announce(hash, None, 100.0, true);
dir.add_peer_announce(hash, None, 200.0, true);
assert_eq!(dir.node_announces().len(), 1);
assert_eq!(dir.peer_announces().len(), 1);
}
#[test]
fn test_remove_announce_by_timestamp_found_in_node() {
let mut dir = NomadDirectory::new();
dir.add_node_announce([0xA1; 16], None, 42.0, false);
dir.add_peer_announce([0xB1; 16], None, 43.0, false);
assert!(dir.remove_announce_by_timestamp(42.0));
assert!(dir.node_announces().is_empty());
assert_eq!(dir.peer_announces().len(), 1);
}
#[test]
fn test_remove_announce_by_timestamp_found_in_peer() {
let mut dir = NomadDirectory::new();
dir.add_node_announce([0xA1; 16], None, 42.0, false);
dir.add_peer_announce([0xB1; 16], None, 43.0, false);
assert!(dir.remove_announce_by_timestamp(43.0));
assert!(dir.peer_announces().is_empty());
assert_eq!(dir.node_announces().len(), 1);
}
#[test]
fn test_remove_announce_by_timestamp_found_in_pn() {
let mut dir = NomadDirectory::new();
dir.add_pn_announce([0xC1; 16], None, 44.0, false);
assert!(dir.remove_announce_by_timestamp(44.0));
assert!(dir.pn_announces().is_empty());
}
#[test]
fn test_remove_announce_by_timestamp_not_found() {
let mut dir = NomadDirectory::new();
dir.add_node_announce([0xA1; 16], None, 42.0, false);
assert!(!dir.remove_announce_by_timestamp(99.0));
assert_eq!(dir.node_announces().len(), 1);
}
#[test]
fn test_announce_stream_ordering_nodes_first() {
let mut dir = NomadDirectory::new();
dir.add_pn_announce([0xC1; 16], None, 100.0, false);
dir.add_peer_announce([0xB1; 16], None, 200.0, false);
dir.add_node_announce([0xA1; 16], None, 300.0, false);
let stream = dir.announce_stream();
assert_eq!(stream[0].kind, AnnounceKind::Node);
assert_eq!(stream[1].kind, AnnounceKind::Peer);
assert_eq!(stream[2].kind, AnnounceKind::Pn);
}
#[test]
fn test_remember_and_find() {
let mut dir = NomadDirectory::new();
let hash = [0x11; 16];
assert!(!dir.is_known(&hash));
assert!(dir.find(&hash).is_none());
dir.remember(PeerEntry {
source_hash: hash,
display_name: Some("Alice".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: true,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: Some(1),
notes: String::new(),
identity_hash: None,
});
assert!(dir.is_known(&hash));
let entry = dir.find(&hash).unwrap();
assert_eq!(entry.display_name.as_deref(), Some("Alice"));
assert_eq!(entry.trust_level, TrustLevel::Trusted);
assert!(entry.hosts_node);
assert_eq!(entry.sort_rank, Some(1));
}
#[test]
fn test_forget() {
let mut dir = NomadDirectory::new();
let hash = [0x22; 16];
dir.remember(PeerEntry {
source_hash: hash,
display_name: None,
trust_level: TrustLevel::Unknown,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
assert!(dir.is_known(&hash));
assert!(dir.forget(&hash));
assert!(!dir.is_known(&hash));
assert!(!dir.forget(&hash));
}
#[test]
fn test_trust_level_default_unknown() {
let dir = NomadDirectory::new();
assert_eq!(dir.trust_level(&[0xFF; 16], None), TrustLevel::Unknown);
}
#[test]
fn test_trust_level_warning_on_name_collision() {
let mut dir = NomadDirectory::new();
let hash_a = [0x01; 16];
let hash_b = [0x02; 16];
dir.remember(PeerEntry {
source_hash: hash_a,
display_name: Some("Bob".to_string()),
trust_level: TrustLevel::Unknown,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
dir.remember(PeerEntry {
source_hash: hash_b,
display_name: None,
trust_level: TrustLevel::Unknown,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
assert_eq!(dir.trust_level(&hash_b, Some("Bob")), TrustLevel::Warning);
assert_eq!(dir.trust_level(&hash_b, Some("Carol")), TrustLevel::Unknown);
assert_eq!(dir.trust_level(&hash_a, Some("Bob")), TrustLevel::Unknown);
}
#[test]
fn test_trust_level_no_warning_for_trusted() {
let mut dir = NomadDirectory::new();
let hash_a = [0x01; 16];
let hash_b = [0x02; 16];
dir.remember(PeerEntry {
source_hash: hash_a,
display_name: Some("Trusted".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
assert_eq!(
dir.trust_level(&hash_b, Some("Trusted")),
TrustLevel::Unknown
);
}
#[test]
fn test_display_name() {
let mut dir = NomadDirectory::new();
let hash = [0x33; 16];
assert!(dir.display_name(&hash).is_none());
dir.remember(PeerEntry {
source_hash: hash,
display_name: Some("Hello World".to_string()),
trust_level: TrustLevel::Unknown,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
assert_eq!(dir.display_name(&hash), Some("Hello World".to_string()));
}
#[test]
fn test_simplest_display_str_known() {
let mut dir = NomadDirectory::new();
let hash = [0x44; 16];
dir.remember(PeerEntry {
source_hash: hash,
display_name: Some("Alice".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
assert_eq!(dir.simplest_display_str(&hash), "Alice");
}
#[test]
fn test_simplest_display_str_warning_appends_hex() {
let mut dir = NomadDirectory::new();
let hash = [0x55; 16];
dir.remember(PeerEntry {
source_hash: hash,
display_name: Some("Evil".to_string()),
trust_level: TrustLevel::Warning,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
let s = dir.simplest_display_str(&hash);
assert!(s.starts_with("Evil <"));
assert!(s.contains(&hex::encode(hash)));
}
#[test]
fn test_simplest_display_str_unknown_uses_hex() {
let dir = NomadDirectory::new();
let hash = [0x66; 16];
let s = dir.simplest_display_str(&hash);
assert_eq!(s, format!("<{}>", hex::encode(hash)));
}
#[test]
fn test_preferred_delivery_default() {
let dir = NomadDirectory::new();
assert_eq!(dir.preferred_delivery(&[0xFF; 16]), DeliveryMethod::Direct);
let mut dir2 = NomadDirectory::new();
let hash = [0x77; 16];
dir2.remember(PeerEntry {
source_hash: hash,
display_name: None,
trust_level: TrustLevel::Unknown,
hosts_node: false,
preferred_delivery: DeliveryMethod::Propagated,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
assert_eq!(dir2.preferred_delivery(&hash), DeliveryMethod::Propagated);
}
#[test]
fn test_identify_on_connect() {
let mut dir = NomadDirectory::new();
let hash = [0x88; 16];
assert!(!dir.should_identify_on_connect(&hash));
dir.remember(PeerEntry {
source_hash: hash,
display_name: None,
trust_level: TrustLevel::Unknown,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: true,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
assert!(dir.should_identify_on_connect(&hash));
dir.set_identify_on_connect(&hash, false);
assert!(!dir.should_identify_on_connect(&hash));
}
#[test]
fn test_save_load_roundtrip() {
let mut dir = NomadDirectory::new();
dir.remember(PeerEntry {
source_hash: [0xAA; 16],
display_name: Some("Alice".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: true,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: Some(1),
notes: "Met at the mesh meetup".to_string(),
identity_hash: Some([0xCC; 16]),
});
dir.remember(PeerEntry {
source_hash: [0xBB; 16],
display_name: Some("Bob".to_string()),
trust_level: TrustLevel::Untrusted,
hosts_node: false,
preferred_delivery: DeliveryMethod::Propagated,
identify_on_connect: true,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
dir.add_node_announce([0xA1; 16], Some(b"NodeData".to_vec()), 100.0, false);
dir.add_peer_announce([0xB1; 16], None, 200.0, false);
let bytes = dir.save_to_bytes();
let mut dir2 = NomadDirectory::new();
dir2.load_from_bytes(&bytes).unwrap();
assert!(dir2.is_known(&[0xAA; 16]));
assert!(dir2.is_known(&[0xBB; 16]));
let alice = dir2.find(&[0xAA; 16]).unwrap();
assert_eq!(alice.display_name.as_deref(), Some("Alice"));
assert_eq!(alice.trust_level, TrustLevel::Trusted);
assert!(alice.hosts_node);
assert_eq!(alice.preferred_delivery, DeliveryMethod::Direct);
assert_eq!(alice.sort_rank, Some(1));
assert_eq!(alice.notes, "Met at the mesh meetup");
assert_eq!(alice.identity_hash, Some([0xCC; 16]));
let bob = dir2.find(&[0xBB; 16]).unwrap();
assert_eq!(bob.display_name.as_deref(), Some("Bob"));
assert_eq!(bob.trust_level, TrustLevel::Untrusted);
assert!(!bob.hosts_node);
assert_eq!(bob.preferred_delivery, DeliveryMethod::Propagated);
assert!(bob.identify_on_connect);
assert!(bob.sort_rank.is_none());
assert_eq!(dir2.node_announces().len(), 1);
assert_eq!(dir2.peer_announces().len(), 1);
assert_eq!(dir2.node_announces()[0].source_hash, [0xA1; 16]);
}
#[test]
fn test_load_migrates_legacy_rust_identity_hash_field() {
let source_hash = [0xAA; 16];
let identity_hash = [0xBB; 16];
let legacy_entry = ciborium::value::Value::Array(vec![
ciborium::value::Value::Bytes(source_hash.to_vec()),
ciborium::value::Value::Text("Legacy".to_string()),
ciborium::value::Value::Integer(ciborium::value::Integer::from(0xFF_u64)),
ciborium::value::Value::Bool(false),
ciborium::value::Value::Integer(ciborium::value::Integer::from(0x01_u64)),
ciborium::value::Value::Bool(false),
ciborium::value::Value::Null,
ciborium::value::Value::Bytes(identity_hash.to_vec()),
]);
let directory = ciborium::value::Value::Map(vec![(
ciborium::value::Value::Text("entry_list".to_string()),
ciborium::value::Value::Array(vec![legacy_entry]),
)]);
let mut data = Vec::new();
ciborium::ser::into_writer(&directory, &mut data).unwrap();
let mut loaded = NomadDirectory::new();
loaded.load_from_bytes(&data).unwrap();
let peer = loaded.find(&source_hash).unwrap();
assert!(peer.notes.is_empty());
assert_eq!(peer.identity_hash, Some(identity_hash));
}
#[test]
fn test_load_preserves_announce_kind() {
let mut dir = NomadDirectory::new();
dir.add_node_announce([0xA1; 16], None, 1.0, false);
dir.add_peer_announce([0xB1; 16], None, 2.0, false);
dir.add_pn_announce([0xC1; 16], None, 3.0, false);
let bytes = dir.save_to_bytes();
let mut dir2 = NomadDirectory::new();
dir2.load_from_bytes(&bytes).unwrap();
assert_eq!(dir2.node_announces().len(), 1);
assert_eq!(dir2.peer_announces().len(), 1);
assert_eq!(dir2.pn_announces().len(), 1);
assert_eq!(dir2.node_announces()[0].kind, AnnounceKind::Node);
assert_eq!(dir2.peer_announces()[0].kind, AnnounceKind::Peer);
assert_eq!(dir2.pn_announces()[0].kind, AnnounceKind::Pn);
}
#[test]
fn test_known_peers_sorted() {
let mut dir = NomadDirectory::new();
dir.remember(PeerEntry {
source_hash: [0x01; 16],
display_name: Some("Zebra".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
dir.remember(PeerEntry {
source_hash: [0x02; 16],
display_name: Some("Alice".to_string()),
trust_level: TrustLevel::Unknown,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
dir.remember(PeerEntry {
source_hash: [0x03; 16],
display_name: Some("Bob".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: Some(0),
notes: String::new(),
identity_hash: None,
});
let peers = dir.known_peers();
assert_eq!(peers[0].display_name.as_deref(), Some("Bob"));
assert_eq!(peers[1].display_name.as_deref(), Some("Zebra"));
assert_eq!(peers[2].display_name.as_deref(), Some("Alice"));
}
#[test]
fn test_sort_rank_accessor() {
let mut dir = NomadDirectory::new();
assert!(dir.sort_rank(&[0xFF; 16]).is_none());
dir.remember(PeerEntry {
source_hash: [0x99; 16],
display_name: None,
trust_level: TrustLevel::Unknown,
hosts_node: false,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: Some(42),
notes: String::new(),
identity_hash: None,
});
assert_eq!(dir.sort_rank(&[0x99; 16]), Some(42));
}
#[test]
fn test_auto_trust_node_from_trusted_peer_on_announce() {
let identity_hash = [0xAA; 16];
let mut dir = NomadDirectory::new();
let peer_hash = associated_lxmf_dest_hash(&identity_hash);
dir.remember(PeerEntry {
source_hash: peer_hash,
display_name: Some("Alice".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: true,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: Some(identity_hash),
});
let announced = make_announced(identity_hash, Some(b"TestNode".to_vec()));
dir.handle_announce(&announced);
let node_hash = associated_node_dest_hash(&identity_hash);
let node = dir.get_node(&node_hash).unwrap();
assert_eq!(node.trust_level, TrustLevel::Trusted);
assert_eq!(node.node_name.as_deref(), Some("TestNode"));
}
#[test]
fn test_no_auto_trust_when_peer_not_trusted() {
let identity_hash = [0xBB; 16];
let mut dir = NomadDirectory::new();
let peer_hash = associated_lxmf_dest_hash(&identity_hash);
dir.remember(PeerEntry {
source_hash: peer_hash,
display_name: Some("Bob".to_string()),
trust_level: TrustLevel::Unknown,
hosts_node: true,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
let announced = make_announced(identity_hash, Some(b"BobNode".to_vec()));
dir.handle_announce(&announced);
let node_hash = associated_node_dest_hash(&identity_hash);
let node = dir.get_node(&node_hash).unwrap();
assert_eq!(node.trust_level, TrustLevel::Unknown);
}
#[test]
fn test_remember_propagates_trust_to_existing_node() {
let identity_hash = [0xCC; 16];
let mut dir = NomadDirectory::new();
let announced = make_announced(identity_hash, Some(b"CarolNode".to_vec()));
dir.handle_announce(&announced);
let node_hash = associated_node_dest_hash(&identity_hash);
let node = dir.get_node(&node_hash).unwrap();
assert_eq!(node.trust_level, TrustLevel::Unknown);
let peer_hash = associated_lxmf_dest_hash(&identity_hash);
dir.remember(PeerEntry {
source_hash: peer_hash,
display_name: Some("Carol".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: true,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: Some(identity_hash),
});
let node = dir.get_node(&node_hash).unwrap();
assert_eq!(node.trust_level, TrustLevel::Trusted);
}
#[test]
fn test_remember_no_propagation_without_identity_hash() {
let identity_hash = [0xDD; 16];
let mut dir = NomadDirectory::new();
let announced = make_announced(identity_hash, Some(b"DaveNode".to_vec()));
dir.handle_announce(&announced);
let node_hash = associated_node_dest_hash(&identity_hash);
assert_eq!(
dir.get_node(&node_hash).unwrap().trust_level,
TrustLevel::Unknown
);
let peer_hash = associated_lxmf_dest_hash(&identity_hash);
dir.remember(PeerEntry {
source_hash: peer_hash,
display_name: Some("Dave".to_string()),
trust_level: TrustLevel::Trusted,
hosts_node: true,
preferred_delivery: DeliveryMethod::Direct,
identify_on_connect: false,
sort_rank: None,
notes: String::new(),
identity_hash: None,
});
assert_eq!(
dir.get_node(&node_hash).unwrap().trust_level,
TrustLevel::Unknown
);
}
}