Skip to main content

rns_core/holepunch/
engine.rs

1//! HolePunchEngine: pure-logic state machine for NAT hole punching.
2//!
3//! Follows the asymmetric protocol from direct-link-protocol.md:
4//!
5//! Phase 1: A probes facilitator T → learns A_pub
6//! Phase 2: A sends UPGRADE_REQUEST {facilitator: T, initiator_public: A_pub} → B
7//!          B responds with UPGRADE_ACCEPT or UPGRADE_REJECT
8//! Phase 3: B probes T (from request) → learns B_pub
9//!          B sends UPGRADE_READY {responder_public: B_pub} → A
10//! Phase 4: Both punch simultaneously
11//! Phase 5: Direct link established
12//!
13//! Methods return `Vec<HolePunchAction>` instead of performing I/O.
14
15use alloc::vec::Vec;
16
17use rns_crypto::hkdf::hkdf;
18use rns_crypto::Rng;
19
20use crate::msgpack::{self, Value};
21
22use super::types::*;
23
24/// Derives a 32-byte punch token from the link's derived key and session ID.
25///
26/// `HKDF(ikm=derived_key, salt=session_id, info="rns-holepunch-v1")[:32]`
27pub fn derive_punch_token(
28    derived_key: &[u8],
29    session_id: &[u8; 16],
30) -> Result<[u8; 32], HolePunchError> {
31    let result = hkdf(32, derived_key, Some(session_id), Some(b"rns-holepunch-v1"))
32        .map_err(|_| HolePunchError::NoDerivedKey)?;
33    let mut token = [0u8; 32];
34    token.copy_from_slice(&result);
35    Ok(token)
36}
37
38/// The hole-punch state machine for a single link.
39pub struct HolePunchEngine {
40    link_id: [u8; 16],
41    session_id: [u8; 16],
42    state: HolePunchState,
43    is_initiator: bool,
44
45    /// Our discovered public endpoint.
46    our_public_endpoint: Option<Endpoint>,
47
48    /// Peer's public endpoint.
49    /// Initiator: received from UPGRADE_READY.
50    /// Responder: received from UPGRADE_REQUEST.
51    peer_public_endpoint: Option<Endpoint>,
52
53    /// Facilitator (STUN probe) address.
54    /// Initiator: our configured probe address.
55    /// Responder: received from UPGRADE_REQUEST.
56    facilitator_addr: Option<Endpoint>,
57
58    /// Punch token derived from link key + session_id.
59    punch_token: [u8; 32],
60
61    /// Probe service address for endpoint discovery (configured on this node).
62    probe_addr: Option<Endpoint>,
63
64    /// Protocol to use for endpoint discovery.
65    probe_protocol: ProbeProtocol,
66
67    /// Timestamp of state entry (for timeout tracking).
68    state_entered_at: f64,
69}
70
71impl HolePunchEngine {
72    /// Validate an upgrade request without creating or mutating a session.
73    pub fn validate_upgrade_request(payload: &[u8]) -> Result<(), HolePunchError> {
74        decode_upgrade_request(payload).map(|_| ())
75    }
76
77    /// Create a new engine for the given link. Does not start any session.
78    pub fn new(
79        link_id: [u8; 16],
80        probe_addr: Option<Endpoint>,
81        probe_protocol: ProbeProtocol,
82    ) -> Self {
83        HolePunchEngine {
84            link_id,
85            session_id: [0u8; 16],
86            state: HolePunchState::Idle,
87            is_initiator: false,
88            our_public_endpoint: None,
89            peer_public_endpoint: None,
90            facilitator_addr: None,
91            punch_token: [0u8; 32],
92            probe_addr,
93            probe_protocol,
94            state_entered_at: 0.0,
95        }
96    }
97
98    pub fn state(&self) -> HolePunchState {
99        self.state
100    }
101
102    pub fn session_id(&self) -> &[u8; 16] {
103        &self.session_id
104    }
105
106    pub fn is_initiator(&self) -> bool {
107        self.is_initiator
108    }
109
110    pub fn punch_token(&self) -> &[u8; 32] {
111        &self.punch_token
112    }
113
114    /// Override the facilitator address.
115    ///
116    /// Used when the orchestrator discovers that a different probe server
117    /// succeeded (failover). Must be called before `endpoints_discovered()`
118    /// so the UPGRADE_REQUEST carries the correct facilitator.
119    pub fn set_facilitator(&mut self, addr: Endpoint) {
120        self.facilitator_addr = Some(addr);
121    }
122
123    /// Peer's discovered public endpoint.
124    pub fn peer_public_endpoint(&self) -> Option<&Endpoint> {
125        self.peer_public_endpoint.as_ref()
126    }
127
128    /// Propose a direct connection (initiator side).
129    ///
130    /// Per the spec, the initiator first discovers its own public endpoint
131    /// (Phase 1) before sending the upgrade request.
132    ///
133    /// Transitions: Idle -> Discovering.
134    pub fn propose(
135        &mut self,
136        derived_key: &[u8],
137        now: f64,
138        rng: &mut dyn Rng,
139    ) -> Result<Vec<HolePunchAction>, HolePunchError> {
140        if self.state != HolePunchState::Idle {
141            return Err(HolePunchError::InvalidState);
142        }
143
144        // Generate session ID
145        let mut session_id = [0u8; 16];
146        rng.fill_bytes(&mut session_id);
147        self.session_id = session_id;
148        self.is_initiator = true;
149
150        // Derive punch token
151        self.punch_token = derive_punch_token(derived_key, &session_id)?;
152
153        let probe_addr = self.probe_addr.clone().ok_or(HolePunchError::NoProbeAddr)?;
154        self.facilitator_addr = Some(probe_addr.clone());
155
156        // Phase 1: discover our public endpoint first
157        self.state = HolePunchState::Discovering;
158        self.state_entered_at = now;
159
160        Ok(alloc::vec![HolePunchAction::DiscoverEndpoints {
161            probe_addr,
162            protocol: self.probe_protocol
163        }])
164    }
165
166    /// Called when endpoint discovery completes.
167    ///
168    /// For initiator: Discovering -> Proposing (sends UPGRADE_REQUEST with facilitator + our addr).
169    /// For responder: Discovering -> Punching (sends UPGRADE_READY with our addr, starts punch).
170    pub fn endpoints_discovered(
171        &mut self,
172        public_endpoint: Endpoint,
173        now: f64,
174    ) -> Result<Vec<HolePunchAction>, HolePunchError> {
175        if self.state != HolePunchState::Discovering {
176            return Err(HolePunchError::InvalidState);
177        }
178
179        self.our_public_endpoint = Some(public_endpoint.clone());
180
181        if self.is_initiator {
182            // Initiator: Phase 1 complete -> Phase 2: send UPGRADE_REQUEST
183            let facilitator = self
184                .facilitator_addr
185                .clone()
186                .ok_or(HolePunchError::NoProbeAddr)?;
187
188            let payload = encode_upgrade_request(
189                &self.session_id,
190                &facilitator,
191                &public_endpoint,
192                self.probe_protocol,
193            );
194
195            self.state = HolePunchState::Proposing;
196            self.state_entered_at = now;
197
198            Ok(alloc::vec![HolePunchAction::SendSignal {
199                link_id: self.link_id,
200                msgtype: UPGRADE_REQUEST,
201                payload,
202            }])
203        } else {
204            // Responder: Phase 3 complete -> send UPGRADE_READY, start punching
205            let payload = encode_upgrade_ready(&self.session_id, &public_endpoint);
206
207            let peer_public = self
208                .peer_public_endpoint
209                .clone()
210                .ok_or(HolePunchError::InvalidState)?;
211
212            self.state = HolePunchState::Punching;
213            self.state_entered_at = now;
214
215            Ok(alloc::vec![
216                HolePunchAction::SendSignal {
217                    link_id: self.link_id,
218                    msgtype: UPGRADE_READY,
219                    payload,
220                },
221                HolePunchAction::StartUdpPunch {
222                    peer_public,
223                    punch_token: self.punch_token,
224                    session_id: self.session_id,
225                },
226            ])
227        }
228    }
229
230    /// Handle an incoming signaling message.
231    ///
232    /// `derived_key` is needed when handling UPGRADE_REQUEST (responder side).
233    pub fn handle_signal(
234        &mut self,
235        msgtype: u16,
236        payload: &[u8],
237        derived_key: Option<&[u8]>,
238        now: f64,
239    ) -> Result<Vec<HolePunchAction>, HolePunchError> {
240        match msgtype {
241            UPGRADE_REQUEST => self.handle_upgrade_request(payload, derived_key, now),
242            UPGRADE_ACCEPT => self.handle_upgrade_accept(payload, now),
243            UPGRADE_REJECT => self.handle_upgrade_reject(payload, now),
244            UPGRADE_READY => self.handle_upgrade_ready(payload, now),
245            UPGRADE_COMPLETE => self.handle_upgrade_complete(payload, now),
246            _ => Err(HolePunchError::InvalidPayload),
247        }
248    }
249
250    /// Called when the punch phase succeeds.
251    ///
252    /// Transitions: Punching -> Connected.
253    pub fn punch_succeeded(&mut self, now: f64) -> Result<Vec<HolePunchAction>, HolePunchError> {
254        if self.state != HolePunchState::Punching {
255            return Err(HolePunchError::InvalidState);
256        }
257
258        self.state = HolePunchState::Connected;
259        self.state_entered_at = now;
260
261        Ok(alloc::vec![HolePunchAction::Succeeded {
262            session_id: self.session_id,
263        },])
264    }
265
266    /// Called when the punch phase fails.
267    ///
268    /// Transitions: Punching -> Failed.
269    pub fn punch_failed(&mut self, now: f64) -> Result<Vec<HolePunchAction>, HolePunchError> {
270        if self.state != HolePunchState::Punching {
271            return Err(HolePunchError::InvalidState);
272        }
273
274        self.state = HolePunchState::Failed;
275        self.state_entered_at = now;
276
277        Ok(alloc::vec![HolePunchAction::Failed {
278            session_id: self.session_id,
279            reason: FAIL_TIMEOUT,
280        },])
281    }
282
283    /// Periodic tick: check timeouts.
284    pub fn tick(&mut self, now: f64) -> Vec<HolePunchAction> {
285        let elapsed = now - self.state_entered_at;
286        match self.state {
287            HolePunchState::Discovering if elapsed > DISCOVER_TIMEOUT => {
288                self.state = HolePunchState::Failed;
289                self.state_entered_at = now;
290                alloc::vec![HolePunchAction::Failed {
291                    session_id: self.session_id,
292                    reason: FAIL_PROBE,
293                }]
294            }
295            HolePunchState::Proposing if elapsed > PROPOSE_TIMEOUT => {
296                self.state = HolePunchState::Failed;
297                self.state_entered_at = now;
298                alloc::vec![HolePunchAction::Failed {
299                    session_id: self.session_id,
300                    reason: FAIL_TIMEOUT,
301                }]
302            }
303            HolePunchState::WaitingReady if elapsed > READY_TIMEOUT => {
304                self.state = HolePunchState::Failed;
305                self.state_entered_at = now;
306                alloc::vec![HolePunchAction::Failed {
307                    session_id: self.session_id,
308                    reason: FAIL_TIMEOUT,
309                }]
310            }
311            HolePunchState::Punching if elapsed > PUNCH_TIMEOUT => {
312                self.state = HolePunchState::Failed;
313                self.state_entered_at = now;
314                alloc::vec![HolePunchAction::Failed {
315                    session_id: self.session_id,
316                    reason: FAIL_TIMEOUT,
317                }]
318            }
319            _ => Vec::new(),
320        }
321    }
322
323    /// Build a reject response for a request payload without creating a full session.
324    ///
325    /// Used when the policy rejects all proposals.
326    pub fn build_reject(
327        link_id: [u8; 16],
328        request_payload: &[u8],
329        reason: u8,
330    ) -> Result<HolePunchAction, HolePunchError> {
331        let (session_id, _, _, _) = decode_upgrade_request(request_payload)?;
332        let payload = encode_upgrade_reject(&session_id, reason);
333        Ok(HolePunchAction::SendSignal {
334            link_id,
335            msgtype: UPGRADE_REJECT,
336            payload,
337        })
338    }
339
340    /// Reset engine back to Idle state for reuse.
341    pub fn reset(&mut self) {
342        self.state = HolePunchState::Idle;
343        self.session_id = [0u8; 16];
344        self.is_initiator = false;
345        self.our_public_endpoint = None;
346        self.peer_public_endpoint = None;
347        self.facilitator_addr = None;
348        self.punch_token = [0u8; 32];
349        self.probe_protocol = ProbeProtocol::Rnsp;
350        self.state_entered_at = 0.0;
351    }
352
353    // --- Private handlers ---
354
355    /// Responder receives UPGRADE_REQUEST.
356    ///
357    /// Spec Phase 2: B evaluates the request, sends UPGRADE_ACCEPT, then
358    /// begins Phase 3 (STUN discovery using facilitator from the request).
359    fn handle_upgrade_request(
360        &mut self,
361        payload: &[u8],
362        derived_key: Option<&[u8]>,
363        now: f64,
364    ) -> Result<Vec<HolePunchAction>, HolePunchError> {
365        if self.state != HolePunchState::Idle {
366            // Already busy — reject
367            let (session_id, _, _, _) = decode_upgrade_request(payload)?;
368            let reject_payload = encode_upgrade_reject(&session_id, REJECT_BUSY);
369            return Ok(alloc::vec![HolePunchAction::SendSignal {
370                link_id: self.link_id,
371                msgtype: UPGRADE_REJECT,
372                payload: reject_payload,
373            }]);
374        }
375
376        let derived_key = derived_key.ok_or(HolePunchError::NoDerivedKey)?;
377        let (session_id, facilitator, initiator_public, protocol) =
378            decode_upgrade_request(payload)?;
379
380        self.session_id = session_id;
381        self.is_initiator = false;
382        self.probe_protocol = protocol;
383        self.punch_token = derive_punch_token(derived_key, &session_id)?;
384
385        // Store A's public address (we'll punch this later)
386        self.peer_public_endpoint = Some(initiator_public);
387
388        // Use facilitator from the request for our own STUN discovery
389        self.facilitator_addr = Some(facilitator.clone());
390
391        self.state = HolePunchState::Discovering;
392        self.state_entered_at = now;
393
394        // Send UPGRADE_ACCEPT, then discover our endpoint using facilitator from request
395        let accept_payload = encode_upgrade_accept(&session_id);
396
397        Ok(alloc::vec![
398            HolePunchAction::SendSignal {
399                link_id: self.link_id,
400                msgtype: UPGRADE_ACCEPT,
401                payload: accept_payload,
402            },
403            HolePunchAction::DiscoverEndpoints {
404                probe_addr: facilitator,
405                protocol
406            },
407        ])
408    }
409
410    /// Initiator receives UPGRADE_ACCEPT.
411    ///
412    /// Transitions: Proposing -> WaitingReady (waiting for B's UPGRADE_READY).
413    fn handle_upgrade_accept(
414        &mut self,
415        payload: &[u8],
416        now: f64,
417    ) -> Result<Vec<HolePunchAction>, HolePunchError> {
418        if self.state != HolePunchState::Proposing || !self.is_initiator {
419            return Err(HolePunchError::InvalidState);
420        }
421
422        let session_id = decode_upgrade_accept(payload)?;
423        if session_id != self.session_id {
424            return Err(HolePunchError::SessionMismatch);
425        }
426
427        self.state = HolePunchState::WaitingReady;
428        self.state_entered_at = now;
429
430        Ok(Vec::new())
431    }
432
433    /// Initiator receives UPGRADE_REJECT.
434    fn handle_upgrade_reject(
435        &mut self,
436        payload: &[u8],
437        now: f64,
438    ) -> Result<Vec<HolePunchAction>, HolePunchError> {
439        if self.state != HolePunchState::Proposing {
440            return Err(HolePunchError::InvalidState);
441        }
442
443        let (session_id, reason) = decode_upgrade_reject(payload)?;
444        if session_id != self.session_id {
445            return Err(HolePunchError::SessionMismatch);
446        }
447
448        self.state = HolePunchState::Failed;
449        self.state_entered_at = now;
450
451        Ok(alloc::vec![HolePunchAction::Failed {
452            session_id: self.session_id,
453            reason,
454        }])
455    }
456
457    /// Initiator receives UPGRADE_READY from responder.
458    ///
459    /// Spec Phase 3 complete: B has discovered its endpoint and sent it to A.
460    /// Both sides now start punching (Phase 4).
461    fn handle_upgrade_ready(
462        &mut self,
463        payload: &[u8],
464        now: f64,
465    ) -> Result<Vec<HolePunchAction>, HolePunchError> {
466        if self.state != HolePunchState::WaitingReady || !self.is_initiator {
467            return Err(HolePunchError::InvalidState);
468        }
469
470        let (session_id, responder_public) = decode_upgrade_ready(payload)?;
471        if session_id != self.session_id {
472            return Err(HolePunchError::SessionMismatch);
473        }
474
475        self.peer_public_endpoint = Some(responder_public.clone());
476
477        self.state = HolePunchState::Punching;
478        self.state_entered_at = now;
479
480        Ok(alloc::vec![HolePunchAction::StartUdpPunch {
481            peer_public: responder_public,
482            punch_token: self.punch_token,
483            session_id: self.session_id,
484        }])
485    }
486
487    /// Receives UPGRADE_COMPLETE (over direct UDP channel after punch succeeds).
488    fn handle_upgrade_complete(
489        &mut self,
490        payload: &[u8],
491        now: f64,
492    ) -> Result<Vec<HolePunchAction>, HolePunchError> {
493        if self.state != HolePunchState::Punching && self.state != HolePunchState::Connected {
494            return Err(HolePunchError::InvalidState);
495        }
496
497        let session_id = decode_session_only(payload)?;
498        if session_id != self.session_id {
499            return Err(HolePunchError::SessionMismatch);
500        }
501
502        if self.state == HolePunchState::Connected {
503            // Already connected — peer is confirming
504            return Ok(Vec::new());
505        }
506
507        self.state = HolePunchState::Connected;
508        self.state_entered_at = now;
509
510        Ok(alloc::vec![HolePunchAction::Succeeded {
511            session_id: self.session_id,
512        }])
513    }
514}
515
516// --- Msgpack encode/decode helpers ---
517
518fn encode_upgrade_request(
519    session_id: &[u8; 16],
520    facilitator: &Endpoint,
521    initiator_public: &Endpoint,
522    protocol: ProbeProtocol,
523) -> Vec<u8> {
524    let mut fields = alloc::vec![
525        (
526            Value::Str(alloc::string::String::from("s")),
527            Value::Bin(session_id.to_vec())
528        ),
529        (
530            Value::Str(alloc::string::String::from("f")),
531            encode_endpoint(facilitator)
532        ),
533        (
534            Value::Str(alloc::string::String::from("a")),
535            encode_endpoint(initiator_public)
536        ),
537    ];
538    // Only include "p" when not RNSP (backward compat: old nodes don't send it)
539    if protocol != ProbeProtocol::Rnsp {
540        fields.push((
541            Value::Str(alloc::string::String::from("p")),
542            Value::UInt(protocol as u64),
543        ));
544    }
545    let val = Value::Map(fields);
546    msgpack::pack(&val)
547}
548
549fn decode_upgrade_request(
550    data: &[u8],
551) -> Result<([u8; 16], Endpoint, Endpoint, ProbeProtocol), HolePunchError> {
552    let (val, _) = msgpack::unpack(data).map_err(|_| HolePunchError::InvalidPayload)?;
553    let session_id = extract_session_id(&val)?;
554    let facilitator = val
555        .map_get("f")
556        .and_then(decode_endpoint)
557        .ok_or(HolePunchError::InvalidPayload)?;
558    let initiator_public = val
559        .map_get("a")
560        .and_then(decode_endpoint)
561        .ok_or(HolePunchError::InvalidPayload)?;
562    // Fallback to Rnsp when "p" is absent (old nodes don't send it)
563    let protocol = val
564        .map_get("p")
565        .and_then(|v| v.as_uint())
566        .map(|p| match p {
567            1 => ProbeProtocol::Stun,
568            _ => ProbeProtocol::Rnsp,
569        })
570        .unwrap_or(ProbeProtocol::Rnsp);
571    Ok((session_id, facilitator, initiator_public, protocol))
572}
573
574fn encode_upgrade_accept(session_id: &[u8; 16]) -> Vec<u8> {
575    let val = Value::Map(alloc::vec![(
576        Value::Str(alloc::string::String::from("s")),
577        Value::Bin(session_id.to_vec())
578    ),]);
579    msgpack::pack(&val)
580}
581
582fn decode_upgrade_accept(data: &[u8]) -> Result<[u8; 16], HolePunchError> {
583    let (val, _) = msgpack::unpack(data).map_err(|_| HolePunchError::InvalidPayload)?;
584    extract_session_id(&val)
585}
586
587fn encode_upgrade_reject(session_id: &[u8; 16], reason: u8) -> Vec<u8> {
588    let val = Value::Map(alloc::vec![
589        (
590            Value::Str(alloc::string::String::from("s")),
591            Value::Bin(session_id.to_vec())
592        ),
593        (
594            Value::Str(alloc::string::String::from("r")),
595            Value::UInt(reason as u64)
596        ),
597    ]);
598    msgpack::pack(&val)
599}
600
601fn decode_upgrade_reject(data: &[u8]) -> Result<([u8; 16], u8), HolePunchError> {
602    let (val, _) = msgpack::unpack(data).map_err(|_| HolePunchError::InvalidPayload)?;
603    let session_id = extract_session_id(&val)?;
604    let reason = val
605        .map_get("r")
606        .and_then(|v| v.as_uint())
607        .ok_or(HolePunchError::InvalidPayload)? as u8;
608    Ok((session_id, reason))
609}
610
611fn encode_upgrade_ready(session_id: &[u8; 16], responder_public: &Endpoint) -> Vec<u8> {
612    let val = Value::Map(alloc::vec![
613        (
614            Value::Str(alloc::string::String::from("s")),
615            Value::Bin(session_id.to_vec())
616        ),
617        (
618            Value::Str(alloc::string::String::from("a")),
619            encode_endpoint(responder_public)
620        ),
621    ]);
622    msgpack::pack(&val)
623}
624
625fn decode_upgrade_ready(data: &[u8]) -> Result<([u8; 16], Endpoint), HolePunchError> {
626    let (val, _) = msgpack::unpack(data).map_err(|_| HolePunchError::InvalidPayload)?;
627    let session_id = extract_session_id(&val)?;
628    let responder_public = val
629        .map_get("a")
630        .and_then(decode_endpoint)
631        .ok_or(HolePunchError::InvalidPayload)?;
632    Ok((session_id, responder_public))
633}
634
635fn encode_endpoint(ep: &Endpoint) -> Value {
636    Value::Array(alloc::vec![
637        Value::Bin(ep.addr.clone()),
638        Value::UInt(ep.port as u64),
639    ])
640}
641
642fn decode_endpoint(val: &Value) -> Option<Endpoint> {
643    let arr = val.as_array()?;
644    if arr.len() < 2 {
645        return None;
646    }
647    let addr = arr[0].as_bin()?.to_vec();
648    let port = arr[1].as_uint()? as u16;
649    Some(Endpoint { addr, port })
650}
651
652#[cfg(test)]
653fn encode_session_only(session_id: &[u8; 16]) -> Vec<u8> {
654    let val = Value::Map(alloc::vec![(
655        Value::Str(alloc::string::String::from("s")),
656        Value::Bin(session_id.to_vec())
657    ),]);
658    msgpack::pack(&val)
659}
660
661fn decode_session_only(data: &[u8]) -> Result<[u8; 16], HolePunchError> {
662    let (val, _) = msgpack::unpack(data).map_err(|_| HolePunchError::InvalidPayload)?;
663    extract_session_id(&val)
664}
665
666fn extract_session_id(val: &Value) -> Result<[u8; 16], HolePunchError> {
667    let bin = val
668        .map_get("s")
669        .and_then(|v| v.as_bin())
670        .ok_or(HolePunchError::InvalidPayload)?;
671    if bin.len() != 16 {
672        return Err(HolePunchError::InvalidPayload);
673    }
674    let mut id = [0u8; 16];
675    id.copy_from_slice(bin);
676    Ok(id)
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use rns_crypto::FixedRng;
683
684    fn make_rng(seed: u8) -> FixedRng {
685        FixedRng::new(&[seed; 128])
686    }
687
688    fn test_derived_key() -> Vec<u8> {
689        vec![0xAA; 32]
690    }
691
692    fn test_probe_addr() -> Endpoint {
693        Endpoint {
694            addr: vec![127, 0, 0, 1],
695            port: 4343,
696        }
697    }
698
699    fn test_public_addr_a() -> Endpoint {
700        Endpoint {
701            addr: vec![1, 2, 3, 4],
702            port: 41000,
703        }
704    }
705
706    fn test_public_addr_b() -> Endpoint {
707        Endpoint {
708            addr: vec![5, 6, 7, 8],
709            port: 52000,
710        }
711    }
712
713    #[test]
714    fn test_propose_initiator_discovers_first() {
715        let link_id = [0x11; 16];
716        let derived_key = test_derived_key();
717        let mut rng = make_rng(0x42);
718
719        let mut initiator =
720            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
721        let actions = initiator.propose(&derived_key, 100.0, &mut rng).unwrap();
722
723        // Should transition to Discovering, not Proposing
724        assert_eq!(initiator.state(), HolePunchState::Discovering);
725        assert_eq!(actions.len(), 1);
726        assert!(matches!(
727            &actions[0],
728            HolePunchAction::DiscoverEndpoints { .. }
729        ));
730    }
731
732    #[test]
733    fn test_initiator_sends_request_after_discovery() {
734        let link_id = [0x11; 16];
735        let derived_key = test_derived_key();
736        let mut rng = make_rng(0x42);
737
738        let mut initiator =
739            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
740        initiator.propose(&derived_key, 100.0, &mut rng).unwrap();
741
742        // Initiator discovers its public endpoint
743        let actions = initiator
744            .endpoints_discovered(test_public_addr_a(), 101.0)
745            .unwrap();
746
747        // Should transition to Proposing and send UPGRADE_REQUEST
748        assert_eq!(initiator.state(), HolePunchState::Proposing);
749        assert_eq!(actions.len(), 1);
750        match &actions[0] {
751            HolePunchAction::SendSignal {
752                msgtype, payload, ..
753            } => {
754                assert_eq!(*msgtype, UPGRADE_REQUEST);
755                // Verify payload contains facilitator and initiator_public
756                let (sid, facilitator, init_pub, _proto) = decode_upgrade_request(payload).unwrap();
757                assert_eq!(sid, *initiator.session_id());
758                assert_eq!(facilitator, test_probe_addr());
759                assert_eq!(init_pub, test_public_addr_a());
760            }
761            _ => panic!("Expected SendSignal(UPGRADE_REQUEST)"),
762        }
763    }
764
765    #[test]
766    fn test_full_asymmetric_flow() {
767        let link_id = [0x22; 16];
768        let derived_key = test_derived_key();
769        let mut rng = make_rng(0x42);
770
771        // Phase 1: Initiator discovers its endpoint
772        let mut initiator =
773            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
774        initiator.propose(&derived_key, 100.0, &mut rng).unwrap();
775        let actions = initiator
776            .endpoints_discovered(test_public_addr_a(), 101.0)
777            .unwrap();
778
779        let request_payload = match &actions[0] {
780            HolePunchAction::SendSignal { payload, .. } => payload.clone(),
781            _ => panic!(),
782        };
783
784        // Phase 2: Responder receives UPGRADE_REQUEST
785        let mut responder = HolePunchEngine::new(link_id, None, ProbeProtocol::Rnsp); // no probe_addr needed, uses facilitator from request
786        let actions = responder
787            .handle_signal(UPGRADE_REQUEST, &request_payload, Some(&derived_key), 102.0)
788            .unwrap();
789
790        assert_eq!(responder.state(), HolePunchState::Discovering);
791        assert_eq!(actions.len(), 2); // UPGRADE_ACCEPT + DiscoverEndpoints
792
793        let accept_payload = match &actions[0] {
794            HolePunchAction::SendSignal {
795                msgtype, payload, ..
796            } => {
797                assert_eq!(*msgtype, UPGRADE_ACCEPT);
798                payload.clone()
799            }
800            _ => panic!("Expected UPGRADE_ACCEPT"),
801        };
802
803        // B discovers using facilitator from request
804        match &actions[1] {
805            HolePunchAction::DiscoverEndpoints { probe_addr, .. } => {
806                assert_eq!(*probe_addr, test_probe_addr()); // facilitator from request
807            }
808            _ => panic!("Expected DiscoverEndpoints"),
809        }
810
811        // Initiator receives UPGRADE_ACCEPT -> WaitingReady
812        let actions = initiator
813            .handle_signal(UPGRADE_ACCEPT, &accept_payload, None, 103.0)
814            .unwrap();
815        assert_eq!(initiator.state(), HolePunchState::WaitingReady);
816        assert!(actions.is_empty()); // Just waiting
817
818        // Phase 3: Responder discovers its endpoint, sends UPGRADE_READY
819        let actions = responder
820            .endpoints_discovered(test_public_addr_b(), 104.0)
821            .unwrap();
822
823        assert_eq!(responder.state(), HolePunchState::Punching);
824        assert_eq!(actions.len(), 2); // UPGRADE_READY + StartUdpPunch
825
826        let ready_payload = match &actions[0] {
827            HolePunchAction::SendSignal {
828                msgtype, payload, ..
829            } => {
830                assert_eq!(*msgtype, UPGRADE_READY);
831                payload.clone()
832            }
833            _ => panic!("Expected UPGRADE_READY"),
834        };
835        assert!(matches!(&actions[1], HolePunchAction::StartUdpPunch { .. }));
836
837        // Phase 4: Initiator receives UPGRADE_READY -> Punching
838        let actions = initiator
839            .handle_signal(UPGRADE_READY, &ready_payload, None, 105.0)
840            .unwrap();
841
842        assert_eq!(initiator.state(), HolePunchState::Punching);
843        assert_eq!(actions.len(), 1);
844        match &actions[0] {
845            HolePunchAction::StartUdpPunch { peer_public, .. } => {
846                assert_eq!(*peer_public, test_public_addr_b());
847            }
848            _ => panic!("Expected StartUdpPunch"),
849        }
850
851        // Both derive the same punch token
852        assert_eq!(initiator.punch_token(), responder.punch_token());
853    }
854
855    #[test]
856    fn test_punch_success() {
857        let link_id = [0x33; 16];
858        let derived_key = test_derived_key();
859        let mut rng = make_rng(0x42);
860
861        let mut engine =
862            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
863        engine.propose(&derived_key, 100.0, &mut rng).unwrap();
864        engine.state = HolePunchState::Punching;
865
866        let actions = engine.punch_succeeded(105.0).unwrap();
867        assert_eq!(engine.state(), HolePunchState::Connected);
868        assert_eq!(actions.len(), 1);
869        assert!(matches!(&actions[0], HolePunchAction::Succeeded { .. }));
870    }
871
872    #[test]
873    fn test_punch_failed() {
874        let link_id = [0x44; 16];
875        let derived_key = test_derived_key();
876        let mut rng = make_rng(0x42);
877
878        let mut engine =
879            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
880        engine.propose(&derived_key, 100.0, &mut rng).unwrap();
881        engine.state = HolePunchState::Punching;
882
883        let actions = engine.punch_failed(120.0).unwrap();
884        assert_eq!(engine.state(), HolePunchState::Failed);
885        assert_eq!(actions.len(), 1);
886        assert!(matches!(&actions[0], HolePunchAction::Failed { .. }));
887    }
888
889    #[test]
890    fn test_reject_when_busy() {
891        let link_id = [0x55; 16];
892        let derived_key = test_derived_key();
893        let mut rng = make_rng(0x42);
894
895        // Create a request payload
896        let mut proposer =
897            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
898        proposer.propose(&derived_key, 100.0, &mut rng).unwrap();
899        let actions = proposer
900            .endpoints_discovered(test_public_addr_a(), 101.0)
901            .unwrap();
902        let request_payload = match &actions[0] {
903            HolePunchAction::SendSignal { payload, .. } => payload.clone(),
904            _ => panic!(),
905        };
906
907        // Responder is already busy (set to Discovering manually)
908        let mut responder =
909            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
910        responder.state = HolePunchState::Discovering;
911
912        let actions = responder
913            .handle_signal(UPGRADE_REQUEST, &request_payload, Some(&derived_key), 102.0)
914            .unwrap();
915
916        // Should reject with REJECT_BUSY
917        assert_eq!(actions.len(), 1);
918        match &actions[0] {
919            HolePunchAction::SendSignal { msgtype, .. } => {
920                assert_eq!(*msgtype, UPGRADE_REJECT);
921            }
922            _ => panic!("Expected UPGRADE_REJECT"),
923        }
924    }
925
926    #[test]
927    fn test_initiator_receives_reject() {
928        let link_id = [0x66; 16];
929        let derived_key = test_derived_key();
930        let mut rng = make_rng(0x42);
931
932        let mut initiator =
933            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
934        initiator.propose(&derived_key, 100.0, &mut rng).unwrap();
935        initiator
936            .endpoints_discovered(test_public_addr_a(), 101.0)
937            .unwrap();
938        assert_eq!(initiator.state(), HolePunchState::Proposing);
939
940        let session_id = *initiator.session_id();
941        let reject_payload = encode_upgrade_reject(&session_id, REJECT_POLICY);
942
943        let actions = initiator
944            .handle_signal(UPGRADE_REJECT, &reject_payload, None, 102.0)
945            .unwrap();
946
947        assert_eq!(initiator.state(), HolePunchState::Failed);
948        assert_eq!(actions.len(), 1);
949        assert!(
950            matches!(&actions[0], HolePunchAction::Failed { reason, .. } if *reason == REJECT_POLICY)
951        );
952    }
953
954    #[test]
955    fn test_discover_timeout() {
956        let link_id = [0x77; 16];
957        let derived_key = test_derived_key();
958        let mut rng = make_rng(0x42);
959
960        let mut engine =
961            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
962        engine.propose(&derived_key, 100.0, &mut rng).unwrap();
963        assert_eq!(engine.state(), HolePunchState::Discovering);
964
965        // Before timeout
966        let actions = engine.tick(100.0 + DISCOVER_TIMEOUT - 1.0);
967        assert!(actions.is_empty());
968
969        // After timeout
970        let actions = engine.tick(100.0 + DISCOVER_TIMEOUT + 1.0);
971        assert_eq!(engine.state(), HolePunchState::Failed);
972        assert!(
973            matches!(&actions[0], HolePunchAction::Failed { reason, .. } if *reason == FAIL_PROBE)
974        );
975    }
976
977    #[test]
978    fn test_propose_timeout() {
979        let link_id = [0x88; 16];
980        let derived_key = test_derived_key();
981        let mut rng = make_rng(0x42);
982
983        let mut engine =
984            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
985        engine.propose(&derived_key, 100.0, &mut rng).unwrap();
986        engine
987            .endpoints_discovered(test_public_addr_a(), 101.0)
988            .unwrap();
989        assert_eq!(engine.state(), HolePunchState::Proposing);
990
991        // After timeout
992        let actions = engine.tick(101.0 + PROPOSE_TIMEOUT + 1.0);
993        assert_eq!(engine.state(), HolePunchState::Failed);
994        assert!(
995            matches!(&actions[0], HolePunchAction::Failed { reason, .. } if *reason == FAIL_TIMEOUT)
996        );
997    }
998
999    #[test]
1000    fn test_waiting_ready_timeout() {
1001        let link_id = [0x99; 16];
1002        let derived_key = test_derived_key();
1003        let mut rng = make_rng(0x42);
1004
1005        let mut engine =
1006            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
1007        engine.propose(&derived_key, 200.0, &mut rng).unwrap();
1008        engine
1009            .endpoints_discovered(test_public_addr_a(), 201.0)
1010            .unwrap();
1011        engine.state = HolePunchState::WaitingReady;
1012        engine.state_entered_at = 202.0;
1013
1014        // After timeout
1015        let actions = engine.tick(202.0 + READY_TIMEOUT + 1.0);
1016        assert_eq!(engine.state(), HolePunchState::Failed);
1017        assert!(
1018            matches!(&actions[0], HolePunchAction::Failed { reason, .. } if *reason == FAIL_TIMEOUT)
1019        );
1020    }
1021
1022    #[test]
1023    fn test_punch_timeout() {
1024        let link_id = [0xAA; 16];
1025        let derived_key = test_derived_key();
1026        let mut rng = make_rng(0x42);
1027
1028        let mut engine =
1029            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
1030        engine.propose(&derived_key, 100.0, &mut rng).unwrap();
1031        engine.state = HolePunchState::Punching;
1032        engine.state_entered_at = 200.0;
1033
1034        // Before timeout
1035        let actions = engine.tick(200.0 + PUNCH_TIMEOUT - 1.0);
1036        assert!(actions.is_empty());
1037
1038        // After timeout
1039        let _actions = engine.tick(200.0 + PUNCH_TIMEOUT + 1.0);
1040        assert_eq!(engine.state(), HolePunchState::Failed);
1041    }
1042
1043    #[test]
1044    fn test_message_serialization_roundtrip() {
1045        let session_id = [0xAB; 16];
1046
1047        // UPGRADE_REQUEST (RNSP)
1048        let facilitator = test_probe_addr();
1049        let init_pub = test_public_addr_a();
1050        let data =
1051            encode_upgrade_request(&session_id, &facilitator, &init_pub, ProbeProtocol::Rnsp);
1052        let (sid, f, a, proto) = decode_upgrade_request(&data).unwrap();
1053        assert_eq!(sid, session_id);
1054        assert_eq!(f, facilitator);
1055        assert_eq!(a, init_pub);
1056        assert_eq!(proto, ProbeProtocol::Rnsp);
1057
1058        // UPGRADE_ACCEPT
1059        let data = encode_upgrade_accept(&session_id);
1060        let sid = decode_upgrade_accept(&data).unwrap();
1061        assert_eq!(sid, session_id);
1062
1063        // UPGRADE_REJECT
1064        let data = encode_upgrade_reject(&session_id, REJECT_POLICY);
1065        let (sid, r) = decode_upgrade_reject(&data).unwrap();
1066        assert_eq!(sid, session_id);
1067        assert_eq!(r, REJECT_POLICY);
1068
1069        // UPGRADE_READY
1070        let resp_pub = test_public_addr_b();
1071        let data = encode_upgrade_ready(&session_id, &resp_pub);
1072        let (sid, rp) = decode_upgrade_ready(&data).unwrap();
1073        assert_eq!(sid, session_id);
1074        assert_eq!(rp, resp_pub);
1075
1076        // Session only (UPGRADE_COMPLETE)
1077        let data = encode_session_only(&session_id);
1078        let sid = decode_session_only(&data).unwrap();
1079        assert_eq!(sid, session_id);
1080    }
1081
1082    #[test]
1083    fn test_punch_token_derivation_consistency() {
1084        let derived_key = vec![0xBB; 32];
1085        let session_id = [0xCC; 16];
1086
1087        let token1 = derive_punch_token(&derived_key, &session_id).unwrap();
1088        let token2 = derive_punch_token(&derived_key, &session_id).unwrap();
1089        assert_eq!(token1, token2);
1090
1091        // Different session_id -> different token
1092        let session_id2 = [0xDD; 16];
1093        let token3 = derive_punch_token(&derived_key, &session_id2).unwrap();
1094        assert_ne!(token1, token3);
1095    }
1096
1097    #[test]
1098    fn test_reset() {
1099        let link_id = [0xBB; 16];
1100        let derived_key = test_derived_key();
1101        let mut rng = make_rng(0x42);
1102
1103        let mut engine =
1104            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
1105        engine.propose(&derived_key, 100.0, &mut rng).unwrap();
1106        assert_eq!(engine.state(), HolePunchState::Discovering);
1107
1108        engine.reset();
1109        assert_eq!(engine.state(), HolePunchState::Idle);
1110        assert_eq!(engine.session_id(), &[0u8; 16]);
1111    }
1112
1113    #[test]
1114    fn test_build_reject_static() {
1115        let link_id = [0xCC; 16];
1116        let derived_key = test_derived_key();
1117        let mut rng = make_rng(0x42);
1118
1119        let mut proposer =
1120            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
1121        proposer.propose(&derived_key, 100.0, &mut rng).unwrap();
1122        let actions = proposer
1123            .endpoints_discovered(test_public_addr_a(), 101.0)
1124            .unwrap();
1125        let request_payload = match &actions[0] {
1126            HolePunchAction::SendSignal { payload, .. } => payload.clone(),
1127            _ => panic!(),
1128        };
1129
1130        let action =
1131            HolePunchEngine::build_reject(link_id, &request_payload, REJECT_POLICY).unwrap();
1132        match action {
1133            HolePunchAction::SendSignal { msgtype, .. } => {
1134                assert_eq!(msgtype, UPGRADE_REJECT);
1135            }
1136            _ => panic!("Expected SendSignal(UPGRADE_REJECT)"),
1137        }
1138    }
1139
1140    #[test]
1141    fn test_responder_needs_no_probe_addr() {
1142        // Responder uses facilitator from UPGRADE_REQUEST, doesn't need its own
1143        let link_id = [0xDD; 16];
1144        let derived_key = test_derived_key();
1145        let mut rng = make_rng(0x42);
1146
1147        // Build a request
1148        let mut initiator =
1149            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Rnsp);
1150        initiator.propose(&derived_key, 100.0, &mut rng).unwrap();
1151        let actions = initiator
1152            .endpoints_discovered(test_public_addr_a(), 101.0)
1153            .unwrap();
1154        let request_payload = match &actions[0] {
1155            HolePunchAction::SendSignal { payload, .. } => payload.clone(),
1156            _ => panic!(),
1157        };
1158
1159        // Responder has NO probe_addr configured
1160        let mut responder = HolePunchEngine::new(link_id, None, ProbeProtocol::Rnsp);
1161        let actions = responder
1162            .handle_signal(UPGRADE_REQUEST, &request_payload, Some(&derived_key), 102.0)
1163            .unwrap();
1164
1165        // Should still work — uses facilitator from the request
1166        assert_eq!(responder.state(), HolePunchState::Discovering);
1167        assert_eq!(actions.len(), 2);
1168        assert!(
1169            matches!(&actions[0], HolePunchAction::SendSignal { msgtype, .. } if *msgtype == UPGRADE_ACCEPT)
1170        );
1171        assert!(matches!(
1172            &actions[1],
1173            HolePunchAction::DiscoverEndpoints { .. }
1174        ));
1175    }
1176
1177    #[test]
1178    fn test_stun_protocol_in_upgrade_request_roundtrip() {
1179        let session_id = [0xAB; 16];
1180        let facilitator = test_probe_addr();
1181        let init_pub = test_public_addr_a();
1182
1183        // Encode with STUN protocol
1184        let data =
1185            encode_upgrade_request(&session_id, &facilitator, &init_pub, ProbeProtocol::Stun);
1186        let (sid, f, a, proto) = decode_upgrade_request(&data).unwrap();
1187        assert_eq!(sid, session_id);
1188        assert_eq!(f, facilitator);
1189        assert_eq!(a, init_pub);
1190        assert_eq!(proto, ProbeProtocol::Stun);
1191    }
1192
1193    #[test]
1194    fn test_rnsp_protocol_omits_p_field() {
1195        let session_id = [0xAB; 16];
1196        let facilitator = test_probe_addr();
1197        let init_pub = test_public_addr_a();
1198
1199        // Encode with RNSP (default) — should NOT include "p" field
1200        let data =
1201            encode_upgrade_request(&session_id, &facilitator, &init_pub, ProbeProtocol::Rnsp);
1202        let (sid, f, a, proto) = decode_upgrade_request(&data).unwrap();
1203        assert_eq!(sid, session_id);
1204        assert_eq!(f, facilitator);
1205        assert_eq!(a, init_pub);
1206        assert_eq!(proto, ProbeProtocol::Rnsp);
1207    }
1208
1209    #[test]
1210    fn test_backward_compat_decode_without_p_field() {
1211        // Simulate old node payload that has no "p" field
1212        let session_id = [0xAB; 16];
1213        let facilitator = test_probe_addr();
1214        let init_pub = test_public_addr_a();
1215
1216        // Manually encode without "p" field (old format)
1217        let val = Value::Map(alloc::vec![
1218            (
1219                Value::Str(alloc::string::String::from("s")),
1220                Value::Bin(session_id.to_vec())
1221            ),
1222            (
1223                Value::Str(alloc::string::String::from("f")),
1224                encode_endpoint(&facilitator)
1225            ),
1226            (
1227                Value::Str(alloc::string::String::from("a")),
1228                encode_endpoint(&init_pub)
1229            ),
1230        ]);
1231        let data = msgpack::pack(&val);
1232
1233        let (sid, f, a, proto) = decode_upgrade_request(&data).unwrap();
1234        assert_eq!(sid, session_id);
1235        assert_eq!(f, facilitator);
1236        assert_eq!(a, init_pub);
1237        assert_eq!(proto, ProbeProtocol::Rnsp); // Default fallback
1238    }
1239
1240    #[test]
1241    fn test_stun_initiator_responder_gets_stun_protocol() {
1242        let link_id = [0xEE; 16];
1243        let derived_key = test_derived_key();
1244        let mut rng = make_rng(0x42);
1245
1246        // Initiator uses STUN
1247        let mut initiator =
1248            HolePunchEngine::new(link_id, Some(test_probe_addr()), ProbeProtocol::Stun);
1249        let actions = initiator.propose(&derived_key, 100.0, &mut rng).unwrap();
1250
1251        // DiscoverEndpoints should carry Stun protocol
1252        match &actions[0] {
1253            HolePunchAction::DiscoverEndpoints { protocol, .. } => {
1254                assert_eq!(*protocol, ProbeProtocol::Stun);
1255            }
1256            _ => panic!("Expected DiscoverEndpoints"),
1257        }
1258
1259        let actions = initiator
1260            .endpoints_discovered(test_public_addr_a(), 101.0)
1261            .unwrap();
1262        let request_payload = match &actions[0] {
1263            HolePunchAction::SendSignal { payload, .. } => payload.clone(),
1264            _ => panic!(),
1265        };
1266
1267        // Responder decodes and gets Stun protocol
1268        let mut responder = HolePunchEngine::new(link_id, None, ProbeProtocol::Rnsp);
1269        let actions = responder
1270            .handle_signal(UPGRADE_REQUEST, &request_payload, Some(&derived_key), 102.0)
1271            .unwrap();
1272
1273        // Responder's DiscoverEndpoints should carry Stun protocol (from request)
1274        match &actions[1] {
1275            HolePunchAction::DiscoverEndpoints { protocol, .. } => {
1276                assert_eq!(*protocol, ProbeProtocol::Stun);
1277            }
1278            _ => panic!("Expected DiscoverEndpoints"),
1279        }
1280    }
1281}