Skip to main content

ant_protocol/
version_gate.rs

1// Copyright 2025 MaidSafe.net limited.
2//
3// This SAFE Network Software is licensed to you under The General Public License (GPL), version 3.
4// Unless required by applicable law or agreed to in writing, the SAFE Network Software distributed
5// under the GPL Licence is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
6// KIND, either express or implied. Please review the Licences for the specific language governing
7// permissions and limitations relating to use of the SAFE Network Software.
8
9//! Version gating module for peer version validation.
10//!
11//! This module provides functionality to parse and compare peer versions from
12//! libp2p identify agent strings, enabling the network to enforce minimum
13//! version requirements for connecting peers.
14//!
15//! # Agent String Format
16//!
17//! The expected agent string format is:
18//! - Nodes: `ant/node/{protocol_version}/{node_version}/{network_id}`
19//! - Clients: `ant/client/{protocol_version}/{client_version}/{network_id}`
20//! - Reachability check peers: `ant/reachability-check-peer/{protocol_version}/{version}/{network_id}`
21//!
22//! Example: `ant/node/1.0/0.4.13/1`
23//!
24//! # Phase 2 Enforcement
25//!
26//! - **Nodes**: Version is enforced - peers below minimum or without version are rejected
27//! - **Clients**: Version is NOT enforced - always allowed (metrics only)
28//! - **Legacy peers**: Rejected (no grace period)
29
30use std::fmt;
31
32/// Minimum required node version for connecting to the network.
33///
34/// Nodes running versions below this will be disconnected (not blocklisted).
35/// This can be overridden via the `ANT_MIN_NODE_VERSION` environment variable.
36///
37/// Format: (major, minor, patch)
38pub const MIN_NODE_VERSION: (u16, u16, u16) = (0, 4, 15);
39
40/// Get the minimum node version, checking environment variable override first.
41///
42/// Environment variable format: `ANT_MIN_NODE_VERSION=0.4.14`
43pub fn get_min_node_version() -> PeerVersion {
44    if let Ok(env_version) = std::env::var("ANT_MIN_NODE_VERSION")
45        && let Some(version) = PeerVersion::parse_semver(&env_version)
46    {
47        return version;
48    }
49    PeerVersion::new(MIN_NODE_VERSION.0, MIN_NODE_VERSION.1, MIN_NODE_VERSION.2)
50}
51
52/// Represents a parsed semantic version from a peer's agent string.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct PeerVersion {
55    /// Major version number
56    pub major: u16,
57    /// Minor version number
58    pub minor: u16,
59    /// Patch version number
60    pub patch: u16,
61}
62
63impl PeerVersion {
64    /// Creates a new PeerVersion.
65    pub fn new(major: u16, minor: u16, patch: u16) -> Self {
66        Self {
67            major,
68            minor,
69            patch,
70        }
71    }
72
73    /// Parse version from an agent string.
74    ///
75    /// Expected formats:
76    /// - `ant/node/{protocol_version}/{node_version}/{network_id}`
77    /// - `ant/client/{protocol_version}/{client_version}/{network_id}`
78    /// - `ant/reachability-check-peer/{protocol_version}/{version}/{network_id}`
79    ///
80    /// Returns `None` if the agent string doesn't match the expected format
81    /// or if the version cannot be parsed.
82    pub fn parse_from_agent_string(agent: &str) -> Option<Self> {
83        let parts: Vec<&str> = agent.split('/').collect();
84
85        // Expected: ["ant", "node"|"client", protocol_version, package_version, network_id]
86        if parts.len() < 5 {
87            return None;
88        }
89
90        // Verify it's an ant agent
91        if parts[0] != "ant" {
92            return None;
93        }
94
95        // parts[3] is the package version (e.g., "0.4.13")
96        Self::parse_semver(parts[3])
97    }
98
99    /// Parse a semver string like "0.4.13" or "0.4.13-alpha.1"
100    pub fn parse_semver(version_str: &str) -> Option<Self> {
101        // Strip any pre-release suffix (e.g., "-alpha.1")
102        let version_core = version_str.split('-').next()?;
103
104        let parts: Vec<&str> = version_core.split('.').collect();
105        if parts.len() < 3 {
106            return None;
107        }
108
109        let major = parts[0].parse::<u16>().ok()?;
110        let minor = parts[1].parse::<u16>().ok()?;
111        let patch = parts[2].parse::<u16>().ok()?;
112
113        Some(Self {
114            major,
115            minor,
116            patch,
117        })
118    }
119
120    /// Check if this version meets the minimum requirement.
121    ///
122    /// Returns `true` if `self >= min_version`.
123    pub fn meets_minimum(&self, min_version: &PeerVersion) -> bool {
124        (self.major, self.minor, self.patch)
125            >= (min_version.major, min_version.minor, min_version.patch)
126    }
127}
128
129impl fmt::Display for PeerVersion {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
132    }
133}
134
135/// The result of checking a peer's version.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum VersionCheckResult {
138    /// Version meets minimum requirements.
139    Accepted {
140        /// The detected version
141        version: PeerVersion,
142    },
143    /// Version is below minimum (with detected version).
144    Rejected {
145        /// The detected version that was rejected
146        detected: PeerVersion,
147        /// The minimum required version
148        minimum: PeerVersion,
149    },
150    /// No version detected - legacy peer without version in agent string.
151    Legacy,
152    /// Could not parse version string.
153    ParseError {
154        /// The agent string that failed to parse
155        agent_string: String,
156    },
157}
158
159impl VersionCheckResult {
160    /// Returns `true` if the version check passed (either accepted or legacy during grace period).
161    pub fn is_allowed(&self, allow_legacy: bool) -> bool {
162        match self {
163            VersionCheckResult::Accepted { .. } => true,
164            VersionCheckResult::Legacy => allow_legacy,
165            VersionCheckResult::Rejected { .. } | VersionCheckResult::ParseError { .. } => false,
166        }
167    }
168}
169
170/// Identifies the type of peer from the agent string.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
172pub enum PeerType {
173    /// A network node
174    Node,
175    /// A client
176    Client,
177    /// A reachability check client (used for NAT traversal checks)
178    ReachabilityCheckClient,
179    /// Unknown peer type
180    Unknown,
181}
182
183impl PeerType {
184    /// Parse peer type from agent string.
185    pub fn from_agent_string(agent: &str) -> Self {
186        if agent.contains("/node/") {
187            PeerType::Node
188        } else if agent.contains("reachability-check-peer") {
189            PeerType::ReachabilityCheckClient
190        } else if agent.contains("/client/") || agent.contains("client") {
191            PeerType::Client
192        } else {
193            PeerType::Unknown
194        }
195    }
196}
197
198impl fmt::Display for PeerType {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        // Use UpperCamelCase for metric tag values
201        match self {
202            PeerType::Node => write!(f, "Node"),
203            PeerType::Client => write!(f, "Client"),
204            PeerType::ReachabilityCheckClient => write!(f, "ReachabilityCheckClient"),
205            PeerType::Unknown => write!(f, "Unknown"),
206        }
207    }
208}
209
210/// Check a peer's version against the minimum requirement.
211///
212/// # Arguments
213/// * `agent_string` - The peer's agent version string from libp2p identify
214/// * `min_version` - The minimum required version (if `None`, all versions are accepted)
215///
216/// # Returns
217/// A `VersionCheckResult` indicating whether the peer should be allowed to connect.
218pub fn check_peer_version(
219    agent_string: &str,
220    min_version: Option<&PeerVersion>,
221) -> VersionCheckResult {
222    // If no minimum version is set, accept all peers
223    let Some(min_version) = min_version else {
224        // Try to parse the version for metrics even if we're not enforcing
225        if let Some(version) = PeerVersion::parse_from_agent_string(agent_string) {
226            return VersionCheckResult::Accepted { version };
227        }
228        return VersionCheckResult::Legacy;
229    };
230
231    // Try to parse the version from the agent string
232    match PeerVersion::parse_from_agent_string(agent_string) {
233        Some(version) => {
234            if version.meets_minimum(min_version) {
235                VersionCheckResult::Accepted { version }
236            } else {
237                VersionCheckResult::Rejected {
238                    detected: version,
239                    minimum: *min_version,
240                }
241            }
242        }
243        None => {
244            // Check if this looks like a legacy agent string (starts with "ant/")
245            if agent_string.starts_with("ant/") {
246                // It's an ant peer but we couldn't parse the version
247                // This could be a legacy node or a malformed agent string
248                VersionCheckResult::Legacy
249            } else {
250                VersionCheckResult::ParseError {
251                    agent_string: agent_string.to_string(),
252                }
253            }
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_parse_node_version() {
264        let agent = "ant/node/1.0/0.4.13/1";
265        let version = PeerVersion::parse_from_agent_string(agent).unwrap();
266        assert_eq!(version, PeerVersion::new(0, 4, 13));
267    }
268
269    #[test]
270    fn test_parse_client_version() {
271        let agent = "ant/client/1.0/0.9.0/1";
272        let version = PeerVersion::parse_from_agent_string(agent).unwrap();
273        assert_eq!(version, PeerVersion::new(0, 9, 0));
274    }
275
276    #[test]
277    fn test_parse_version_with_prerelease() {
278        let version = PeerVersion::parse_semver("0.4.13-alpha.1").unwrap();
279        assert_eq!(version, PeerVersion::new(0, 4, 13));
280    }
281
282    #[test]
283    fn test_parse_legacy_agent() {
284        // Old format without version
285        let agent = "ant/1.0/1";
286        assert!(PeerVersion::parse_from_agent_string(agent).is_none());
287    }
288
289    #[test]
290    fn test_parse_invalid_agent() {
291        let agent = "some-other-agent/1.0.0";
292        assert!(PeerVersion::parse_from_agent_string(agent).is_none());
293    }
294
295    #[test]
296    fn test_version_comparison() {
297        let v1 = PeerVersion::new(0, 4, 10);
298        let v2 = PeerVersion::new(0, 4, 13);
299        let v3 = PeerVersion::new(0, 5, 0);
300        let v4 = PeerVersion::new(1, 0, 0);
301
302        assert!(v2.meets_minimum(&v1)); // 0.4.13 >= 0.4.10
303        assert!(!v1.meets_minimum(&v2)); // 0.4.10 < 0.4.13
304        assert!(v3.meets_minimum(&v2)); // 0.5.0 >= 0.4.13
305        assert!(v4.meets_minimum(&v3)); // 1.0.0 >= 0.5.0
306    }
307
308    #[test]
309    fn test_version_display() {
310        let version = PeerVersion::new(0, 4, 13);
311        assert_eq!(version.to_string(), "0.4.13");
312    }
313
314    #[test]
315    fn test_check_peer_version_accepted() {
316        let agent = "ant/node/1.0/0.4.13/1";
317        let min = PeerVersion::new(0, 4, 10);
318        let result = check_peer_version(agent, Some(&min));
319        assert!(
320            matches!(result, VersionCheckResult::Accepted { version } if version == PeerVersion::new(0, 4, 13))
321        );
322    }
323
324    #[test]
325    fn test_check_peer_version_rejected() {
326        let agent = "ant/node/1.0/0.4.9/1";
327        let min = PeerVersion::new(0, 4, 10);
328        let result = check_peer_version(agent, Some(&min));
329        assert!(
330            matches!(result, VersionCheckResult::Rejected { detected, minimum }
331            if detected == PeerVersion::new(0, 4, 9) && minimum == PeerVersion::new(0, 4, 10))
332        );
333    }
334
335    #[test]
336    fn test_check_peer_version_legacy() {
337        let agent = "ant/1.0/1"; // Old format
338        let min = PeerVersion::new(0, 4, 10);
339        let result = check_peer_version(agent, Some(&min));
340        assert!(matches!(result, VersionCheckResult::Legacy));
341    }
342
343    #[test]
344    fn test_check_peer_version_no_minimum() {
345        let agent = "ant/node/1.0/0.4.13/1";
346        let result = check_peer_version(agent, None);
347        assert!(matches!(result, VersionCheckResult::Accepted { .. }));
348    }
349
350    #[test]
351    fn test_peer_type_detection() {
352        assert_eq!(
353            PeerType::from_agent_string("ant/node/1.0/0.4.13/1"),
354            PeerType::Node
355        );
356        assert_eq!(
357            PeerType::from_agent_string("ant/client/1.0/0.9.0/1"),
358            PeerType::Client
359        );
360        assert_eq!(
361            PeerType::from_agent_string("ant/reachability-check-peer/1.0/0.1.0/1"),
362            PeerType::ReachabilityCheckClient
363        );
364        assert_eq!(
365            PeerType::from_agent_string("unknown-agent"),
366            PeerType::Unknown
367        );
368    }
369
370    #[test]
371    fn test_is_allowed() {
372        let accepted = VersionCheckResult::Accepted {
373            version: PeerVersion::new(0, 4, 13),
374        };
375        assert!(accepted.is_allowed(true));
376        assert!(accepted.is_allowed(false));
377
378        let legacy = VersionCheckResult::Legacy;
379        assert!(legacy.is_allowed(true));
380        assert!(!legacy.is_allowed(false));
381
382        let rejected = VersionCheckResult::Rejected {
383            detected: PeerVersion::new(0, 4, 9),
384            minimum: PeerVersion::new(0, 4, 10),
385        };
386        assert!(!rejected.is_allowed(true));
387        assert!(!rejected.is_allowed(false));
388    }
389
390    #[test]
391    fn test_get_min_node_version() {
392        // Without env var, should return the constant
393        let min_version = get_min_node_version();
394        assert_eq!(
395            min_version,
396            PeerVersion::new(MIN_NODE_VERSION.0, MIN_NODE_VERSION.1, MIN_NODE_VERSION.2)
397        );
398    }
399
400    #[test]
401    fn test_min_version_constant() {
402        // Verify the constant is set correctly
403        let (major, minor, patch) = MIN_NODE_VERSION;
404        assert_eq!(major, 0);
405        assert_eq!(minor, 4);
406        assert_eq!(patch, 15);
407    }
408
409    // ============ Release Candidate (RC) Version Tests ============
410
411    #[test]
412    fn test_parse_rc_version_semver() {
413        // RC versions should be parsed, stripping the -rc.X suffix
414        let version = PeerVersion::parse_semver("0.4.14-rc.1").unwrap();
415        assert_eq!(version, PeerVersion::new(0, 4, 14));
416
417        let version = PeerVersion::parse_semver("0.4.14-rc.2").unwrap();
418        assert_eq!(version, PeerVersion::new(0, 4, 14));
419
420        let version = PeerVersion::parse_semver("1.0.0-rc.1").unwrap();
421        assert_eq!(version, PeerVersion::new(1, 0, 0));
422    }
423
424    #[test]
425    fn test_parse_rc_version_from_agent_string() {
426        // RC versions in agent strings should be parsed correctly
427        let agent = "ant/node/1.0/0.4.14-rc.1/1";
428        let version = PeerVersion::parse_from_agent_string(agent).unwrap();
429        assert_eq!(version, PeerVersion::new(0, 4, 14));
430
431        let agent = "ant/node/1.0/0.5.0-rc.3/1";
432        let version = PeerVersion::parse_from_agent_string(agent).unwrap();
433        assert_eq!(version, PeerVersion::new(0, 5, 0));
434    }
435
436    #[test]
437    fn test_rc_version_comparison() {
438        // RC versions should compare based on their numeric part only
439        let min_version = PeerVersion::new(0, 4, 10);
440
441        // 0.4.14-rc.1 -> 0.4.14 >= 0.4.10 (should pass)
442        let rc_version = PeerVersion::parse_semver("0.4.14-rc.1").unwrap();
443        assert!(rc_version.meets_minimum(&min_version));
444
445        // 0.4.9-rc.1 -> 0.4.9 < 0.4.10 (should fail)
446        let old_rc_version = PeerVersion::parse_semver("0.4.9-rc.1").unwrap();
447        assert!(!old_rc_version.meets_minimum(&min_version));
448
449        // 0.4.10-rc.1 -> 0.4.10 >= 0.4.10 (should pass - equal)
450        let exact_rc_version = PeerVersion::parse_semver("0.4.10-rc.1").unwrap();
451        assert!(exact_rc_version.meets_minimum(&min_version));
452    }
453
454    #[test]
455    fn test_check_peer_version_with_rc_accepted() {
456        // Peer running RC version above minimum should be accepted
457        let agent = "ant/node/1.0/0.4.14-rc.1/1";
458        let min = PeerVersion::new(0, 4, 10);
459        let result = check_peer_version(agent, Some(&min));
460
461        // Should be accepted with version 0.4.14 (stripped of -rc.1)
462        assert!(
463            matches!(result, VersionCheckResult::Accepted { version } if version == PeerVersion::new(0, 4, 14))
464        );
465    }
466
467    #[test]
468    fn test_check_peer_version_with_rc_rejected() {
469        // Peer running RC version below minimum should be rejected
470        let agent = "ant/node/1.0/0.4.9-rc.2/1";
471        let min = PeerVersion::new(0, 4, 10);
472        let result = check_peer_version(agent, Some(&min));
473
474        // Should be rejected with detected version 0.4.9
475        assert!(
476            matches!(result, VersionCheckResult::Rejected { detected, minimum }
477                if detected == PeerVersion::new(0, 4, 9) && minimum == PeerVersion::new(0, 4, 10))
478        );
479    }
480
481    #[test]
482    fn test_various_prerelease_formats() {
483        // Test various pre-release version formats
484        assert_eq!(
485            PeerVersion::parse_semver("0.4.14-rc.1").unwrap(),
486            PeerVersion::new(0, 4, 14)
487        );
488        assert_eq!(
489            PeerVersion::parse_semver("0.4.14-alpha.1").unwrap(),
490            PeerVersion::new(0, 4, 14)
491        );
492        assert_eq!(
493            PeerVersion::parse_semver("0.4.14-beta.2").unwrap(),
494            PeerVersion::new(0, 4, 14)
495        );
496        assert_eq!(
497            PeerVersion::parse_semver("0.4.14-dev").unwrap(),
498            PeerVersion::new(0, 4, 14)
499        );
500        assert_eq!(
501            PeerVersion::parse_semver("0.4.14-SNAPSHOT").unwrap(),
502            PeerVersion::new(0, 4, 14)
503        );
504        // Multiple hyphens - only first part before hyphen is used
505        assert_eq!(
506            PeerVersion::parse_semver("0.4.14-rc.1-build.123").unwrap(),
507            PeerVersion::new(0, 4, 14)
508        );
509    }
510}