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}