Skip to main content

sui_protocol/
version.rs

1//! Version-negotiation handshake.
2//!
3//! Every connection opens with both sides exchanging a
4//! [`VersionHandshake`] — their max-supported version + their min-
5//! supported version (the floor of their backwards-compat window).
6//! Each side then takes `min(my_max, their_max)` as the negotiated
7//! version. If that's below either side's `min`, the connection is
8//! refused with a typed error.
9//!
10//! Discipline: every breaking change to a wire type bumps
11//! [`MAX_LOCAL_PROTOCOL_VERSION`] and (if it's been long enough since
12//! the last bump) [`MIN_LOCAL_PROTOCOL_VERSION`]. The window in
13//! between is the support contract.
14
15use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
16use rkyv::rancor;
17
18/// Maximum local-protocol version this build can speak.
19///
20/// Bump this when a breaking change lands. Bumping past
21/// [`MIN_LOCAL_PROTOCOL_VERSION`] sunsets older daemons; do that on a
22/// known cadence (quarterly is the current target).
23pub const MAX_LOCAL_PROTOCOL_VERSION: u16 = 1;
24
25/// Minimum local-protocol version this build understands. Older peers
26/// negotiating below this floor are refused.
27///
28/// Keep within the support window of `MAX_LOCAL_PROTOCOL_VERSION` —
29/// initially they're equal (v1 only); bump `MIN` only when an older
30/// version is genuinely sunset.
31pub const MIN_LOCAL_PROTOCOL_VERSION: u16 = 1;
32
33/// First-frame body each side sends. Carries the peer's supported
34/// version window so the other side can compute the negotiated
35/// version (or refuse).
36#[derive(
37    Archive,
38    RkyvSerialize,
39    RkyvDeserialize,
40    Debug,
41    Clone,
42    Copy,
43    PartialEq,
44    Eq,
45)]
46#[rkyv(derive(Debug))]
47pub struct VersionHandshake {
48    /// Max protocol version this peer can speak.
49    pub max_version: u16,
50    /// Min protocol version this peer understands.
51    pub min_version: u16,
52    /// Stable build identity — operators read this in `sui-daemon
53    /// status` output. Free-form; never load-bearing for negotiation.
54    pub build_id: [u8; 32],
55}
56
57/// Outcome of a successful negotiation.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct NegotiatedVersion {
60    pub version: u16,
61}
62
63impl VersionHandshake {
64    /// Construct the local side of the handshake using this build's
65    /// compile-time version window.
66    #[must_use]
67    pub fn local(build_id: [u8; 32]) -> Self {
68        Self {
69            max_version: MAX_LOCAL_PROTOCOL_VERSION,
70            min_version: MIN_LOCAL_PROTOCOL_VERSION,
71            build_id,
72        }
73    }
74
75    /// Compute the negotiated version against a peer's handshake.
76    ///
77    /// # Errors
78    ///
79    /// Returns `None` when the two windows don't overlap (peer too
80    /// old to talk to us, or we're too old to talk to them).
81    #[must_use]
82    pub fn negotiate(&self, peer: &VersionHandshake) -> Option<NegotiatedVersion> {
83        let candidate = self.max_version.min(peer.max_version);
84        if candidate >= self.min_version && candidate >= peer.min_version {
85            Some(NegotiatedVersion { version: candidate })
86        } else {
87            None
88        }
89    }
90
91    /// Validate-and-cast helper for callers that just received the
92    /// archived handshake from the wire. Wraps the rkyv access
93    /// machinery so call sites don't reinvent it.
94    ///
95    /// # Errors
96    ///
97    /// Propagates the rkyv validation error verbatim — typically a
98    /// length/alignment/tag mismatch means the peer isn't speaking
99    /// our protocol at all.
100    pub fn access(bytes: &[u8]) -> Result<&ArchivedVersionHandshake, rancor::Error> {
101        rkyv::access::<ArchivedVersionHandshake, rancor::Error>(bytes)
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    fn build(id: u8) -> [u8; 32] {
110        let mut a = [0u8; 32];
111        a[0] = id;
112        a
113    }
114
115    #[test]
116    fn equal_windows_pick_the_max() {
117        let a = VersionHandshake::local(build(1));
118        let b = VersionHandshake::local(build(2));
119        let neg = a.negotiate(&b).unwrap();
120        assert_eq!(neg.version, MAX_LOCAL_PROTOCOL_VERSION);
121    }
122
123    #[test]
124    fn overlapping_window_picks_the_intersection_max() {
125        let a = VersionHandshake {
126            max_version: 5,
127            min_version: 3,
128            build_id: build(1),
129        };
130        let b = VersionHandshake {
131            max_version: 4,
132            min_version: 2,
133            build_id: build(2),
134        };
135        let neg = a.negotiate(&b).unwrap();
136        assert_eq!(neg.version, 4);
137    }
138
139    #[test]
140    fn disjoint_windows_refuse() {
141        let old = VersionHandshake {
142            max_version: 2,
143            min_version: 1,
144            build_id: build(1),
145        };
146        let modern = VersionHandshake {
147            max_version: 5,
148            min_version: 4,
149            build_id: build(2),
150        };
151        assert!(old.negotiate(&modern).is_none());
152        assert!(modern.negotiate(&old).is_none());
153    }
154
155    #[test]
156    fn handshake_roundtrips_via_rkyv() {
157        let h = VersionHandshake::local(build(7));
158        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&h).unwrap();
159        let arc = VersionHandshake::access(&bytes).unwrap();
160        assert_eq!(arc.max_version, h.max_version);
161        assert_eq!(arc.min_version, h.min_version);
162        assert_eq!(arc.build_id, h.build_id);
163    }
164}