From 1c2cda480d608ee2522c60f00d00cb36ff474ca0 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Tue, 10 Feb 2026 23:35:09 +0000 Subject: [PATCH] Implement spanning tree announcement send/receive protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TreeAnnounce v1 wire format with versioned encoding (version byte 0x01), slim ancestry entries (32 bytes each, no per-entry signatures), and transitive trust model where only the direct peer's declaration signature is verified. Key changes: - Enrich TreeCoordinate with CoordEntry metadata (sequence, timestamp) - TreeAnnounce encode/decode with roundtrip tests - Per-peer rate limiting (500ms minimum interval) on ActivePeer - Parent selection: depth-based algorithm with broken-path detection - New node/tree.rs module: send/receive, periodic refresh, cleanup - Wire up dispatch, initial announce on promotion, tick integration - Update gossip protocol design doc with trust model (§2.7) 304 tests pass, clean build. --- docs/design/fips-gossip-protocol.md | 135 ++++--- src/cache.rs | 2 +- src/lib.rs | 2 +- src/node/handlers.rs | 34 +- src/node/mod.rs | 7 + src/node/tree.rs | 284 ++++++++++++++ src/peer/active.rs | 38 +- src/protocol.rs | 330 ++++++++++++++++- src/tree.rs | 554 ++++++++++++++++++++++++---- 9 files changed, 1260 insertions(+), 126 deletions(-) create mode 100644 src/node/tree.rs diff --git a/docs/design/fips-gossip-protocol.md b/docs/design/fips-gossip-protocol.md index f71bbc0..25a0c5f 100644 --- a/docs/design/fips-gossip-protocol.md +++ b/docs/design/fips-gossip-protocol.md @@ -35,24 +35,30 @@ coordinates and distances. ### 2.1 Wire Format ```text -TreeAnnounce { +TreeAnnounce (v1) { + version: u8, // Protocol version (0x01 for v1) sequence: u64, // Monotonic, increments on parent change timestamp: u64, // Unix timestamp (seconds) - parent: NodeAddr, // 16 bytes, truncated SHA-256(pubkey) of selected parent - ancestry: Vec, // Path from self to root - signature: Signature, // 64 bytes, signs (sequence || timestamp || parent || ancestry) + parent: NodeAddr, // 16 bytes, truncated SHA-256(pubkey) of selected parent + ancestry_count: u16, // Number of ancestry entries + ancestry: [AncestryEntry], // Path from self to root + signature: Signature, // 64 bytes, outer signature over entire message } -AncestryEntry { - node_addr: NodeAddr, // 16 bytes +AncestryEntry (v1) { + node_addr: NodeAddr, // 16 bytes sequence: u64, // That node's sequence number timestamp: u64, // That node's timestamp - signature: Signature, // That node's signature over its declaration } ``` +Note: v1 ancestry entries are 32 bytes each (no per-entry signature). See §2.7 Trust Model. + ### 2.2 Field Semantics +**version**: Protocol version number. v1 = 0x01. Receivers MUST reject messages +with unrecognized version numbers to ensure forward compatibility. + **sequence**: Incremented each time the node changes its parent declaration. Higher sequence numbers supersede lower ones for conflict resolution. @@ -63,20 +69,33 @@ stale if `now - timestamp > ROOT_TIMEOUT` (default 60 minutes for root). the node is declaring itself as root. **ancestry**: The chain from this node up to the root. The first entry is this -node's own declaration, followed by parent, grandparent, etc. Each entry is -signed by the declaring node, allowing verification of the entire chain. +node's own declaration, followed by parent, grandparent, etc. In v1, entries +carry only routing metadata (node_addr, sequence, timestamp) without per-entry +signatures. See §2.7 for the trust model. ### 2.3 Size Estimate | Component | Size | |-----------|------| +| version | 1 byte | | sequence | 8 bytes | | timestamp | 8 bytes | | parent | 16 bytes | +| ancestry_count | 2 bytes | | signature | 64 bytes | -| Per ancestry entry | 16 + 8 + 8 + 64 = 96 bytes | +| Per ancestry entry (v1) | 16 + 8 + 8 = 32 bytes | -For tree depth D: `96 + D * 96` bytes. At depth 10: ~1.1 KB. +For tree depth D (ancestry_count = D + 1): `100 + (D + 1) × 32` bytes payload. + +| Tree Depth | Payload Size | With Link Overhead | +|------------|--------------|--------------------| +| 0 (root) | 132 bytes | 161 bytes | +| 3 | 228 bytes | 257 bytes | +| 5 | 292 bytes | 321 bytes | +| 10 | 452 bytes | 481 bytes | + +Note: v1 ancestry entries omit per-entry signatures (32 bytes vs 96 bytes in +the original design). See §2.7 for the rationale. ### 2.4 Exchange Rules @@ -104,18 +123,22 @@ For tree depth D: `96 + D * 96` bytes. At depth 10: ~1.1 KB. When receiving TreeAnnounce from peer P: ```text -1. Verify signature on sender's declaration -2. Verify signatures on all ancestry entries -3. For each entry in ancestry: - - If entry.sequence > stored.sequence: update stored entry - - If entry.sequence == stored.sequence && entry.timestamp > stored.timestamp: update - - Otherwise: keep existing entry -4. Update peer P's record with new ancestry -5. Re-evaluate parent selection: - - If better path to root available: change parent, announce to all peers - - Apply stability threshold to prevent flapping +1. Decode message; reject if version != 0x01 +2. Verify P's declaration signature using P's known public key (from Noise IK) +3. Verify that declaration node_addr matches sender's identity +4. Check sequence freshness: + - If sequence <= stored sequence for P: discard (stale) +5. Update peer P's tree state (declaration + ancestry) +6. Re-evaluate parent selection: + - Find smallest root visible across all peers + - Among peers reaching smallest root, prefer shallowest depth + - Apply stability threshold to prevent flapping (depth improvement ≥ 1) + - If parent changed: increment own sequence, sign, recompute coords, announce to all ``` +Note: In v1, only the sender's declaration signature is verified (step 2). +Ancestry entries beyond the direct peer are accepted on trust. See §2.7. + ### 2.6 Rate Limiting To prevent announcement storms during reconvergence: @@ -124,6 +147,34 @@ To prevent announcement storms during reconvergence: - If change occurs during cooldown: mark pending, send after cooldown - Coalesce multiple pending changes into single announcement +### 2.7 Trust Model (v1) + +**v1 uses transitive trust**: each node verifies only its direct peer's +declaration signature. The peer's public key is known from the Noise IK +handshake, so verification is straightforward. Ancestry entries from nodes +beyond the direct peer are accepted on trust from the authenticated sender. + +**Why transitive trust?** NodeAddr values are truncated SHA-256 hashes of +public keys — this mapping is intentionally one-way. To verify an ancestry +entry's signature, a node would need the entry's public key, but FIPS does not +distribute node_addr→pubkey mappings by design. Exposing these mappings would +enable traffic analysis, undermining a core privacy property. + +**Limitation**: An adversarial interior node could fabricate ancestry chains, +potentially attracting traffic to itself (sinkhole attack) or manipulating tree +topology. This risk is mitigated by: + +- **Authenticated peers have reputation cost**: Misbehaving nodes can be + disconnected and blocked by their direct peers. +- **Multi-path observation**: Nodes receiving conflicting tree state from + multiple peers can detect inconsistencies (future enhancement). + +**Versioning**: The wire format includes a version byte (v1 = 0x01) to enable +future protocol evolution. A future version could introduce stronger ancestry +verification (e.g., zero-knowledge proofs of key ownership) without breaking +backward compatibility. Nodes MUST reject TreeAnnounce messages with +unrecognized version numbers. + --- ## 3. FilterAnnounce @@ -433,79 +484,79 @@ Propagates spanning tree state between directly connected peers. │ │ Decrypt │ │ ▼ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ TREE ANNOUNCE (plaintext) │ │ +│ │ TREE ANNOUNCE v1 (plaintext) │ │ │ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │ │ │ Offset │ Field │ Size │ Description │ │ │ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │ │ │ 0 │ msg_type │ 1 byte │ 0x10 │ │ -│ │ 1 │ sequence │ 8 bytes │ u64 LE, monotonic counter │ │ -│ │ 9 │ timestamp │ 8 bytes │ u64 LE, Unix seconds │ │ -│ │ 17 │ parent │ 16 bytes │ NodeAddr of selected parent │ │ -│ │ 33 │ ancestry_count │ 2 bytes │ u16 LE, number of entries │ │ -│ │ 35 │ ancestry[0..n] │ 96 × n │ AncestryEntry array │ │ +│ │ 1 │ version │ 1 byte │ 0x01 (v1) │ │ +│ │ 2 │ sequence │ 8 bytes │ u64 LE, monotonic counter │ │ +│ │ 10 │ timestamp │ 8 bytes │ u64 LE, Unix seconds │ │ +│ │ 18 │ parent │ 16 bytes │ NodeAddr of selected parent │ │ +│ │ 34 │ ancestry_count │ 2 bytes │ u16 LE, number of entries │ │ +│ │ 36 │ ancestry[0..n] │ 32 × n │ AncestryEntry array │ │ │ │ ... │ signature │ 64 bytes │ Schnorr sig over all above │ │ │ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────────────────┐ │ -│ │ ANCESTRY ENTRY (96 bytes each) │ │ +│ │ ANCESTRY ENTRY v1 (32 bytes each) │ │ │ ├────────┬──────────────────┬───────────┬───────────────────────────────┤ │ │ │ Offset │ Field │ Size │ Description │ │ │ ├────────┼──────────────────┼───────────┼───────────────────────────────┤ │ -│ │ 0 │ node_addr │ 16 bytes │ Truncated SHA-256(pubkey) │ │ +│ │ 0 │ node_addr │ 16 bytes │ Truncated SHA-256(pubkey) │ │ │ │ 16 │ sequence │ 8 bytes │ u64 LE, node's seq number │ │ │ │ 24 │ timestamp │ 8 bytes │ u64 LE, node's timestamp │ │ -│ │ 32 │ signature │ 64 bytes │ Node's sig over its decl │ │ │ └────────┴──────────────────┴───────────┴───────────────────────────────┘ │ │ │ +│ Note: v1 entries omit per-entry signatures. Only the sender's outer │ +│ signature is verified (transitive trust model, see §2.7). │ +│ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` -**Size calculation**: `1 + 8 + 8 + 16 + 2 + (depth × 96) + 64` bytes +**Size calculation**: `1 + 1 + 8 + 8 + 16 + 2 + (depth × 32) + 64 = 100 + (depth × 32)` bytes | Tree Depth | Payload Size | With Link Overhead | -|------------|--------------|-------------------| -| 1 (root) | 195 bytes | 224 bytes | -| 3 | 387 bytes | 416 bytes | -| 5 | 579 bytes | 608 bytes | -| 10 | 1059 bytes | 1088 bytes | +|------------|--------------|--------------------| +| 0 (root) | 132 bytes | 161 bytes | +| 3 | 228 bytes | 257 bytes | +| 5 | 292 bytes | 321 bytes | +| 10 | 452 bytes | 481 bytes | **Concrete example** (node D at depth 3, ancestry = [D, P1, P2, Root]): ```text PLAINTEXT BYTES (hex layout): 10 ← msg_type = TreeAnnounce +01 ← version = 1 05 00 00 00 00 00 00 00 ← sequence = 5 C3 B2 A1 67 00 00 00 00 ← timestamp (Unix seconds) -[16 bytes P1's node_addr] ← parent +[16 bytes P1's node_addr] ← parent 04 00 ← ancestry_count = 4 ANCESTRY[0] - Self (D): [16 bytes D's node_addr] 05 00 00 00 00 00 00 00 ← D's sequence C3 B2 A1 67 00 00 00 00 ← D's timestamp - [64 bytes D's signature] ANCESTRY[1] - Parent (P1): [16 bytes P1's node_addr] 0A 00 00 00 00 00 00 00 ← P1's sequence 00 B0 A1 67 00 00 00 00 ← P1's timestamp - [64 bytes P1's signature] ANCESTRY[2] - Grandparent (P2): [16 bytes P2's node_addr] 03 00 00 00 00 00 00 00 ← P2's sequence 00 A0 A1 67 00 00 00 00 ← P2's timestamp - [64 bytes P2's signature] ANCESTRY[3] - Root: [16 bytes Root's node_addr] 01 00 00 00 00 00 00 00 ← Root's sequence 00 90 A1 67 00 00 00 00 ← Root's timestamp - [64 bytes Root's signature] [64 bytes D's outer signature] ← signs entire message -Total payload: 1 + 8 + 8 + 16 + 2 + (4 × 96) + 64 = 483 bytes +Total payload: 1 + 1 + 8 + 8 + 16 + 2 + (4 × 32) + 64 = 228 bytes ``` ### A.2 FilterAnnounce (0x11) diff --git a/src/cache.rs b/src/cache.rs index 86c4cab..ca7ca7a 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -540,7 +540,7 @@ mod tests { } fn make_coords(ids: &[u8]) -> TreeCoordinate { - TreeCoordinate::new(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() + TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() } // ===== CacheEntry Tests ===== diff --git a/src/lib.rs b/src/lib.rs index ae16d04..401c586 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,7 +29,7 @@ pub use identity::{ pub use config::{Config, ConfigError, IdentityConfig, TunConfig, UdpConfig}; // Re-export tree types -pub use tree::{ParentDeclaration, TreeCoordinate, TreeError, TreeState}; +pub use tree::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeState}; // Re-export bloom filter types pub use bloom::{BloomError, BloomFilter, BloomState}; diff --git a/src/node/handlers.rs b/src/node/handlers.rs index a4d592b..07a4b37 100644 --- a/src/node/handlers.rs +++ b/src/node/handlers.rs @@ -43,6 +43,7 @@ impl Node { .map(|d| d.as_millis() as u64) .unwrap_or(0); self.process_pending_retries(now_ms).await; + self.check_tree_state().await; } } } @@ -315,6 +316,10 @@ impl Node { our_index = %our_index, "Inbound peer promoted to active" ); + // Send initial tree announce to new peer + if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await { + debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce"); + } } PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => { // Clean up the losing connection's link @@ -324,6 +329,10 @@ impl Node { loser_link_id = %loser_link_id, "Inbound cross-connection won, loser link cleaned up" ); + // Send initial tree announce to peer (new or reconnected) + if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await { + debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce"); + } } PromotionResult::CrossConnectionLost { winner_link_id } => { // This connection lost — clean up its link @@ -435,6 +444,10 @@ impl Node { node_addr = %node_addr, "Peer promoted to active" ); + // Send initial tree announce to new peer + if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await { + debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce"); + } } PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => { // Clean up the losing connection's link @@ -449,6 +462,10 @@ impl Node { loser_link_id = %loser_link_id, "Outbound cross-connection won, loser link cleaned up" ); + // Send initial tree announce to peer (new or reconnected) + if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await { + debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce"); + } } PromotionResult::CrossConnectionLost { winner_link_id } => { // This connection lost — clean up its link @@ -673,7 +690,7 @@ impl Node { match msg_type { 0x10 => { // TreeAnnounce - debug!("Received TreeAnnounce (not yet implemented)"); + self.handle_tree_announce(from, payload).await; } 0x20 => { // FilterAnnounce @@ -727,6 +744,10 @@ impl Node { /// /// Frees session index, removes link and address mappings. Used for /// both graceful disconnect and timeout-based eviction. + /// + /// Also handles tree state cleanup: if the removed peer was our parent, + /// selects an alternative or becomes root, and marks remaining peers + /// for pending tree announce (delivered on next tick). pub(super) fn remove_active_peer(&mut self, node_addr: &NodeAddr) { let peer = match self.peers.remove(node_addr) { Some(p) => p, @@ -747,9 +768,20 @@ impl Node { // Remove link and address mapping self.remove_link(&link_id); + // Tree state cleanup + let tree_changed = self.handle_peer_removal_tree_cleanup(node_addr); + if tree_changed { + // Mark all remaining peers for pending tree announce. + // These will be sent on the next tick via check_tree_state(). + for peer in self.peers.values_mut() { + peer.mark_tree_announce_pending(); + } + } + info!( node_addr = %node_addr, link_id = %link_id, + tree_changed = tree_changed, "Peer removed and state cleaned up" ); } diff --git a/src/node/mod.rs b/src/node/mod.rs index c3ce9cf..c78ffca 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -7,6 +7,7 @@ mod handlers; mod lifecycle; mod retry; +mod tree; #[cfg(test)] mod tests; @@ -255,6 +256,10 @@ pub struct Node { /// Rate limiter for msg1 processing (DoS protection). msg1_rate_limiter: HandshakeRateLimiter, + // === Tree Announce Timing === + /// Last time we refreshed our root announcement (Unix seconds). + last_root_refresh_secs: u64, + // === Connection Retry === /// Retry state for peers whose outbound connections have failed. /// Keyed by NodeAddr. Entries are created when a handshake times out @@ -317,6 +322,7 @@ impl Node { peers_by_index: HashMap::new(), pending_outbound: HashMap::new(), msg1_rate_limiter: HandshakeRateLimiter::new(), + last_root_refresh_secs: 0, retry_pending: HashMap::new(), }) } @@ -365,6 +371,7 @@ impl Node { peers_by_index: HashMap::new(), pending_outbound: HashMap::new(), msg1_rate_limiter: HandshakeRateLimiter::new(), + last_root_refresh_secs: 0, retry_pending: HashMap::new(), } } diff --git a/src/node/tree.rs b/src/node/tree.rs new file mode 100644 index 0000000..3df87c3 --- /dev/null +++ b/src/node/tree.rs @@ -0,0 +1,284 @@ +//! Spanning Tree Announce send/receive logic. +//! +//! Handles building, sending, and receiving TreeAnnounce messages, +//! including periodic root refresh and rate-limited propagation. + +use crate::protocol::TreeAnnounce; +use crate::NodeAddr; + +use super::{Node, NodeError}; +use tracing::{debug, info, warn}; + +/// Root nodes re-announce every 30 minutes to keep the tree alive. +const ROOT_REFRESH_INTERVAL_SECS: u64 = 30 * 60; + +impl Node { + /// Build a TreeAnnounce from our current tree state. + fn build_tree_announce(&self) -> Result { + let decl = self.tree_state.my_declaration().clone(); + let ancestry = self.tree_state.my_coords().clone(); + + if !decl.is_signed() { + return Err(NodeError::SendFailed { + node_addr: *self.identity.node_addr(), + reason: "declaration not signed".into(), + }); + } + + Ok(TreeAnnounce::new(decl, ancestry)) + } + + /// Send a TreeAnnounce to a specific peer, respecting rate limits. + /// + /// If the peer is rate-limited, the announce is marked pending for + /// delivery on the next tick cycle. + pub(super) async fn send_tree_announce_to_peer( + &mut self, + peer_addr: &NodeAddr, + ) -> Result<(), NodeError> { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + // Check rate limit + let peer = match self.peers.get_mut(peer_addr) { + Some(p) => p, + None => return Err(NodeError::PeerNotFound(*peer_addr)), + }; + + if !peer.can_send_tree_announce(now_ms) { + peer.mark_tree_announce_pending(); + debug!( + peer = %peer_addr, + "TreeAnnounce rate-limited, marking pending" + ); + return Ok(()); + } + + // Build and encode + let announce = self.build_tree_announce()?; + let encoded = announce.encode().map_err(|e| NodeError::SendFailed { + node_addr: *peer_addr, + reason: format!("encode failed: {}", e), + })?; + + // Send + self.send_encrypted_link_message(peer_addr, &encoded).await?; + + // Record send time + if let Some(peer) = self.peers.get_mut(peer_addr) { + peer.record_tree_announce_sent(now_ms); + } + + debug!(peer = %peer_addr, "Sent TreeAnnounce"); + Ok(()) + } + + /// Send a TreeAnnounce to all active peers. + pub(super) async fn send_tree_announce_to_all(&mut self) { + let peer_addrs: Vec = self.peers.keys().copied().collect(); + + for peer_addr in peer_addrs { + if let Err(e) = self.send_tree_announce_to_peer(&peer_addr).await { + debug!( + peer = %peer_addr, + error = %e, + "Failed to send TreeAnnounce" + ); + } + } + } + + /// Send pending rate-limited tree announces whose cooldown has expired. + pub(super) async fn send_pending_tree_announces(&mut self) { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + let ready: Vec = self + .peers + .iter() + .filter(|(_, peer)| peer.has_pending_tree_announce() && peer.can_send_tree_announce(now_ms)) + .map(|(addr, _)| *addr) + .collect(); + + for peer_addr in ready { + if let Err(e) = self.send_tree_announce_to_peer(&peer_addr).await { + debug!( + peer = %peer_addr, + error = %e, + "Failed to send pending TreeAnnounce" + ); + } + } + } + + /// Handle an inbound TreeAnnounce from an authenticated peer. + /// + /// 1. Decode the message + /// 2. Verify the sender's declaration signature (pubkey from handshake) + /// 3. Update the peer's tree state + /// 4. Re-evaluate parent selection + /// 5. If parent changed: increment seq, sign, recompute coords, announce to all + pub(super) async fn handle_tree_announce(&mut self, from: &NodeAddr, payload: &[u8]) { + let announce = match TreeAnnounce::decode(payload) { + Ok(a) => a, + Err(e) => { + debug!(from = %from, error = %e, "Malformed TreeAnnounce"); + return; + } + }; + + // Verify sender's declaration signature using their known pubkey + let pubkey = match self.peers.get(from) { + Some(peer) => peer.pubkey(), + None => { + debug!(from = %from, "TreeAnnounce from unknown peer"); + return; + } + }; + + // The declaring node_addr in the announce should match the sender + if announce.declaration.node_addr() != from { + debug!( + from = %from, + declared = %announce.declaration.node_addr(), + "TreeAnnounce node_addr mismatch" + ); + return; + } + + if let Err(e) = announce.declaration.verify(&pubkey) { + warn!( + from = %from, + error = %e, + "TreeAnnounce signature verification failed" + ); + return; + } + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + // Update peer's tree state in ActivePeer + if let Some(peer) = self.peers.get_mut(from) { + peer.update_tree_position( + announce.declaration.clone(), + announce.ancestry.clone(), + now_ms, + ); + } + + // Update in TreeState + let updated = self.tree_state.update_peer( + announce.declaration.clone(), + announce.ancestry.clone(), + ); + + if !updated { + debug!(from = %from, "TreeAnnounce not fresher than existing, ignored"); + return; + } + + info!( + from = %from, + seq = announce.declaration.sequence(), + depth = announce.ancestry.depth(), + root = %announce.ancestry.root_id(), + "Processed TreeAnnounce" + ); + + // Re-evaluate parent selection + if let Some(new_parent) = self.tree_state.evaluate_parent() { + let new_seq = self.tree_state.my_declaration().sequence() + 1; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + self.tree_state.set_parent(new_parent, new_seq, timestamp); + if let Err(e) = self.tree_state.sign_declaration(&self.identity) { + warn!(error = %e, "Failed to sign declaration after parent switch"); + return; + } + self.tree_state.recompute_coords(); + + info!( + new_parent = %new_parent, + new_seq = new_seq, + new_root = %self.tree_state.root(), + depth = self.tree_state.my_coords().depth(), + "Parent switched, announcing to all peers" + ); + + self.send_tree_announce_to_all().await; + } + } + + /// Periodic tree maintenance, called from the tick handler. + /// + /// - Sends pending rate-limited announces + /// - Refreshes root announcement every ROOT_REFRESH_INTERVAL_SECS (if root) + pub(super) async fn check_tree_state(&mut self) { + // Send any pending rate-limited announces + self.send_pending_tree_announces().await; + + // Root refresh + if self.tree_state.is_root() && !self.peers.is_empty() { + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if now_secs.saturating_sub(self.last_root_refresh_secs) >= ROOT_REFRESH_INTERVAL_SECS { + let new_seq = self.tree_state.my_declaration().sequence() + 1; + self.tree_state + .set_parent(*self.identity.node_addr(), new_seq, now_secs); + if let Err(e) = self.tree_state.sign_declaration(&self.identity) { + warn!(error = %e, "Failed to sign root refresh declaration"); + return; + } + self.tree_state.recompute_coords(); + self.last_root_refresh_secs = now_secs; + + debug!(seq = new_seq, "Root refresh: announcing to all peers"); + self.send_tree_announce_to_all().await; + } + } + } + + /// Handle tree state cleanup when a peer is removed. + /// + /// Called from `remove_active_peer`. If the removed peer was our parent, + /// attempts to find an alternative or becomes root. + /// + /// Returns `true` if our tree state changed (caller should announce). + pub(super) fn handle_peer_removal_tree_cleanup(&mut self, node_addr: &NodeAddr) -> bool { + let was_parent = !self.tree_state.is_root() + && self.tree_state.my_declaration().parent_id() == node_addr; + + self.tree_state.remove_peer(node_addr); + + if was_parent { + let changed = self.tree_state.handle_parent_lost(); + if changed { + // Re-sign the new declaration + if let Err(e) = self.tree_state.sign_declaration(&self.identity) { + warn!(error = %e, "Failed to sign declaration after parent loss"); + } + info!( + new_root = %self.tree_state.root(), + is_root = self.tree_state.is_root(), + "Tree state updated after parent loss" + ); + } + changed + } else { + false + } + } +} diff --git a/src/peer/active.rs b/src/peer/active.rs index b610bf2..1fceee5 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -94,6 +94,12 @@ pub struct ActivePeer { /// Their path to root. ancestry: Option, + // === Tree Announce Rate Limiting === + /// Last time we sent a TreeAnnounce to this peer (Unix milliseconds). + last_tree_announce_sent_ms: u64, + /// Whether a tree announce is pending (deferred due to rate limit). + pending_tree_announce: bool, + // === Bloom Filter === /// What's reachable through them (inbound filter). inbound_filter: Option, @@ -132,6 +138,8 @@ impl ActivePeer { current_addr: None, declaration: None, ancestry: None, + last_tree_announce_sent_ms: 0, + pending_tree_announce: false, inbound_filter: None, filter_sequence: 0, filter_ttl: 0, @@ -185,6 +193,8 @@ impl ActivePeer { current_addr: Some(current_addr), declaration: None, ancestry: None, + last_tree_announce_sent_ms: 0, + pending_tree_announce: false, inbound_filter: None, filter_sequence: 0, filter_ttl: 0, @@ -441,6 +451,32 @@ impl ActivePeer { self.ancestry = None; } + // === Tree Announce Rate Limiting === + + /// Minimum interval between TreeAnnounce messages to the same peer (milliseconds). + const TREE_ANNOUNCE_MIN_INTERVAL_MS: u64 = 500; + + /// Check if we can send a TreeAnnounce now (rate limiting). + pub fn can_send_tree_announce(&self, now_ms: u64) -> bool { + now_ms.saturating_sub(self.last_tree_announce_sent_ms) >= Self::TREE_ANNOUNCE_MIN_INTERVAL_MS + } + + /// Record that we sent a TreeAnnounce to this peer. + pub fn record_tree_announce_sent(&mut self, now_ms: u64) { + self.last_tree_announce_sent_ms = now_ms; + self.pending_tree_announce = false; + } + + /// Mark that a tree announce is pending (deferred due to rate limit). + pub fn mark_tree_announce_pending(&mut self) { + self.pending_tree_announce = true; + } + + /// Check if a deferred tree announce is waiting to be sent. + pub fn has_pending_tree_announce(&self) -> bool { + self.pending_tree_announce + } + // === Filter Updates === /// Update peer's inbound filter. @@ -494,7 +530,7 @@ mod tests { } fn make_coords(ids: &[u8]) -> TreeCoordinate { - TreeCoordinate::new(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() + TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() } #[test] diff --git a/src/protocol.rs b/src/protocol.rs index 6377674..95625d9 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -21,7 +21,7 @@ //! layer, encrypted end-to-end independently of per-hop link encryption. use crate::bloom::BloomFilter; -use crate::tree::{ParentDeclaration, TreeCoordinate}; +use crate::tree::{CoordEntry, ParentDeclaration, TreeCoordinate}; use crate::NodeAddr; use secp256k1::schnorr::Signature; use std::fmt; @@ -383,6 +383,13 @@ pub struct TreeAnnounce { } impl TreeAnnounce { + /// TreeAnnounce wire format version 1. + pub const VERSION_1: u8 = 0x01; + + /// Minimum payload size (after msg_type stripped by dispatcher): + /// version(1) + sequence(8) + timestamp(8) + parent(16) + ancestry_count(2) + signature(64) = 99 + const MIN_PAYLOAD_SIZE: usize = 99; + /// Create a new TreeAnnounce message. pub fn new(declaration: ParentDeclaration, ancestry: TreeCoordinate) -> Self { Self { @@ -390,6 +397,160 @@ impl TreeAnnounce { ancestry, } } + + /// Encode as link-layer plaintext (includes msg_type byte). + /// + /// The declaration must be signed. The encoded format is: + /// ```text + /// [0x10][version:1][sequence:8 LE][timestamp:8 LE][parent:16] + /// [ancestry_count:2 LE][entries:32×n][signature:64] + /// ``` + pub fn encode(&self) -> Result, ProtocolError> { + let signature = self + .declaration + .signature() + .ok_or(ProtocolError::InvalidSignature)?; + + let entries = self.ancestry.entries(); + let ancestry_count = entries.len() as u16; + let size = 1 + Self::MIN_PAYLOAD_SIZE + entries.len() * CoordEntry::WIRE_SIZE; + let mut buf = Vec::with_capacity(size); + + // msg_type + buf.push(LinkMessageType::TreeAnnounce.to_byte()); + // version + buf.push(Self::VERSION_1); + // sequence (8 LE) + buf.extend_from_slice(&self.declaration.sequence().to_le_bytes()); + // timestamp (8 LE) + buf.extend_from_slice(&self.declaration.timestamp().to_le_bytes()); + // parent (16) + buf.extend_from_slice(self.declaration.parent_id().as_bytes()); + // ancestry_count (2 LE) + buf.extend_from_slice(&ancestry_count.to_le_bytes()); + // ancestry entries (32 bytes each) + for entry in entries { + buf.extend_from_slice(entry.node_addr.as_bytes()); // 16 + buf.extend_from_slice(&entry.sequence.to_le_bytes()); // 8 + buf.extend_from_slice(&entry.timestamp.to_le_bytes()); // 8 + } + // outer signature (64) + buf.extend_from_slice(signature.as_ref()); + + Ok(buf) + } + + /// Decode from link-layer payload (after msg_type byte stripped by dispatcher). + /// + /// The payload starts with the version byte. + pub fn decode(payload: &[u8]) -> Result { + if payload.len() < Self::MIN_PAYLOAD_SIZE { + return Err(ProtocolError::MessageTooShort { + expected: Self::MIN_PAYLOAD_SIZE, + got: payload.len(), + }); + } + + let mut pos = 0; + + // version + let version = payload[pos]; + pos += 1; + if version != Self::VERSION_1 { + return Err(ProtocolError::UnsupportedVersion(version)); + } + + // sequence (8 LE) + let sequence = u64::from_le_bytes( + payload[pos..pos + 8] + .try_into() + .map_err(|_| ProtocolError::Malformed("bad sequence".into()))?, + ); + pos += 8; + + // timestamp (8 LE) + let timestamp = u64::from_le_bytes( + payload[pos..pos + 8] + .try_into() + .map_err(|_| ProtocolError::Malformed("bad timestamp".into()))?, + ); + pos += 8; + + // parent (16) + let parent = NodeAddr::from_bytes( + payload[pos..pos + 16] + .try_into() + .map_err(|_| ProtocolError::Malformed("bad parent".into()))?, + ); + pos += 16; + + // ancestry_count (2 LE) + let ancestry_count = u16::from_le_bytes( + payload[pos..pos + 2] + .try_into() + .map_err(|_| ProtocolError::Malformed("bad ancestry count".into()))?, + ) as usize; + pos += 2; + + // Validate remaining length: entries + signature + let expected_remaining = ancestry_count * CoordEntry::WIRE_SIZE + 64; + if payload.len() - pos < expected_remaining { + return Err(ProtocolError::MessageTooShort { + expected: pos + expected_remaining, + got: payload.len(), + }); + } + + // ancestry entries (32 bytes each) + let mut entries = Vec::with_capacity(ancestry_count); + for _ in 0..ancestry_count { + let node_addr = NodeAddr::from_bytes( + payload[pos..pos + 16] + .try_into() + .map_err(|_| ProtocolError::Malformed("bad entry node_addr".into()))?, + ); + pos += 16; + let entry_seq = u64::from_le_bytes( + payload[pos..pos + 8] + .try_into() + .map_err(|_| ProtocolError::Malformed("bad entry sequence".into()))?, + ); + pos += 8; + let entry_ts = u64::from_le_bytes( + payload[pos..pos + 8] + .try_into() + .map_err(|_| ProtocolError::Malformed("bad entry timestamp".into()))?, + ); + pos += 8; + entries.push(CoordEntry::new(node_addr, entry_seq, entry_ts)); + } + + // signature (64) + let sig_bytes: [u8; 64] = payload[pos..pos + 64] + .try_into() + .map_err(|_| ProtocolError::Malformed("bad signature".into()))?; + let signature = Signature::from_slice(&sig_bytes) + .map_err(|_| ProtocolError::InvalidSignature)?; + + // The first entry's node_addr is the declaring node + if entries.is_empty() { + return Err(ProtocolError::Malformed( + "ancestry must have at least one entry".into(), + )); + } + let node_addr = entries[0].node_addr; + + let declaration = + ParentDeclaration::with_signature(node_addr, parent, sequence, timestamp, signature); + + let ancestry = TreeCoordinate::new(entries) + .map_err(|e| ProtocolError::Malformed(format!("bad ancestry: {}", e)))?; + + Ok(Self { + declaration, + ancestry, + }) + } } // ============ Bloom Filter Messages ============ @@ -978,7 +1139,7 @@ mod tests { } fn make_coords(ids: &[u8]) -> TreeCoordinate { - TreeCoordinate::new(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() + TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() } // ===== HandshakeMessageType Tests ===== @@ -1389,4 +1550,169 @@ mod tests { assert_eq!(announce.declaration.node_addr(), &node); assert_eq!(announce.ancestry.depth(), 2); } + + #[test] + fn test_tree_announce_encode_decode_root() { + use crate::identity::Identity; + + let identity = Identity::generate(); + let node_addr = *identity.node_addr(); + + // Root declaration: parent == self + let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 5000); + decl.sign(&identity).unwrap(); + + // Root ancestry: just the root itself + let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 5000)]).unwrap(); + + let announce = TreeAnnounce::new(decl, ancestry); + let encoded = announce.encode().unwrap(); + + // msg_type (1) + version (1) + seq (8) + ts (8) + parent (16) + count (2) + 1 entry (32) + sig (64) = 132 + assert_eq!(encoded.len(), 132); + assert_eq!(encoded[0], 0x10); // LinkMessageType::TreeAnnounce + + // Decode strips msg_type byte (as dispatcher does) + let decoded = TreeAnnounce::decode(&encoded[1..]).unwrap(); + + assert_eq!(decoded.declaration.node_addr(), &node_addr); + assert_eq!(decoded.declaration.parent_id(), &node_addr); + assert_eq!(decoded.declaration.sequence(), 1); + assert_eq!(decoded.declaration.timestamp(), 5000); + assert!(decoded.declaration.is_root()); + assert!(decoded.declaration.is_signed()); + assert_eq!(decoded.ancestry.depth(), 0); // root has depth 0 + assert_eq!(decoded.ancestry.entries().len(), 1); + assert_eq!(decoded.ancestry.entries()[0].node_addr, node_addr); + assert_eq!(decoded.ancestry.entries()[0].sequence, 1); + assert_eq!(decoded.ancestry.entries()[0].timestamp, 5000); + } + + #[test] + fn test_tree_announce_encode_decode_depth3() { + use crate::identity::Identity; + + let identity = Identity::generate(); + let node_addr = *identity.node_addr(); + let parent = make_node_addr(2); + let grandparent = make_node_addr(3); + let root = make_node_addr(4); + + let mut decl = ParentDeclaration::new(node_addr, parent, 5, 10000); + decl.sign(&identity).unwrap(); + + let ancestry = TreeCoordinate::new(vec![ + CoordEntry::new(node_addr, 5, 10000), + CoordEntry::new(parent, 4, 9000), + CoordEntry::new(grandparent, 3, 8000), + CoordEntry::new(root, 2, 7000), + ]) + .unwrap(); + + let announce = TreeAnnounce::new(decl, ancestry); + let encoded = announce.encode().unwrap(); + + // 1 + 99 + 4*32 = 228 + assert_eq!(encoded.len(), 228); + + let decoded = TreeAnnounce::decode(&encoded[1..]).unwrap(); + + assert_eq!(decoded.declaration.node_addr(), &node_addr); + assert_eq!(decoded.declaration.parent_id(), &parent); + assert_eq!(decoded.declaration.sequence(), 5); + assert_eq!(decoded.declaration.timestamp(), 10000); + assert!(!decoded.declaration.is_root()); + assert_eq!(decoded.ancestry.depth(), 3); + assert_eq!(decoded.ancestry.entries().len(), 4); + + // Verify all entries preserved + let entries = decoded.ancestry.entries(); + assert_eq!(entries[0].node_addr, node_addr); + assert_eq!(entries[0].sequence, 5); + assert_eq!(entries[1].node_addr, parent); + assert_eq!(entries[1].sequence, 4); + assert_eq!(entries[2].node_addr, grandparent); + assert_eq!(entries[2].timestamp, 8000); + assert_eq!(entries[3].node_addr, root); + assert_eq!(entries[3].timestamp, 7000); + + // Root ID is last entry + assert_eq!(decoded.ancestry.root_id(), &root); + } + + #[test] + fn test_tree_announce_decode_unsupported_version() { + use crate::identity::Identity; + + let identity = Identity::generate(); + let node_addr = *identity.node_addr(); + + let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 1000); + decl.sign(&identity).unwrap(); + + let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 1000)]).unwrap(); + let announce = TreeAnnounce::new(decl, ancestry); + let mut encoded = announce.encode().unwrap(); + + // Corrupt version byte (byte index 1, after msg_type) + encoded[1] = 0xFF; + + let result = TreeAnnounce::decode(&encoded[1..]); + assert!(matches!(result, Err(ProtocolError::UnsupportedVersion(0xFF)))); + } + + #[test] + fn test_tree_announce_decode_truncated() { + // Way too short + let result = TreeAnnounce::decode(&[0x01]); + assert!(matches!( + result, + Err(ProtocolError::MessageTooShort { expected: 99, .. }) + )); + + // Just under minimum (98 bytes) + let short = vec![0u8; 98]; + let result = TreeAnnounce::decode(&short); + assert!(matches!( + result, + Err(ProtocolError::MessageTooShort { expected: 99, .. }) + )); + } + + #[test] + fn test_tree_announce_decode_ancestry_count_mismatch() { + use crate::identity::Identity; + + let identity = Identity::generate(); + let node_addr = *identity.node_addr(); + + let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 1000); + decl.sign(&identity).unwrap(); + + let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 1000)]).unwrap(); + let announce = TreeAnnounce::new(decl, ancestry); + let mut encoded = announce.encode().unwrap(); + + // The ancestry_count is at offset: 1 (msg_type) + 1 (version) + 8 (seq) + 8 (ts) + 16 (parent) = 34 + // Set ancestry_count to 5 but we only have 1 entry's worth of data + encoded[34] = 5; + encoded[35] = 0; + + let result = TreeAnnounce::decode(&encoded[1..]); + assert!(matches!( + result, + Err(ProtocolError::MessageTooShort { .. }) + )); + } + + #[test] + fn test_tree_announce_encode_unsigned_fails() { + let node = make_node_addr(1); + let decl = ParentDeclaration::new(node, node, 1, 1000); + let ancestry = make_coords(&[1, 0]); + + let announce = TreeAnnounce::new(decl, ancestry); + let result = announce.encode(); + assert!(matches!(result, Err(ProtocolError::InvalidSignature))); + } } diff --git a/src/tree.rs b/src/tree.rs index 072d22d..2f305a8 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -207,46 +207,115 @@ impl PartialEq for ParentDeclaration { impl Eq for ParentDeclaration {} +/// Metadata for a single node in a tree coordinate path. +/// +/// Carries the node address and its declaration metadata (sequence number +/// and timestamp). Used in TreeCoordinate entries and TreeAnnounce wire +/// format. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CoordEntry { + /// The node's routing address. + pub node_addr: NodeAddr, + /// The node's declaration sequence number. + pub sequence: u64, + /// The node's declaration timestamp (Unix seconds). + pub timestamp: u64, +} + +impl CoordEntry { + /// Wire size of a serialized entry: node_addr(16) + sequence(8) + timestamp(8). + pub const WIRE_SIZE: usize = 32; + + /// Create a new coordinate entry. + pub fn new(node_addr: NodeAddr, sequence: u64, timestamp: u64) -> Self { + Self { + node_addr, + sequence, + timestamp, + } + } + + /// Create an entry with default metadata (sequence=0, timestamp=0). + /// + /// Useful for constructing coordinates when only routing (not wire format) + /// is needed, e.g., in tests or distance calculations. + pub fn addr_only(node_addr: NodeAddr) -> Self { + Self { + node_addr, + sequence: 0, + timestamp: 0, + } + } +} + /// A node's coordinates in the spanning tree. /// /// Coordinates are the path from the node to the root: /// `[self, parent, grandparent, ..., root]` /// +/// Each entry carries the node address plus declaration metadata (sequence +/// and timestamp) for the wire protocol. Routing operations (distance, +/// LCA) use only the node addresses. +/// /// The coordinate enables greedy routing via tree distance calculation. /// Two nodes can compute the hops between them by finding their lowest /// common ancestor (LCA) in the tree. #[derive(Clone, PartialEq, Eq)] -pub struct TreeCoordinate(Vec); +pub struct TreeCoordinate(Vec); impl TreeCoordinate { - /// Create a coordinate from a path (self to root). + /// Create a coordinate from a path of entries (self to root). /// /// The path must be non-empty and ordered from the node to the root. - pub fn new(path: Vec) -> Result { + pub fn new(path: Vec) -> Result { if path.is_empty() { return Err(TreeError::EmptyCoordinate); } Ok(Self(path)) } + /// Create a coordinate from node addresses only (no metadata). + /// + /// Convenience constructor for cases where only routing is needed. + /// Each entry gets sequence=0, timestamp=0. + pub fn from_addrs(addrs: Vec) -> Result { + if addrs.is_empty() { + return Err(TreeError::EmptyCoordinate); + } + Ok(Self( + addrs + .into_iter() + .map(CoordEntry::addr_only) + .collect(), + )) + } + /// Create a coordinate for a root node. pub fn root(node_addr: NodeAddr) -> Self { - Self(vec![node_addr]) + Self(vec![CoordEntry::addr_only(node_addr)]) + } + + /// Create a root coordinate with metadata. + pub fn root_with_meta(node_addr: NodeAddr, sequence: u64, timestamp: u64) -> Self { + Self(vec![CoordEntry::new(node_addr, sequence, timestamp)]) } /// The node this coordinate belongs to (first element). pub fn node_addr(&self) -> &NodeAddr { - &self.0[0] + &self.0[0].node_addr } /// The root of the tree (last element). pub fn root_id(&self) -> &NodeAddr { - self.0.last().expect("coordinate never empty") + &self.0.last().expect("coordinate never empty").node_addr } /// The immediate parent (second element, or self if root). pub fn parent_id(&self) -> &NodeAddr { - self.0.get(1).unwrap_or(&self.0[0]) + self.0 + .get(1) + .map(|e| &e.node_addr) + .unwrap_or(&self.0[0].node_addr) } /// Depth in the tree (0 = root). @@ -254,11 +323,19 @@ impl TreeCoordinate { self.0.len() - 1 } - /// The full ancestry path. - pub fn path(&self) -> &[NodeAddr] { + /// The full path of entries with metadata. + pub fn entries(&self) -> &[CoordEntry] { &self.0 } + /// Iterator over node addresses in the path (self to root). + /// + /// Use this for routing operations (distance, LCA, ancestor checks) + /// that only need the address path. + pub fn node_addrs(&self) -> impl Iterator + DoubleEndedIterator { + self.0.iter().map(|e| &e.node_addr) + } + /// Check if this coordinate is a root (length 1). pub fn is_root(&self) -> bool { self.0.len() == 1 @@ -286,8 +363,8 @@ impl TreeCoordinate { /// Returns the depth (from root) of the LCA. pub fn lca_depth(&self, other: &TreeCoordinate) -> usize { let mut common: usize = 0; - let self_rev = self.0.iter().rev(); - let other_rev = other.0.iter().rev(); + let self_rev = self.node_addrs().rev(); + let other_rev = other.node_addrs().rev(); for (a, b) in self_rev.zip(other_rev) { if a == b { @@ -303,8 +380,8 @@ impl TreeCoordinate { /// Get the lowest common ancestor node ID. pub fn lca(&self, other: &TreeCoordinate) -> Option<&NodeAddr> { - let self_rev: Vec<_> = self.0.iter().rev().collect(); - let other_rev: Vec<_> = other.0.iter().rev().collect(); + let self_rev: Vec<_> = self.node_addrs().rev().collect(); + let other_rev: Vec<_> = other.node_addrs().rev().collect(); let mut lca = None; for (a, b) in self_rev.iter().zip(other_rev.iter()) { @@ -319,42 +396,41 @@ impl TreeCoordinate { /// Check if `other` is an ancestor (appears in our path after self). pub fn has_ancestor(&self, other: &NodeAddr) -> bool { - self.0.iter().skip(1).any(|id| id == other) + self.node_addrs().skip(1).any(|id| id == other) } /// Check if `other` is in our ancestry (including self). pub fn contains(&self, other: &NodeAddr) -> bool { - self.0.iter().any(|id| id == other) + self.node_addrs().any(|id| id == other) } /// Get the ancestor at a specific depth from self. /// /// `ancestor_at(0)` returns self, `ancestor_at(1)` returns parent, etc. pub fn ancestor_at(&self, depth: usize) -> Option<&NodeAddr> { - self.0.get(depth) + self.0.get(depth).map(|e| &e.node_addr) } } impl fmt::Debug for TreeCoordinate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "TreeCoordinate(depth={}, path=[", self.depth())?; - for (i, id) in self.0.iter().enumerate() { + for (i, entry) in self.0.iter().enumerate() { if i > 0 { write!(f, " → ")?; } // Show first 4 bytes of each node ID - write!(f, "{:02x}{:02x}", id.as_bytes()[0], id.as_bytes()[1])?; + write!( + f, + "{:02x}{:02x}", + entry.node_addr.as_bytes()[0], + entry.node_addr.as_bytes()[1] + )?; } write!(f, "])") } } -impl AsRef<[NodeAddr]> for TreeCoordinate { - fn as_ref(&self) -> &[NodeAddr] { - &self.0 - } -} - /// Local spanning tree state for a node. /// /// Contains this node's declaration, coordinates, and view of peers' @@ -386,7 +462,7 @@ impl TreeState { .map(|d| d.as_secs()) .unwrap_or(0); let my_declaration = ParentDeclaration::self_root(my_node_addr, 1, timestamp); - let my_coords = TreeCoordinate::root(my_node_addr); + let my_coords = TreeCoordinate::root_with_meta(my_node_addr, 1, timestamp); Self { my_node_addr, @@ -482,17 +558,26 @@ impl TreeState { /// Update this node's coordinates based on current parent's ancestry. pub fn recompute_coords(&mut self) { if self.my_declaration.is_root() { - self.my_coords = TreeCoordinate::root(self.my_node_addr); + self.my_coords = TreeCoordinate::root_with_meta( + self.my_node_addr, + self.my_declaration.sequence(), + self.my_declaration.timestamp(), + ); self.root = self.my_node_addr; return; } let parent_id = self.my_declaration.parent_id(); if let Some(parent_coords) = self.peer_ancestry.get(parent_id) { - // Our coords = [self] ++ parent_coords - let mut path = vec![self.my_node_addr]; - path.extend_from_slice(parent_coords.path()); - self.my_coords = TreeCoordinate::new(path).expect("non-empty path"); + // Our coords = [self_entry] ++ parent_coords entries + let self_entry = CoordEntry::new( + self.my_node_addr, + self.my_declaration.sequence(), + self.my_declaration.timestamp(), + ); + let mut entries = vec![self_entry]; + entries.extend_from_slice(parent_coords.entries()); + self.my_coords = TreeCoordinate::new(entries).expect("non-empty path"); self.root = *self.my_coords.root_id(); } } @@ -513,12 +598,131 @@ impl TreeState { None } - /// Check if a parent switch to `candidate` would be beneficial. + /// Minimum depth improvement required to switch parents (same root). + /// Prevents thrashing on equivalent-depth paths. + const PARENT_SWITCH_THRESHOLD: usize = 1; + + /// Evaluate whether to switch parents based on current peer tree state. /// - /// This is a stub - full implementation requires policy decisions. - pub fn should_switch_parent(&self, _candidate: &NodeAddr) -> bool { - // Stub: would evaluate parent switch criteria - false + /// v1 algorithm: depth-based, no latency/loss metrics. + /// + /// Returns `Some(peer_node_addr)` if a parent switch is recommended, + /// or `None` if the current parent is adequate. + pub fn evaluate_parent(&self) -> Option { + if self.peer_ancestry.is_empty() { + return None; + } + + // Find the smallest root visible across all peers + let mut smallest_root: Option = None; + for coords in self.peer_ancestry.values() { + let peer_root = coords.root_id(); + smallest_root = Some(match smallest_root { + None => *peer_root, + Some(current) => { + if *peer_root < current { + *peer_root + } else { + current + } + } + }); + } + + let smallest_root = match smallest_root { + Some(r) => r, + None => return None, + }; + + // If we are the smallest node in the network, stay root + if self.my_node_addr <= smallest_root && self.is_root() { + return None; + } + + // Among peers that reach the smallest root, find the shallowest + let mut best_peer: Option<(NodeAddr, usize)> = None; // (peer_addr, depth) + for (peer_id, coords) in &self.peer_ancestry { + if *coords.root_id() != smallest_root { + continue; + } + let depth = coords.depth(); + match &best_peer { + None => best_peer = Some((*peer_id, depth)), + Some((_, best_depth)) => { + if depth < *best_depth { + best_peer = Some((*peer_id, depth)); + } + } + } + } + + let (best_peer_id, best_depth) = match best_peer { + Some(bp) => bp, + None => return None, + }; + + // If already using this peer as parent, no switch needed + if *self.my_declaration.parent_id() == best_peer_id && !self.is_root() { + return None; + } + + // If our current parent is gone from peer_ancestry, our path is broken — always switch + if !self.is_root() && !self.peer_ancestry.contains_key(self.my_declaration.parent_id()) { + return Some(best_peer_id); + } + + // Switching roots (smaller root found) → always switch + if smallest_root < self.root || (self.is_root() && smallest_root < self.my_node_addr) { + return Some(best_peer_id); + } + + // Same root: require depth improvement ≥ threshold + if self.is_root() { + // We're root but shouldn't be (peers have a smaller root) — always switch + return Some(best_peer_id); + } + + // Compare depth: our current depth vs what we'd get through best_peer + // Our new depth would be best_depth + 1 + let current_depth = self.my_coords.depth(); + let proposed_depth = best_depth + 1; + + if current_depth >= proposed_depth + Self::PARENT_SWITCH_THRESHOLD { + return Some(best_peer_id); + } + + None + } + + /// Handle loss of current parent. + /// + /// Tries to find an alternative parent among remaining peers. + /// If none available, becomes its own root (increments sequence). + /// + /// Returns `true` if the tree state changed (caller should re-announce). + pub fn handle_parent_lost(&mut self) -> bool { + // Try to find an alternative parent + if let Some(new_parent) = self.evaluate_parent() { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let new_seq = self.my_declaration.sequence() + 1; + self.set_parent(new_parent, new_seq, timestamp); + self.recompute_coords(); + return true; + } + + // No alternative: become own root + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let new_seq = self.my_declaration.sequence() + 1; + self.my_declaration = + ParentDeclaration::self_root(self.my_node_addr, new_seq, timestamp); + self.recompute_coords(); + true } /// Sign this node's declaration with the given identity. @@ -556,6 +760,10 @@ mod tests { NodeAddr::from_bytes(bytes) } + fn make_coords(ids: &[u8]) -> TreeCoordinate { + TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() + } + // ===== TreeCoordinate Tests ===== #[test] @@ -576,7 +784,7 @@ mod tests { let parent = make_node_addr(2); let root = make_node_addr(3); - let coord = TreeCoordinate::new(vec![node, parent, root]).unwrap(); + let coord = make_coords(&[1, 2, 3]); assert!(!coord.is_root()); assert_eq!(coord.depth(), 2); @@ -587,10 +795,27 @@ mod tests { #[test] fn test_tree_coordinate_empty_fails() { - let result = TreeCoordinate::new(vec![]); + let result = TreeCoordinate::from_addrs(vec![]); assert!(matches!(result, Err(TreeError::EmptyCoordinate))); } + #[test] + fn test_tree_coordinate_entries_metadata() { + let node = make_node_addr(1); + let root = make_node_addr(0); + + let coord = TreeCoordinate::new(vec![ + CoordEntry::new(node, 5, 1000), + CoordEntry::new(root, 1, 500), + ]) + .unwrap(); + + assert_eq!(coord.entries()[0].sequence, 5); + assert_eq!(coord.entries()[0].timestamp, 1000); + assert_eq!(coord.entries()[1].sequence, 1); + assert_eq!(coord.entries()[1].timestamp, 500); + } + #[test] fn test_tree_distance_same_node() { let node = make_node_addr(1); @@ -601,12 +826,8 @@ mod tests { #[test] fn test_tree_distance_siblings() { - let root = make_node_addr(0); - let a = make_node_addr(1); - let b = make_node_addr(2); - - let coord_a = TreeCoordinate::new(vec![a, root]).unwrap(); - let coord_b = TreeCoordinate::new(vec![b, root]).unwrap(); + let coord_a = make_coords(&[1, 0]); + let coord_b = make_coords(&[2, 0]); // a -> root -> b = 2 hops assert_eq!(coord_a.distance_to(&coord_b), 2); @@ -614,12 +835,8 @@ mod tests { #[test] fn test_tree_distance_ancestor() { - let root = make_node_addr(0); - let parent = make_node_addr(1); - let child = make_node_addr(2); - - let coord_parent = TreeCoordinate::new(vec![parent, root]).unwrap(); - let coord_child = TreeCoordinate::new(vec![child, parent, root]).unwrap(); + let coord_parent = make_coords(&[1, 0]); + let coord_child = make_coords(&[2, 1, 0]); // child -> parent = 1 hop assert_eq!(coord_child.distance_to(&coord_parent), 1); @@ -628,19 +845,13 @@ mod tests { #[test] fn test_tree_distance_cousins() { // Tree structure: - // root + // root(0) // / \ - // a b + // a(1) b(2) // / \ - // c d - let root = make_node_addr(0); - let a = make_node_addr(1); - let b = make_node_addr(2); - let c = make_node_addr(3); - let d = make_node_addr(4); - - let coord_c = TreeCoordinate::new(vec![c, a, root]).unwrap(); - let coord_d = TreeCoordinate::new(vec![d, b, root]).unwrap(); + // c(3) d(4) + let coord_c = make_coords(&[3, 1, 0]); + let coord_d = make_coords(&[4, 2, 0]); // c -> a -> root -> b -> d = 4 hops assert_eq!(coord_c.distance_to(&coord_d), 4); @@ -648,11 +859,8 @@ mod tests { #[test] fn test_tree_distance_different_roots() { - let root1 = make_node_addr(1); - let root2 = make_node_addr(2); - - let coord1 = TreeCoordinate::root(root1); - let coord2 = TreeCoordinate::root(root2); + let coord1 = TreeCoordinate::root(make_node_addr(1)); + let coord2 = TreeCoordinate::root(make_node_addr(2)); assert_eq!(coord1.distance_to(&coord2), usize::MAX); } @@ -663,7 +871,7 @@ mod tests { let parent = make_node_addr(1); let child = make_node_addr(2); - let coord = TreeCoordinate::new(vec![child, parent, root]).unwrap(); + let coord = make_coords(&[2, 1, 0]); assert!(coord.has_ancestor(&parent)); assert!(coord.has_ancestor(&root)); @@ -677,7 +885,7 @@ mod tests { let child = make_node_addr(2); let other = make_node_addr(99); - let coord = TreeCoordinate::new(vec![child, parent, root]).unwrap(); + let coord = make_coords(&[2, 1, 0]); assert!(coord.contains(&child)); assert!(coord.contains(&parent)); @@ -691,7 +899,7 @@ mod tests { let parent = make_node_addr(1); let child = make_node_addr(2); - let coord = TreeCoordinate::new(vec![child, parent, root]).unwrap(); + let coord = make_coords(&[2, 1, 0]); assert_eq!(coord.ancestor_at(0), Some(&child)); assert_eq!(coord.ancestor_at(1), Some(&parent)); @@ -703,18 +911,15 @@ mod tests { fn test_lca() { let root = make_node_addr(0); let a = make_node_addr(1); - let b = make_node_addr(2); - let c = make_node_addr(3); - let d = make_node_addr(4); // c under a, d under b, both under root - let coord_c = TreeCoordinate::new(vec![c, a, root]).unwrap(); - let coord_d = TreeCoordinate::new(vec![d, b, root]).unwrap(); + let coord_c = make_coords(&[3, 1, 0]); + let coord_d = make_coords(&[4, 2, 0]); assert_eq!(coord_c.lca(&coord_d), Some(&root)); // c and a share ancestry through a and root - let coord_a = TreeCoordinate::new(vec![a, root]).unwrap(); + let coord_a = make_coords(&[1, 0]); assert_eq!(coord_c.lca(&coord_a), Some(&a)); } @@ -812,7 +1017,7 @@ mod tests { let root = make_node_addr(2); let decl = ParentDeclaration::new(peer, root, 1, 1000); - let coords = TreeCoordinate::new(vec![peer, root]).unwrap(); + let coords = make_coords(&[1, 2]); assert!(state.update_peer(decl.clone(), coords.clone())); assert_eq!(state.peer_count(), 1); @@ -837,7 +1042,7 @@ mod tests { let root = make_node_addr(2); let decl = ParentDeclaration::new(peer, root, 1, 1000); - let coords = TreeCoordinate::new(vec![peer, root]).unwrap(); + let coords = make_coords(&[1, 2]); state.update_peer(decl, coords); assert_eq!(state.peer_count(), 1); @@ -867,13 +1072,13 @@ mod tests { // Update my state to have shared root state.set_parent(shared_root, 1, 1000); - let my_new_coords = TreeCoordinate::new(vec![my_node, shared_root]).unwrap(); + let my_new_coords = make_coords(&[0, 99]); // Manually set coords for test (normally done by recompute_coords) state.my_coords = my_new_coords; state.root = shared_root; // Update peer to have same root - let peer_coords = TreeCoordinate::new(vec![peer, shared_root]).unwrap(); + let peer_coords = make_coords(&[1, 99]); let decl = ParentDeclaration::new(peer, shared_root, 2, 2000); state.update_peer(decl, peer_coords); @@ -903,4 +1108,197 @@ mod tests { assert!(ids.contains(&&peer1)); assert!(ids.contains(&&peer2)); } + + // ===== Parent Selection Tests ===== + + #[test] + fn test_evaluate_parent_picks_smallest_root() { + // Node 5 starts as root. Peers 3 and 7 each claim different roots. + // Peer 3's path: [3, 1] (root=1) + // Peer 7's path: [7, 2] (root=2) + // Should pick peer 3 because root 1 < root 2. + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + + let peer3 = make_node_addr(3); + let peer7 = make_node_addr(7); + + state.update_peer( + ParentDeclaration::new(peer3, make_node_addr(1), 1, 1000), + make_coords(&[3, 1]), + ); + state.update_peer( + ParentDeclaration::new(peer7, make_node_addr(2), 1, 1000), + make_coords(&[7, 2]), + ); + + let result = state.evaluate_parent(); + assert_eq!(result, Some(peer3)); + } + + #[test] + fn test_evaluate_parent_prefers_shallowest_depth() { + // Node 5, root=0 (shared). Peer 1 at depth 1, peer 2 at depth 3. + // Both reach root 0. Should pick peer 1 (shallowest). + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + + let peer1 = make_node_addr(1); + let peer2 = make_node_addr(2); + let root = make_node_addr(0); + + // Peer 1: depth 1 (path = [1, 0]) + state.update_peer( + ParentDeclaration::new(peer1, root, 1, 1000), + make_coords(&[1, 0]), + ); + // Peer 2: depth 3 (path = [2, 3, 4, 0]) + state.update_peer( + ParentDeclaration::new(peer2, make_node_addr(3), 1, 1000), + make_coords(&[2, 3, 4, 0]), + ); + + let result = state.evaluate_parent(); + assert_eq!(result, Some(peer1)); + } + + #[test] + fn test_evaluate_parent_stays_root_when_smallest() { + // Node 0 (smallest possible) should stay root even if peers exist. + let my_node = make_node_addr(0); + let mut state = TreeState::new(my_node); + + let peer1 = make_node_addr(1); + // Peer 1 has root 0 (us) — shouldn't trigger switch + state.update_peer( + ParentDeclaration::new(peer1, my_node, 1, 1000), + make_coords(&[1, 0]), + ); + + assert_eq!(state.evaluate_parent(), None); + } + + #[test] + fn test_evaluate_parent_no_switch_when_already_best() { + // Node 5, already using peer 1 as parent. No better option. + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + + let peer1 = make_node_addr(1); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer1, root, 1, 1000), + make_coords(&[1, 0]), + ); + + // Switch to peer1 as parent first + state.set_parent(peer1, 1, 1000); + state.recompute_coords(); + + // Now evaluate — should return None since peer1 is already our parent + assert_eq!(state.evaluate_parent(), None); + } + + #[test] + fn test_evaluate_parent_no_peers() { + let my_node = make_node_addr(5); + let state = TreeState::new(my_node); + + assert_eq!(state.evaluate_parent(), None); + } + + #[test] + fn test_evaluate_parent_depth_threshold() { + // Node 5, currently at depth 4 through peer 2. + // Peer 1 offers depth 3 (improvement of 1, which equals threshold). + // Peer 3 offers depth 1 (improvement of 3, exceeds threshold). + // Should switch to peer 3. + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + + let peer2 = make_node_addr(2); + let peer3 = make_node_addr(3); + let root = make_node_addr(0); + + // Peer 2: depth 3 (we'd be depth 4 through them) + state.update_peer( + ParentDeclaration::new(peer2, make_node_addr(6), 1, 1000), + make_coords(&[2, 6, 7, 0]), + ); + + // Set peer2 as our parent, making us depth 4 + state.set_parent(peer2, 1, 1000); + state.recompute_coords(); + assert_eq!(state.my_coords().depth(), 4); + + // Peer 3: depth 1 (we'd be depth 2 through them) — improvement of 2 + state.update_peer( + ParentDeclaration::new(peer3, root, 1, 1000), + make_coords(&[3, 0]), + ); + + let result = state.evaluate_parent(); + assert_eq!(result, Some(peer3)); + } + + #[test] + fn test_handle_parent_lost_finds_alternative() { + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + + let peer1 = make_node_addr(1); + let peer2 = make_node_addr(2); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer1, root, 1, 1000), + make_coords(&[1, 0]), + ); + state.update_peer( + ParentDeclaration::new(peer2, root, 1, 1000), + make_coords(&[2, 0]), + ); + + // Set peer1 as parent + state.set_parent(peer1, 1, 1000); + state.recompute_coords(); + + // Remove peer1 (parent lost) + state.remove_peer(&peer1); + let changed = state.handle_parent_lost(); + + assert!(changed); + // Should have switched to peer2 + assert_eq!(state.my_declaration().parent_id(), &peer2); + assert!(!state.is_root()); + } + + #[test] + fn test_handle_parent_lost_becomes_root() { + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + + let peer1 = make_node_addr(1); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer1, root, 1, 1000), + make_coords(&[1, 0]), + ); + + // Set peer1 as parent + state.set_parent(peer1, 1, 1000); + state.recompute_coords(); + let seq_before = state.my_declaration().sequence(); + + // Remove peer1 (only parent) + state.remove_peer(&peer1); + let changed = state.handle_parent_lost(); + + assert!(changed); + assert!(state.is_root()); + assert!(state.my_declaration().sequence() > seq_before); + assert_eq!(state.root(), &my_node); + } }