1pub use crate::common::discovery::*;
13
14use std::fs;
15use std::io::{self, Read};
16use std::path::PathBuf;
17use std::process::{Command, Stdio};
18use std::sync::{Mutex, MutexGuard};
19use std::time::{Duration, Instant};
20
21use rns_core::msgpack::{self, Value};
22use rns_core::stamp::{stamp_valid, stamp_workblock};
23use rns_crypto::sha256::sha256;
24
25use crate::time;
26
27static DISCOVERY_STORAGE_LOCK: Mutex<()> = Mutex::new(());
32
33pub struct DiscoveredInterfaceStorage {
35 base_path: PathBuf,
36}
37
38impl DiscoveredInterfaceStorage {
39 pub fn new(base_path: PathBuf) -> Self {
41 Self { base_path }
42 }
43
44 pub fn store(&self, iface: &DiscoveredInterface) -> io::Result<()> {
46 let _guard = discovery_storage_guard();
47 self.store_unlocked(iface)
48 }
49
50 fn store_unlocked(&self, iface: &DiscoveredInterface) -> io::Result<()> {
51 let filename = hex_encode(&iface.discovery_hash);
52 let filepath = self.base_path.join(filename);
53
54 let data = self.serialize_interface(iface)?;
55 fs::write(&filepath, &data)
56 }
57
58 pub fn store_received(&self, iface: &mut DiscoveredInterface) -> io::Result<()> {
60 let _guard = discovery_storage_guard();
61 match self.load_unlocked(&iface.discovery_hash) {
62 Ok(Some(existing)) => {
63 iface.discovered = existing.discovered;
64 iface.heard_count = existing.heard_count.saturating_add(1);
65 }
66 Ok(None) => {
67 iface.discovered = iface.last_heard;
68 iface.heard_count = 1;
69 }
70 Err(err) => {
71 log::error!(
72 "Error while reading existing data for discovered interface, re-creating data: {}",
73 err
74 );
75 iface.discovered = iface.last_heard;
76 iface.heard_count = 1;
77 }
78 }
79
80 self.store_unlocked(iface)
81 }
82
83 pub fn load(&self, discovery_hash: &[u8; 32]) -> io::Result<Option<DiscoveredInterface>> {
85 let _guard = discovery_storage_guard();
86 self.load_unlocked(discovery_hash)
87 }
88
89 fn load_unlocked(&self, discovery_hash: &[u8; 32]) -> io::Result<Option<DiscoveredInterface>> {
90 let filename = hex_encode(discovery_hash);
91 let filepath = self.base_path.join(filename);
92
93 if !filepath.exists() {
94 return Ok(None);
95 }
96
97 let data = fs::read(&filepath)?;
98 self.deserialize_interface(&data).map(Some)
99 }
100
101 pub fn list(&self) -> io::Result<Vec<DiscoveredInterface>> {
103 let _guard = discovery_storage_guard();
104 self.list_unlocked()
105 }
106
107 fn list_unlocked(&self) -> io::Result<Vec<DiscoveredInterface>> {
108 let mut interfaces = Vec::new();
109
110 let entries = match fs::read_dir(&self.base_path) {
111 Ok(e) => e,
112 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(interfaces),
113 Err(e) => return Err(e),
114 };
115
116 for entry in entries {
117 let entry = entry?;
118 let path = entry.path();
119
120 if !path.is_file() {
121 continue;
122 }
123
124 match fs::read(&path) {
125 Ok(data) => {
126 if let Ok(iface) = self.deserialize_interface(&data) {
127 interfaces.push(iface);
128 }
129 }
130 Err(_) => continue,
131 }
132 }
133
134 Ok(interfaces)
135 }
136
137 pub fn remove(&self, discovery_hash: &[u8; 32]) -> io::Result<()> {
139 let _guard = discovery_storage_guard();
140 self.remove_unlocked(discovery_hash)
141 }
142
143 fn remove_unlocked(&self, discovery_hash: &[u8; 32]) -> io::Result<()> {
144 let filename = hex_encode(discovery_hash);
145 let filepath = self.base_path.join(filename);
146
147 if filepath.exists() {
148 fs::remove_file(&filepath)?;
149 }
150 Ok(())
151 }
152
153 pub fn cleanup(&self) -> io::Result<usize> {
156 self.cleanup_with_blackholes(|_| false)
157 }
158
159 pub fn cleanup_with_blackholes<F>(&self, mut is_blackholed: F) -> io::Result<usize>
161 where
162 F: FnMut(&[u8; 16]) -> bool,
163 {
164 let _guard = discovery_storage_guard();
165 let mut removed = 0;
166 let now = time::now();
167
168 let entries = match fs::read_dir(&self.base_path) {
169 Ok(entries) => entries,
170 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(0),
171 Err(err) => return Err(err),
172 };
173 for entry in entries {
174 let entry = entry?;
175 let path = entry.path();
176 if !path.is_file() {
177 continue;
178 }
179 let iface = match fs::read(&path).and_then(|data| self.deserialize_interface(&data)) {
180 Ok(iface) => iface,
181 Err(err) => {
182 log::debug!(
183 "Removing invalid cached interface discovery {}: {}",
184 path.display(),
185 err
186 );
187 fs::remove_file(&path)?;
188 removed += 1;
189 continue;
190 }
191 };
192 let invalid_reachable_on = iface
193 .reachable_on
194 .as_ref()
195 .map(|reachable_on| !(is_ip_address(reachable_on) || is_hostname(reachable_on)))
196 .unwrap_or(false);
197
198 if !is_discoverable_type(&iface.interface_type)
199 || invalid_reachable_on
200 || now - iface.last_heard > THRESHOLD_REMOVE
201 || is_blackholed(&iface.network_id)
202 || is_blackholed(&iface.transport_id)
203 {
204 fs::remove_file(&path)?;
205 removed += 1;
206 }
207 }
208
209 Ok(removed)
210 }
211
212 fn serialize_interface(&self, iface: &DiscoveredInterface) -> io::Result<Vec<u8>> {
214 let mut entries: Vec<(Value, Value)> = vec![
215 (
216 Value::Str("type".into()),
217 Value::Str(iface.interface_type.clone()),
218 ),
219 (Value::Str("transport".into()), Value::Bool(iface.transport)),
220 (Value::Str("name".into()), Value::Str(iface.name.clone())),
221 (
222 Value::Str("discovered".into()),
223 Value::Float(iface.discovered),
224 ),
225 (
226 Value::Str("last_heard".into()),
227 Value::Float(iface.last_heard),
228 ),
229 (
230 Value::Str("heard_count".into()),
231 Value::UInt(iface.heard_count as u64),
232 ),
233 (
234 Value::Str("status".into()),
235 Value::Str(iface.status.as_str().into()),
236 ),
237 (Value::Str("stamp".into()), Value::Bin(iface.stamp.clone())),
238 (
239 Value::Str("value".into()),
240 Value::UInt(iface.stamp_value as u64),
241 ),
242 (
243 Value::Str("transport_id".into()),
244 Value::Bin(iface.transport_id.to_vec()),
245 ),
246 (
247 Value::Str("network_id".into()),
248 Value::Bin(iface.network_id.to_vec()),
249 ),
250 (Value::Str("hops".into()), Value::UInt(iface.hops as u64)),
251 ];
252
253 if let Some(v) = iface.latitude {
254 entries.push((Value::Str("latitude".into()), Value::Float(v)));
255 }
256 if let Some(v) = iface.longitude {
257 entries.push((Value::Str("longitude".into()), Value::Float(v)));
258 }
259 if let Some(v) = iface.height {
260 entries.push((Value::Str("height".into()), Value::Float(v)));
261 }
262 if let Some(v) = iface.operator_lxmf_address {
263 entries.push((
264 Value::Str("operator_lxmf_address".into()),
265 Value::Bin(v.to_vec()),
266 ));
267 }
268 if let Some(ref v) = iface.reachable_on {
269 entries.push((Value::Str("reachable_on".into()), Value::Str(v.clone())));
270 }
271 if let Some(v) = iface.port {
272 entries.push((Value::Str("port".into()), Value::UInt(v as u64)));
273 }
274 if let Some(v) = iface.frequency {
275 entries.push((Value::Str("frequency".into()), Value::UInt(v as u64)));
276 }
277 if let Some(v) = iface.bandwidth {
278 entries.push((Value::Str("bandwidth".into()), Value::UInt(v as u64)));
279 }
280 if let Some(v) = iface.spreading_factor {
281 entries.push((Value::Str("sf".into()), Value::UInt(v as u64)));
282 }
283 if let Some(v) = iface.coding_rate {
284 entries.push((Value::Str("cr".into()), Value::UInt(v as u64)));
285 }
286 if let Some(ref v) = iface.modulation {
287 entries.push((Value::Str("modulation".into()), Value::Str(v.clone())));
288 }
289 if let Some(v) = iface.channel {
290 entries.push((Value::Str("channel".into()), Value::UInt(v as u64)));
291 }
292 if let Some(ref v) = iface.ifac_netname {
293 entries.push((Value::Str("ifac_netname".into()), Value::Str(v.clone())));
294 }
295 if let Some(ref v) = iface.ifac_netkey {
296 entries.push((Value::Str("ifac_netkey".into()), Value::Str(v.clone())));
297 }
298 if let Some(ref v) = iface.config_entry {
299 entries.push((Value::Str("config_entry".into()), Value::Str(v.clone())));
300 }
301
302 entries.push((
303 Value::Str("discovery_hash".into()),
304 Value::Bin(iface.discovery_hash.to_vec()),
305 ));
306
307 Ok(msgpack::pack(&Value::Map(entries)))
308 }
309
310 fn deserialize_interface(&self, data: &[u8]) -> io::Result<DiscoveredInterface> {
312 let (value, _) = msgpack::unpack(data).map_err(|e| {
313 io::Error::new(io::ErrorKind::InvalidData, format!("msgpack error: {}", e))
314 })?;
315
316 let get_str = |v: &Value, key: &str| -> io::Result<String> {
318 v.map_get(key)
319 .and_then(|val| val.as_str())
320 .map(|s| s.to_string())
321 .ok_or_else(|| {
322 io::Error::new(io::ErrorKind::InvalidData, format!("{} not a string", key))
323 })
324 };
325
326 let get_opt_str = |v: &Value, key: &str| -> Option<String> {
327 v.map_get(key)
328 .and_then(|val| val.as_str().map(|s| s.to_string()))
329 };
330
331 let get_bool = |v: &Value, key: &str| -> io::Result<bool> {
332 v.map_get(key).and_then(|val| val.as_bool()).ok_or_else(|| {
333 io::Error::new(io::ErrorKind::InvalidData, format!("{} not a bool", key))
334 })
335 };
336
337 let get_float = |v: &Value, key: &str| -> io::Result<f64> {
338 v.map_get(key)
339 .and_then(|val| val.as_float())
340 .ok_or_else(|| {
341 io::Error::new(io::ErrorKind::InvalidData, format!("{} not a float", key))
342 })
343 };
344
345 let get_opt_float =
346 |v: &Value, key: &str| -> Option<f64> { v.map_get(key).and_then(|val| val.as_float()) };
347
348 let get_uint = |v: &Value, key: &str| -> io::Result<u64> {
349 v.map_get(key).and_then(|val| val.as_uint()).ok_or_else(|| {
350 io::Error::new(io::ErrorKind::InvalidData, format!("{} not a uint", key))
351 })
352 };
353
354 let get_opt_uint =
355 |v: &Value, key: &str| -> Option<u64> { v.map_get(key).and_then(|val| val.as_uint()) };
356
357 let get_bytes = |v: &Value, key: &str| -> io::Result<Vec<u8>> {
358 v.map_get(key)
359 .and_then(|val| val.as_bin())
360 .map(|b| b.to_vec())
361 .ok_or_else(|| {
362 io::Error::new(io::ErrorKind::InvalidData, format!("{} not bytes", key))
363 })
364 };
365
366 let fixed_bytes = |key: &str, expected_len: usize| -> io::Result<Vec<u8>> {
367 let bytes = get_bytes(&value, key)?;
368 if bytes.len() != expected_len {
369 return Err(io::Error::new(
370 io::ErrorKind::InvalidData,
371 format!("{} must be {} bytes", key, expected_len),
372 ));
373 }
374 Ok(bytes)
375 };
376
377 let transport_id_bytes = fixed_bytes("transport_id", 16)?;
378 let mut transport_id = [0u8; 16];
379 transport_id.copy_from_slice(&transport_id_bytes);
380
381 let network_id_bytes = fixed_bytes("network_id", 16)?;
382 let mut network_id = [0u8; 16];
383 network_id.copy_from_slice(&network_id_bytes);
384
385 let discovery_hash_bytes = fixed_bytes("discovery_hash", 32)?;
386 let mut discovery_hash = [0u8; 32];
387 discovery_hash.copy_from_slice(&discovery_hash_bytes);
388
389 let status_str = get_str(&value, "status")?;
390 let status = match status_str.as_str() {
391 "available" => DiscoveredStatus::Available,
392 "unknown" => DiscoveredStatus::Unknown,
393 "stale" => DiscoveredStatus::Stale,
394 _ => DiscoveredStatus::Unknown,
395 };
396
397 let interface_type = get_str(&value, "type")?;
398 let raw_name = get_str(&value, "name")?;
399 let name = sanitize_discovered_name(&raw_name)
400 .unwrap_or_else(|| format!("Discovered {}", interface_type));
401
402 Ok(DiscoveredInterface {
403 interface_type,
404 transport: get_bool(&value, "transport")?,
405 name,
406 discovered: get_float(&value, "discovered")?,
407 last_heard: get_float(&value, "last_heard")?,
408 heard_count: get_uint(&value, "heard_count")? as u32,
409 status,
410 stamp: get_bytes(&value, "stamp")?,
411 stamp_value: get_uint(&value, "value")? as u32,
412 transport_id,
413 network_id,
414 hops: get_uint(&value, "hops")? as u8,
415 latitude: get_opt_float(&value, "latitude"),
416 longitude: get_opt_float(&value, "longitude"),
417 height: get_opt_float(&value, "height"),
418 operator_lxmf_address: value
419 .map_get("operator_lxmf_address")
420 .and_then(Value::as_bin)
421 .filter(|bytes| bytes.len() == 16)
422 .map(|bytes| {
423 let mut address = [0u8; 16];
424 address.copy_from_slice(bytes);
425 address
426 }),
427 reachable_on: get_opt_str(&value, "reachable_on"),
428 port: get_opt_uint(&value, "port").map(|v| v as u16),
429 frequency: get_opt_uint(&value, "frequency").map(|v| v as u32),
430 bandwidth: get_opt_uint(&value, "bandwidth").map(|v| v as u32),
431 spreading_factor: get_opt_uint(&value, "sf").map(|v| v as u8),
432 coding_rate: get_opt_uint(&value, "cr").map(|v| v as u8),
433 modulation: get_opt_str(&value, "modulation"),
434 channel: get_opt_uint(&value, "channel").map(|v| v as u8),
435 ifac_netname: get_opt_str(&value, "ifac_netname"),
436 ifac_netkey: get_opt_str(&value, "ifac_netkey"),
437 config_entry: get_opt_str(&value, "config_entry"),
438 discovery_hash,
439 })
440 }
441}
442
443fn discovery_storage_guard() -> MutexGuard<'static, ()> {
444 match DISCOVERY_STORAGE_LOCK.lock() {
445 Ok(guard) => guard,
446 Err(poisoned) => {
447 log::error!("recovering from poisoned discovery storage lock");
448 poisoned.into_inner()
449 }
450 }
451}
452
453pub fn generate_discovery_stamp(packed_data: &[u8], stamp_cost: u8) -> ([u8; STAMP_SIZE], u32) {
461 use rns_crypto::{OsRng, Rng};
462 use std::sync::atomic::{AtomicBool, Ordering};
463 use std::sync::{Arc, Mutex};
464
465 let infohash = sha256(packed_data);
466 let workblock = stamp_workblock(&infohash, WORKBLOCK_EXPAND_ROUNDS);
467
468 let found: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
469 let result: Arc<Mutex<Option<[u8; STAMP_SIZE]>>> = Arc::new(Mutex::new(None));
470
471 let num_threads = rayon::current_num_threads();
472
473 rayon::scope(|s| {
474 for _ in 0..num_threads {
475 let found = found.clone();
476 let result = result.clone();
477 let workblock = &workblock;
478 s.spawn(move |_| {
479 let mut rng = OsRng;
480 let mut nonce = [0u8; STAMP_SIZE];
481 loop {
482 if found.load(Ordering::Relaxed) {
483 return;
484 }
485 rng.fill_bytes(&mut nonce);
486 if stamp_valid(&nonce, stamp_cost, workblock) {
487 let mut r = match result.lock() {
488 Ok(guard) => guard,
489 Err(poisoned) => {
490 log::error!(
491 "recovering from poisoned discovery stamp result buffer"
492 );
493 poisoned.into_inner()
494 }
495 };
496 if r.is_none() {
497 *r = Some(nonce);
498 }
499 found.store(true, Ordering::Relaxed);
500 return;
501 }
502 }
503 });
504 }
505 });
506
507 let stamp = match result.lock() {
508 Ok(mut guard) => guard.take(),
509 Err(poisoned) => {
510 log::error!("recovering from poisoned discovery stamp result buffer");
511 poisoned.into_inner().take()
512 }
513 }
514 .unwrap_or_else(|| {
515 log::error!("parallel discovery stamp search returned no result; retrying synchronously");
516 let mut rng = OsRng;
517 let mut nonce = [0u8; STAMP_SIZE];
518 loop {
519 rng.fill_bytes(&mut nonce);
520 if stamp_valid(&nonce, stamp_cost, &workblock) {
521 return nonce;
522 }
523 }
524 });
525 let value = rns_core::stamp::stamp_value(&workblock, &stamp);
526 (stamp, value)
527}
528
529#[derive(Debug, Clone)]
535pub struct DiscoverableInterface {
536 pub interface_name: String,
538 pub config: DiscoveryConfig,
539 pub transport_enabled: bool,
541 pub ifac_netname: Option<String>,
543 pub ifac_netkey: Option<String>,
545}
546
547pub struct AnnounceResult {
549 pub interface_name: String,
551 pub app_data: Result<Vec<u8>, String>,
554}
555
556pub struct InterfaceAnnouncer {
562 transport_id: [u8; 16],
564 interfaces: Vec<DiscoverableInterface>,
566 last_announced: Vec<f64>,
568 stamp_rx: std::sync::mpsc::Receiver<AnnounceResult>,
570 stamp_tx: std::sync::mpsc::Sender<AnnounceResult>,
572 stamp_pending: bool,
574}
575
576const LOCATION_CMD_TIMEOUT: Duration = Duration::from_secs(5);
577const LOCATION_CMD_MAX_STDOUT: usize = 4096;
578
579fn parse_location_output(output: &[u8]) -> Result<(f64, f64, f64), String> {
580 if output.len() > LOCATION_CMD_MAX_STDOUT {
581 return Err(format!(
582 "location command output exceeds {} bytes",
583 LOCATION_CMD_MAX_STDOUT
584 ));
585 }
586 let line = std::str::from_utf8(output)
587 .map_err(|error| format!("location command output is not UTF-8: {error}"))?;
588 let line = line.strip_suffix('\n').unwrap_or(line);
589 let line = line.strip_suffix('\r').unwrap_or(line);
590 if line.contains(['\n', '\r']) {
591 return Err("location command must output exactly one line".into());
592 }
593 let values: Vec<&str> = line.split(',').map(str::trim).collect();
594 if values.len() != 3 {
595 return Err(format!(
596 "location command returned {} components; expected 3",
597 values.len()
598 ));
599 }
600 let parse = |value: &str, label: &str| {
601 value
602 .parse::<f64>()
603 .map_err(|error| format!("invalid {label} '{value}': {error}"))
604 .and_then(|number| {
605 number
606 .is_finite()
607 .then_some(number)
608 .ok_or_else(|| format!("{label} must be finite"))
609 })
610 };
611 let latitude = parse(values[0], "latitude")?;
612 let longitude = parse(values[1], "longitude")?;
613 let height = parse(values[2], "height")?;
614 if !(-90.0..=90.0).contains(&latitude) {
615 return Err(format!("latitude {latitude} is outside -90..=90"));
616 }
617 if !(-180.0..=180.0).contains(&longitude) {
618 return Err(format!("longitude {longitude} is outside -180..=180"));
619 }
620 if !(-4000.0..=1_000_000.0).contains(&height) {
621 return Err(format!("height {height} is outside -4000..=1000000"));
622 }
623 Ok((latitude, longitude, height))
624}
625
626#[cfg(unix)]
627fn expand_location_path(
628 command: &str,
629 home: Option<std::ffi::OsString>,
630) -> Result<PathBuf, String> {
631 let path = if command == "~" {
632 home.map(PathBuf::from).ok_or_else(|| {
633 "cannot expand location command '~' because HOME is not set".to_string()
634 })?
635 } else if let Some(suffix) = command.strip_prefix("~/") {
636 let home = home.map(PathBuf::from).ok_or_else(|| {
637 "cannot expand location command '~/' because HOME is not set".to_string()
638 })?;
639 home.join(suffix)
640 } else {
641 PathBuf::from(command)
642 };
643 Ok(path)
644}
645
646#[cfg(unix)]
647fn resolve_location(command: &str) -> Result<(f64, f64, f64), String> {
648 use std::os::unix::fs::PermissionsExt;
649
650 let path = expand_location_path(command, std::env::var_os("HOME"))?;
651 let metadata = fs::metadata(&path)
652 .map_err(|error| format!("cannot inspect '{}': {error}", path.display()))?;
653 if !metadata.is_file() {
654 return Err(format!("'{}' is not a regular file", path.display()));
655 }
656 if metadata.permissions().mode() & 0o111 == 0 {
657 return Err(format!("'{}' is not executable", path.display()));
658 }
659
660 let mut child = Command::new(&path)
661 .stdin(Stdio::null())
662 .stdout(Stdio::piped())
663 .stderr(Stdio::null())
664 .spawn()
665 .map_err(|error| format!("cannot execute '{}': {error}", path.display()))?;
666 let stdout = child
667 .stdout
668 .take()
669 .ok_or_else(|| "location command stdout was unavailable".to_string())?;
670 let reader = std::thread::spawn(move || {
671 let mut output = Vec::new();
672 stdout
673 .take((LOCATION_CMD_MAX_STDOUT + 1) as u64)
674 .read_to_end(&mut output)
675 .map(|_| output)
676 });
677 let started = Instant::now();
678 let status = loop {
679 match child.try_wait() {
680 Ok(Some(status)) => break status,
681 Ok(None) if started.elapsed() < LOCATION_CMD_TIMEOUT => {
682 std::thread::sleep(Duration::from_millis(10));
683 }
684 Ok(None) => {
685 let _ = child.kill();
686 let _ = child.wait();
687 return Err("location command timed out after 5 seconds".into());
688 }
689 Err(error) => {
690 let _ = child.kill();
691 let _ = child.wait();
692 return Err(format!("failed waiting for location command: {error}"));
693 }
694 }
695 };
696 let output = reader
697 .join()
698 .map_err(|_| "location command stdout reader panicked".to_string())?
699 .map_err(|error| format!("failed reading location command stdout: {error}"))?;
700 if !status.success() {
701 return Err(format!("location command exited with {status}"));
702 }
703 parse_location_output(&output)
704}
705
706#[cfg(windows)]
707fn resolve_location(_command: &str) -> Result<(f64, f64, f64), String> {
708 Err("location_cmd is not supported on Windows".into())
709}
710
711fn prepare_interface_info(
712 transport_id: &[u8; 16],
713 iface: &DiscoverableInterface,
714) -> Result<Vec<u8>, String> {
715 let mut resolved = iface.clone();
716 if let Some(command) = &resolved.config.location_cmd {
717 let (latitude, longitude, height) = resolve_location(command)?;
718 resolved.config.latitude = Some(latitude);
719 resolved.config.longitude = Some(longitude);
720 resolved.config.height = Some(height);
721 }
722 InterfaceAnnouncer::pack_interface_info(transport_id, &resolved)
723}
724
725impl InterfaceAnnouncer {
726 pub fn new(transport_id: [u8; 16], interfaces: Vec<DiscoverableInterface>) -> Self {
728 let n = interfaces.len();
729 let (stamp_tx, stamp_rx) = std::sync::mpsc::channel();
730 InterfaceAnnouncer {
731 transport_id,
732 interfaces,
733 last_announced: vec![0.0; n],
734 stamp_rx,
735 stamp_tx,
736 stamp_pending: false,
737 }
738 }
739
740 pub fn maybe_start(&mut self, now: f64) {
744 if self.stamp_pending {
745 return;
746 }
747 let due_index = self
748 .interfaces
749 .iter()
750 .enumerate()
751 .filter(|(i, iface)| {
752 now - self.last_announced[*i] >= iface.config.announce_interval as f64
753 })
754 .min_by(|(left, _), (right, _)| {
755 self.last_announced[*left].total_cmp(&self.last_announced[*right])
756 })
757 .map(|(i, _)| i);
758
759 if let Some(idx) = due_index {
760 let iface = self.interfaces[idx].clone();
761 let transport_id = self.transport_id;
762 let stamp_cost = iface.config.stamp_value;
763 let name = iface.config.discovery_name.clone();
764 let interface_name = iface.interface_name.clone();
765 let tx = self.stamp_tx.clone();
766
767 log::info!(
768 "Spawning discovery stamp generation (cost={}) for '{}'...",
769 stamp_cost,
770 name,
771 );
772
773 self.stamp_pending = true;
774 self.last_announced[idx] = now;
775
776 std::thread::spawn(move || {
777 let packed = match prepare_interface_info(&transport_id, &iface) {
778 Ok(packed) => packed,
779 Err(error) => {
780 let _ = tx.send(AnnounceResult {
781 interface_name,
782 app_data: Err(error),
783 });
784 return;
785 }
786 };
787 let (stamp, value) = generate_discovery_stamp(&packed, stamp_cost);
788 log::info!("Discovery stamp generated (value={}) for '{}'", value, name,);
789
790 let flags: u8 = 0x00; let mut app_data = Vec::with_capacity(1 + packed.len() + STAMP_SIZE);
792 app_data.push(flags);
793 app_data.extend_from_slice(&packed);
794 app_data.extend_from_slice(&stamp);
795
796 let _ = tx.send(AnnounceResult {
797 interface_name,
798 app_data: Ok(app_data),
799 });
800 });
801 }
802 }
803
804 pub fn poll_ready(&mut self) -> Option<AnnounceResult> {
807 match self.stamp_rx.try_recv() {
808 Ok(result) => {
809 self.stamp_pending = false;
810 Some(result)
811 }
812 Err(_) => None,
813 }
814 }
815
816 pub fn contains_interface(&self, interface_name: &str) -> bool {
818 self.interfaces
819 .iter()
820 .any(|iface| iface.interface_name == interface_name)
821 }
822
823 pub fn upsert_interface(&mut self, iface: DiscoverableInterface) {
825 if let Some(index) = self
826 .interfaces
827 .iter()
828 .position(|existing| existing.interface_name == iface.interface_name)
829 {
830 self.interfaces[index] = iface;
831 return;
832 }
833 self.interfaces.push(iface);
834 self.last_announced.push(0.0);
835 }
836
837 pub fn remove_interface(&mut self, interface_name: &str) -> bool {
839 if let Some(index) = self
840 .interfaces
841 .iter()
842 .position(|iface| iface.interface_name == interface_name)
843 {
844 self.interfaces.remove(index);
845 self.last_announced.remove(index);
846 true
847 } else {
848 false
849 }
850 }
851
852 pub fn is_empty(&self) -> bool {
854 self.interfaces.is_empty()
855 }
856
857 fn pack_interface_info(
859 transport_id: &[u8; 16],
860 iface: &DiscoverableInterface,
861 ) -> Result<Vec<u8>, String> {
862 if matches!(
863 iface.config.interface_type.as_str(),
864 "BackboneInterface" | "TCPServerInterface"
865 ) && iface.config.reachable_on.is_none()
866 {
867 return Err(format!(
868 "{} discovery requires a reachable_on address",
869 iface.config.interface_type
870 ));
871 }
872 let mut entries: Vec<(msgpack::Value, msgpack::Value)> = vec![
873 (
874 msgpack::Value::UInt(INTERFACE_TYPE as u64),
875 msgpack::Value::Str(iface.config.interface_type.clone()),
876 ),
877 (
878 msgpack::Value::UInt(TRANSPORT as u64),
879 msgpack::Value::Bool(iface.transport_enabled),
880 ),
881 (
882 msgpack::Value::UInt(NAME as u64),
883 msgpack::Value::Str(iface.config.discovery_name.clone()),
884 ),
885 (
886 msgpack::Value::UInt(TRANSPORT_ID as u64),
887 msgpack::Value::Bin(transport_id.to_vec()),
888 ),
889 (
890 msgpack::Value::UInt(TRANSPORT_IMPL as u64),
891 msgpack::Value::Str(TRANSPORT_IMPLEMENTATION_NAME.into()),
892 ),
893 (
894 msgpack::Value::UInt(TRANSPORT_VERS as u64),
895 msgpack::Value::Str(TRANSPORT_IMPLEMENTATION_VERSION.into()),
896 ),
897 ];
898 if let Some(ref reachable) = iface.config.reachable_on {
899 entries.push((
900 msgpack::Value::UInt(REACHABLE_ON as u64),
901 msgpack::Value::Str(reachable.clone()),
902 ));
903 }
904 if let Some(port) = iface.config.listen_port {
905 entries.push((
906 msgpack::Value::UInt(PORT as u64),
907 msgpack::Value::UInt(port as u64),
908 ));
909 }
910 if let Some(lat) = iface.config.latitude {
911 entries.push((
912 msgpack::Value::UInt(LATITUDE as u64),
913 msgpack::Value::Float(lat),
914 ));
915 }
916 if let Some(lon) = iface.config.longitude {
917 entries.push((
918 msgpack::Value::UInt(LONGITUDE as u64),
919 msgpack::Value::Float(lon),
920 ));
921 }
922 if let Some(h) = iface.config.height {
923 entries.push((
924 msgpack::Value::UInt(HEIGHT as u64),
925 msgpack::Value::Float(h),
926 ));
927 }
928 if let Some(address) = iface.config.operator_lxmf_address {
929 entries.push((
930 msgpack::Value::UInt(OP_ADDR as u64),
931 msgpack::Value::Bin(address.to_vec()),
932 ));
933 }
934 if let Some(ref netname) = iface.ifac_netname {
935 entries.push((
936 msgpack::Value::UInt(IFAC_NETNAME as u64),
937 msgpack::Value::Str(netname.clone()),
938 ));
939 }
940 if let Some(ref netkey) = iface.ifac_netkey {
941 entries.push((
942 msgpack::Value::UInt(IFAC_NETKEY as u64),
943 msgpack::Value::Str(netkey.clone()),
944 ));
945 }
946
947 Ok(msgpack::pack(&msgpack::Value::Map(entries)))
948 }
949}
950
951#[cfg(test)]
956mod tests {
957 use super::*;
958 use std::sync::atomic::{AtomicU64, Ordering};
959
960 fn discovery_test_dir(label: &str) -> PathBuf {
961 static COUNTER: AtomicU64 = AtomicU64::new(0);
962 std::env::temp_dir().join(format!(
963 "rns-discovery-{label}-{}-{}",
964 std::process::id(),
965 COUNTER.fetch_add(1, Ordering::Relaxed)
966 ))
967 }
968
969 fn test_announce_interface(name: &str, interval: u64) -> DiscoverableInterface {
970 DiscoverableInterface {
971 interface_name: name.to_string(),
972 config: DiscoveryConfig {
973 discovery_name: format!("Discovery {name}"),
974 announce_interval: interval,
975 stamp_value: 0,
976 reachable_on: Some("example.test".into()),
977 interface_type: "BackboneInterface".into(),
978 listen_port: Some(4242),
979 location_cmd: None,
980 latitude: Some(45.0),
981 longitude: Some(9.0),
982 height: Some(100.0),
983 operator_lxmf_address: None,
984 },
985 transport_enabled: true,
986 ifac_netname: Some("testnet".into()),
987 ifac_netkey: Some("secret".into()),
988 }
989 }
990
991 fn wait_for_announce(announcer: &mut InterfaceAnnouncer) -> AnnounceResult {
992 for _ in 0..10_000 {
993 if let Some(result) = announcer.poll_ready() {
994 return result;
995 }
996 std::thread::sleep(Duration::from_millis(1));
997 }
998 panic!("background discovery job did not complete");
999 }
1000
1001 #[test]
1002 fn location_output_parser_enforces_shape_finiteness_and_ranges() {
1003 for valid in [
1004 "-90,-180,-4000",
1005 "90,180,1000000\n",
1006 " 45.5, 9.25, 123.0\r\n",
1007 ] {
1008 assert!(parse_location_output(valid.as_bytes()).is_ok(), "{valid}");
1009 }
1010 for invalid in [
1011 "",
1012 "1,2",
1013 "1,2,3,4",
1014 "one,2,3",
1015 "NaN,2,3",
1016 "inf,2,3",
1017 "91,2,3",
1018 "1,181,3",
1019 "1,2,-4001",
1020 "1,2,1000001",
1021 "1,2,3\n4,5,6\n",
1022 ] {
1023 assert!(
1024 parse_location_output(invalid.as_bytes()).is_err(),
1025 "{invalid}"
1026 );
1027 }
1028 assert!(parse_location_output(&[0xff, 0xfe]).is_err());
1029 assert!(parse_location_output(&vec![b'0'; LOCATION_CMD_MAX_STDOUT + 1]).is_err());
1030 }
1031
1032 #[cfg(unix)]
1033 fn executable_script(body: &str) -> PathBuf {
1034 use std::os::unix::fs::PermissionsExt;
1035 use std::sync::atomic::{AtomicU64, Ordering};
1036
1037 static NEXT: AtomicU64 = AtomicU64::new(0);
1038 let path = std::env::temp_dir().join(format!(
1039 "rns-location-command-{}-{}",
1040 std::process::id(),
1041 NEXT.fetch_add(1, Ordering::Relaxed)
1042 ));
1043 fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1044 let mut permissions = fs::metadata(&path).unwrap().permissions();
1045 permissions.set_mode(0o700);
1046 fs::set_permissions(&path, permissions).unwrap();
1047 std::thread::sleep(Duration::from_millis(10));
1050 path
1051 }
1052
1053 #[cfg(unix)]
1054 #[test]
1055 fn location_command_is_direct_bounded_and_overrides_static_coordinates() {
1056 use std::os::unix::fs::PermissionsExt;
1057
1058 let home = std::ffi::OsString::from("/tmp/rns-home");
1059 assert_eq!(
1060 expand_location_path("~/bin/location", Some(home)).unwrap(),
1061 PathBuf::from("/tmp/rns-home/bin/location")
1062 );
1063 assert!(expand_location_path("~/bin/location", None).is_err());
1064
1065 let success = executable_script("printf '12.5,-44.25,321\\n'");
1066 assert_eq!(
1067 resolve_location(success.to_str().unwrap()).unwrap(),
1068 (12.5, -44.25, 321.0)
1069 );
1070
1071 let mut iface = test_announce_interface("dynamic", 1);
1072 iface.config.location_cmd = Some(success.to_string_lossy().into_owned());
1073 let packed = prepare_interface_info(&[0x55; 16], &iface).unwrap();
1074 let (value, consumed) = msgpack::unpack(&packed).unwrap();
1075 assert_eq!(consumed, packed.len());
1076 let Value::Map(entries) = value else {
1077 panic!("discovery metadata was not a map")
1078 };
1079 let coordinate = |key: u8| {
1080 entries.iter().find_map(|(candidate, value)| {
1081 (*candidate == Value::UInt(key as u64)).then_some(value)
1082 })
1083 };
1084 assert_eq!(coordinate(LATITUDE), Some(&Value::Float(12.5)));
1085 assert_eq!(coordinate(LONGITUDE), Some(&Value::Float(-44.25)));
1086 assert_eq!(coordinate(HEIGHT), Some(&Value::Float(321.0)));
1087 assert_eq!(iface.config.latitude, Some(45.0));
1088 assert_eq!(iface.config.longitude, Some(9.0));
1089 assert_eq!(iface.config.height, Some(100.0));
1090
1091 let nonzero = executable_script("exit 7");
1092 assert!(resolve_location(nonzero.to_str().unwrap()).is_err());
1093 let oversized = executable_script("head -c 4097 /dev/zero");
1094 assert!(resolve_location(oversized.to_str().unwrap()).is_err());
1095 let missing = success.with_extension("missing");
1096 assert!(resolve_location(missing.to_str().unwrap()).is_err());
1097
1098 let non_executable = executable_script("printf '1,2,3'");
1099 let mut permissions = fs::metadata(&non_executable).unwrap().permissions();
1100 permissions.set_mode(0o600);
1101 fs::set_permissions(&non_executable, permissions).unwrap();
1102 assert!(resolve_location(non_executable.to_str().unwrap()).is_err());
1103
1104 for path in [success, nonzero, oversized, non_executable] {
1105 let _ = fs::remove_file(path);
1106 }
1107 }
1108
1109 #[cfg(unix)]
1110 #[test]
1111 fn failed_dynamic_location_is_suppressed_then_retried_at_normal_interval() {
1112 let failure = executable_script("exit 1");
1113 let success = executable_script("printf '1,2,3\\n'");
1114 let mut iface = test_announce_interface("recovering", 10);
1115 iface.config.location_cmd = Some(failure.to_string_lossy().into_owned());
1116 let mut announcer = InterfaceAnnouncer::new([0x77; 16], vec![iface.clone()]);
1117
1118 announcer.maybe_start(10.0);
1119 assert!(wait_for_announce(&mut announcer).app_data.is_err());
1120 announcer.maybe_start(19.0);
1121 assert!(!announcer.stamp_pending);
1122
1123 iface.config.location_cmd = Some(success.to_string_lossy().into_owned());
1124 announcer.upsert_interface(iface);
1125 announcer.maybe_start(20.0);
1126 assert!(wait_for_announce(&mut announcer).app_data.is_ok());
1127
1128 let _ = fs::remove_file(failure);
1129 let _ = fs::remove_file(success);
1130 }
1131
1132 #[cfg(unix)]
1133 #[test]
1134 fn location_command_timeout_kills_and_reaps_child() {
1135 let command = executable_script("exec sleep 30");
1136 let started = Instant::now();
1137 let error = resolve_location(command.to_str().unwrap()).unwrap_err();
1138 assert!(error.contains("timed out"));
1139 assert!(started.elapsed() < Duration::from_secs(7));
1140 let _ = fs::remove_file(command);
1141 }
1142
1143 #[cfg(unix)]
1144 #[test]
1145 fn documented_location_stanza_parses_and_example_output_resolves() {
1146 use std::os::unix::fs::PermissionsExt;
1147
1148 const STANZA: &str = r#"[interfaces]
1149 [[Mobile Backbone]]
1150 type = BackboneInterface
1151 enabled = yes
1152 interface_mode = internal
1153 listen_ip = 0.0.0.0
1154 listen_port = 4242
1155 discoverable = yes
1156 discovery_name = Mobile Backbone
1157 discovery_lxmf_address = 0123456789abcdef0123456789abcdef
1158 reachable_on = backbone.example.net
1159 location_cmd = ~/bin/reticulum-location"#;
1160 const OUTPUT: &str = "45.4642,9.1900,122.5\n";
1161 let documentation = include_str!("../../docs/interface-discovery.md");
1162 assert!(documentation.contains(STANZA));
1163 assert!(documentation.contains(OUTPUT.trim_end()));
1164
1165 let parsed = crate::config::parse(STANZA).unwrap();
1166 assert_eq!(
1167 parsed.interfaces[0]
1168 .params
1169 .get("discovery_lxmf_address")
1170 .map(String::as_str),
1171 Some("0123456789abcdef0123456789abcdef")
1172 );
1173 let command = parsed.interfaces[0].params.get("location_cmd").unwrap();
1174 let home = std::env::temp_dir().join(format!(
1175 "rns-documented-location-home-{}",
1176 std::process::id()
1177 ));
1178 let bin = home.join("bin");
1179 fs::create_dir_all(&bin).unwrap();
1180 let path = expand_location_path(command, Some(home.clone().into_os_string())).unwrap();
1181 fs::write(&path, format!("#!/bin/sh\nprintf '{OUTPUT}'\n")).unwrap();
1182 let mut permissions = fs::metadata(&path).unwrap().permissions();
1183 permissions.set_mode(0o700);
1184 fs::set_permissions(&path, permissions).unwrap();
1185 std::thread::sleep(Duration::from_millis(10));
1186
1187 assert_eq!(
1188 resolve_location(path.to_str().unwrap()).unwrap(),
1189 (45.4642, 9.19, 122.5)
1190 );
1191 let _ = fs::remove_dir_all(home);
1192 }
1193
1194 #[test]
1195 fn announcer_selects_due_interfaces_one_job_at_a_time_and_accounts_interval() {
1196 let mut announcer = InterfaceAnnouncer::new(
1197 [0x42; 16],
1198 vec![
1199 test_announce_interface("first", 10),
1200 test_announce_interface("second", 10),
1201 ],
1202 );
1203
1204 announcer.maybe_start(10.0);
1205 assert!(announcer.stamp_pending);
1206 assert_eq!(announcer.last_announced, vec![10.0, 0.0]);
1207 announcer.maybe_start(20.0);
1208 assert_eq!(announcer.last_announced, vec![10.0, 0.0]);
1209
1210 let first = wait_for_announce(&mut announcer);
1211 assert_eq!(first.interface_name, "first");
1212 assert!(first.app_data.is_ok());
1213 assert!(!announcer.stamp_pending);
1214
1215 announcer.maybe_start(20.0);
1216 let second = wait_for_announce(&mut announcer);
1217 assert_eq!(second.interface_name, "second");
1218 assert_eq!(announcer.last_announced, vec![10.0, 20.0]);
1219 }
1220
1221 #[test]
1222 fn poll_ready_clears_pending_for_failed_generation() {
1223 let mut announcer = InterfaceAnnouncer::new([0; 16], vec![]);
1224 announcer.stamp_pending = true;
1225 announcer
1226 .stamp_tx
1227 .send(AnnounceResult {
1228 interface_name: "failed".into(),
1229 app_data: Err("metadata failed".into()),
1230 })
1231 .unwrap();
1232
1233 let result = announcer.poll_ready().unwrap();
1234 assert_eq!(result.app_data.unwrap_err(), "metadata failed");
1235 assert!(!announcer.stamp_pending);
1236 }
1237
1238 #[test]
1239 fn background_packing_preserves_static_metadata_bytes() {
1240 let iface = test_announce_interface("static", 1);
1241 let expected = InterfaceAnnouncer::pack_interface_info(&[0x24; 16], &iface).unwrap();
1242 let mut announcer = InterfaceAnnouncer::new([0x24; 16], vec![iface]);
1243
1244 announcer.maybe_start(1.0);
1245 let result = wait_for_announce(&mut announcer).app_data.unwrap();
1246 assert_eq!(&result[1..result.len() - STAMP_SIZE], expected.as_slice());
1247 }
1248
1249 #[test]
1250 fn announcer_packs_operator_lxmf_address() {
1251 let mut iface = test_announce_interface("operator", 1);
1252 iface.config.operator_lxmf_address = Some([0xa5; 16]);
1253
1254 let packed = InterfaceAnnouncer::pack_interface_info(&[0x24; 16], &iface).unwrap();
1255 let (Value::Map(entries), consumed) = msgpack::unpack(&packed).unwrap() else {
1256 panic!("discovery metadata was not a map")
1257 };
1258
1259 assert_eq!(consumed, packed.len());
1260 assert!(entries.contains(&(Value::UInt(OP_ADDR as u64), Value::Bin(vec![0xa5; 16]),)));
1261 }
1262
1263 #[test]
1264 fn announcer_identifies_native_transport_implementation_and_version() {
1265 let iface = test_announce_interface("implementation", 1);
1266 let packed = InterfaceAnnouncer::pack_interface_info(&[0x24; 16], &iface).unwrap();
1267 let (Value::Map(entries), consumed) = msgpack::unpack(&packed).unwrap() else {
1268 panic!("discovery metadata was not a map")
1269 };
1270
1271 assert_eq!(consumed, packed.len());
1272 assert!(entries.contains(&(
1273 Value::UInt(TRANSPORT_IMPL as u64),
1274 Value::Str("rns-rs".into()),
1275 )));
1276 assert!(entries.contains(&(
1277 Value::UInt(TRANSPORT_VERS as u64),
1278 Value::Str(env!("CARGO_PKG_VERSION").into()),
1279 )));
1280 }
1281
1282 #[test]
1283 fn announcer_suppresses_network_discovery_without_reachable_address() {
1284 for interface_type in ["BackboneInterface", "TCPServerInterface"] {
1285 let mut iface = test_announce_interface("missing-address", 1);
1286 iface.config.interface_type = interface_type.into();
1287 iface.config.reachable_on = None;
1288
1289 assert_eq!(
1290 InterfaceAnnouncer::pack_interface_info(&[0x24; 16], &iface),
1291 Err(format!(
1292 "{interface_type} discovery requires a reachable_on address"
1293 ))
1294 );
1295 }
1296 }
1297
1298 #[test]
1299 fn test_hex_encode() {
1300 assert_eq!(hex_encode(&[0x00, 0xff, 0x12]), "00ff12");
1301 assert_eq!(hex_encode(&[]), "");
1302 }
1303
1304 #[test]
1305 fn test_compute_discovery_hash() {
1306 let transport_id = [0x42u8; 16];
1307 let name = "TestInterface";
1308 let hash = compute_discovery_hash(&transport_id, name);
1309
1310 let hash2 = compute_discovery_hash(&transport_id, name);
1312 assert_eq!(hash, hash2);
1313
1314 let hash3 = compute_discovery_hash(&transport_id, "OtherInterface");
1316 assert_ne!(hash, hash3);
1317 }
1318
1319 #[test]
1320 fn test_is_ip_address() {
1321 assert!(is_ip_address("192.168.1.1"));
1322 assert!(is_ip_address("::1"));
1323 assert!(is_ip_address("2001:db8::1"));
1324 assert!(!is_ip_address("not-an-ip"));
1325 assert!(!is_ip_address("hostname.example.com"));
1326 }
1327
1328 #[test]
1329 fn test_is_hostname() {
1330 assert!(is_hostname("example.com"));
1331 assert!(is_hostname("sub.example.com"));
1332 assert!(is_hostname("my-node"));
1333 assert!(is_hostname("my-node.example.com"));
1334 assert!(!is_hostname(""));
1335 assert!(!is_hostname("-invalid"));
1336 assert!(!is_hostname("invalid-"));
1337 assert!(!is_hostname("a".repeat(300).as_str()));
1338 }
1339
1340 #[test]
1341 fn test_discovered_status() {
1342 let now = time::now();
1343
1344 let mut iface = DiscoveredInterface {
1345 interface_type: "TestInterface".into(),
1346 transport: true,
1347 name: "Test".into(),
1348 discovered: now,
1349 last_heard: now,
1350 heard_count: 0,
1351 status: DiscoveredStatus::Available,
1352 stamp: vec![],
1353 stamp_value: 14,
1354 transport_id: [0u8; 16],
1355 network_id: [0u8; 16],
1356 hops: 0,
1357 latitude: None,
1358 longitude: None,
1359 height: None,
1360 operator_lxmf_address: None,
1361 reachable_on: None,
1362 port: None,
1363 frequency: None,
1364 bandwidth: None,
1365 spreading_factor: None,
1366 coding_rate: None,
1367 modulation: None,
1368 channel: None,
1369 ifac_netname: None,
1370 ifac_netkey: None,
1371 config_entry: None,
1372 discovery_hash: [0u8; 32],
1373 };
1374
1375 assert_eq!(iface.compute_status(), DiscoveredStatus::Available);
1377
1378 iface.last_heard = now - THRESHOLD_UNKNOWN - 3600.0;
1380 assert_eq!(iface.compute_status(), DiscoveredStatus::Unknown);
1381
1382 iface.last_heard = now - THRESHOLD_STALE - 3600.0;
1384 assert_eq!(iface.compute_status(), DiscoveredStatus::Stale);
1385 }
1386
1387 fn test_discovered_interface(name: &str) -> DiscoveredInterface {
1388 DiscoveredInterface {
1389 interface_type: "BackboneInterface".into(),
1390 transport: true,
1391 name: name.into(),
1392 discovered: 1700000000.0,
1393 last_heard: 1700001000.0,
1394 heard_count: 5,
1395 status: DiscoveredStatus::Available,
1396 stamp: vec![0x42u8; 64],
1397 stamp_value: 18,
1398 transport_id: [0x01u8; 16],
1399 network_id: [0x02u8; 16],
1400 hops: 2,
1401 latitude: Some(45.0),
1402 longitude: Some(9.0),
1403 height: Some(100.0),
1404 operator_lxmf_address: Some([0xa5; 16]),
1405 reachable_on: Some("example.com".into()),
1406 port: Some(4242),
1407 frequency: None,
1408 bandwidth: None,
1409 spreading_factor: None,
1410 coding_rate: None,
1411 modulation: None,
1412 channel: None,
1413 ifac_netname: Some("mynetwork".into()),
1414 ifac_netkey: Some("secretkey".into()),
1415 config_entry: Some("test config".into()),
1416 discovery_hash: compute_discovery_hash(&[0x01u8; 16], name),
1417 }
1418 }
1419
1420 #[test]
1421 fn test_storage_roundtrip() {
1422 use std::sync::atomic::{AtomicU64, Ordering};
1423 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
1424
1425 let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
1426 let dir =
1427 std::env::temp_dir().join(format!("rns-discovery-test-{}-{}", std::process::id(), id));
1428 let _ = fs::remove_dir_all(&dir);
1429 fs::create_dir_all(&dir).unwrap();
1430
1431 let storage = DiscoveredInterfaceStorage::new(dir.clone());
1432
1433 let iface = test_discovered_interface("TestNode");
1434
1435 storage.store(&iface).unwrap();
1437
1438 let loaded = storage.load(&iface.discovery_hash).unwrap().unwrap();
1440
1441 assert_eq!(loaded.interface_type, iface.interface_type);
1442 assert_eq!(loaded.name, iface.name);
1443 assert_eq!(loaded.stamp_value, iface.stamp_value);
1444 assert_eq!(loaded.transport_id, iface.transport_id);
1445 assert_eq!(loaded.hops, iface.hops);
1446 assert_eq!(loaded.latitude, iface.latitude);
1447 assert_eq!(loaded.operator_lxmf_address, iface.operator_lxmf_address);
1448 assert_eq!(loaded.reachable_on, iface.reachable_on);
1449 assert_eq!(loaded.port, iface.port);
1450
1451 let list = storage.list().unwrap();
1453 assert_eq!(list.len(), 1);
1454
1455 storage.remove(&iface.discovery_hash).unwrap();
1457 let list = storage.list().unwrap();
1458 assert!(list.is_empty());
1459
1460 let _ = fs::remove_dir_all(&dir);
1461 }
1462
1463 #[test]
1464 fn storage_load_sanitizes_cached_interface_names() {
1465 let dir = std::env::temp_dir().join(format!(
1466 "rns-discovery-sanitize-test-{}",
1467 std::process::id()
1468 ));
1469 let _ = fs::remove_dir_all(&dir);
1470 fs::create_dir_all(&dir).unwrap();
1471 let storage = DiscoveredInterfaceStorage::new(dir.clone());
1472 let iface = test_discovered_interface("\t**Cached Name!!!\n");
1473
1474 storage.store(&iface).unwrap();
1475
1476 let loaded = storage.load(&iface.discovery_hash).unwrap().unwrap();
1477 let listed = storage.list().unwrap();
1478
1479 assert_eq!(loaded.name, "Cached Name");
1480 assert_eq!(listed[0].name, "Cached Name");
1481
1482 let _ = fs::remove_dir_all(&dir);
1483 }
1484
1485 #[test]
1486 fn storage_rejects_cached_transport_id_with_invalid_length() {
1487 let storage = DiscoveredInterfaceStorage::new(std::env::temp_dir());
1488 let iface = test_discovered_interface("BadTransportId");
1489 let mut data = storage.serialize_interface(&iface).unwrap();
1490 let (mut value, _) = msgpack::unpack(&data).unwrap();
1491 if let Value::Map(ref mut entries) = value {
1492 for (key, val) in entries {
1493 if key.as_str() == Some("transport_id") {
1494 *val = Value::Bin(vec![0x01; 15]);
1495 }
1496 }
1497 }
1498 data = msgpack::pack(&value);
1499
1500 let err = storage.deserialize_interface(&data).unwrap_err();
1501
1502 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1503 assert!(err.to_string().contains("transport_id"));
1504 }
1505
1506 #[test]
1507 fn store_received_preserves_existing_first_seen_and_increments_heard_count() {
1508 let dir = std::env::temp_dir().join(format!(
1509 "rns-discovery-received-preserve-test-{}",
1510 std::process::id()
1511 ));
1512 let _ = fs::remove_dir_all(&dir);
1513 fs::create_dir_all(&dir).unwrap();
1514 let storage = DiscoveredInterfaceStorage::new(dir.clone());
1515
1516 let mut existing = test_discovered_interface("ExistingDiscovery");
1517 existing.discovered = 1000.0;
1518 existing.last_heard = 1100.0;
1519 existing.heard_count = 7;
1520 storage.store(&existing).unwrap();
1521
1522 let mut received = existing.clone();
1523 received.discovered = 2000.0;
1524 received.last_heard = 3000.0;
1525 received.heard_count = 0;
1526 storage.store_received(&mut received).unwrap();
1527
1528 let loaded = storage.load(&received.discovery_hash).unwrap().unwrap();
1529 assert_eq!(received.discovered, 1000.0);
1530 assert_eq!(received.last_heard, 3000.0);
1531 assert_eq!(received.heard_count, 8);
1532 assert_eq!(loaded.discovered, 1000.0);
1533 assert_eq!(loaded.last_heard, 3000.0);
1534 assert_eq!(loaded.heard_count, 8);
1535
1536 let _ = fs::remove_dir_all(&dir);
1537 }
1538
1539 #[test]
1540 fn store_received_serializes_concurrent_counter_updates() {
1541 use std::sync::{Arc, Barrier};
1542 use std::thread;
1543
1544 let dir = std::env::temp_dir().join(format!(
1545 "rns-discovery-concurrent-received-test-{}",
1546 std::process::id()
1547 ));
1548 let _ = fs::remove_dir_all(&dir);
1549 fs::create_dir_all(&dir).unwrap();
1550 let storage = Arc::new(DiscoveredInterfaceStorage::new(dir.clone()));
1551
1552 let mut existing = test_discovered_interface("ConcurrentDiscovery");
1553 existing.discovered = 1000.0;
1554 existing.last_heard = 1000.0;
1555 existing.heard_count = 0;
1556 storage.store(&existing).unwrap();
1557
1558 let threads = 16;
1559 let updates_per_thread = 25;
1560 let barrier = Arc::new(Barrier::new(threads));
1561 let mut handles = Vec::new();
1562 for thread_id in 0..threads {
1563 let storage = Arc::clone(&storage);
1564 let barrier = Arc::clone(&barrier);
1565 let template = existing.clone();
1566 handles.push(thread::spawn(move || {
1567 barrier.wait();
1568 for update in 0..updates_per_thread {
1569 let mut received = template.clone();
1570 received.last_heard = 2000.0 + (thread_id * updates_per_thread + update) as f64;
1571 storage.store_received(&mut received).unwrap();
1572 }
1573 }));
1574 }
1575
1576 for handle in handles {
1577 handle.join().unwrap();
1578 }
1579
1580 let loaded = storage.load(&existing.discovery_hash).unwrap().unwrap();
1581 assert_eq!(loaded.discovered, 1000.0);
1582 assert_eq!(loaded.heard_count as usize, threads * updates_per_thread);
1583
1584 let _ = fs::remove_dir_all(&dir);
1585 }
1586
1587 #[test]
1588 fn store_received_recreates_corrupt_cache_with_received_time_as_first_seen() {
1589 let dir = std::env::temp_dir().join(format!(
1590 "rns-discovery-corrupt-recreate-test-{}",
1591 std::process::id()
1592 ));
1593 let _ = fs::remove_dir_all(&dir);
1594 fs::create_dir_all(&dir).unwrap();
1595 let storage = DiscoveredInterfaceStorage::new(dir.clone());
1596
1597 let mut received = test_discovered_interface("CorruptDiscovery");
1598 received.discovered = 1234.0;
1599 received.last_heard = 5678.0;
1600 received.heard_count = 0;
1601 let filepath = dir.join(hex_encode(&received.discovery_hash));
1602 fs::write(&filepath, b"not msgpack").unwrap();
1603
1604 storage.store_received(&mut received).unwrap();
1605
1606 let loaded = storage.load(&received.discovery_hash).unwrap().unwrap();
1607 assert_eq!(received.discovered, 5678.0);
1608 assert_eq!(received.heard_count, 1);
1609 assert_eq!(loaded.discovered, 5678.0);
1610 assert_eq!(loaded.last_heard, 5678.0);
1611 assert_eq!(loaded.heard_count, 1);
1612 assert_eq!(loaded.name, "CorruptDiscovery");
1613
1614 let _ = fs::remove_dir_all(&dir);
1615 }
1616
1617 #[test]
1618 fn cleanup_removes_discoveries_for_blackholed_network_and_transport_identities() {
1619 let dir = discovery_test_dir("blackholed-cleanup");
1620 fs::create_dir_all(&dir).unwrap();
1621 let storage = DiscoveredInterfaceStorage::new(dir.clone());
1622 let now = time::now();
1623
1624 let mut network_blocked = test_discovered_interface("NetworkBlocked");
1625 network_blocked.last_heard = now;
1626 network_blocked.network_id = [0x31; 16];
1627 let mut transport_blocked = test_discovered_interface("TransportBlocked");
1628 transport_blocked.last_heard = now;
1629 transport_blocked.transport_id = [0x42; 16];
1630 let mut retained = test_discovered_interface("Retained");
1631 retained.last_heard = now;
1632 retained.network_id = [0x53; 16];
1633 retained.transport_id = [0x64; 16];
1634 storage.store(&network_blocked).unwrap();
1635 storage.store(&transport_blocked).unwrap();
1636 storage.store(&retained).unwrap();
1637
1638 let removed = storage
1639 .cleanup_with_blackholes(|identity| identity == &[0x31; 16] || identity == &[0x42; 16])
1640 .unwrap();
1641
1642 assert_eq!(removed, 2);
1643 let listed = storage.list().unwrap();
1644 assert_eq!(listed.len(), 1);
1645 assert_eq!(listed[0].name, "Retained");
1646 let _ = fs::remove_dir_all(&dir);
1647 }
1648
1649 #[test]
1650 fn cleanup_removes_cached_discoveries_with_missing_or_empty_identities() {
1651 let dir = discovery_test_dir("invalid-identity-cleanup");
1652 fs::create_dir_all(&dir).unwrap();
1653 let storage = DiscoveredInterfaceStorage::new(dir.clone());
1654 let iface = test_discovered_interface("InvalidIdentity");
1655 let valid = storage.serialize_interface(&iface).unwrap();
1656
1657 let (mut missing_transport, _) = msgpack::unpack(&valid).unwrap();
1658 if let Value::Map(entries) = &mut missing_transport {
1659 entries.retain(|(key, _)| key.as_str() != Some("transport_id"));
1660 }
1661 fs::write(
1662 dir.join("missing-transport"),
1663 msgpack::pack(&missing_transport),
1664 )
1665 .unwrap();
1666
1667 let (mut empty_network, _) = msgpack::unpack(&valid).unwrap();
1668 if let Value::Map(entries) = &mut empty_network {
1669 for (key, value) in entries {
1670 if key.as_str() == Some("network_id") {
1671 *value = Value::Bin(Vec::new());
1672 }
1673 }
1674 }
1675 fs::write(dir.join("empty-network"), msgpack::pack(&empty_network)).unwrap();
1676 fs::write(dir.join("corrupt-msgpack"), b"not msgpack").unwrap();
1677
1678 assert_eq!(storage.cleanup().unwrap(), 3);
1679 assert!(storage.list().unwrap().is_empty());
1680 assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
1681 let _ = fs::remove_dir_all(&dir);
1682 }
1683
1684 #[test]
1685 fn test_filter_and_sort() {
1686 let now = time::now();
1687
1688 let ifaces = vec![
1689 DiscoveredInterface {
1690 interface_type: "BackboneInterface".into(),
1691 transport: true,
1692 name: "high-value-stale".into(),
1693 discovered: now,
1694 last_heard: now - THRESHOLD_STALE - 100.0, heard_count: 0,
1696 status: DiscoveredStatus::Stale,
1697 stamp: vec![],
1698 stamp_value: 20,
1699 transport_id: [0u8; 16],
1700 network_id: [0u8; 16],
1701 hops: 0,
1702 latitude: None,
1703 longitude: None,
1704 height: None,
1705 operator_lxmf_address: None,
1706 reachable_on: None,
1707 port: None,
1708 frequency: None,
1709 bandwidth: None,
1710 spreading_factor: None,
1711 coding_rate: None,
1712 modulation: None,
1713 channel: None,
1714 ifac_netname: None,
1715 ifac_netkey: None,
1716 config_entry: None,
1717 discovery_hash: [0u8; 32],
1718 },
1719 DiscoveredInterface {
1720 interface_type: "TCPServerInterface".into(),
1721 transport: true,
1722 name: "low-value-available".into(),
1723 discovered: now,
1724 last_heard: now - 10.0, heard_count: 0,
1726 status: DiscoveredStatus::Available,
1727 stamp: vec![],
1728 stamp_value: 10,
1729 transport_id: [0u8; 16],
1730 network_id: [0u8; 16],
1731 hops: 0,
1732 latitude: None,
1733 longitude: None,
1734 height: None,
1735 operator_lxmf_address: None,
1736 reachable_on: None,
1737 port: None,
1738 frequency: None,
1739 bandwidth: None,
1740 spreading_factor: None,
1741 coding_rate: None,
1742 modulation: None,
1743 channel: None,
1744 ifac_netname: None,
1745 ifac_netkey: None,
1746 config_entry: None,
1747 discovery_hash: [1u8; 32],
1748 },
1749 DiscoveredInterface {
1750 interface_type: "I2PInterface".into(),
1751 transport: false,
1752 name: "high-value-available".into(),
1753 discovered: now,
1754 last_heard: now - 10.0, heard_count: 0,
1756 status: DiscoveredStatus::Available,
1757 stamp: vec![],
1758 stamp_value: 20,
1759 transport_id: [0u8; 16],
1760 network_id: [0u8; 16],
1761 hops: 0,
1762 latitude: None,
1763 longitude: None,
1764 height: None,
1765 operator_lxmf_address: None,
1766 reachable_on: None,
1767 port: None,
1768 frequency: None,
1769 bandwidth: None,
1770 spreading_factor: None,
1771 coding_rate: None,
1772 modulation: None,
1773 channel: None,
1774 ifac_netname: None,
1775 ifac_netkey: None,
1776 config_entry: None,
1777 discovery_hash: [2u8; 32],
1778 },
1779 ];
1780
1781 let mut result = ifaces.clone();
1783 filter_and_sort_interfaces(&mut result, false, false);
1784 assert_eq!(result.len(), 3);
1785 assert_eq!(result[0].name, "high-value-available");
1787 assert_eq!(result[1].name, "low-value-available");
1788 assert_eq!(result[2].name, "high-value-stale");
1789
1790 let mut result = ifaces.clone();
1792 filter_and_sort_interfaces(&mut result, true, false);
1793 assert_eq!(result.len(), 2); let mut result = ifaces.clone();
1797 filter_and_sort_interfaces(&mut result, false, true);
1798 assert_eq!(result.len(), 2); }
1800
1801 #[test]
1802 fn test_discovery_name_hash_deterministic() {
1803 let h1 = discovery_name_hash();
1804 let h2 = discovery_name_hash();
1805 assert_eq!(h1, h2);
1806 assert_ne!(h1, [0u8; 10]); }
1808}