Skip to main content

fips_core/peer/
mod.rs

1//! Peer Management
2//!
3//! Two-phase peer lifecycle:
4//! 1. **PeerConnection** - Handshake phase, before identity is verified
5//! 2. **ActivePeer** - Authenticated phase, after successful Noise handshake
6//!
7//! The PeerSlot enum represents either phase, enabling unified storage
8//! while maintaining type safety for phase-specific operations.
9
10mod active;
11mod connection;
12
13pub use active::{ActivePeer, ConnectivityState};
14pub use connection::{HandshakeState, PeerConnection};
15
16use crate::NodeAddr;
17use crate::transport::LinkId;
18use std::fmt;
19use thiserror::Error;
20
21// ============================================================================
22// Errors
23// ============================================================================
24
25/// Errors related to peer operations.
26#[derive(Debug, Error)]
27pub enum PeerError {
28    #[error("peer not authenticated")]
29    NotAuthenticated,
30
31    #[error("peer not found: {0:?}")]
32    NotFound(NodeAddr),
33
34    #[error("connection not found: {0}")]
35    ConnectionNotFound(LinkId),
36
37    #[error("peer already exists: {0:?}")]
38    AlreadyExists(NodeAddr),
39
40    #[error("handshake failed: {0}")]
41    HandshakeFailed(String),
42
43    #[error("handshake timeout")]
44    HandshakeTimeout,
45
46    #[error("identity mismatch: expected {expected:?}, got {actual:?}")]
47    IdentityMismatch {
48        expected: NodeAddr,
49        actual: NodeAddr,
50    },
51
52    #[error("peer disconnected")]
53    Disconnected,
54
55    #[error("max connections exceeded: {max}")]
56    MaxConnectionsExceeded { max: usize },
57
58    #[error("max peers exceeded: {max}")]
59    MaxPeersExceeded { max: usize },
60}
61
62// ============================================================================
63// Cross-Connection Handling
64// ============================================================================
65
66/// Result of attempting to promote a connection to active peer.
67///
68/// When a handshake completes, we may discover that we already have a
69/// connection to this peer (cross-connection). The tie-breaker rule
70/// determines which connection survives.
71///
72/// Note: Returns NodeAddr instead of ActivePeer because ActivePeer cannot
73/// be cloned (it contains NoiseSession which has cryptographic state).
74/// Callers can look up the peer from the peers map using the NodeAddr.
75#[derive(Debug, Clone, Copy)]
76pub enum PromotionResult {
77    /// New peer created successfully.
78    Promoted(NodeAddr),
79
80    /// Cross-connection detected. This connection lost the tie-breaker
81    /// and should be closed.
82    CrossConnectionLost {
83        /// The link that won (existing connection).
84        winner_link_id: LinkId,
85    },
86
87    /// Cross-connection detected. This connection won the tie-breaker.
88    /// The existing connection was replaced.
89    CrossConnectionWon {
90        /// The link that lost (previous connection, now closed).
91        loser_link_id: LinkId,
92        /// The node ID of the peer.
93        node_addr: NodeAddr,
94    },
95}
96
97impl PromotionResult {
98    /// Get the node ID if promotion succeeded.
99    pub fn node_addr(&self) -> Option<NodeAddr> {
100        match self {
101            PromotionResult::Promoted(node_addr) => Some(*node_addr),
102            PromotionResult::CrossConnectionWon { node_addr, .. } => Some(*node_addr),
103            PromotionResult::CrossConnectionLost { .. } => None,
104        }
105    }
106
107    /// Check if this connection should be closed.
108    pub fn should_close_this_connection(&self) -> bool {
109        matches!(self, PromotionResult::CrossConnectionLost { .. })
110    }
111
112    /// Get the link that should be closed, if any.
113    pub fn link_to_close(&self) -> Option<LinkId> {
114        match self {
115            PromotionResult::CrossConnectionLost { .. } => None, // Caller's link
116            PromotionResult::CrossConnectionWon { loser_link_id, .. } => Some(*loser_link_id),
117            PromotionResult::Promoted(_) => None,
118        }
119    }
120}
121
122/// Determine winner of cross-connection tie-breaker.
123///
124/// Rule: The node with the smaller node_addr prefers its OUTBOUND connection.
125/// This is deterministic and symmetric: both nodes will reach the same conclusion.
126///
127/// # Arguments
128/// * `our_node_addr` - Our node's ID
129/// * `their_node_addr` - The peer's node ID
130/// * `this_is_outbound` - Whether the connection being evaluated is our outbound
131///
132/// # Returns
133/// `true` if this connection should win (survive), `false` if it should close.
134pub fn cross_connection_winner(
135    our_node_addr: &NodeAddr,
136    their_node_addr: &NodeAddr,
137    this_is_outbound: bool,
138) -> bool {
139    let we_are_smaller = our_node_addr < their_node_addr;
140
141    // Smaller node's outbound wins
142    // If we're smaller: our outbound wins, our inbound loses
143    // If they're smaller: our outbound loses, our inbound wins
144    if we_are_smaller {
145        this_is_outbound
146    } else {
147        !this_is_outbound
148    }
149}
150
151// ============================================================================
152// PeerSlot
153// ============================================================================
154
155/// A slot in the peer table, representing either connection or active phase.
156#[derive(Debug)]
157pub enum PeerSlot {
158    /// Connection in handshake phase.
159    Connecting(Box<PeerConnection>),
160    /// Authenticated peer.
161    Active(Box<ActivePeer>),
162}
163
164impl PeerSlot {
165    /// Create a new connecting slot (outbound).
166    pub fn outbound(conn: PeerConnection) -> Self {
167        PeerSlot::Connecting(Box::new(conn))
168    }
169
170    /// Create a new connecting slot (inbound).
171    pub fn inbound(conn: PeerConnection) -> Self {
172        PeerSlot::Connecting(Box::new(conn))
173    }
174
175    /// Create a new active slot.
176    pub fn active(peer: ActivePeer) -> Self {
177        PeerSlot::Active(Box::new(peer))
178    }
179
180    /// Check if this is a connecting slot.
181    pub fn is_connecting(&self) -> bool {
182        matches!(self, PeerSlot::Connecting(_))
183    }
184
185    /// Check if this is an active slot.
186    pub fn is_active(&self) -> bool {
187        matches!(self, PeerSlot::Active(_))
188    }
189
190    /// Get the link ID for this slot.
191    pub fn link_id(&self) -> LinkId {
192        match self {
193            PeerSlot::Connecting(conn) => conn.link_id(),
194            PeerSlot::Active(peer) => peer.link_id(),
195        }
196    }
197
198    /// Get as connection reference, if connecting.
199    pub fn as_connection(&self) -> Option<&PeerConnection> {
200        match self {
201            PeerSlot::Connecting(conn) => Some(conn),
202            PeerSlot::Active(_) => None,
203        }
204    }
205
206    /// Get as mutable connection reference, if connecting.
207    pub fn as_connection_mut(&mut self) -> Option<&mut PeerConnection> {
208        match self {
209            PeerSlot::Connecting(conn) => Some(conn),
210            PeerSlot::Active(_) => None,
211        }
212    }
213
214    /// Get as active peer reference, if active.
215    pub fn as_active(&self) -> Option<&ActivePeer> {
216        match self {
217            PeerSlot::Active(peer) => Some(peer),
218            PeerSlot::Connecting(_) => None,
219        }
220    }
221
222    /// Get as mutable active peer reference, if active.
223    pub fn as_active_mut(&mut self) -> Option<&mut ActivePeer> {
224        match self {
225            PeerSlot::Active(peer) => Some(peer),
226            PeerSlot::Connecting(_) => None,
227        }
228    }
229
230    /// Get the known node_addr, if any.
231    ///
232    /// For connections, this is the expected identity (may be None for inbound).
233    /// For active peers, this is always known.
234    pub fn node_addr(&self) -> Option<&NodeAddr> {
235        match self {
236            PeerSlot::Connecting(conn) => conn.expected_identity().map(|id| id.node_addr()),
237            PeerSlot::Active(peer) => Some(peer.node_addr()),
238        }
239    }
240}
241
242impl fmt::Display for PeerSlot {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        match self {
245            PeerSlot::Connecting(conn) => {
246                write!(
247                    f,
248                    "connecting(link={}, state={})",
249                    conn.link_id(),
250                    conn.handshake_state()
251                )
252            }
253            PeerSlot::Active(peer) => {
254                write!(
255                    f,
256                    "active(node={:?}, link={})",
257                    peer.node_addr(),
258                    peer.link_id()
259                )
260            }
261        }
262    }
263}
264
265// ============================================================================
266// Tests
267// ============================================================================
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::transport::LinkId;
273    use crate::{Identity, PeerIdentity};
274
275    fn make_node_addr(val: u8) -> NodeAddr {
276        let mut bytes = [0u8; 16];
277        bytes[0] = val;
278        NodeAddr::from_bytes(bytes)
279    }
280
281    fn make_peer_identity() -> PeerIdentity {
282        let identity = Identity::generate();
283        PeerIdentity::from_pubkey(identity.pubkey())
284    }
285
286    #[test]
287    fn test_cross_connection_smaller_node_wins_outbound() {
288        let node_a = make_node_addr(1); // smaller
289        let node_b = make_node_addr(2); // larger
290
291        // Node A's perspective
292        assert!(cross_connection_winner(&node_a, &node_b, true)); // A's outbound wins
293        assert!(!cross_connection_winner(&node_a, &node_b, false)); // A's inbound loses
294
295        // Node B's perspective
296        assert!(!cross_connection_winner(&node_b, &node_a, true)); // B's outbound loses
297        assert!(cross_connection_winner(&node_b, &node_a, false)); // B's inbound wins
298    }
299
300    #[test]
301    fn test_cross_connection_symmetric() {
302        let node_a = make_node_addr(1);
303        let node_b = make_node_addr(2);
304
305        // A's outbound = B's inbound
306        let a_outbound_wins = cross_connection_winner(&node_a, &node_b, true);
307        let b_inbound_wins = cross_connection_winner(&node_b, &node_a, false);
308        assert_eq!(a_outbound_wins, b_inbound_wins);
309
310        // A's inbound = B's outbound
311        let a_inbound_wins = cross_connection_winner(&node_a, &node_b, false);
312        let b_outbound_wins = cross_connection_winner(&node_b, &node_a, true);
313        assert_eq!(a_inbound_wins, b_outbound_wins);
314
315        // Exactly one survives
316        assert!(a_outbound_wins != a_inbound_wins);
317    }
318
319    #[test]
320    fn test_peer_slot_connecting() {
321        let identity = make_peer_identity();
322        let conn = PeerConnection::outbound(LinkId::new(1), identity, 1000);
323        let slot = PeerSlot::Connecting(Box::new(conn));
324
325        assert!(slot.is_connecting());
326        assert!(!slot.is_active());
327        assert!(slot.as_connection().is_some());
328        assert!(slot.as_active().is_none());
329        assert_eq!(slot.link_id(), LinkId::new(1));
330    }
331
332    #[test]
333    fn test_peer_slot_active() {
334        let identity = make_peer_identity();
335        let peer = ActivePeer::new(identity, LinkId::new(2), 2000);
336        let slot = PeerSlot::Active(Box::new(peer));
337
338        assert!(!slot.is_connecting());
339        assert!(slot.is_active());
340        assert!(slot.as_connection().is_none());
341        assert!(slot.as_active().is_some());
342        assert_eq!(slot.link_id(), LinkId::new(2));
343    }
344
345    #[test]
346    fn test_promotion_result_promoted() {
347        let identity = make_peer_identity();
348        let node_addr = *identity.node_addr();
349        let result = PromotionResult::Promoted(node_addr);
350
351        assert!(result.node_addr().is_some());
352        assert_eq!(result.node_addr(), Some(node_addr));
353        assert!(!result.should_close_this_connection());
354        assert!(result.link_to_close().is_none());
355    }
356
357    #[test]
358    fn test_promotion_result_cross_lost() {
359        let result = PromotionResult::CrossConnectionLost {
360            winner_link_id: LinkId::new(1),
361        };
362
363        assert!(result.node_addr().is_none());
364        assert!(result.should_close_this_connection());
365        assert!(result.link_to_close().is_none()); // Caller closes their own
366    }
367
368    #[test]
369    fn test_promotion_result_cross_won() {
370        let identity = make_peer_identity();
371        let node_addr = *identity.node_addr();
372        let result = PromotionResult::CrossConnectionWon {
373            loser_link_id: LinkId::new(1),
374            node_addr,
375        };
376
377        assert!(result.node_addr().is_some());
378        assert_eq!(result.node_addr(), Some(node_addr));
379        assert!(!result.should_close_this_connection());
380        assert_eq!(result.link_to_close(), Some(LinkId::new(1)));
381    }
382
383    #[test]
384    fn test_peer_slot_node_addr() {
385        // Outbound connection knows expected identity
386        let identity = make_peer_identity();
387        let expected_node_addr = *identity.node_addr();
388        let conn = PeerConnection::outbound(LinkId::new(1), identity, 1000);
389        let slot = PeerSlot::Connecting(Box::new(conn));
390        assert_eq!(slot.node_addr(), Some(&expected_node_addr));
391
392        // Inbound connection doesn't know identity yet
393        let conn_inbound = PeerConnection::inbound(LinkId::new(2), 2000);
394        let slot_inbound = PeerSlot::Connecting(Box::new(conn_inbound));
395        assert!(slot_inbound.node_addr().is_none());
396
397        // Active peer always knows identity
398        let identity2 = make_peer_identity();
399        let active_node_addr = *identity2.node_addr();
400        let peer = ActivePeer::new(identity2, LinkId::new(3), 3000);
401        let slot_active = PeerSlot::Active(Box::new(peer));
402        assert_eq!(slot_active.node_addr(), Some(&active_node_addr));
403    }
404}