umsh_node/
identity.rs

1use alloc::string::String;
2use alloc::vec::Vec;
3
4use bitflags::bitflags;
5use umsh_core::options::{OptionDecoder, OptionEncoder, parse_be_i32, parse_be_u32};
6
7use crate::app_util::parse_utf8;
8use crate::location::NodeLocation;
9use crate::{AppEncodeError, AppParseError};
10
11mod opt {
12    pub const NAME: u16 = 0;
13    pub const LOCATION: u16 = 1;
14    pub const ALTITUDE: u16 = 2;
15    pub const TIMESTAMP: u16 = 3;
16    pub const SUPPORTED_REGIONS: u16 = 4;
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum NodeRole {
21    Unspecified,
22    Repeater,
23    Chat,
24    Tracker,
25    Sensor,
26    Bridge,
27    ChatRoom,
28    TemporarySession,
29    /// A role value not recognized by this implementation; preserved for round-tripping.
30    Unknown(u8),
31}
32
33impl NodeRole {
34    pub fn from_byte(value: u8) -> Self {
35        match value {
36            0 => Self::Unspecified,
37            1 => Self::Repeater,
38            2 => Self::Chat,
39            3 => Self::Tracker,
40            4 => Self::Sensor,
41            5 => Self::Bridge,
42            6 => Self::ChatRoom,
43            7 => Self::TemporarySession,
44            n => Self::Unknown(n),
45        }
46    }
47
48    pub fn as_byte(self) -> u8 {
49        match self {
50            Self::Unspecified => 0,
51            Self::Repeater => 1,
52            Self::Chat => 2,
53            Self::Tracker => 3,
54            Self::Sensor => 4,
55            Self::Bridge => 5,
56            Self::ChatRoom => 6,
57            Self::TemporarySession => 7,
58            Self::Unknown(n) => n,
59        }
60    }
61}
62
63bitflags! {
64    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
65    pub struct NodeCapabilities: u8 {
66        const REPEATER       = 0x01;
67        const MOBILE         = 0x02;
68        const TEXT_MESSAGES  = 0x04;
69        const TELEMETRY      = 0x08;
70        const CHAT_ROOM      = 0x10;
71        const COAP           = 0x20;
72    }
73}
74
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct NodeIdentityPayload {
77    pub role: NodeRole,
78    pub capabilities: NodeCapabilities,
79    /// Option 0 — display name (UTF-8).
80    pub name: Option<String>,
81    /// Option 1 — geographic position.
82    pub location: Option<NodeLocation>,
83    /// Option 2 — altitude above mean sea level, in meters.
84    pub altitude_m: Option<i32>,
85    /// Option 3 — seconds since the Unix epoch (freshness marker).
86    pub timestamp: Option<u32>,
87    /// Option 4 — concatenated 2-byte region codes this repeater serves.
88    pub supported_regions: Option<Vec<u8>>,
89    /// EdDSA signature over ROLE..=0xFF, present when the identity stands alone.
90    pub signature: Option<[u8; 64]>,
91}
92
93impl NodeIdentityPayload {
94    pub fn from_bytes(payload: &[u8]) -> Result<NodeIdentityPayload, AppParseError> {
95        if payload.len() < 2 {
96            return Err(AppParseError::Core(umsh_core::ParseError::Truncated));
97        }
98
99        let role = NodeRole::from_byte(payload[0]);
100        let capabilities = NodeCapabilities::from_bits_truncate(payload[1]);
101        let remaining = &payload[2..];
102
103        let mut name = None;
104        let mut location = None;
105        let mut altitude_m = None;
106        let mut timestamp = None;
107        let mut supported_regions = None;
108
109        let mut decoder = OptionDecoder::new(remaining);
110        for result in decoder.by_ref() {
111            let (number, value) = result?;
112            match number {
113                opt::NAME => name = Some(String::from(parse_utf8(value)?)),
114                opt::LOCATION => {
115                    // Spec: MUST ignore bytes after the 7th
116                    location = Some(NodeLocation::from_bytes(value));
117                }
118                opt::ALTITUDE => altitude_m = Some(parse_be_i32(value)?),
119                opt::TIMESTAMP => timestamp = Some(parse_be_u32(value)?),
120                opt::SUPPORTED_REGIONS => {
121                    if value.len() % 2 != 0 {
122                        return Err(AppParseError::InvalidOptionValue);
123                    }
124                    supported_regions = Some(Vec::from(value));
125                }
126                _ => {} // unknown options are silently skipped
127            }
128        }
129
130        let sig_bytes = decoder.remainder();
131        let signature = match sig_bytes.len() {
132            0 => None,
133            64 => Some(
134                sig_bytes
135                    .try_into()
136                    .map_err(|_| AppParseError::InvalidLength {
137                        expected: 64,
138                        actual: sig_bytes.len(),
139                    })?,
140            ),
141            n => {
142                return Err(AppParseError::InvalidLength {
143                    expected: 64,
144                    actual: n,
145                });
146            }
147        };
148
149        Ok(NodeIdentityPayload {
150            role,
151            capabilities,
152            name,
153            location,
154            altitude_m,
155            timestamp,
156            supported_regions,
157            signature,
158        })
159    }
160
161    pub fn encode(&self, buf: &mut [u8]) -> Result<usize, AppEncodeError> {
162        if buf.len() < 2 {
163            return Err(AppEncodeError::BufferTooSmall);
164        }
165        buf[0] = self.role.as_byte();
166        buf[1] = self.capabilities.bits();
167        let mut pos = 2;
168
169        {
170            let mut enc = OptionEncoder::new(&mut buf[pos..]);
171            if let Some(name) = self.name.as_deref() {
172                enc.put(opt::NAME, name.as_bytes())?;
173            }
174            if let Some(loc) = self.location {
175                enc.put(opt::LOCATION, loc.as_bytes())?;
176            }
177            if let Some(alt) = self.altitude_m {
178                enc.put_i32(opt::ALTITUDE, alt)?;
179            }
180            if let Some(ts) = self.timestamp {
181                enc.put_u32(opt::TIMESTAMP, ts)?;
182            }
183            if let Some(regions) = self.supported_regions.as_deref() {
184                enc.put(opt::SUPPORTED_REGIONS, regions)?;
185            }
186            if self.signature.is_some() {
187                enc.end_marker()?;
188            }
189            pos += enc.finish();
190        }
191
192        if let Some(sig) = &self.signature {
193            if pos + 64 > buf.len() {
194                return Err(AppEncodeError::BufferTooSmall);
195            }
196            buf[pos..pos + 64].copy_from_slice(sig);
197            pos += 64;
198        }
199
200        Ok(pos)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn round_trip(id: &NodeIdentityPayload) -> bool {
209        let mut buf = [0u8; 256];
210        let len = id.encode(&mut buf).expect("encode failed");
211        let decoded = NodeIdentityPayload::from_bytes(&buf[..len]).expect("parse failed");
212        decoded == *id
213    }
214
215    #[test]
216    fn minimal_two_bytes() {
217        let id = NodeIdentityPayload {
218            role: NodeRole::Chat,
219            capabilities: NodeCapabilities::TEXT_MESSAGES,
220            name: None,
221            location: None,
222            altitude_m: None,
223            timestamp: None,
224            supported_regions: None,
225            signature: None,
226        };
227        let mut buf = [0u8; 16];
228        let len = id.encode(&mut buf).unwrap();
229        assert_eq!(len, 2);
230        assert_eq!(buf[0], 2); // Chat role
231        assert_eq!(buf[1], NodeCapabilities::TEXT_MESSAGES.bits());
232        assert!(round_trip(&id));
233    }
234
235    #[test]
236    fn name_only() {
237        let id = NodeIdentityPayload {
238            role: NodeRole::Unspecified,
239            capabilities: NodeCapabilities::empty(),
240            name: Some("Alice".into()),
241            location: None,
242            altitude_m: None,
243            timestamp: None,
244            supported_regions: None,
245            signature: None,
246        };
247        assert!(round_trip(&id));
248    }
249
250    #[test]
251    fn all_options() {
252        let loc = NodeLocation::from_bytes(&[0x2B, 0x95, 0x51]);
253        let id = NodeIdentityPayload {
254            role: NodeRole::Repeater,
255            capabilities: NodeCapabilities::REPEATER | NodeCapabilities::TEXT_MESSAGES,
256            name: Some("tower".into()),
257            location: Some(loc),
258            altitude_m: Some(1500),
259            timestamp: Some(1_700_000_000),
260            supported_regions: Some(vec![0x00, 0x01, 0x00, 0x02]),
261            signature: None,
262        };
263        assert!(round_trip(&id));
264    }
265
266    #[test]
267    fn negative_altitude() {
268        let id = NodeIdentityPayload {
269            role: NodeRole::Sensor,
270            capabilities: NodeCapabilities::empty(),
271            name: None,
272            location: None,
273            altitude_m: Some(-430), // Dead Sea
274            timestamp: None,
275            supported_regions: None,
276            signature: None,
277        };
278        assert!(round_trip(&id));
279    }
280
281    #[test]
282    fn altitude_zero() {
283        let id = NodeIdentityPayload {
284            role: NodeRole::Sensor,
285            capabilities: NodeCapabilities::empty(),
286            name: None,
287            location: None,
288            altitude_m: Some(0),
289            timestamp: None,
290            supported_regions: None,
291            signature: None,
292        };
293        assert!(round_trip(&id));
294    }
295
296    #[test]
297    fn with_signature() {
298        let id = NodeIdentityPayload {
299            role: NodeRole::Chat,
300            capabilities: NodeCapabilities::empty(),
301            name: Some("Bob".into()),
302            location: None,
303            altitude_m: None,
304            timestamp: Some(1_700_000_000),
305            supported_regions: None,
306            signature: Some([0xAAu8; 64]),
307        };
308        assert!(round_trip(&id));
309    }
310}