Merge branch 'master' into next

# Conflicts:
#	src/node/tests/discovery.rs
#	testing/ci-local.sh
This commit is contained in:
Johnathan Corgan
2026-05-03 23:47:16 +00:00
51 changed files with 4107 additions and 44 deletions
+46
View File
@@ -1096,6 +1096,52 @@ mod tests {
assert_eq!(c.startup_sweep_max_age_secs, 3_600);
}
#[test]
fn test_log_level_parser() {
// Pin the observed behavior of NodeConfig::log_level():
// - 5 explicit lowercased match arms (trace/debug/warn|warning/error)
// - INFO is the default (no explicit "info" arm; falls through default)
// - Case-insensitive via .to_lowercase()
// - Unknown strings and None both fall through to INFO
let cases: &[(Option<&str>, tracing::Level)] = &[
// Explicit arms (lowercase canonical form)
(Some("trace"), tracing::Level::TRACE),
(Some("debug"), tracing::Level::DEBUG),
(Some("warn"), tracing::Level::WARN),
(Some("warning"), tracing::Level::WARN),
(Some("error"), tracing::Level::ERROR),
// "info" has no explicit arm — falls through default
(Some("info"), tracing::Level::INFO),
// None → default INFO
(None, tracing::Level::INFO),
// Case-insensitivity (parser lowercases via .to_lowercase())
(Some("TRACE"), tracing::Level::TRACE),
(Some("Debug"), tracing::Level::DEBUG),
(Some("Warning"), tracing::Level::WARN),
(Some("WARN"), tracing::Level::WARN),
(Some("ERROR"), tracing::Level::ERROR),
(Some("INFO"), tracing::Level::INFO),
// Unknown strings → INFO default (no error path)
(Some("verbose"), tracing::Level::INFO),
(Some("nonsense"), tracing::Level::INFO),
(Some(""), tracing::Level::INFO),
];
for (input, expected) in cases {
let cfg = NodeConfig {
log_level: input.map(|s| s.to_string()),
..NodeConfig::default()
};
assert_eq!(
cfg.log_level(),
*expected,
"input {:?} should map to {:?}",
input,
expected
);
}
}
#[cfg(windows)]
#[test]
fn test_default_socket_path_windows() {
+370
View File
@@ -1105,3 +1105,373 @@ pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::pr
_ => super::protocol::Response::error(format!("unknown command: {}", command)),
}
}
#[cfg(test)]
mod tests {
//! Schema-stability snapshot tests for all 18 control-socket query
//! handlers.
//!
//! Each handler is invoked against a deterministically-constructed
//! `Node` (fixed identity seed, empty peer/link/transport/cache
//! state). The resulting JSON is normalized — fields whose values
//! depend on wall-clock, PID, build environment, or filesystem
//! layout are replaced with the literal string `"<redacted>"` —
//! and compared against versioned fixtures under
//! `src/control/snapshots/`.
//!
//! The point is to catch accidental schema drift (renames, type
//! changes, dropped fields) in the operator-facing wire format.
//! Empty-state snapshots are sufficient because every top-level
//! key still appears, and per-element shapes inside `[]` arrays
//! are covered by the dispatcher contract test plus serde
//! derives elsewhere.
//!
//! ## Updating snapshots
//!
//! When a schema change is intentional, regenerate fixtures by
//! deleting the relevant `.json` files (or the whole
//! `snapshots/` directory) and re-running this test. Missing
//! fixtures are written from the current output rather than
//! failing — the next run then enforces the new shape. Review
//! the resulting diff before committing.
//!
//! ## Determinism
//!
//! The `Node` is built via `Node::with_identity` from a fixed
//! 32-byte seed (`[0xAB; 32]`), so `npub`, `node_addr`, and
//! `ipv6_addr` are stable across runs and machines.
//! Time-dependent scalars are redacted in `normalize_value` —
//! see the `VOLATILE_KEYS` list there for the exact set.
//! Empty arrays/maps are intrinsically stable and need no
//! redaction.
//!
//! Schnorr signatures are non-deterministic, but the only
//! signature surfaced by these handlers is `declaration_signed:
//! bool` (a flag, not the signature itself), so no redaction is
//! needed for that.
use super::*;
use crate::config::Config;
use crate::identity::Identity;
use crate::node::Node;
use serde_json::{Map, Value, json};
use std::path::PathBuf;
/// 32-byte seed for the deterministic test identity.
/// Any non-zero secret-key-shaped value works; 0xAB-fill is just
/// readable in hex.
const TEST_SEED: [u8; 32] = [0xAB; 32];
/// Fields whose value is environment-, time-, or build-dependent
/// and therefore must be redacted before comparison. Matched by
/// JSON key name anywhere in the document.
const VOLATILE_KEYS: &[&str] = &[
// Process / build environment
"version",
"pid",
"exe_path",
"control_socket",
"tun_name",
// Filesystem layout (ACL, hosts, etc.)
"allow_file",
"deny_file",
// Wall-clock derived
"uptime_secs",
"started_at_ms",
"session_start_ms",
"authenticated_at_ms",
"last_seen_ms",
"last_activity_ms",
"last_recv_ms",
"created_at_ms",
"initiated_ms",
"last_sent_ms",
"age_ms",
"last_used_ms",
"idle_ms",
"first_seen_secs_ago",
"last_contact_secs_ago",
];
/// Build a Node with a fixed identity, default config, and empty
/// runtime state (no peers, links, sessions, transports, or cache
/// entries). This keeps every per-element list empty and every
/// scalar deterministic modulo `VOLATILE_KEYS`.
fn build_test_node() -> Node {
let identity =
Identity::from_secret_bytes(&TEST_SEED).expect("test seed is a valid secret key");
let config = Config::new();
Node::with_identity(identity, config).expect("default config is valid")
}
/// Recursively walk a JSON value, replacing the value of any key
/// listed in `VOLATILE_KEYS` with the literal string
/// `"<redacted>"`. Array elements are recursed into.
fn normalize_value(value: &mut Value) {
match value {
Value::Object(map) => {
for (key, v) in map.iter_mut() {
if VOLATILE_KEYS.contains(&key.as_str()) {
*v = Value::String("<redacted>".to_string());
} else {
normalize_value(v);
}
}
}
Value::Array(items) => {
for item in items.iter_mut() {
normalize_value(item);
}
}
_ => {}
}
}
/// Wrap a handler value in the on-the-wire `Response` envelope so
/// the snapshot reflects exactly what a control-socket client
/// receives. Pretty-printed and sorted-keyed for readable diffs.
fn render(value: Value) -> String {
let mut wrapped = json!({ "status": "ok", "data": value });
normalize_value(&mut wrapped);
let sorted = sort_object_keys(&wrapped);
serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
}
/// Same as `render` but takes a `Response` directly (for handlers
/// that return `Response`, not `Value`).
fn render_response(resp: super::super::protocol::Response) -> String {
let value = serde_json::to_value(&resp).expect("response always serializes");
let mut value = value;
normalize_value(&mut value);
let sorted = sort_object_keys(&value);
serde_json::to_string_pretty(&sorted).expect("json serialization is infallible")
}
/// Recursively sort object keys for stable diff-friendly output.
/// `serde_json::Value` preserves insertion order; handlers don't
/// guarantee any particular emit order, so normalize here.
fn sort_object_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted: Map<String, Value> = Map::new();
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
sorted.insert(key.clone(), sort_object_keys(&map[key]));
}
Value::Object(sorted)
}
Value::Array(items) => Value::Array(items.iter().map(sort_object_keys).collect()),
other => other.clone(),
}
}
fn snapshot_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("control")
.join("snapshots")
}
/// Compare `actual` against the on-disk fixture for `name`. If the
/// fixture does not exist, write it (first-run convention) and
/// pass. Any subsequent mismatch fails with an inline diff hint.
fn assert_snapshot(name: &str, actual: &str) {
let path = snapshot_dir().join(format!("{name}.json"));
if !path.exists() {
std::fs::create_dir_all(path.parent().unwrap())
.expect("failed to create snapshots dir");
std::fs::write(&path, actual).expect("failed to write new snapshot");
// Newly written: nothing to compare. Subsequent runs enforce.
return;
}
let expected = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read snapshot {}: {e}", path.display()));
// Normalize line endings: Windows checkouts with core.autocrlf=true
// convert fixture files to CRLF; the in-memory JSON output is LF.
let expected = expected.replace("\r\n", "\n");
// Tolerate trailing newline differences from editors.
if expected.trim_end() != actual.trim_end() {
panic!(
"snapshot mismatch for {name}\n\
fixture: {}\n\
-- expected --\n{expected}\n\
-- actual --\n{actual}\n\
-- end --\n\
If the schema change is intentional, delete the fixture \
and re-run to regenerate.",
path.display()
);
}
}
// ---- 18 handler snapshot tests --------------------------------------
#[test]
fn snapshot_show_status() {
let node = build_test_node();
assert_snapshot("show_status", &render(show_status(&node)));
}
#[test]
fn snapshot_show_acl() {
let node = build_test_node();
assert_snapshot("show_acl", &render(show_acl(&node)));
}
#[test]
fn snapshot_show_peers() {
let node = build_test_node();
assert_snapshot("show_peers", &render(show_peers(&node)));
}
#[test]
fn snapshot_show_links() {
let node = build_test_node();
assert_snapshot("show_links", &render(show_links(&node)));
}
#[test]
fn snapshot_show_tree() {
let node = build_test_node();
assert_snapshot("show_tree", &render(show_tree(&node)));
}
#[test]
fn snapshot_show_sessions() {
let node = build_test_node();
assert_snapshot("show_sessions", &render(show_sessions(&node)));
}
#[test]
fn snapshot_show_bloom() {
let node = build_test_node();
assert_snapshot("show_bloom", &render(show_bloom(&node)));
}
#[test]
fn snapshot_show_mmp() {
let node = build_test_node();
assert_snapshot("show_mmp", &render(show_mmp(&node)));
}
#[test]
fn snapshot_show_cache() {
let node = build_test_node();
assert_snapshot("show_cache", &render(show_cache(&node)));
}
#[test]
fn snapshot_show_connections() {
let node = build_test_node();
assert_snapshot("show_connections", &render(show_connections(&node)));
}
#[test]
fn snapshot_show_transports() {
let node = build_test_node();
assert_snapshot("show_transports", &render(show_transports(&node)));
}
#[test]
fn snapshot_show_routing() {
let node = build_test_node();
assert_snapshot("show_routing", &render(show_routing(&node)));
}
#[test]
fn snapshot_show_identity_cache() {
let node = build_test_node();
assert_snapshot("show_identity_cache", &render(show_identity_cache(&node)));
}
#[test]
fn snapshot_show_stats_list() {
// Static — no Node needed.
assert_snapshot("show_stats_list", &render(show_stats_list()));
}
#[test]
fn snapshot_show_stats_history() {
let node = build_test_node();
// Pin the empty-history series shape for one node-level metric.
let params = json!({ "metric": "mesh_size", "window": "10s", "granularity": "1s" });
let resp = show_stats_history(&node, Some(&params));
assert_snapshot("show_stats_history", &render_response(resp));
}
#[test]
fn snapshot_show_stats_all_history() {
let node = build_test_node();
// Empty-history all-node series; small window keeps the
// per-series `values` arrays short and stable.
let params = json!({ "window": "10s", "granularity": "1s" });
let resp = show_stats_all_history(&node, Some(&params));
assert_snapshot("show_stats_all_history", &render_response(resp));
}
#[test]
fn snapshot_show_stats_peers() {
let node = build_test_node();
assert_snapshot("show_stats_peers", &render(show_stats_peers(&node)));
}
#[test]
fn snapshot_show_stats_history_all_peers() {
let node = build_test_node();
// No peers tracked → empty `peers: []` envelope. Per-peer
// `values` shape is exercised once a real peer is wired in;
// here we only pin the envelope.
let params = json!({ "metric": "srtt_ms", "window": "10s", "granularity": "1s" });
let resp = show_stats_history_all_peers(&node, Some(&params));
assert_snapshot("show_stats_history_all_peers", &render_response(resp));
}
/// Sanity check: every handler advertised in `dispatch` is also
/// covered by a snapshot test above. If a new handler is added
/// without a matching snapshot, this test fails.
#[test]
fn dispatch_covers_all_snapshotted_handlers() {
let expected = [
"show_status",
"show_acl",
"show_peers",
"show_links",
"show_tree",
"show_sessions",
"show_bloom",
"show_mmp",
"show_cache",
"show_connections",
"show_transports",
"show_routing",
"show_identity_cache",
"show_stats_list",
"show_stats_history",
"show_stats_all_history",
"show_stats_peers",
"show_stats_history_all_peers",
];
assert_eq!(expected.len(), 18, "expected exactly 18 query handlers");
let node = build_test_node();
for cmd in expected {
// Each must dispatch successfully (status == "ok") with
// minimal params. Handlers requiring params get them.
let params = match cmd {
"show_stats_history" => Some(json!({
"metric": "mesh_size", "window": "10s", "granularity": "1s"
})),
"show_stats_all_history" => Some(json!({ "window": "10s", "granularity": "1s" })),
"show_stats_history_all_peers" => Some(json!({
"metric": "srtt_ms", "window": "10s", "granularity": "1s"
})),
_ => None,
};
let resp = dispatch(&node, cmd, params.as_ref());
assert_eq!(
resp.status, "ok",
"dispatch({cmd}) returned status={} message={:?}",
resp.status, resp.message
);
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"data": {
"allow_all": false,
"allow_entries": [],
"allow_file": "<redacted>",
"allow_file_entries": [],
"default_decision": "allow",
"deny_all": false,
"deny_entries": [],
"deny_file": "<redacted>",
"deny_file_entries": [],
"effective_mode": "default_open",
"enforcement_active": false
},
"status": "ok"
}
+24
View File
@@ -0,0 +1,24 @@
{
"data": {
"is_leaf_only": false,
"leaf_dependent_count": 0,
"leaf_dependents": [],
"own_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"peer_filters": [],
"sequence": 0,
"stats": {
"accepted": 0,
"debounce_suppressed": 0,
"decode_error": 0,
"fill_exceeded": 0,
"invalid": 0,
"non_v1": 0,
"received": 0,
"send_failed": 0,
"sent": 0,
"stale": 0,
"unknown_peer": 0
}
},
"status": "ok"
}
+12
View File
@@ -0,0 +1,12 @@
{
"data": {
"avg_age_ms": 0,
"count": 0,
"default_ttl_ms": 300000,
"entries": [],
"expired": 0,
"fill_ratio": 0.0,
"max_entries": 50000
},
"status": "ok"
}
@@ -0,0 +1,6 @@
{
"data": {
"connections": []
},
"status": "ok"
}
@@ -0,0 +1,8 @@
{
"data": {
"count": 0,
"entries": [],
"max_entries": 10000
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"links": []
},
"status": "ok"
}
+7
View File
@@ -0,0 +1,7 @@
{
"data": {
"peers": [],
"sessions": []
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"peers": []
},
"status": "ok"
}
+65
View File
@@ -0,0 +1,65 @@
{
"data": {
"congestion": {
"ce_forwarded": 0,
"ce_received": 0,
"congestion_detected": 0,
"kernel_drop_events": 0
},
"coord_cache_entries": 0,
"discovery": {
"req_backoff_suppressed": 0,
"req_bloom_miss": 0,
"req_decode_error": 0,
"req_deduplicated": 0,
"req_duplicate": 0,
"req_fallback_forwarded": 0,
"req_forward_rate_limited": 0,
"req_forwarded": 0,
"req_initiated": 0,
"req_no_tree_peer": 0,
"req_received": 0,
"req_target_is_us": 0,
"req_ttl_exhausted": 0,
"resp_accepted": 0,
"resp_decode_error": 0,
"resp_forwarded": 0,
"resp_identity_miss": 0,
"resp_proof_failed": 0,
"resp_received": 0,
"resp_timed_out": 0
},
"error_signals": {
"coords_required": 0,
"mtu_exceeded": 0,
"path_broken": 0
},
"forwarding": {
"decode_error_bytes": 0,
"decode_error_packets": 0,
"delivered_bytes": 0,
"delivered_packets": 0,
"drop_mtu_exceeded_bytes": 0,
"drop_mtu_exceeded_packets": 0,
"drop_no_route_bytes": 0,
"drop_no_route_packets": 0,
"drop_send_error_bytes": 0,
"drop_send_error_packets": 0,
"forwarded_bytes": 0,
"forwarded_packets": 0,
"originated_bytes": 0,
"originated_packets": 0,
"received_bytes": 0,
"received_packets": 0,
"ttl_exhausted_bytes": 0,
"ttl_exhausted_packets": 0
},
"identity_cache_entries": 0,
"pending_lookups": [],
"pending_tun_destinations": 0,
"pending_tun_packets": 0,
"recent_requests": 0,
"retries": []
},
"status": "ok"
}
+6
View File
@@ -0,0 +1,6 @@
{
"data": {
"sessions": []
},
"status": "ok"
}
@@ -0,0 +1,180 @@
{
"data": {
"granularity_seconds": 1,
"peer": null,
"series": [
{
"granularity_seconds": 1,
"metric": "mesh_size",
"unit": "nodes",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "tree_depth",
"unit": "hops",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "peer_count",
"unit": "peers",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "parent_switches",
"unit": "events/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "bytes_in",
"unit": "bytes/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "bytes_out",
"unit": "bytes/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "packets_in",
"unit": "packets/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "packets_out",
"unit": "packets/s",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "loss_rate",
"unit": "fraction",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
{
"granularity_seconds": 1,
"metric": "active_sessions",
"unit": "sessions",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
}
],
"window_seconds": 10
},
"status": "ok"
}
@@ -0,0 +1,20 @@
{
"data": {
"granularity_seconds": 1,
"metric": "mesh_size",
"unit": "nodes",
"values": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"status": "ok"
}
@@ -0,0 +1,10 @@
{
"data": {
"granularity_seconds": 1,
"metric": "srtt_ms",
"peers": [],
"unit": "ms",
"window_seconds": 10
},
"status": "ok"
}
@@ -0,0 +1,95 @@
{
"data": {
"fast_ring_seconds": 3600,
"metrics": [
{
"name": "mesh_size",
"scope": "node",
"unit": "nodes"
},
{
"name": "tree_depth",
"scope": "node",
"unit": "hops"
},
{
"name": "peer_count",
"scope": "node",
"unit": "peers"
},
{
"name": "parent_switches",
"scope": "node",
"unit": "events/s"
},
{
"name": "bytes_in",
"scope": "node",
"unit": "bytes/s"
},
{
"name": "bytes_out",
"scope": "node",
"unit": "bytes/s"
},
{
"name": "packets_in",
"scope": "node",
"unit": "packets/s"
},
{
"name": "packets_out",
"scope": "node",
"unit": "packets/s"
},
{
"name": "loss_rate",
"scope": "node",
"unit": "fraction"
},
{
"name": "active_sessions",
"scope": "node",
"unit": "sessions"
},
{
"name": "srtt_ms",
"scope": "peer",
"unit": "ms"
},
{
"name": "loss_rate",
"scope": "peer",
"unit": "fraction"
},
{
"name": "bytes_in",
"scope": "peer",
"unit": "bytes/s"
},
{
"name": "bytes_out",
"scope": "peer",
"unit": "bytes/s"
},
{
"name": "packets_in",
"scope": "peer",
"unit": "packets/s"
},
{
"name": "packets_out",
"scope": "peer",
"unit": "packets/s"
},
{
"name": "ecn_ce",
"scope": "peer",
"unit": "events/s"
}
],
"peer_retention_seconds": 86400,
"slow_ring_minutes": 1440
},
"status": "ok"
}
@@ -0,0 +1,7 @@
{
"data": {
"count": 0,
"peers": []
},
"status": "ok"
}
+52
View File
@@ -0,0 +1,52 @@
{
"data": {
"connection_count": 0,
"control_socket": "<redacted>",
"effective_ipv6_mtu": 1203,
"estimated_mesh_size": null,
"exe_path": "<redacted>",
"forwarding": {
"decode_error_bytes": 0,
"decode_error_packets": 0,
"delivered_bytes": 0,
"delivered_packets": 0,
"drop_mtu_exceeded_bytes": 0,
"drop_mtu_exceeded_packets": 0,
"drop_no_route_bytes": 0,
"drop_no_route_packets": 0,
"drop_send_error_bytes": 0,
"drop_send_error_packets": 0,
"forwarded_bytes": 0,
"forwarded_packets": 0,
"originated_bytes": 0,
"originated_packets": 0,
"received_bytes": 0,
"received_packets": 0,
"ttl_exhausted_bytes": 0,
"ttl_exhausted_packets": 0
},
"ipv6_addr": "fd1b:4788:b7ab:7a43:6a61:1fc5:9fb1:e34c",
"is_leaf_only": false,
"link_count": 0,
"node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"npub": "npub1sx42mj99aql52aklsg70y2jmr95u7uz2p40k769aw46ppjv302kqkhmu5r",
"peer_count": 0,
"pid": "<redacted>",
"session_count": 0,
"sparklines": {
"bytes_in": [],
"bytes_out": [],
"loss_rate": [],
"mesh_size": [],
"peer_count": [],
"tree_depth": []
},
"state": "created",
"transport_count": 0,
"tun_name": "<redacted>",
"tun_state": "disabled",
"uptime_secs": "<redacted>",
"version": "<redacted>"
},
"status": "ok"
}
@@ -0,0 +1,6 @@
{
"data": {
"transports": []
},
"status": "ok"
}
+36
View File
@@ -0,0 +1,36 @@
{
"data": {
"declaration_sequence": 1,
"declaration_signed": true,
"depth": 0,
"is_root": true,
"my_coords": [
"1b4788b7ab7a436a611fc59fb1e34c6e"
],
"my_node_addr": "1b4788b7ab7a436a611fc59fb1e34c6e",
"parent": "1b4788b7ab7a436a611fc59fb1e34c6e",
"parent_display_name": "1b4788b7...",
"peer_tree_count": 0,
"peers": [],
"root": "1b4788b7ab7a436a611fc59fb1e34c6e",
"stats": {
"accepted": 0,
"addr_mismatch": 0,
"ancestry_changed": 0,
"decode_error": 0,
"flap_dampened": 0,
"loop_detected": 0,
"parent_losses": 0,
"parent_switched": 0,
"parent_switches": 0,
"rate_limited": 0,
"received": 0,
"send_failed": 0,
"sent": 0,
"sig_failed": 0,
"stale": 0,
"unknown_peer": 0
}
},
"status": "ok"
}
+66
View File
@@ -1100,3 +1100,69 @@ impl NostrDiscovery {
Ok(())
}
}
#[cfg(test)]
impl NostrDiscovery {
/// Build a minimal `NostrDiscovery` for unit tests. No relay client is
/// connected and no background tasks are spawned; only the in-memory
/// `advert_cache` and `npub` are usable. Intended for cache-injection
/// tests of consumers (e.g. `Node::run_open_discovery_sweep`).
pub(crate) fn new_for_test() -> Self {
let keys = nostr::Keys::generate();
let pubkey = keys.public_key();
let npub = pubkey.to_bech32().expect("bech32 encode");
let client = Client::builder()
.signer(keys.clone())
.opts(ClientOptions::new().autoconnect(false))
.build();
let config = NostrDiscoveryConfig::default();
let offer_slots = Arc::new(Semaphore::new(config.max_concurrent_incoming_offers));
let (event_tx, event_rx) = mpsc::unbounded_channel();
Self {
client,
keys,
pubkey,
npub,
config,
advert_cache: RwLock::new(HashMap::new()),
local_advert: RwLock::new(None),
current_advert_event_id: RwLock::new(None),
pending_answers: Mutex::new(HashMap::new()),
active_initiators: Mutex::new(HashSet::new()),
seen_sessions: Mutex::new(HashMap::new()),
offer_slots,
event_tx,
event_rx: Mutex::new(event_rx),
notify_task: Mutex::new(None),
advertise_task: Mutex::new(None),
}
}
/// Build a `CachedOverlayAdvert` for tests with a single endpoint and
/// a generous validity window (one hour from `now_ms()`).
pub(crate) fn cached_advert_for_test(
author_npub: String,
endpoint: OverlayEndpointAdvert,
created_at_secs: u64,
) -> CachedOverlayAdvert {
CachedOverlayAdvert {
author_npub: author_npub.clone(),
advert: OverlayAdvert {
identifier: ADVERT_IDENTIFIER.to_string(),
version: ADVERT_VERSION,
endpoints: vec![endpoint],
signal_relays: None,
stun_servers: None,
},
created_at: created_at_secs,
valid_until_ms: now_ms().saturating_add(3_600_000),
}
}
/// Insert a cached advert directly into the in-memory cache. Used by
/// unit tests to set up consumer-side state without needing live relays.
pub(crate) async fn insert_advert_for_test(&self, npub: String, advert: CachedOverlayAdvert) {
let mut cache = self.advert_cache.write().await;
cache.insert(npub, advert);
}
}
+120 -1
View File
@@ -354,9 +354,128 @@ fn random_txn_id() -> [u8; 12] {
#[cfg(test)]
mod tests {
use super::is_private_overlay_candidate_ip;
use super::{is_private_overlay_candidate_ip, parse_stun_binding_success};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
const STUN_MAGIC_COOKIE: u32 = 0x2112_a442;
const TEST_TXN_ID: [u8; 12] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
];
/// Build a STUN Binding Success header with the given message length and txn id.
fn build_success_header(message_length: u16, txn_id: &[u8; 12]) -> Vec<u8> {
let mut packet = Vec::with_capacity(20);
packet.extend_from_slice(&0x0101u16.to_be_bytes()); // Binding Success
packet.extend_from_slice(&message_length.to_be_bytes());
packet.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
packet.extend_from_slice(txn_id);
packet
}
#[test]
fn parse_stun_binding_success_rejects_truncated_header() {
// Anything shorter than the 20-byte header must be rejected.
for len in 0..20usize {
let packet = vec![0u8; len];
assert!(
parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none(),
"expected None for {}-byte packet",
len
);
}
}
#[test]
fn parse_stun_binding_success_rejects_bad_magic_cookie() {
let mut packet = build_success_header(0, &TEST_TXN_ID);
// Corrupt the magic cookie at bytes 4..8.
packet[4..8].copy_from_slice(&0xdead_beefu32.to_be_bytes());
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
}
#[test]
fn parse_stun_binding_success_skips_unknown_attribute_type() {
// Unknown attribute (type 0x00ff, 4-byte body) followed by a valid
// XOR-MAPPED-ADDRESS. The parser should skip the unknown attr and
// still return the mapped address from the second TLV.
let mut packet = build_success_header(0, &TEST_TXN_ID);
// Unknown attribute: type=0x00ff, len=4, body=4 zero bytes.
packet.extend_from_slice(&0x00ffu16.to_be_bytes());
packet.extend_from_slice(&4u16.to_be_bytes());
packet.extend_from_slice(&[0u8; 4]);
// XOR-MAPPED-ADDRESS for 192.0.2.1:1234.
// Build the unxored body, then XOR with cookie/txn so the parser
// recovers the original IP/port.
let cookie = STUN_MAGIC_COOKIE.to_be_bytes();
let xport = 1234u16 ^ ((STUN_MAGIC_COOKIE >> 16) as u16);
let xip = [192 ^ cookie[0], cookie[1], 2 ^ cookie[2], 1 ^ cookie[3]];
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
packet.extend_from_slice(&8u16.to_be_bytes()); // length
packet.push(0x00); // reserved
packet.push(0x01); // family IPv4
packet.extend_from_slice(&xport.to_be_bytes());
packet.extend_from_slice(&xip);
// Patch the message length (everything after the 20-byte header).
let body_len = (packet.len() - 20) as u16;
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
let mapped = parse_stun_binding_success(&packet, &TEST_TXN_ID)
.expect("parser should skip unknown attr and find XOR-MAPPED-ADDRESS");
assert_eq!(mapped.ip(), IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)));
assert_eq!(mapped.port(), 1234);
}
#[test]
fn parse_stun_binding_success_rejects_truncated_xor_mapped_address() {
// XOR-MAPPED-ADDRESS attribute with family=IPv4 but only 6 bytes of
// body (need 8). Parser should silently ignore and return None.
let mut packet = build_success_header(0, &TEST_TXN_ID);
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
packet.extend_from_slice(&6u16.to_be_bytes()); // declared length 6 (too short)
packet.push(0x00); // reserved
packet.push(0x01); // family IPv4
packet.extend_from_slice(&[0u8; 4]); // truncated: port + partial IP only
let body_len = (packet.len() - 20) as u16;
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
}
#[test]
fn parse_stun_binding_success_rejects_length_overflow_attribute() {
// Attribute declares length larger than what's actually present in
// the buffer; parser must break out of the loop and return None
// rather than panic or read past the end.
let mut packet = build_success_header(0, &TEST_TXN_ID);
packet.extend_from_slice(&0x0020u16.to_be_bytes()); // XOR-MAPPED-ADDRESS
packet.extend_from_slice(&64u16.to_be_bytes()); // claims 64 bytes...
packet.extend_from_slice(&[0u8; 4]); // ...but only 4 bytes follow
let body_len = (packet.len() - 20) as u16;
packet[2..4].copy_from_slice(&body_len.to_be_bytes());
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
}
#[test]
fn parse_stun_binding_success_rejects_txn_id_mismatch() {
// Valid header + valid XOR-MAPPED-ADDRESS, but txn id in the packet
// does not match the expected one. Parser must reject.
let other_txn: [u8; 12] = [0xff; 12];
let mut packet = build_success_header(12, &other_txn);
packet.extend_from_slice(&0x0020u16.to_be_bytes());
packet.extend_from_slice(&8u16.to_be_bytes());
packet.push(0x00);
packet.push(0x01);
packet.extend_from_slice(&[0u8; 6]);
assert!(parse_stun_binding_success(&packet, &TEST_TXN_ID).is_none());
}
#[test]
fn private_overlay_candidate_filter_includes_rfc1918_and_ula() {
assert!(is_private_overlay_candidate_ip(IpAddr::V4(Ipv4Addr::new(
+1 -1
View File
@@ -219,7 +219,7 @@ impl Node {
}
/// Increment decrypt failure counter and force-remove peer if threshold exceeded.
fn handle_decrypt_failure(&mut self, node_addr: &crate::NodeAddr) {
pub(in crate::node) fn handle_decrypt_failure(&mut self, node_addr: &crate::NodeAddr) {
if let Some(peer) = self.peers.get_mut(node_addr) {
let count = peer.increment_decrypt_failures();
if count >= DECRYPT_FAILURE_THRESHOLD {
+1 -1
View File
@@ -1207,7 +1207,7 @@ impl Node {
///
/// `caller` is a short label included in log lines so per-tick and
/// startup sweeps are distinguishable in operator-facing logs.
async fn run_open_discovery_sweep(
pub(in crate::node) async fn run_open_discovery_sweep(
&mut self,
bootstrap: &std::sync::Arc<NostrDiscovery>,
max_age_secs: Option<u64>,
+93
View File
@@ -0,0 +1,93 @@
//! Tests for the consecutive-decrypt-failure threshold force-removal path.
//!
//! Covers `Node::handle_decrypt_failure` (in `node/handlers/encrypted.rs`),
//! which increments `ActivePeer::increment_decrypt_failures` on each AEAD
//! verification failure and force-removes the peer once
//! `DECRYPT_FAILURE_THRESHOLD` consecutive failures are observed. The
//! threshold is a defensive signal against a peer whose session is
//! desynchronized or under attack, so regression coverage of the wiring
//! between counter, threshold, and peer eviction is security-relevant.
use super::*;
/// Drive a fully-promoted peer to the decrypt-failure threshold and verify
/// it is removed from both `peers` and `peers_by_index`.
///
/// Setup uses the `make_completed_connection` harness so the peer has a
/// real `our_index`/`transport_id`, ensuring `remove_active_peer` exercises
/// the full `peers_by_index` cleanup path (not just the bare `peers` table).
#[test]
fn test_decrypt_failure_threshold_removes_peer() {
// Threshold constant in node/handlers/encrypted.rs (kept in sync with
// production code; see DECRYPT_FAILURE_THRESHOLD).
const THRESHOLD: u32 = 20;
let mut node = make_node();
let transport_id = TransportId::new(1);
let link_id = LinkId::new(1);
// Build a fully-promoted active peer with our_index/transport_id set
// so peers_by_index is populated by promote_connection.
let (conn, identity) = make_completed_connection(&mut node, link_id, transport_id, 1_000);
let node_addr = *identity.node_addr();
node.add_connection(conn).unwrap();
node.promote_connection(link_id, identity, 2_000).unwrap();
// Sanity: peer is registered and indexed.
assert_eq!(node.peer_count(), 1, "peer should be present after promote");
let our_index = node
.get_peer(&node_addr)
.and_then(|p| p.our_index())
.expect("promoted peer must have our_index");
assert_eq!(
node.peers_by_index.get(&(transport_id, our_index.as_u32())),
Some(&node_addr),
"peers_by_index must be populated after promote"
);
assert_eq!(
node.get_peer(&node_addr)
.unwrap()
.consecutive_decrypt_failures(),
0,
"fresh peer's failure counter must start at zero"
);
// Drive failures up to (but not including) the threshold; peer must
// remain present and the counter must increase monotonically.
for expected in 1..THRESHOLD {
node.handle_decrypt_failure(&node_addr);
let count = node
.get_peer(&node_addr)
.expect("peer must still be present below threshold")
.consecutive_decrypt_failures();
assert_eq!(
count, expected,
"counter should track failures pre-threshold"
);
}
assert_eq!(
node.peer_count(),
1,
"peer must remain registered until threshold is reached"
);
// The Nth failure crosses the threshold and triggers force-removal.
node.handle_decrypt_failure(&node_addr);
assert!(
node.get_peer(&node_addr).is_none(),
"peer must be removed from peers table at threshold"
);
assert_eq!(
node.peer_count(),
0,
"peer_count must be zero after eviction"
);
assert!(
!node
.peers_by_index
.contains_key(&(transport_id, our_index.as_u32())),
"peers_by_index entry must be cleaned up at threshold"
);
}
+308
View File
@@ -1033,3 +1033,311 @@ async fn test_response_path_mtu_four_node_chain() {
cleanup_nodes(&mut nodes).await;
}
// ============================================================================
// Open-Discovery Sweep — cache-injection unit test
// ============================================================================
/// Pin the iterate-filter-queue contract of `run_open_discovery_sweep`.
///
/// Builds a `Node` with `nostr.policy = Open` and an empty peer list,
/// then injects three cached adverts into a test `NostrDiscovery` and
/// asserts the sweep:
/// - queues a retry for an eligible (unknown, not-self) advert,
/// - skips the advert whose author is our own node identity, and
/// - skips the advert whose author is an already-connected peer.
///
/// Uses `NostrDiscovery::new_for_test()` and `insert_advert_for_test()`
/// (both `#[cfg(test)]`-gated test escape hatches in
/// `src/discovery/nostr/runtime.rs`) to populate the cache without
/// requiring live relay subscriptions.
#[tokio::test]
async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
use crate::config::NostrDiscoveryPolicy;
use crate::discovery::nostr::{NostrDiscovery, OverlayEndpointAdvert, OverlayTransportKind};
use crate::peer::ActivePeer;
use crate::transport::LinkId;
use std::sync::Arc;
// Build node with open-discovery enabled.
let mut config = crate::Config::new();
config.node.discovery.nostr.enabled = true;
config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
let mut node = crate::Node::new(config).unwrap();
// Identity of an already-connected peer; insert into node.peers
// so the sweep's `self.peers.contains_key(&node_addr)` filter fires.
let connected_identity = crate::Identity::generate();
let connected_npub = crate::encode_npub(&connected_identity.pubkey());
let connected_node_addr = *connected_identity.node_addr();
let connected_peer_identity = crate::PeerIdentity::from_pubkey(connected_identity.pubkey());
node.peers.insert(
connected_node_addr,
ActivePeer::new(connected_peer_identity, LinkId::new(1), 1_000),
);
// Eligible peer: fresh identity not in node.peers / retry_pending.
let eligible_identity = crate::Identity::generate();
let eligible_npub = crate::encode_npub(&eligible_identity.pubkey());
let eligible_node_addr = *eligible_identity.node_addr();
// Self filter: advert authored by node's own identity.
let self_npub = crate::encode_npub(&node.identity().pubkey());
let self_node_addr = *node.identity().node_addr();
// Build a NostrDiscovery test instance and inject the three adverts.
let bootstrap = Arc::new(NostrDiscovery::new_for_test());
let endpoint = OverlayEndpointAdvert {
transport: OverlayTransportKind::Udp,
addr: "203.0.113.7:2121".to_string(),
};
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for npub in [&eligible_npub, &connected_npub, &self_npub] {
let advert =
NostrDiscovery::cached_advert_for_test(npub.clone(), endpoint.clone(), now_secs);
bootstrap.insert_advert_for_test(npub.clone(), advert).await;
}
// Run the sweep.
node.run_open_discovery_sweep(&bootstrap, Some(3_600), "test")
.await;
// Eligible peer was queued.
assert!(
node.retry_pending.contains_key(&eligible_node_addr),
"eligible advert should be queued for retry"
);
let queued = node.retry_pending.get(&eligible_node_addr).unwrap();
assert_eq!(queued.peer_config.npub, eligible_npub);
// Connected-peer skip filter held.
assert!(
!node.retry_pending.contains_key(&connected_node_addr),
"advert for already-connected peer must not be queued"
);
// Self skip filter held.
assert!(
!node.retry_pending.contains_key(&self_node_addr),
"advert authored by own node must not be queued"
);
// Exactly one queued entry from the three injected adverts.
assert_eq!(node.retry_pending.len(), 1);
}
// ============================================================================
// Per-Attempt Timeout State Machine — IF-3-A
// ============================================================================
/// Pin the per-attempt timeout sequence in `check_pending_lookups`.
///
/// Drives the state machine deterministically through the default
/// `node.discovery.attempt_timeouts_secs = [1, 2, 4, 8]` sequence.
/// Asserts:
/// 1. **Sequence timing** — retries fire at the cumulative deadlines
/// (t=1100ms, 3100ms, 7100ms) and unreachable at t=15100ms.
/// 2. **Fresh `initiate_lookup` per attempt** — `req_initiated` counter
/// increments by exactly one on each retry. The actual `request_id`
/// is generated by `LookupRequest::generate(...)` via `rand::random()`
/// inside `initiate_lookup` and is not stored on the originator
/// side, so per-attempt freshness is verified indirectly: each
/// `req_initiated` increment corresponds to one fresh
/// `LookupRequest::generate` call.
/// 3. **Final-timeout state transitions** — `pending_lookups` entry is
/// removed, `discovery.resp_timed_out` counter ticks, queued packet
/// is drained, and an ICMPv6 Destination Unreachable frame is
/// emitted via the TUN sender.
///
/// Skipped: direct request_id capture (originator does not record its
/// own request_ids; would require production instrumentation). The
/// `req_initiated` counter is the strongest cleanly-observable signal
/// that `initiate_lookup` ran fresh on each attempt.
#[tokio::test]
async fn test_check_pending_lookups_default_sequence_unreachable() {
use crate::bloom::BloomFilter;
use crate::node::handlers::discovery::PendingLookup;
use crate::peer::ActivePeer;
use crate::transport::LinkId;
use std::sync::mpsc;
let mut node = make_node();
// Default attempt_timeouts_secs is [1, 2, 4, 8]. Confirm so the test
// cannot silently drift if the default changes.
assert_eq!(
node.config.node.discovery.attempt_timeouts_secs,
vec![1, 2, 4, 8],
"test pins the [1,2,4,8] default; update the test if the default changes"
);
// Inject a TUN sender so `send_icmpv6_dest_unreachable` is observable.
let (tun_tx, tun_rx) = mpsc::channel::<Vec<u8>>();
node.tun_tx = Some(tun_tx);
// Build a target identity (the unreachable destination).
let target_identity = Identity::generate();
let target_addr = *target_identity.node_addr();
// Build a tree-peer that:
// - has the target in its inbound bloom filter (so `may_reach` is true),
// - declares us as its parent (so `is_tree_peer` returns true).
// The peer has no Noise session, so `send_encrypted_link_message` will
// fail at the wire-send step — but `initiate_lookup` already incremented
// `req_initiated` and the failure is logged at `debug!`. The state-
// machine bookkeeping we want to test runs to completion either way.
let peer_identity_full = Identity::generate();
let peer_addr = *peer_identity_full.node_addr();
let peer_identity = crate::PeerIdentity::from_pubkey(peer_identity_full.pubkey());
let mut peer = ActivePeer::new(peer_identity, LinkId::new(1), 0);
let mut bloom = BloomFilter::new();
bloom.insert(&target_addr);
peer.update_filter(bloom, 1, 0);
node.peers.insert(peer_addr, peer);
// Make the peer a tree-peer: install a peer declaration that names us
// as its parent. `is_tree_peer` checks both directions — the child
// direction (peer.parent_id == self.node_addr) is what we exercise.
let our_addr = *node.node_addr();
let peer_decl = crate::tree::ParentDeclaration::new(peer_addr, our_addr, 1, 0);
let peer_coords = TreeCoordinate::from_addrs(vec![peer_addr, our_addr]).unwrap();
node.tree_state_mut().update_peer(peer_decl, peer_coords);
assert!(node.is_tree_peer(&peer_addr), "peer must be a tree peer");
// Queue an IPv6 packet for the target so the final-timeout drop +
// ICMPv6 emission can be observed. Build a minimal valid IPv6 header
// with a non-multicast, non-unspecified source so
// `should_send_icmp_error` returns true.
let mut ipv6_pkt = vec![0u8; 40];
ipv6_pkt[0] = 0x60; // version 6
ipv6_pkt[6] = 17; // next_header = UDP (not ICMPv6)
ipv6_pkt[7] = 64; // hop limit
// src = fd00::1 (non-multicast, non-unspecified)
ipv6_pkt[8] = 0xfd;
ipv6_pkt[23] = 0x01;
// dst = target's IPv6 representation (not strictly required, just non-multicast)
let target_ipv6 = crate::FipsAddress::from_node_addr(&target_addr).to_ipv6();
ipv6_pkt[24..40].copy_from_slice(&target_ipv6.octets());
let mut queue = std::collections::VecDeque::new();
queue.push_back(ipv6_pkt);
node.pending_tun_packets.insert(target_addr, queue);
// Inject a PendingLookup directly: attempt=1, last_sent_ms=0. This
// mirrors the post-condition of a successful `maybe_initiate_lookup`
// at t=0 without depending on wall-clock-derived `Self::now_ms()`.
node.pending_lookups
.insert(target_addr, PendingLookup::new(0));
let baseline_initiated = node.stats().discovery.req_initiated;
let baseline_timed_out = node.stats().discovery.resp_timed_out;
// --- t = 1100ms: first retry deadline (1*1000) ---
node.check_pending_lookups(1100).await;
{
let entry = node
.pending_lookups
.get(&target_addr)
.expect("still pending");
assert_eq!(entry.attempt, 2, "after retry #1, attempt should be 2");
assert_eq!(entry.last_sent_ms, 1100);
}
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 1,
"retry #1 must invoke initiate_lookup exactly once"
);
// --- t = 3100ms: second retry deadline (cumulative 1+2 = 3s) ---
node.check_pending_lookups(3100).await;
{
let entry = node
.pending_lookups
.get(&target_addr)
.expect("still pending");
assert_eq!(entry.attempt, 3, "after retry #2, attempt should be 3");
assert_eq!(entry.last_sent_ms, 3100);
}
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 2,
"retry #2 must invoke initiate_lookup exactly once more"
);
// --- t = 7100ms: third retry deadline (cumulative 1+2+4 = 7s) ---
node.check_pending_lookups(7100).await;
{
let entry = node
.pending_lookups
.get(&target_addr)
.expect("still pending");
assert_eq!(entry.attempt, 4, "after retry #3, attempt should be 4");
assert_eq!(entry.last_sent_ms, 7100);
}
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 3,
"retry #3 must invoke initiate_lookup exactly once more"
);
// --- Just-before-final: at t=15099ms the 8s window is not yet reached ---
node.check_pending_lookups(15_099).await;
assert!(
node.pending_lookups.contains_key(&target_addr),
"8s window not yet expired: pending_lookup must persist"
);
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 3,
"no new attempt before final deadline"
);
assert_eq!(
node.stats().discovery.resp_timed_out,
baseline_timed_out,
"no timeout before final deadline"
);
// --- t = 15100ms: final deadline (cumulative 1+2+4+8 = 15s) ---
// Drain any TUN frames that may have leaked from earlier steps so the
// post-final-timeout drain observes only the unreachable-emission output.
while tun_rx.try_recv().is_ok() {}
node.check_pending_lookups(15_100).await;
// Pending lookup is dropped.
assert!(
!node.pending_lookups.contains_key(&target_addr),
"final timeout must remove the pending_lookups entry"
);
// resp_timed_out counter ticked.
assert_eq!(
node.stats().discovery.resp_timed_out,
baseline_timed_out + 1,
"final timeout must increment discovery.resp_timed_out"
);
// No additional initiate_lookup on the timeout step.
assert_eq!(
node.stats().discovery.req_initiated,
baseline_initiated + 3,
"the final-timeout step must NOT call initiate_lookup"
);
// Queued packet was drained from pending_tun_packets.
assert!(
!node.pending_tun_packets.contains_key(&target_addr),
"queued packets for the unreachable target must be drained"
);
// ICMPv6 Destination Unreachable was emitted to the TUN sender.
let icmp_frame = tun_rx
.try_recv()
.expect("ICMPv6 Destination Unreachable must be emitted on final timeout");
assert!(
icmp_frame.len() >= 48,
"ICMPv6 frame must be at least IPv6 header (40) + ICMPv6 header (8)"
);
assert_eq!(icmp_frame[0] >> 4, 6, "must be IPv6");
assert_eq!(icmp_frame[6], 58, "next_header must be IPPROTO_ICMPV6 (58)");
assert_eq!(icmp_frame[40], 1, "ICMPv6 type 1 = Destination Unreachable");
}
+1
View File
@@ -10,6 +10,7 @@ mod ble;
mod bloom;
mod bloom_poison;
mod bootstrap;
mod decrypt_failure;
mod disconnect;
mod discovery;
#[cfg(target_os = "linux")]
+92
View File
@@ -201,6 +201,98 @@ fn test_routing_tree_fallback() {
assert_eq!(result.unwrap().node_addr(), &peer_addr);
}
/// Regression: bloom hit on a peer that is NOT strictly closer to dest
/// than we are must fall through to greedy tree routing rather than
/// returning None. Pinned by commit a859da7.
///
/// Pre-fix behavior: bloom candidates exist but `select_best_candidate`
/// rejects them all under the self-distance check (peer dist >= my dist),
/// and `find_next_hop` returned None — a NoRoute failure even though the
/// tree had a valid greedy next hop.
///
/// Post-fix behavior: same scenario falls through to greedy tree routing
/// and returns the tree-routing-selected next hop.
#[test]
fn test_routing_bloom_hit_not_closer_falls_through_to_tree() {
let mut node = make_node();
let transport_id = TransportId::new(1);
let my_addr = *node.node_addr();
// tree_peer: child of self, on the path to dest (greedy tree pick).
let tree_link = LinkId::new(1);
let (tree_conn, tree_id) = make_completed_connection(&mut node, tree_link, transport_id, 1000);
let tree_peer_addr = *tree_id.node_addr();
node.add_connection(tree_conn).unwrap();
node.promote_connection(tree_link, tree_id, 2000).unwrap();
// bloom_peer: also a child of self, but with a stale/false-positive
// bloom hit for dest. Its tree distance to dest is NOT closer than
// ours, so the self-distance check in select_best_candidate excludes
// it — leaving zero viable bloom candidates.
let bloom_link = LinkId::new(2);
let (bloom_conn, bloom_id) =
make_completed_connection(&mut node, bloom_link, transport_id, 1000);
let bloom_peer_addr = *bloom_id.node_addr();
node.add_connection(bloom_conn).unwrap();
node.promote_connection(bloom_link, bloom_id, 2000).unwrap();
// Tree topology (we are root):
// self ── tree_peer ── dest
// └──── bloom_peer
//
// Distances to dest:
// self : 2 (root → tree_peer → dest)
// tree_peer : 1 (tree_peer → dest) ← greedy winner
// bloom_peer : 3 (bloom_peer → root → tree_peer → dest) ← NOT closer than self
let tree_peer_coords = TreeCoordinate::from_addrs(vec![tree_peer_addr, my_addr]).unwrap();
node.tree_state_mut().update_peer(
ParentDeclaration::new(tree_peer_addr, my_addr, 1, 1000),
tree_peer_coords,
);
let bloom_peer_coords = TreeCoordinate::from_addrs(vec![bloom_peer_addr, my_addr]).unwrap();
node.tree_state_mut().update_peer(
ParentDeclaration::new(bloom_peer_addr, my_addr, 1, 1000),
bloom_peer_coords,
);
// Destination is a child of tree_peer in the tree.
let dest = make_node_addr(99);
let dest_coords = TreeCoordinate::from_addrs(vec![dest, tree_peer_addr, my_addr]).unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
node.coord_cache_mut().insert(dest, dest_coords, now_ms);
// dest is in bloom_peer's filter only (the "bloom hit" candidate),
// but bloom_peer's tree distance (3) is NOT strictly less than our
// distance (2), so select_best_candidate yields no winner.
// tree_peer has NO bloom entry for dest.
let bloom_peer = node.get_peer_mut(&bloom_peer_addr).unwrap();
let mut filter = BloomFilter::new();
filter.insert(&dest);
bloom_peer.update_filter(filter, 1, 3000);
// Pre-fix this returned None. Post-fix it falls through to greedy
// tree routing and picks tree_peer (distance 1 < self distance 2).
let result = node.find_next_hop(&dest);
assert!(
result.is_some(),
"find_next_hop must fall through to tree routing when bloom \
candidates exist but none are strictly closer than self"
);
let next_hop = result.unwrap().node_addr();
assert_eq!(
next_hop, &tree_peer_addr,
"tree-routing winner expected (tree_peer), got {:?}",
next_hop,
);
assert_ne!(
next_hop, &bloom_peer_addr,
"bloom_peer must be excluded by the self-distance check",
);
}
#[test]
fn test_routing_tree_no_coords_in_cache() {
let mut node = make_node();
+43
View File
@@ -1345,4 +1345,47 @@ mod tests {
peer.reset_replay_suppressed();
assert_eq!(peer.reset_replay_suppressed(), 0);
}
#[test]
fn test_increment_decrypt_failures_monotonic() {
let identity = make_peer_identity();
let mut peer = ActivePeer::new(identity, LinkId::new(1), 1000);
// Initial count is zero
assert_eq!(peer.consecutive_decrypt_failures(), 0);
// Each call returns a strictly increasing count
let mut prev = 0u32;
for expected in 1..=25u32 {
let count = peer.increment_decrypt_failures();
assert_eq!(count, expected, "increment must return monotonic count");
assert!(count > prev, "count must strictly increase");
assert_eq!(peer.consecutive_decrypt_failures(), count);
prev = count;
}
}
#[test]
fn test_reset_decrypt_failures_zeroes_counter() {
let identity = make_peer_identity();
let mut peer = ActivePeer::new(identity, LinkId::new(1), 1000);
// Drive counter up
for _ in 0..7 {
peer.increment_decrypt_failures();
}
assert_eq!(peer.consecutive_decrypt_failures(), 7);
// Reset zeroes it
peer.reset_decrypt_failures();
assert_eq!(peer.consecutive_decrypt_failures(), 0);
// Reset on zero is a no-op (still zero, no panic)
peer.reset_decrypt_failures();
assert_eq!(peer.consecutive_decrypt_failures(), 0);
// Counter resumes at 1 after reset
assert_eq!(peer.increment_decrypt_failures(), 1);
assert_eq!(peer.consecutive_decrypt_failures(), 1);
}
}
+129
View File
@@ -554,9 +554,17 @@ fn get_mac_addr(interface: &str) -> Result<[u8; 6], TransportError> {
// ============================================================================
// Unit tests
//
// The whole `socket_macos.rs` file is `#[cfg(target_os = "macos")]`-included
// by `socket.rs`, so this `#[cfg(test)]` mod naturally only compiles on macOS.
// The redundant `#[cfg(target_os = "macos")]` below is belt-and-suspenders:
// it makes the macOS-only intent explicit so that any future refactor that
// includes this file on additional targets won't silently activate macOS-
// specific tests.
// ============================================================================
#[cfg(test)]
#[cfg(target_os = "macos")]
mod tests {
use super::*;
@@ -814,6 +822,127 @@ mod tests {
}
assert!(readable, "pipe read end should be readable after write");
}
// -----------------------------------------------------------------------
// BpfHeader layout pin
//
// The `bpf_hdr` wire layout is fixed by the macOS kernel. If `BpfHeader`
// ever drifts (e.g. someone adds a field, or the timestamp field type
// changes), `parse_next_frame` will misread the kernel's frames. Pin
// both the size and the per-field byte offsets so any such drift fails
// at unit-test time rather than as runtime garbage MAC addresses.
// -----------------------------------------------------------------------
#[test]
fn test_bpf_header_layout_matches_kernel() {
// size_of pinned at type-define site too via `const _: () = assert!`,
// but repeating here makes the failure mode obvious in test output.
assert_eq!(std::mem::size_of::<BpfHeader>(), 20);
// bh_hdrlen lives at offset 16 (4 + 4 + 4 + 4).
let hdr = BpfHeader {
bh_tstamp_sec: 0,
bh_tstamp_usec: 0,
bh_caplen: 0,
bh_datalen: 0,
bh_hdrlen: 0xABCD,
_pad: 0,
};
let bytes: &[u8] =
unsafe { std::slice::from_raw_parts(&hdr as *const BpfHeader as *const u8, 20) };
assert_eq!(&bytes[16..18], &0xABCDu16.to_ne_bytes());
}
// -----------------------------------------------------------------------
// parse_next_frame — additional malformed-header rejection cases
// -----------------------------------------------------------------------
#[test]
fn test_parse_next_frame_caplen_exceeds_remaining_buffer() {
// Build a header that claims more captured data than the buffer
// actually holds. parse_next_frame should reject (return None)
// rather than read past the end.
let hdr_size = std::mem::size_of::<BpfHeader>();
let claimed_cap_len: usize = 200; // > what's actually in the buffer
let hdr = BpfHeader {
bh_tstamp_sec: 0,
bh_tstamp_usec: 0,
bh_caplen: claimed_cap_len as u32,
bh_datalen: claimed_cap_len as u32,
bh_hdrlen: hdr_size as u16,
_pad: 0,
};
// Allocate only header + 32 bytes — far short of claimed cap_len.
let mut buf = vec![0u8; hdr_size + 32];
unsafe {
std::ptr::copy_nonoverlapping(
&hdr as *const BpfHeader as *const u8,
buf.as_mut_ptr(),
hdr_size,
);
}
let mut out_buf = vec![0u8; 1500];
let mut offset = 0usize;
// Truncated frame: returns None (skipped, not an error).
assert!(parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf).is_none());
}
// -----------------------------------------------------------------------
// Ethernet-header construction round-trip
//
// Mirrors the byte layout that `send_to` lays down in front of the
// payload, then runs that through `parse_next_frame` to confirm the
// source MAC bytes survive the round trip. Pure data — no actual fd.
// -----------------------------------------------------------------------
#[test]
fn test_ethernet_header_round_trip_via_parse() {
// Hand-build the Ethernet header the way send_to() does.
let dst_mac: [u8; 6] = [0xff; 6];
let src_mac: [u8; 6] = [0x02, 0x00, 0x00, 0x12, 0x34, 0x56];
let ethertype: u16 = 0x88B5; // local-experimental EtherType
let payload: &[u8] = b"FIPS-frame-payload";
// Construct the in-buffer BPF frame the kernel would have written:
// [bpf_hdr][dst_mac][src_mac][ethertype_be][payload]
let cap_len = ETH_HDRLEN + payload.len();
let hdr = BpfHeader {
bh_tstamp_sec: 0,
bh_tstamp_usec: 0,
bh_caplen: cap_len as u32,
bh_datalen: cap_len as u32,
bh_hdrlen: std::mem::size_of::<BpfHeader>() as u16,
_pad: 0,
};
let hdr_size = std::mem::size_of::<BpfHeader>();
let total = bpf_wordalign(hdr_size + cap_len);
let mut buf = vec![0u8; total];
unsafe {
std::ptr::copy_nonoverlapping(
&hdr as *const BpfHeader as *const u8,
buf.as_mut_ptr(),
hdr_size,
);
}
let frame_start = hdr_size;
buf[frame_start..frame_start + 6].copy_from_slice(&dst_mac);
buf[frame_start + 6..frame_start + 12].copy_from_slice(&src_mac);
buf[frame_start + 12..frame_start + 14].copy_from_slice(&ethertype.to_be_bytes());
buf[frame_start + ETH_HDRLEN..frame_start + ETH_HDRLEN + payload.len()]
.copy_from_slice(payload);
// Parse it back.
let mut out_buf = vec![0u8; 1500];
let mut offset = 0usize;
let (n, parsed_src) = parse_next_frame(&buf, &mut offset, buf.len(), &mut out_buf)
.expect("Some")
.expect("Ok");
// The 14-byte Ethernet header is stripped; only the payload survives.
assert_eq!(n, payload.len());
assert_eq!(&out_buf[..n], payload);
// Source MAC is reported directly from bytes [6..12] of the frame.
assert_eq!(parsed_src, src_mac);
}
}
/// Get the MTU of an interface by index.
+105 -2
View File
@@ -361,6 +361,41 @@ impl TunDevice {
}
}
/// macOS utun protocol family value for IPv6 (matches `<sys/socket.h>`
/// `AF_INET6` on Darwin). Used as the 4-byte big-endian packet-info
/// header prepended to every utun frame.
#[cfg(target_os = "macos")]
const UTUN_AF_INET6: u32 = 30;
/// Build the 4-byte big-endian utun packet-info header for an IPv6 frame.
///
/// utun devices on macOS require a 4-byte address-family prefix on every
/// frame: a single big-endian `u32` carrying the protocol family. For
/// IPv6 traffic (the only family FIPS sends) this is `AF_INET6 = 30`,
/// which serializes as `[0x00, 0x00, 0x00, 0x1e]`.
#[cfg(target_os = "macos")]
#[inline]
fn utun_af_inet6_header() -> [u8; 4] {
UTUN_AF_INET6.to_be_bytes()
}
/// Parse the 4-byte big-endian utun packet-info header.
///
/// Returns the address-family value (`AF_INET6 = 30` for IPv6 frames),
/// or `None` if the buffer is shorter than the 4-byte header. The `tun`
/// crate's `Read` impl strips this transparently for us in the read
/// path; this helper exists for round-trip testability with
/// [`utun_af_inet6_header`] and for any future code path that reads
/// from the dup'd fd directly.
#[cfg(target_os = "macos")]
#[inline]
fn parse_utun_af_prefix(buf: &[u8]) -> Option<u32> {
if buf.len() < 4 {
return None;
}
Some(u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]))
}
/// Writer thread for TUN device.
///
/// Services a queue of outbound packets and writes them to the TUN device.
@@ -413,10 +448,10 @@ impl TunWriter {
#[cfg(target_os = "macos")]
let write_result = {
use std::os::unix::io::AsRawFd;
const AF_INET6_HEADER: [u8; 4] = [0, 0, 0, 30];
let af_header = utun_af_inet6_header();
let iov = [
libc::iovec {
iov_base: AF_INET6_HEADER.as_ptr() as *mut libc::c_void,
iov_base: af_header.as_ptr() as *mut libc::c_void,
iov_len: 4,
},
libc::iovec {
@@ -1482,4 +1517,72 @@ mod tests {
assert_eq!(per_flow_max_mss(&lookup, a.as_bytes(), 1360), 1143);
assert_eq!(per_flow_max_mss(&lookup, b.as_bytes(), 1360), 1315);
}
// ========================================================================
// macOS utun packet-info header (AF_INET6 4-byte big-endian prefix)
//
// These tests are pure-data byte-buffer manipulation and require no
// privilege, no actual TUN device, no system calls. They pin the wire
// format that `TunWriter::run` emits ahead of every IPv6 frame on the
// dup'd utun fd, and the inverse parse used for round-trip checking.
// ========================================================================
#[cfg(target_os = "macos")]
mod macos_utun_header {
use super::super::{UTUN_AF_INET6, parse_utun_af_prefix, utun_af_inet6_header};
#[test]
fn af_inet6_constant_matches_darwin() {
// Darwin's <sys/socket.h> defines AF_INET6 = 30. If this ever
// diverges, every utun write FIPS issues will be misclassified
// by the kernel and dropped.
assert_eq!(UTUN_AF_INET6, 30);
}
#[test]
fn encode_produces_big_endian_af_inet6() {
// The kernel reads the 4-byte prefix as a big-endian u32.
// 30 == 0x0000001e, so the wire bytes are [0, 0, 0, 0x1e].
let header = utun_af_inet6_header();
assert_eq!(header, [0x00, 0x00, 0x00, 0x1e]);
}
#[test]
fn encode_round_trips_through_parse() {
let header = utun_af_inet6_header();
let parsed = parse_utun_af_prefix(&header).expect("4 bytes is enough");
assert_eq!(parsed, UTUN_AF_INET6);
}
#[test]
fn parse_rejects_short_buffer() {
// Anything shorter than the 4-byte header is ill-formed.
assert_eq!(parse_utun_af_prefix(&[]), None);
assert_eq!(parse_utun_af_prefix(&[0x00]), None);
assert_eq!(parse_utun_af_prefix(&[0x00, 0x00]), None);
assert_eq!(parse_utun_af_prefix(&[0x00, 0x00, 0x00]), None);
}
#[test]
fn parse_accepts_minimum_header_with_trailing_payload() {
// A real utun read returns header + IP packet concatenated.
// The parser only consumes the first 4 bytes.
let mut frame = utun_af_inet6_header().to_vec();
frame.extend_from_slice(&[0x60; 40]); // dummy IPv6 header
let parsed = parse_utun_af_prefix(&frame).expect("4 bytes is enough");
assert_eq!(parsed, UTUN_AF_INET6);
}
#[test]
fn parse_garbage_bytes_returns_garbage_value_not_panic() {
// A well-formed 4-byte buffer whose value is not AF_INET6
// should parse successfully (returning the raw u32) without
// panicking. Discriminating "expected" vs "unexpected" AF
// values is the caller's responsibility.
let buf = [0xde, 0xad, 0xbe, 0xef];
let parsed = parse_utun_af_prefix(&buf).expect("4 bytes is enough");
assert_eq!(parsed, 0xdeadbeef);
assert_ne!(parsed, UTUN_AF_INET6);
}
}
}