Skip to main content

ipfrs_network/
protocol_handshake.rs

1//! Protocol handshake negotiation between peers.
2//!
3//! When two peers connect, they exchange `HandshakeOffer` messages to negotiate:
4//! - A mutually compatible protocol version (same major version required)
5//! - The intersection of supported feature flags
6//! - The minimum supported frame size
7//!
8//! ## Example
9//!
10//! ```rust
11//! use ipfrs_network::protocol_handshake::{
12//!     FeatureFlag, HandshakeOffer, ProtocolHandshaker, ProtocolVersion,
13//! };
14//!
15//! let local_offer = HandshakeOffer {
16//!     peer_id: "local-peer".to_string(),
17//!     protocol_version: ProtocolVersion::new(1, 0, 0),
18//!     supported_features: vec![FeatureFlag::Encryption, FeatureFlag::Compression],
19//!     max_frame_size: HandshakeOffer::DEFAULT_MAX_FRAME_SIZE,
20//!     timestamp_ms: 0,
21//! };
22//!
23//! let handshaker = ProtocolHandshaker::new(local_offer);
24//!
25//! let remote_offer = HandshakeOffer {
26//!     peer_id: "remote-peer".to_string(),
27//!     protocol_version: ProtocolVersion::new(1, 2, 0),
28//!     supported_features: vec![FeatureFlag::Encryption, FeatureFlag::VectorSearch],
29//!     max_frame_size: HandshakeOffer::DEFAULT_MAX_FRAME_SIZE,
30//!     timestamp_ms: 1000,
31//! };
32//!
33//! let result = handshaker.negotiate(&remote_offer).expect("handshake failed");
34//! assert_eq!(result.negotiated_features.len(), 1); // only Encryption
35//! ```
36
37use serde::{Deserialize, Serialize};
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::sync::Arc;
40use thiserror::Error;
41
42// ─────────────────────────────────────────────
43//  Errors
44// ─────────────────────────────────────────────
45
46/// Errors that can occur during the protocol handshake.
47#[derive(Debug, Error)]
48pub enum HandshakeError {
49    /// The local and remote peers do not share the same protocol major version.
50    #[error("Incompatible protocol version: local={local}, remote={remote}")]
51    IncompatibleVersion { local: String, remote: String },
52
53    /// After intersecting feature flags, no common features remain.
54    #[error("No common features between peers")]
55    NoCommonFeatures,
56
57    /// The remote offer is structurally or semantically invalid.
58    #[error("Invalid handshake offer: {0}")]
59    InvalidOffer(String),
60}
61
62// ─────────────────────────────────────────────
63//  ProtocolVersion
64// ─────────────────────────────────────────────
65
66/// Semantic protocol version used during handshake.
67///
68/// Versions are compared lexicographically by `(major, minor, patch)`.
69/// Compatibility requires matching major versions.
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
71pub struct ProtocolVersion {
72    pub major: u16,
73    pub minor: u16,
74    pub patch: u16,
75}
76
77impl ProtocolVersion {
78    /// Create a new protocol version.
79    pub fn new(major: u16, minor: u16, patch: u16) -> Self {
80        Self {
81            major,
82            minor,
83            patch,
84        }
85    }
86
87    /// Returns `true` when both versions share the same major version number.
88    ///
89    /// Minor/patch differences are acceptable — backward-compatibility is
90    /// assumed within a major series.
91    pub fn is_compatible_with(&self, other: &Self) -> bool {
92        self.major == other.major
93    }
94}
95
96impl std::fmt::Display for ProtocolVersion {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
99    }
100}
101
102impl PartialOrd for ProtocolVersion {
103    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
104        Some(self.cmp(other))
105    }
106}
107
108impl Ord for ProtocolVersion {
109    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
110        (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
111    }
112}
113
114// ─────────────────────────────────────────────
115//  FeatureFlag
116// ─────────────────────────────────────────────
117
118/// Optional capabilities that a peer may support.
119///
120/// Each flag occupies a single bit in a `u32` bitmask.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
122pub enum FeatureFlag {
123    Encryption,
124    Compression,
125    VectorSearch,
126    TensorLogic,
127    GradientSync,
128    WebRtc,
129}
130
131impl FeatureFlag {
132    /// All defined feature flags in canonical order.
133    pub const ALL: &'static [Self] = &[
134        Self::Encryption,
135        Self::Compression,
136        Self::VectorSearch,
137        Self::TensorLogic,
138        Self::GradientSync,
139        Self::WebRtc,
140    ];
141
142    /// Returns the bit position (0-based) used to represent this flag in a bitmask.
143    ///
144    /// | Flag          | Bit |
145    /// |---------------|-----|
146    /// | Encryption    |  0  |
147    /// | Compression   |  1  |
148    /// | VectorSearch  |  2  |
149    /// | TensorLogic   |  3  |
150    /// | GradientSync  |  4  |
151    /// | WebRtc        |  5  |
152    pub fn flag_bit(&self) -> u32 {
153        match self {
154            Self::Encryption => 0,
155            Self::Compression => 1,
156            Self::VectorSearch => 2,
157            Self::TensorLogic => 3,
158            Self::GradientSync => 4,
159            Self::WebRtc => 5,
160        }
161    }
162
163    /// Decode a bitmask into the set of active feature flags.
164    ///
165    /// Unknown bits are silently ignored.
166    pub fn from_bits(bits: u32) -> Vec<Self> {
167        Self::ALL
168            .iter()
169            .filter(|flag| (bits >> flag.flag_bit()) & 1 == 1)
170            .copied()
171            .collect()
172    }
173}
174
175// ─────────────────────────────────────────────
176//  HandshakeOffer
177// ─────────────────────────────────────────────
178
179/// 4 MiB — the default maximum frame size used when no override is configured.
180pub const DEFAULT_MAX_FRAME_SIZE: u32 = 4 * 1024 * 1024;
181
182/// The packet a peer sends at the start of a connection to advertise its
183/// capabilities.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct HandshakeOffer {
186    /// Peer identifier of the sender.
187    pub peer_id: String,
188    /// Protocol version spoken by the sender.
189    pub protocol_version: ProtocolVersion,
190    /// Feature flags supported by the sender.
191    pub supported_features: Vec<FeatureFlag>,
192    /// Maximum frame size (in bytes) the sender can handle.  Default: 4 MiB.
193    pub max_frame_size: u32,
194    /// Wall-clock timestamp at offer creation (milliseconds since UNIX epoch).
195    pub timestamp_ms: u64,
196}
197
198impl HandshakeOffer {
199    /// Default maximum frame size (4 MiB).
200    pub const DEFAULT_MAX_FRAME_SIZE: u32 = DEFAULT_MAX_FRAME_SIZE;
201
202    /// Encode `supported_features` as a `u32` bitmask.
203    pub fn feature_bits(&self) -> u32 {
204        self.supported_features
205            .iter()
206            .fold(0u32, |acc, flag| acc | (1 << flag.flag_bit()))
207    }
208
209    /// Validate that the offer is internally consistent.
210    pub(crate) fn validate(&self) -> Result<(), HandshakeError> {
211        if self.peer_id.is_empty() {
212            return Err(HandshakeError::InvalidOffer(
213                "peer_id must not be empty".to_string(),
214            ));
215        }
216        if self.max_frame_size == 0 {
217            return Err(HandshakeError::InvalidOffer(
218                "max_frame_size must be greater than zero".to_string(),
219            ));
220        }
221        Ok(())
222    }
223}
224
225// ─────────────────────────────────────────────
226//  HandshakeResult
227// ─────────────────────────────────────────────
228
229/// The outcome of a successful protocol negotiation.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct HandshakeResult {
232    /// The protocol version both peers agreed to use.
233    pub agreed_version: ProtocolVersion,
234    /// Feature flags supported by *both* peers.
235    pub negotiated_features: Vec<FeatureFlag>,
236    /// Effective frame size cap — the minimum of both peers' advertised limits.
237    pub max_frame_size: u32,
238    /// Peer ID of the local side.
239    pub local_peer_id: String,
240    /// Peer ID of the remote side.
241    pub remote_peer_id: String,
242    /// Wall-clock timestamp when negotiation completed (ms since UNIX epoch).
243    pub negotiated_at_ms: u64,
244}
245
246// ─────────────────────────────────────────────
247//  HandshakeStats
248// ─────────────────────────────────────────────
249
250/// Live atomic counters tracking handshake outcomes.
251#[derive(Debug, Default)]
252pub struct HandshakeStats {
253    /// Number of handshake attempts started (including those in progress).
254    pub total_attempted: AtomicU64,
255    /// Number of handshakes that completed successfully.
256    pub total_succeeded: AtomicU64,
257    /// Number of handshakes that ended in an error.
258    pub total_failed: AtomicU64,
259}
260
261/// Point-in-time copy of [`HandshakeStats`].
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct HandshakeStatsSnapshot {
264    pub total_attempted: u64,
265    pub total_succeeded: u64,
266    pub total_failed: u64,
267}
268
269impl HandshakeStats {
270    /// Create a new zeroed stats instance.
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    /// Take a consistent snapshot of the current counters.
276    pub fn snapshot(&self) -> HandshakeStatsSnapshot {
277        HandshakeStatsSnapshot {
278            total_attempted: self.total_attempted.load(Ordering::Relaxed),
279            total_succeeded: self.total_succeeded.load(Ordering::Relaxed),
280            total_failed: self.total_failed.load(Ordering::Relaxed),
281        }
282    }
283}
284
285// ─────────────────────────────────────────────
286//  ProtocolHandshaker
287// ─────────────────────────────────────────────
288
289/// Drives the protocol negotiation for a single local peer.
290///
291/// A `ProtocolHandshaker` is constructed once per local node and then
292/// called for every incoming or outgoing connection to negotiate shared
293/// parameters with the remote peer.
294///
295/// # Thread safety
296///
297/// The statistics field uses `Arc<HandshakeStats>` so the same stats object
298/// can be shared across tasks/threads without requiring `&mut self`.
299#[derive(Debug)]
300pub struct ProtocolHandshaker {
301    /// The offer that the local node will present to every remote peer.
302    pub local_offer: HandshakeOffer,
303    /// Accumulated handshake statistics.
304    pub stats: Arc<HandshakeStats>,
305}
306
307impl ProtocolHandshaker {
308    /// Create a new handshaker with the given local offer.
309    pub fn new(local_offer: HandshakeOffer) -> Self {
310        Self {
311            local_offer,
312            stats: Arc::new(HandshakeStats::new()),
313        }
314    }
315
316    /// Return the feature flags advertised by the local peer.
317    pub fn local_features(&self) -> Vec<FeatureFlag> {
318        self.local_offer.supported_features.clone()
319    }
320
321    /// Negotiate protocol parameters with a remote peer.
322    ///
323    /// On success, returns a [`HandshakeResult`] describing the agreed
324    /// parameters.  On failure, returns a [`HandshakeError`].
325    ///
326    /// # Steps
327    ///
328    /// 1. Validate the remote offer.
329    /// 2. Check that major versions match.
330    /// 3. Intersect feature-flag bitmasks.
331    /// 4. Agree on the smaller of the two maximum frame sizes.
332    /// 5. Choose the *lower* of the two protocol versions as the agreed
333    ///    version so that the less-advanced peer is never asked to speak a
334    ///    version it cannot fully implement.
335    pub fn negotiate(&self, remote: &HandshakeOffer) -> Result<HandshakeResult, HandshakeError> {
336        self.stats.total_attempted.fetch_add(1, Ordering::Relaxed);
337
338        // Validate local + remote offers.
339        if let Err(e) = self.local_offer.validate() {
340            self.stats.total_failed.fetch_add(1, Ordering::Relaxed);
341            return Err(e);
342        }
343        if let Err(e) = remote.validate() {
344            self.stats.total_failed.fetch_add(1, Ordering::Relaxed);
345            return Err(e);
346        }
347
348        // Version compatibility.
349        let local_ver = &self.local_offer.protocol_version;
350        let remote_ver = &remote.protocol_version;
351        if !local_ver.is_compatible_with(remote_ver) {
352            self.stats.total_failed.fetch_add(1, Ordering::Relaxed);
353            return Err(HandshakeError::IncompatibleVersion {
354                local: local_ver.to_string(),
355                remote: remote_ver.to_string(),
356            });
357        }
358
359        // Feature intersection via bitmask.
360        let local_bits = self.local_offer.feature_bits();
361        let remote_bits = remote.feature_bits();
362        let common_bits = local_bits & remote_bits;
363
364        // At least one shared feature is required for a meaningful session.
365        // (A peer with zero features advertised on both sides is unusual but
366        //  we allow it only when both sides explicitly sent zero features;
367        //  see the `NoCommonFeatures` variant for the non-zero / empty-intersection case.)
368        if local_bits != 0 && remote_bits != 0 && common_bits == 0 {
369            self.stats.total_failed.fetch_add(1, Ordering::Relaxed);
370            return Err(HandshakeError::NoCommonFeatures);
371        }
372
373        let negotiated_features = FeatureFlag::from_bits(common_bits);
374
375        // Agreed version: the lower of the two (most conservative superset).
376        let agreed_version = std::cmp::min(local_ver, remote_ver).clone();
377
378        // Frame size: take the more restrictive limit.
379        let max_frame_size = std::cmp::min(self.local_offer.max_frame_size, remote.max_frame_size);
380
381        // Timestamp: use the current instant.  Since we are in a no-std-async
382        // context we derive it from std::time rather than tokio.
383        let negotiated_at_ms = std::time::SystemTime::now()
384            .duration_since(std::time::UNIX_EPOCH)
385            .map(|d| d.as_millis() as u64)
386            .unwrap_or(0);
387
388        self.stats.total_succeeded.fetch_add(1, Ordering::Relaxed);
389
390        Ok(HandshakeResult {
391            agreed_version,
392            negotiated_features,
393            max_frame_size,
394            local_peer_id: self.local_offer.peer_id.clone(),
395            remote_peer_id: remote.peer_id.clone(),
396            negotiated_at_ms,
397        })
398    }
399}
400
401// ─────────────────────────────────────────────
402//  Tests
403// ─────────────────────────────────────────────
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    // ── ProtocolVersion helpers ────────────────
410
411    fn v(major: u16, minor: u16, patch: u16) -> ProtocolVersion {
412        ProtocolVersion::new(major, minor, patch)
413    }
414
415    fn make_offer(
416        peer_id: &str,
417        version: ProtocolVersion,
418        features: Vec<FeatureFlag>,
419        max_frame_size: u32,
420    ) -> HandshakeOffer {
421        HandshakeOffer {
422            peer_id: peer_id.to_string(),
423            protocol_version: version,
424            supported_features: features,
425            max_frame_size,
426            timestamp_ms: 0,
427        }
428    }
429
430    // ── 1. ProtocolVersion::is_compatible_with ─
431
432    #[test]
433    fn compatible_same_major_same_minor() {
434        let a = v(1, 0, 0);
435        let b = v(1, 0, 0);
436        assert!(a.is_compatible_with(&b));
437    }
438
439    #[test]
440    fn compatible_same_major_different_minor() {
441        let a = v(1, 0, 0);
442        let b = v(1, 5, 3);
443        assert!(a.is_compatible_with(&b));
444    }
445
446    #[test]
447    fn incompatible_different_major() {
448        let a = v(1, 0, 0);
449        let b = v(2, 0, 0);
450        assert!(!a.is_compatible_with(&b));
451    }
452
453    #[test]
454    fn incompatible_major_zero_vs_one() {
455        let a = v(0, 9, 9);
456        let b = v(1, 0, 0);
457        assert!(!a.is_compatible_with(&b));
458    }
459
460    // ── 2. ProtocolVersion ordering ───────────
461
462    #[test]
463    fn version_ordering_major_takes_precedence() {
464        assert!(v(2, 0, 0) > v(1, 99, 99));
465    }
466
467    #[test]
468    fn version_ordering_minor_within_same_major() {
469        assert!(v(1, 3, 0) > v(1, 2, 99));
470    }
471
472    #[test]
473    fn version_ordering_patch_within_same_major_minor() {
474        assert!(v(1, 0, 5) > v(1, 0, 4));
475    }
476
477    #[test]
478    fn version_ordering_equal() {
479        assert_eq!(v(1, 2, 3), v(1, 2, 3));
480        assert!((v(1, 2, 3) <= v(1, 2, 3)));
481    }
482
483    // ── 3. ProtocolVersion Display ────────────
484
485    #[test]
486    fn version_display() {
487        assert_eq!(v(1, 2, 3).to_string(), "1.2.3");
488        assert_eq!(v(0, 0, 0).to_string(), "0.0.0");
489    }
490
491    // ── 4. FeatureFlag::flag_bit ──────────────
492
493    #[test]
494    fn feature_flag_bit_values() {
495        assert_eq!(FeatureFlag::Encryption.flag_bit(), 0);
496        assert_eq!(FeatureFlag::Compression.flag_bit(), 1);
497        assert_eq!(FeatureFlag::VectorSearch.flag_bit(), 2);
498        assert_eq!(FeatureFlag::TensorLogic.flag_bit(), 3);
499        assert_eq!(FeatureFlag::GradientSync.flag_bit(), 4);
500        assert_eq!(FeatureFlag::WebRtc.flag_bit(), 5);
501    }
502
503    // ── 5. FeatureFlag::from_bits roundtrip ───
504
505    #[test]
506    fn feature_flag_from_bits_roundtrip_all() {
507        let all_bits: u32 = FeatureFlag::ALL
508            .iter()
509            .fold(0, |acc, f| acc | (1 << f.flag_bit()));
510        let decoded = FeatureFlag::from_bits(all_bits);
511        // All flags should be present.
512        for flag in FeatureFlag::ALL {
513            assert!(
514                decoded.contains(flag),
515                "{:?} missing from decoded set",
516                flag
517            );
518        }
519    }
520
521    #[test]
522    fn feature_flag_from_bits_subset() {
523        // Only Encryption (bit 0) + VectorSearch (bit 2) = 0b0000_0101 = 5
524        let bits: u32 = (1 << 0) | (1 << 2);
525        let flags = FeatureFlag::from_bits(bits);
526        assert_eq!(flags.len(), 2);
527        assert!(flags.contains(&FeatureFlag::Encryption));
528        assert!(flags.contains(&FeatureFlag::VectorSearch));
529    }
530
531    #[test]
532    fn feature_flag_from_bits_zero() {
533        let flags = FeatureFlag::from_bits(0);
534        assert!(flags.is_empty());
535    }
536
537    // ── 6. HandshakeOffer::feature_bits ───────
538
539    #[test]
540    fn offer_feature_bits_encoding() {
541        let offer = make_offer(
542            "peer",
543            v(1, 0, 0),
544            vec![FeatureFlag::Encryption, FeatureFlag::GradientSync],
545            DEFAULT_MAX_FRAME_SIZE,
546        );
547        // Encryption = bit 0, GradientSync = bit 4  →  0b0001_0001 = 17
548        assert_eq!(offer.feature_bits(), (1 << 0) | (1 << 4));
549    }
550
551    #[test]
552    fn offer_feature_bits_empty() {
553        let offer = make_offer("peer", v(1, 0, 0), vec![], DEFAULT_MAX_FRAME_SIZE);
554        assert_eq!(offer.feature_bits(), 0);
555    }
556
557    // ── 7. negotiate succeeds with compatible versions ─
558
559    #[test]
560    fn negotiate_success_compatible_versions() {
561        let local = make_offer(
562            "local",
563            v(1, 0, 0),
564            vec![FeatureFlag::Encryption, FeatureFlag::Compression],
565            DEFAULT_MAX_FRAME_SIZE,
566        );
567        let handshaker = ProtocolHandshaker::new(local);
568        let remote = make_offer(
569            "remote",
570            v(1, 2, 0),
571            vec![FeatureFlag::Encryption, FeatureFlag::VectorSearch],
572            DEFAULT_MAX_FRAME_SIZE,
573        );
574        let result = handshaker
575            .negotiate(&remote)
576            .expect("negotiate should succeed");
577        assert_eq!(result.local_peer_id, "local");
578        assert_eq!(result.remote_peer_id, "remote");
579        // agreed version = min(1.0.0, 1.2.0) = 1.0.0
580        assert_eq!(result.agreed_version, v(1, 0, 0));
581    }
582
583    // ── 8. negotiate fails with incompatible major versions ─
584
585    #[test]
586    fn negotiate_fails_incompatible_major() {
587        let local = make_offer(
588            "local",
589            v(1, 0, 0),
590            vec![FeatureFlag::Encryption],
591            DEFAULT_MAX_FRAME_SIZE,
592        );
593        let handshaker = ProtocolHandshaker::new(local);
594        let remote = make_offer(
595            "remote",
596            v(2, 0, 0),
597            vec![FeatureFlag::Encryption],
598            DEFAULT_MAX_FRAME_SIZE,
599        );
600        match handshaker.negotiate(&remote) {
601            Err(HandshakeError::IncompatibleVersion { local, remote }) => {
602                assert_eq!(local, "1.0.0");
603                assert_eq!(remote, "2.0.0");
604            }
605            other => panic!("expected IncompatibleVersion, got {:?}", other),
606        }
607    }
608
609    // ── 9. Feature intersection correctness ───
610
611    #[test]
612    fn negotiate_feature_intersection() {
613        let local = make_offer(
614            "local",
615            v(1, 0, 0),
616            vec![
617                FeatureFlag::Encryption,
618                FeatureFlag::Compression,
619                FeatureFlag::VectorSearch,
620            ],
621            DEFAULT_MAX_FRAME_SIZE,
622        );
623        let handshaker = ProtocolHandshaker::new(local);
624        let remote = make_offer(
625            "remote",
626            v(1, 0, 0),
627            vec![FeatureFlag::Encryption, FeatureFlag::TensorLogic],
628            DEFAULT_MAX_FRAME_SIZE,
629        );
630        let result = handshaker
631            .negotiate(&remote)
632            .expect("negotiate should succeed");
633        assert_eq!(result.negotiated_features.len(), 1);
634        assert!(result
635            .negotiated_features
636            .contains(&FeatureFlag::Encryption));
637    }
638
639    // ── 10. NoCommonFeatures error ────────────
640
641    #[test]
642    fn negotiate_no_common_features() {
643        let local = make_offer(
644            "local",
645            v(1, 0, 0),
646            vec![FeatureFlag::Compression],
647            DEFAULT_MAX_FRAME_SIZE,
648        );
649        let handshaker = ProtocolHandshaker::new(local);
650        let remote = make_offer(
651            "remote",
652            v(1, 0, 0),
653            vec![FeatureFlag::VectorSearch],
654            DEFAULT_MAX_FRAME_SIZE,
655        );
656        assert!(matches!(
657            handshaker.negotiate(&remote),
658            Err(HandshakeError::NoCommonFeatures)
659        ));
660    }
661
662    // ── 11. max_frame_size takes minimum ──────
663
664    #[test]
665    fn negotiate_frame_size_takes_minimum() {
666        let local = make_offer(
667            "local",
668            v(1, 0, 0),
669            vec![FeatureFlag::Encryption],
670            8 * 1024 * 1024,
671        );
672        let handshaker = ProtocolHandshaker::new(local);
673        let remote = make_offer(
674            "remote",
675            v(1, 0, 0),
676            vec![FeatureFlag::Encryption],
677            2 * 1024 * 1024,
678        );
679        let result = handshaker
680            .negotiate(&remote)
681            .expect("negotiate should succeed");
682        assert_eq!(result.max_frame_size, 2 * 1024 * 1024);
683    }
684
685    #[test]
686    fn negotiate_frame_size_local_smaller() {
687        let local = make_offer("local", v(1, 0, 0), vec![FeatureFlag::Encryption], 1024);
688        let handshaker = ProtocolHandshaker::new(local);
689        let remote = make_offer(
690            "remote",
691            v(1, 0, 0),
692            vec![FeatureFlag::Encryption],
693            4 * 1024 * 1024,
694        );
695        let result = handshaker
696            .negotiate(&remote)
697            .expect("negotiate should succeed");
698        assert_eq!(result.max_frame_size, 1024);
699    }
700
701    // ── 12. Stats accumulation ────────────────
702
703    #[test]
704    fn stats_accumulate_success() {
705        let local = make_offer(
706            "local",
707            v(1, 0, 0),
708            vec![FeatureFlag::Encryption],
709            DEFAULT_MAX_FRAME_SIZE,
710        );
711        let handshaker = ProtocolHandshaker::new(local);
712        let remote = make_offer(
713            "remote",
714            v(1, 0, 0),
715            vec![FeatureFlag::Encryption],
716            DEFAULT_MAX_FRAME_SIZE,
717        );
718
719        handshaker
720            .negotiate(&remote)
721            .expect("negotiate should succeed");
722        handshaker
723            .negotiate(&remote)
724            .expect("negotiate should succeed");
725
726        let snap = handshaker.stats.snapshot();
727        assert_eq!(snap.total_attempted, 2);
728        assert_eq!(snap.total_succeeded, 2);
729        assert_eq!(snap.total_failed, 0);
730    }
731
732    #[test]
733    fn stats_accumulate_failure() {
734        let local = make_offer(
735            "local",
736            v(1, 0, 0),
737            vec![FeatureFlag::Encryption],
738            DEFAULT_MAX_FRAME_SIZE,
739        );
740        let handshaker = ProtocolHandshaker::new(local);
741        // Will fail due to incompatible versions.
742        let remote = make_offer(
743            "remote",
744            v(2, 0, 0),
745            vec![FeatureFlag::Encryption],
746            DEFAULT_MAX_FRAME_SIZE,
747        );
748
749        let _ = handshaker.negotiate(&remote);
750        let _ = handshaker.negotiate(&remote);
751
752        let snap = handshaker.stats.snapshot();
753        assert_eq!(snap.total_attempted, 2);
754        assert_eq!(snap.total_succeeded, 0);
755        assert_eq!(snap.total_failed, 2);
756    }
757
758    // ── 13. InvalidOffer: empty peer_id ───────
759
760    #[test]
761    fn negotiate_invalid_offer_empty_peer_id() {
762        let local = make_offer(
763            "local",
764            v(1, 0, 0),
765            vec![FeatureFlag::Encryption],
766            DEFAULT_MAX_FRAME_SIZE,
767        );
768        let handshaker = ProtocolHandshaker::new(local);
769        let bad_remote = make_offer(
770            "",
771            v(1, 0, 0),
772            vec![FeatureFlag::Encryption],
773            DEFAULT_MAX_FRAME_SIZE,
774        );
775        assert!(matches!(
776            handshaker.negotiate(&bad_remote),
777            Err(HandshakeError::InvalidOffer(_))
778        ));
779    }
780
781    // ── 14. Both sides zero features (no error) ─
782
783    #[test]
784    fn negotiate_both_zero_features_succeeds() {
785        // When both sides send zero feature flags the intersection is also
786        // zero, but `NoCommonFeatures` is only raised when *at least one*
787        // side advertises features. Two bare peers may still negotiate a
788        // frame size and version.
789        let local = make_offer("local", v(1, 0, 0), vec![], DEFAULT_MAX_FRAME_SIZE);
790        let handshaker = ProtocolHandshaker::new(local);
791        let remote = make_offer("remote", v(1, 0, 0), vec![], DEFAULT_MAX_FRAME_SIZE);
792        let result = handshaker
793            .negotiate(&remote)
794            .expect("negotiate should succeed");
795        assert!(result.negotiated_features.is_empty());
796    }
797
798    // ── 15. local_features helper ─────────────
799
800    #[test]
801    fn local_features_returns_offer_features() {
802        let local = make_offer(
803            "local",
804            v(1, 0, 0),
805            vec![FeatureFlag::Encryption, FeatureFlag::WebRtc],
806            DEFAULT_MAX_FRAME_SIZE,
807        );
808        let handshaker = ProtocolHandshaker::new(local);
809        let features = handshaker.local_features();
810        assert_eq!(features.len(), 2);
811        assert!(features.contains(&FeatureFlag::Encryption));
812        assert!(features.contains(&FeatureFlag::WebRtc));
813    }
814
815    // ── 16. agreed_version is the lower one ───
816
817    #[test]
818    fn negotiate_agreed_version_is_lower() {
819        // local is 1.3.0, remote is 1.1.0 → agreed should be 1.1.0
820        let local = make_offer(
821            "local",
822            v(1, 3, 0),
823            vec![FeatureFlag::Encryption],
824            DEFAULT_MAX_FRAME_SIZE,
825        );
826        let handshaker = ProtocolHandshaker::new(local);
827        let remote = make_offer(
828            "remote",
829            v(1, 1, 0),
830            vec![FeatureFlag::Encryption],
831            DEFAULT_MAX_FRAME_SIZE,
832        );
833        let result = handshaker
834            .negotiate(&remote)
835            .expect("negotiate should succeed");
836        assert_eq!(result.agreed_version, v(1, 1, 0));
837    }
838}