Skip to main content

hopr_transport_probe/
types.rs

1use hopr_api::{
2    OffchainPublicKey,
3    types::{
4        crypto_random::Randomizable,
5        internal::{
6            NodeId,
7            protocol::HoprPseudonym,
8            routing::{DestinationRouting, PathId, RoutingOptions},
9        },
10        primitive::{bounded::BoundedVec, errors::GeneralError},
11    },
12};
13
14pub struct TaggedDestinationRouting {
15    /// The destination node.
16    pub destination: Box<NodeId>,
17    /// Pseudonym shown to the destination.
18    pub pseudonym: HoprPseudonym,
19    /// The path to the destination.
20    pub forward_options: RoutingOptions,
21    /// Optional return path.
22    pub return_options: Option<RoutingOptions>,
23}
24
25impl TaggedDestinationRouting {
26    pub fn neighbor(destination: Box<NodeId>) -> Self {
27        Self {
28            destination,
29            pseudonym: HoprPseudonym::random(),
30            forward_options: RoutingOptions::Hops(0.try_into().expect("0 is a valid u8")),
31            return_options: Some(RoutingOptions::Hops(0.try_into().expect("0 is a valid u8"))),
32        }
33    }
34
35    pub fn loopback(me: Box<NodeId>, path: BoundedVec<NodeId, { RoutingOptions::MAX_INTERMEDIATE_HOPS }>) -> Self {
36        Self {
37            destination: me,
38            pseudonym: HoprPseudonym::random(),
39            forward_options: RoutingOptions::IntermediatePath(path),
40            return_options: None,
41        }
42    }
43}
44
45impl From<TaggedDestinationRouting> for DestinationRouting {
46    fn from(value: TaggedDestinationRouting) -> Self {
47        DestinationRouting::Forward {
48            destination: value.destination,
49            pseudonym: Some(value.pseudonym),
50            forward_options: value.forward_options,
51            return_options: value.return_options,
52        }
53    }
54}
55
56/// Serializable and deserializable enum for the probe message content.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::EnumDiscriminants)]
58#[strum_discriminants(vis(pub(crate)), derive(strum::FromRepr, strum::EnumCount), repr(u8))]
59pub enum NeighborProbe {
60    /// Ping message with random nonce
61    Ping([u8; Self::NONCE_SIZE]),
62    /// Pong message replying to a specific nonce
63    Pong([u8; Self::NONCE_SIZE]),
64}
65
66impl NeighborProbe {
67    pub const NONCE_SIZE: usize = 32;
68    pub const SIZE: usize = 1 + Self::NONCE_SIZE;
69
70    /// Creates a new Ping probe with a random nonce
71    pub fn random_nonce() -> Self {
72        Self::Ping(hopr_api::types::crypto_random::random_bytes::<{ Self::NONCE_SIZE }>())
73    }
74
75    pub fn is_complement_to(&self, other: Self) -> bool {
76        match (self, &other) {
77            (Self::Ping(nonce1), Self::Pong(nonce2)) => nonce1 == nonce2,
78            (Self::Pong(nonce1), Self::Ping(nonce2)) => nonce1 == nonce2,
79            _ => false,
80        }
81    }
82
83    pub fn to_bytes(self) -> Box<[u8]> {
84        let mut out = Vec::with_capacity(1 + Self::NONCE_SIZE);
85        out.push(NeighborProbeDiscriminants::from(&self) as u8);
86        out.extend_from_slice(self.as_ref());
87        out.into_boxed_slice()
88    }
89}
90
91impl<'a> TryFrom<&'a [u8]> for NeighborProbe {
92    type Error = GeneralError;
93
94    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
95        if value.len() == 1 + Self::NONCE_SIZE {
96            match NeighborProbeDiscriminants::from_repr(value[0])
97                .ok_or(GeneralError::ParseError("NeighborProbe.disc".into()))?
98            {
99                NeighborProbeDiscriminants::Ping => {
100                    Ok(Self::Ping((&value[1..]).try_into().map_err(|_| {
101                        GeneralError::ParseError("NeighborProbe.ping_nonce".into())
102                    })?))
103                }
104                NeighborProbeDiscriminants::Pong => {
105                    Ok(Self::Pong((&value[1..]).try_into().map_err(|_| {
106                        GeneralError::ParseError("NeighborProbe.pong_nonce".into())
107                    })?))
108                }
109            }
110        } else {
111            Err(GeneralError::ParseError("NeighborProbe.size".into()))
112        }
113    }
114}
115
116impl std::fmt::Display for NeighborProbe {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            NeighborProbe::Ping(nonce) => write!(f, "Ping({})", hex::encode(nonce)),
120            NeighborProbe::Pong(nonce) => write!(f, "Pong({})", hex::encode(nonce)),
121        }
122    }
123}
124
125impl AsRef<[u8]> for NeighborProbe {
126    fn as_ref(&self) -> &[u8] {
127        match self {
128            NeighborProbe::Ping(nonce) | NeighborProbe::Pong(nonce) => nonce,
129        }
130    }
131}
132
133/// Path telemetry data for multi-hop loopback probing.
134///
135/// Contains an identifier, path information, and timestamp for tracking
136/// telemetry through the network back to self.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub struct PathTelemetry {
139    /// Unique identifier for the telemetry data
140    pub id: [u8; Self::ID_SIZE],
141    /// Path information encoded as bytes
142    pub path: [u8; Self::PATH_SIZE],
143    /// Timestamp when the telemetry was created
144    pub timestamp: u128,
145}
146
147impl PathTelemetry {
148    pub const ID_SIZE: usize = 8;
149    pub const PATH_SIZE: usize = size_of::<PathId>();
150    pub const SIZE: usize = Self::ID_SIZE + Self::PATH_SIZE + size_of::<u128>();
151
152    pub fn to_bytes(self) -> Box<[u8]> {
153        let mut out = Vec::with_capacity(Self::SIZE);
154        out.extend_from_slice(&self.id);
155        out.extend_from_slice(&self.path);
156        out.extend_from_slice(&self.timestamp.to_be_bytes());
157        out.into_boxed_slice()
158    }
159}
160
161impl hopr_api::graph::MeasurablePath for PathTelemetry {
162    fn id(&self) -> &[u8] {
163        &self.id
164    }
165
166    fn path(&self) -> &[u8] {
167        &self.path
168    }
169
170    fn timestamp(&self) -> u128 {
171        self.timestamp
172    }
173}
174
175const _: () = assert!(size_of::<u128>() > PathTelemetry::ID_SIZE);
176
177impl std::fmt::Display for PathTelemetry {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        write!(
180            f,
181            "PathTelemetry(id: {}, path: {}, timestamp: {})",
182            hex::encode(self.id),
183            hex::encode(self.path),
184            self.timestamp
185        )
186    }
187}
188
189impl<'a> TryFrom<&'a [u8]> for PathTelemetry {
190    type Error = GeneralError;
191
192    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
193        if value.len() == Self::SIZE {
194            Ok(Self {
195                id: (&value[0..Self::ID_SIZE])
196                    .try_into()
197                    .map_err(|_| GeneralError::ParseError("PathTelemetry.id".into()))?,
198                path: (&value[Self::ID_SIZE..(Self::ID_SIZE + Self::PATH_SIZE)])
199                    .try_into()
200                    .map_err(|_| GeneralError::ParseError("PathTelemetry.path".into()))?,
201                timestamp: u128::from_be_bytes(
202                    (&value[(Self::ID_SIZE + Self::PATH_SIZE)..Self::SIZE])
203                        .try_into()
204                        .map_err(|_| GeneralError::ParseError("PathTelemetry.timestamp".into()))?,
205                ),
206            })
207        } else {
208            Err(GeneralError::ParseError("PathTelemetry".into()))
209        }
210    }
211}
212
213/// Intermediate neighbor telemetry object.
214///
215/// Represents the finding of an intermediate peer probing operation.
216#[derive(Debug, Clone)]
217pub struct NeighborTelemetry {
218    pub peer: OffchainPublicKey,
219    pub rtt: std::time::Duration,
220}
221
222impl hopr_api::graph::MeasurablePeer for NeighborTelemetry {
223    fn peer(&self) -> &OffchainPublicKey {
224        &self.peer
225    }
226
227    fn rtt(&self) -> std::time::Duration {
228        self.rtt
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use anyhow::Context;
235    use hopr_api::types::crypto::prelude::{Keypair, OffchainKeypair};
236    use rstest::rstest;
237
238    use super::*;
239
240    #[test]
241    fn tagged_routing_neighbor_should_create_zero_hop_forward() -> anyhow::Result<()> {
242        let dest = Box::new(NodeId::Offchain(*OffchainKeypair::random().public()));
243        let routing = TaggedDestinationRouting::neighbor(dest.clone());
244
245        anyhow::ensure!(
246            matches!(routing.forward_options, RoutingOptions::Hops(h) if u8::from(h) == 0),
247            "neighbor forward should be Hops(0)"
248        );
249        anyhow::ensure!(
250            matches!(&routing.return_options, Some(RoutingOptions::Hops(h)) if u8::from(*h) == 0),
251            "neighbor return should be Some(Hops(0))"
252        );
253        anyhow::ensure!(*routing.destination == *dest, "destination mismatch");
254        Ok(())
255    }
256
257    #[test]
258    fn tagged_routing_loopback_should_create_intermediate_path() -> anyhow::Result<()> {
259        let me = Box::new(NodeId::Offchain(*OffchainKeypair::random().public()));
260        let path = BoundedVec::try_from(vec![NodeId::Offchain(*OffchainKeypair::random().public())])
261            .context("building path")?;
262
263        let routing = TaggedDestinationRouting::loopback(me.clone(), path);
264
265        anyhow::ensure!(
266            matches!(routing.forward_options, RoutingOptions::IntermediatePath(_)),
267            "loopback forward should be IntermediatePath"
268        );
269        anyhow::ensure!(routing.return_options.is_none(), "loopback should have no return");
270        anyhow::ensure!(*routing.destination == *me, "destination mismatch");
271        Ok(())
272    }
273
274    #[test]
275    fn tagged_routing_should_convert_to_destination_routing() -> anyhow::Result<()> {
276        let dest = Box::new(NodeId::Offchain(*OffchainKeypair::random().public()));
277        let routing = TaggedDestinationRouting::neighbor(dest);
278        let converted: DestinationRouting = routing.into();
279
280        anyhow::ensure!(
281            matches!(converted, DestinationRouting::Forward { .. }),
282            "conversion should produce Forward variant"
283        );
284        Ok(())
285    }
286
287    #[rstest]
288    #[case::ping_ping(
289        NeighborProbe::Ping([1u8; NeighborProbe::NONCE_SIZE]),
290        NeighborProbe::Ping([1u8; NeighborProbe::NONCE_SIZE])
291    )]
292    #[case::pong_pong(
293        NeighborProbe::Pong([2u8; NeighborProbe::NONCE_SIZE]),
294        NeighborProbe::Pong([2u8; NeighborProbe::NONCE_SIZE])
295    )]
296    fn neighbor_probe_complement_should_return_false_when_same_variant(
297        #[case] a: NeighborProbe,
298        #[case] b: NeighborProbe,
299    ) {
300        assert!(!a.is_complement_to(b));
301    }
302
303    #[test]
304    fn neighbor_probe_deserialization_should_roundtrip_ping() -> anyhow::Result<()> {
305        let ping = NeighborProbe::Ping([42u8; NeighborProbe::NONCE_SIZE]);
306        let bytes = ping.to_bytes();
307        let restored = NeighborProbe::try_from(bytes.as_ref()).context("deserializing ping")?;
308        assert_eq!(ping, restored);
309        Ok(())
310    }
311
312    #[test]
313    fn neighbor_probe_deserialization_should_roundtrip_pong() -> anyhow::Result<()> {
314        let pong = NeighborProbe::Pong([99u8; NeighborProbe::NONCE_SIZE]);
315        let bytes = pong.to_bytes();
316        let restored = NeighborProbe::try_from(bytes.as_ref()).context("deserializing pong")?;
317        assert_eq!(pong, restored);
318        Ok(())
319    }
320
321    #[test]
322    fn neighbor_probe_deserialization_should_fail_on_wrong_size() {
323        let short = [0u8; 5];
324        assert!(matches!(
325            NeighborProbe::try_from(short.as_slice()),
326            Err(GeneralError::ParseError(ref s)) if s.contains("size")
327        ));
328    }
329
330    #[test]
331    fn neighbor_probe_display_should_show_variant_and_hex() {
332        let nonce = [0xABu8; NeighborProbe::NONCE_SIZE];
333        let ping = NeighborProbe::Ping(nonce);
334        let pong = NeighborProbe::Pong(nonce);
335
336        let ping_str = ping.to_string();
337        let pong_str = pong.to_string();
338
339        assert!(ping_str.starts_with("Ping("), "got: {ping_str}");
340        assert!(pong_str.starts_with("Pong("), "got: {pong_str}");
341        assert!(ping_str.contains("abab"), "should contain hex nonce");
342    }
343
344    #[test]
345    fn path_telemetry_roundtrip_should_preserve_all_fields() -> anyhow::Result<()> {
346        let telemetry = PathTelemetry {
347            id: [1, 2, 3, 4, 5, 6, 7, 8],
348            path: [0xFFu8; PathTelemetry::PATH_SIZE],
349            timestamp: 1234567890,
350        };
351        let bytes = telemetry.to_bytes();
352        let restored = PathTelemetry::try_from(bytes.as_ref()).context("deserializing telemetry")?;
353        assert_eq!(telemetry, restored);
354        Ok(())
355    }
356
357    #[test]
358    fn path_telemetry_display_should_include_hex_id_and_timestamp() {
359        let telemetry = PathTelemetry {
360            id: [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE],
361            path: [0u8; PathTelemetry::PATH_SIZE],
362            timestamp: 42,
363        };
364        let display = telemetry.to_string();
365        assert!(display.contains("deadbeefcafebabe"), "should contain hex id: {display}");
366        assert!(display.contains("42"), "should contain timestamp: {display}");
367    }
368}