Skip to main content

tsoracle_core/
peer.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//  https://www.tsoracle.rs
8//
9//  Copyright (c) 2026 Prisma Risk
10//
11//  Licensed under the Apache License, Version 2.0 (the "License");
12//  you may not use this file except in compliance with the License.
13//  You may obtain a copy of the License at
14//
15//      https://www.apache.org/licenses/LICENSE-2.0
16//
17//  Unless required by applicable law or agreed to in writing, software
18//  distributed under the License is distributed on an "AS IS" BASIS,
19//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20//  See the License for the specific language governing permissions and
21//  limitations under the License.
22//
23
24//! A consensus peer and the tsoracle service endpoint it advertises.
25
26use core::fmt;
27use core::str::FromStr;
28
29/// Error returned when a string fails the [`PeerEndpoint`] contract.
30#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
31pub enum PeerEndpointError {
32    /// The endpoint was an empty string. `PeerEndpoint` carries no "absent"
33    /// semantics; callers that need optionality wrap it in `Option`.
34    #[error("peer endpoint must not be empty")]
35    Empty,
36    /// The endpoint begins with `http://` or `https://`. The TLS client
37    /// silently drops scheme-bearing leader hints to refuse a transport
38    /// downgrade, so a scheme-bearing endpoint makes its owner unreachable
39    /// via redirect.
40    #[error("peer endpoint {endpoint:?} must be scheme-less host:port, not a http(s):// URL")]
41    SchemeBearing { endpoint: String },
42    /// The endpoint has no `:port` suffix. A bare host is technically a valid
43    /// authority but is never an intentional production value — the consensus
44    /// drivers always advertise an explicit port.
45    #[error("peer endpoint {endpoint:?} must include an explicit :port")]
46    MissingPort { endpoint: String },
47    /// The host portion before `:port` is empty (e.g. `":50051"`).
48    #[error("peer endpoint {endpoint:?} has an empty host")]
49    EmptyHost { endpoint: String },
50    /// The port portion is empty, non-numeric, or outside `1..=65535`.
51    #[error("peer endpoint {endpoint:?} has an invalid port: {reason}")]
52    InvalidPort {
53        endpoint: String,
54        reason: &'static str,
55    },
56}
57
58/// A scheme-less `host:port` advertising the tsoracle service address of a
59/// peer node.
60///
61/// **Contract (enforced at construction):**
62///
63/// * No `http://` / `https://` prefix. The TLS client silently drops
64///   scheme-bearing leader hints (`rejects_plaintext_hint`) to refuse a
65///   transport downgrade, so a scheme-bearing endpoint would make its owner
66///   unreachable via follower redirect — a silent prod trap. (See
67///   `tsoracle_server::fence::endpoint_is_scheme_less` for the historical
68///   runtime guard this newtype subsumes.)
69/// * Non-empty.
70/// * Includes an explicit `:port` with a numeric port in `1..=65535`.
71/// * Bracketed IPv6 supported (`[::1]:50051`).
72///
73/// Consumers that need to dial this endpoint prepend `http://` / `https://`
74/// at dial time based on their TLS configuration (see e.g.
75/// `tsoracle_client::channel_pool`); the endpoint itself is transport-agnostic.
76#[derive(Clone, Debug, PartialEq, Eq, Hash)]
77pub struct PeerEndpoint(String);
78
79impl PeerEndpoint {
80    /// Borrow the underlying `host:port` string.
81    pub fn as_str(&self) -> &str {
82        &self.0
83    }
84
85    /// Consume self, returning the underlying `String`.
86    pub fn into_inner(self) -> String {
87        self.0
88    }
89}
90
91impl TryFrom<String> for PeerEndpoint {
92    type Error = PeerEndpointError;
93
94    fn try_from(endpoint: String) -> Result<Self, Self::Error> {
95        if endpoint.is_empty() {
96            return Err(PeerEndpointError::Empty);
97        }
98        // ASCII-lowercase only, matching the client's `normalize_uri`. An
99        // uppercase variant would already fail to parse downstream.
100        if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
101            return Err(PeerEndpointError::SchemeBearing { endpoint });
102        }
103        // `rsplit_once(':')` splits on the LAST colon, which is the port
104        // separator for both `host:port` and the bracketed-IPv6 form
105        // `[::1]:50051` (host=`[::1]`, port=`50051`). `split_once(':')` would
106        // break IPv6 by splitting at the first colon inside the address.
107        let (host, port_str) = match endpoint.rsplit_once(':') {
108            Some(pair) => pair,
109            None => return Err(PeerEndpointError::MissingPort { endpoint }),
110        };
111        if host.is_empty() {
112            return Err(PeerEndpointError::EmptyHost { endpoint });
113        }
114        // `u16::from_str` rejects empty, non-numeric, and `> 65535`. Port 0
115        // parses cleanly but is reserved per RFC 6335; reject it explicitly.
116        let port = port_str
117            .parse::<u16>()
118            .map_err(|_| PeerEndpointError::InvalidPort {
119                endpoint: endpoint.clone(),
120                reason: "must be a base-10 number in 1..=65535",
121            })?;
122        if port == 0 {
123            return Err(PeerEndpointError::InvalidPort {
124                endpoint,
125                reason: "port 0 is reserved",
126            });
127        }
128        Ok(PeerEndpoint(endpoint))
129    }
130}
131
132impl TryFrom<&str> for PeerEndpoint {
133    type Error = PeerEndpointError;
134    fn try_from(s: &str) -> Result<Self, Self::Error> {
135        Self::try_from(s.to_string())
136    }
137}
138
139impl FromStr for PeerEndpoint {
140    type Err = PeerEndpointError;
141    fn from_str(s: &str) -> Result<Self, Self::Err> {
142        Self::try_from(s.to_string())
143    }
144}
145
146impl fmt::Display for PeerEndpoint {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        f.write_str(&self.0)
149    }
150}
151
152impl AsRef<str> for PeerEndpoint {
153    fn as_ref(&self) -> &str {
154        &self.0
155    }
156}
157
158#[cfg(feature = "serde")]
159impl serde::Serialize for PeerEndpoint {
160    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
161        serializer.serialize_str(&self.0)
162    }
163}
164
165#[cfg(feature = "serde")]
166impl<'de> serde::Deserialize<'de> for PeerEndpoint {
167    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
168        // Re-validate on the way in: a wire-supplied or persisted endpoint
169        // that violates the contract is rejected here, not silently observed
170        // as a runtime trap downstream.
171        let s = String::deserialize(deserializer)?;
172        Self::try_from(s).map_err(serde::de::Error::custom)
173    }
174}
175
176/// A known peer node and the network endpoint where its TSO service can be
177/// reached for follower-redirect hints.
178///
179/// `node_id` is the consensus node identity (the OmniPaxos `NodeId`/pid or the
180/// openraft `NodeId`). `endpoint` is the advertised tsoracle service address
181/// surfaced via `LeaderState::Follower::leader_endpoint` when this peer is the
182/// elected leader. This is the cross-backend shape consensus drivers share so
183/// the `leader_endpoint` derivation reads the same regardless of the engine
184/// underneath.
185#[derive(Clone, Debug, PartialEq, Eq, Hash)]
186#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
187pub struct TsoPeer {
188    pub node_id: u64,
189    pub endpoint: PeerEndpoint,
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    // ---- TsoPeer struct shape (preserved from pre-newtype tests; updated
197    // ---- to use contract-conforming endpoints instead of the http://...
198    // ---- forms that were latent contract violations).
199
200    #[test]
201    fn carries_node_id_and_endpoint() {
202        let peer = TsoPeer {
203            node_id: 2,
204            endpoint: PeerEndpoint::try_from("node-2:50051").unwrap(),
205        };
206        assert_eq!(peer.node_id, 2);
207        assert_eq!(peer.endpoint.as_str(), "node-2:50051");
208    }
209
210    #[test]
211    fn equality_is_by_node_id_and_endpoint() {
212        let left = TsoPeer {
213            node_id: 1,
214            endpoint: PeerEndpoint::try_from("a:1").unwrap(),
215        };
216        let same = TsoPeer {
217            node_id: 1,
218            endpoint: PeerEndpoint::try_from("a:1").unwrap(),
219        };
220        let different_endpoint = TsoPeer {
221            node_id: 1,
222            endpoint: PeerEndpoint::try_from("b:1").unwrap(),
223        };
224        assert_eq!(left, same);
225        assert_ne!(left, different_endpoint);
226    }
227
228    #[test]
229    fn is_usable_as_a_hash_set_member() {
230        use std::collections::HashSet;
231
232        let mut peers = HashSet::new();
233        peers.insert(TsoPeer {
234            node_id: 1,
235            endpoint: PeerEndpoint::try_from("a:1").unwrap(),
236        });
237        let duplicate = peers.insert(TsoPeer {
238            node_id: 1,
239            endpoint: PeerEndpoint::try_from("a:1").unwrap(),
240        });
241        assert!(!duplicate, "an equal peer must hash to the same bucket");
242        assert_eq!(peers.len(), 1);
243    }
244
245    // ---- PeerEndpoint: accepted shapes
246
247    #[test]
248    fn accepts_basic_hostname_with_port() {
249        let e = PeerEndpoint::try_from("node-1:50051").unwrap();
250        assert_eq!(e.as_str(), "node-1:50051");
251    }
252
253    #[test]
254    fn accepts_ipv4_with_port() {
255        let e = PeerEndpoint::try_from("10.0.0.7:50551").unwrap();
256        assert_eq!(e.as_str(), "10.0.0.7:50551");
257    }
258
259    #[test]
260    fn accepts_ipv6_bracketed_with_port() {
261        let e = PeerEndpoint::try_from("[::1]:50551").unwrap();
262        assert_eq!(e.as_str(), "[::1]:50551");
263    }
264
265    #[test]
266    fn accepts_fqdn_with_port() {
267        let e = PeerEndpoint::try_from("leader.example.com:9000").unwrap();
268        assert_eq!(e.as_str(), "leader.example.com:9000");
269    }
270
271    #[test]
272    fn accepts_max_port() {
273        let e = PeerEndpoint::try_from("h:65535").unwrap();
274        assert_eq!(e.as_str(), "h:65535");
275    }
276
277    #[test]
278    fn accepts_min_valid_port() {
279        let e = PeerEndpoint::try_from("h:1").unwrap();
280        assert_eq!(e.as_str(), "h:1");
281    }
282
283    // ---- PeerEndpoint: rejected shapes
284
285    #[test]
286    fn rejects_empty() {
287        assert_eq!(PeerEndpoint::try_from(""), Err(PeerEndpointError::Empty),);
288    }
289
290    #[test]
291    fn rejects_http_scheme() {
292        let err = PeerEndpoint::try_from("http://node-1:50051").unwrap_err();
293        assert!(
294            matches!(err, PeerEndpointError::SchemeBearing { .. }),
295            "expected SchemeBearing, got {err:?}",
296        );
297    }
298
299    #[test]
300    fn rejects_https_scheme() {
301        let err = PeerEndpoint::try_from("https://node-1:50051").unwrap_err();
302        assert!(
303            matches!(err, PeerEndpointError::SchemeBearing { .. }),
304            "expected SchemeBearing, got {err:?}",
305        );
306    }
307
308    #[test]
309    fn rejects_mem_scheme_via_missing_port() {
310        // `mem://foo` is not http(s):// so it bypasses the scheme check, but
311        // it has no numeric port — either the missing-port branch or the
312        // invalid-port branch must reject it. Either is contract-correct;
313        // both are acceptable.
314        let err = PeerEndpoint::try_from("mem://foo").unwrap_err();
315        assert!(
316            matches!(
317                err,
318                PeerEndpointError::MissingPort { .. }
319                    | PeerEndpointError::InvalidPort { .. }
320                    | PeerEndpointError::EmptyHost { .. }
321            ),
322            "expected port-shape rejection, got {err:?}",
323        );
324    }
325
326    #[test]
327    fn rejects_bare_host_no_port() {
328        let err = PeerEndpoint::try_from("node-1").unwrap_err();
329        assert!(
330            matches!(err, PeerEndpointError::MissingPort { .. }),
331            "expected MissingPort, got {err:?}",
332        );
333    }
334
335    #[test]
336    fn rejects_empty_port() {
337        let err = PeerEndpoint::try_from("node-1:").unwrap_err();
338        assert!(
339            matches!(err, PeerEndpointError::InvalidPort { .. }),
340            "expected InvalidPort, got {err:?}",
341        );
342    }
343
344    #[test]
345    fn rejects_non_numeric_port() {
346        let err = PeerEndpoint::try_from("node-1:abc").unwrap_err();
347        assert!(
348            matches!(err, PeerEndpointError::InvalidPort { .. }),
349            "expected InvalidPort, got {err:?}",
350        );
351    }
352
353    #[test]
354    fn rejects_empty_host() {
355        let err = PeerEndpoint::try_from(":50051").unwrap_err();
356        assert!(
357            matches!(err, PeerEndpointError::EmptyHost { .. }),
358            "expected EmptyHost, got {err:?}",
359        );
360    }
361
362    #[test]
363    fn rejects_port_zero() {
364        let err = PeerEndpoint::try_from("h:0").unwrap_err();
365        assert!(
366            matches!(err, PeerEndpointError::InvalidPort { .. }),
367            "expected InvalidPort, got {err:?}",
368        );
369    }
370
371    #[test]
372    fn rejects_port_overflow() {
373        let err = PeerEndpoint::try_from("h:65536").unwrap_err();
374        assert!(
375            matches!(err, PeerEndpointError::InvalidPort { .. }),
376            "expected InvalidPort, got {err:?}",
377        );
378    }
379
380    // ---- PeerEndpoint: API surface
381
382    #[test]
383    fn display_round_trips() {
384        let e = PeerEndpoint::try_from("node-1:50051").unwrap();
385        assert_eq!(format!("{e}"), "node-1:50051");
386    }
387
388    #[test]
389    fn from_str_works() {
390        let e: PeerEndpoint = "node-1:50051".parse().unwrap();
391        assert_eq!(e.as_str(), "node-1:50051");
392    }
393
394    #[test]
395    fn try_from_str_ref_works() {
396        let e: PeerEndpoint = "node-1:50051".try_into().unwrap();
397        assert_eq!(e.as_str(), "node-1:50051");
398    }
399
400    #[test]
401    fn into_inner_returns_string() {
402        let e = PeerEndpoint::try_from("node-1:50051").unwrap();
403        assert_eq!(e.into_inner(), String::from("node-1:50051"));
404    }
405
406    // ---- PeerEndpoint: serde re-validates on deserialize
407
408    #[cfg(feature = "serde")]
409    #[test]
410    fn serde_round_trip_preserves_value() {
411        let e = PeerEndpoint::try_from("10.0.0.7:50551").unwrap();
412        let json = serde_json::to_string(&e).unwrap();
413        assert_eq!(json, "\"10.0.0.7:50551\"");
414        let back: PeerEndpoint = serde_json::from_str(&json).unwrap();
415        assert_eq!(back, e);
416    }
417
418    #[cfg(feature = "serde")]
419    #[test]
420    fn serde_rejects_scheme_bearing_input() {
421        // A scheme-bearing endpoint that snuck onto the wire must NOT be
422        // observable as a `PeerEndpoint` — re-validate on deserialize.
423        let err = serde_json::from_str::<PeerEndpoint>("\"http://node-1:50051\"")
424            .expect_err("deserialize must reject scheme-bearing input");
425        let msg = err.to_string();
426        assert!(
427            msg.contains("scheme-less") || msg.contains("http"),
428            "error should mention the scheme contract, got: {msg}",
429        );
430    }
431}