umsh_node/
mac_command.rs

1use alloc::vec::Vec;
2
3use umsh_core::options::{OptionDecoder, OptionEncoder, parse_be_u32};
4use umsh_core::{NodeHint, PublicKey};
5
6use crate::app_util::{copy_into, fixed, push_byte};
7use crate::identity::{NodeCapabilities, NodeRole};
8use crate::{AppEncodeError, AppParseError};
9
10/// Option keys carried in an [`MacCommand::IdentityRequest`] payload.
11///
12/// Keys follow the CoAP convention: an odd key (least-significant bit set) is
13/// **critical**, so a responder that does not understand it MUST NOT respond.
14/// All currently defined keys are critical. `NONCE` is a correlation
15/// identifier rather than a filter and does not participate in filter matching.
16pub mod identity_filter {
17    /// Correlation identifier the responder echoes into the identity Nonce
18    /// option (identity option 5). 4 bytes. Not a filter.
19    pub const NONCE: u16 = 1;
20    /// Match only nodes whose own [node hint](umsh_core::NodeHint) starts with
21    /// this value. 1 to 3 bytes; a 2-byte value is a
22    /// [router hint](umsh_core::RouterHint).
23    pub const FILTER_NODE_HINT: u16 = 3;
24    /// Match only nodes whose primary role equals this value. 1 byte.
25    pub const FILTER_NODE_ROLE: u16 = 5;
26    /// Match only nodes whose capability bitmap has every bit set that is set
27    /// in this value. 1 byte.
28    pub const FILTER_NODE_CAPS: u16 = 7;
29}
30
31/// Option keys carried in a [`MacCommand::PeerRepeatersRequest`] payload.
32///
33/// Both keys are even, so a responder that does not understand one ignores it
34/// rather than declining to answer.
35pub mod peer_repeaters_request {
36    /// Correlation identifier the responder echoes into its response. 2 bytes.
37    pub const NONCE: u16 = 0;
38    /// Resume token copied verbatim from a previous response's `CURSOR`.
39    /// Absent on the first request of an enumeration.
40    pub const CURSOR: u16 = 1;
41}
42
43/// Option keys carried in a [`MacCommand::PeerRepeatersResponse`]'s leading
44/// option block, before the `0xFF` that introduces the entries.
45pub mod peer_repeaters_response {
46    /// Copied verbatim from the request; present only when the request
47    /// carried one. 2 bytes.
48    pub const NONCE: u16 = 0;
49    /// Opaque resume token for the next page. Absent on the final page.
50    pub const CURSOR: u16 = 1;
51    /// Entries in the whole list, not the page. 1 byte.
52    pub const TOTAL: u16 = 2;
53}
54
55/// Option keys carried in one peer-repeater entry.
56///
57/// An option the responder has no value for is omitted, so every one but the
58/// hint is optional.
59pub mod peer_repeater_entry {
60    /// The peer's node hint: 3 bytes when the responder holds the whole of
61    /// it, or the 2-byte router hint when that is all it has observed.
62    pub const NODE_HINT: u16 = 0;
63    /// The peer's display name, as learned from its identity. UTF-8, at most
64    /// 24 bytes.
65    pub const NODE_NAME: u16 = 1;
66    /// The most recent reception: RSSI as an unsigned negative-dBm value,
67    /// then SNR in quarter-dB steps. 2 bytes.
68    pub const RSSI_SNR: u16 = 2;
69    /// Minutes since the peer was last heard, minimal big-endian. 1–2 bytes.
70    pub const LAST_HEARD: u16 = 3;
71    /// The peer's position in the variable-precision location format.
72    pub const LOCATION: u16 = 4;
73    /// The region codes the peer flood-forwards for, n × 2 bytes.
74    pub const REGIONS: u16 = 5;
75}
76
77/// Length of a [`NodeHint`], the longest a `FILTER_NODE_HINT` value may be.
78const NODE_HINT_LEN: usize = 3;
79
80/// The shortest hint a peer-repeater entry may name a peer by: the 2-byte
81/// router hint a trace or source route reveals.
82const ROUTER_HINT_LEN: usize = 2;
83
84/// Longest peer name a peer-repeater entry carries.
85pub const PEER_REPEATER_NAME_MAX_LEN: usize = 24;
86
87/// The shortest `FILTER_NODE_HINT` that names a node rather than a share of
88/// the mesh.
89///
90/// Two bytes is one node in 65,536 and three is one in 16.7 million, either of
91/// which is unique across any plausible mesh. One byte is one node in 256 — a
92/// fraction, not a name.
93const UNIQUE_HINT_PREFIX_LEN: usize = 2;
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96#[repr(u8)]
97pub enum CommandId {
98    IdentityRequest = 1,
99    SignalReportRequest = 2,
100    SignalReportResponse = 3,
101    EchoRequest = 4,
102    EchoResponse = 5,
103    PfsSessionRequest = 6,
104    PfsSessionResponse = 7,
105    EndPfsSession = 8,
106    Noop = 9,
107    PeerRepeatersRequest = 10,
108    PeerRepeatersResponse = 11,
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum MacCommand<'a> {
113    /// Request that the destination respond with its node identity.
114    ///
115    /// `options` is a CoAP-style option block of [`identity_filter`] keys,
116    /// empty for a plain unicast request. Interpret it with
117    /// [`IdentityRequestFilters`].
118    IdentityRequest {
119        options: &'a [u8],
120    },
121    SignalReportRequest,
122    SignalReportResponse {
123        rssi: u8,
124        snr: i8,
125    },
126    EchoRequest {
127        data: &'a [u8],
128    },
129    EchoResponse {
130        data: &'a [u8],
131    },
132    PfsSessionRequest {
133        ephemeral_key: umsh_core::PublicKey,
134        duration_minutes: u16,
135    },
136    PfsSessionResponse {
137        ephemeral_key: umsh_core::PublicKey,
138        duration_minutes: u16,
139    },
140    EndPfsSession,
141    /// Costs a frame and asks for nothing. Useful for keeping a session's
142    /// counters moving and for proving a peer is still reachable.
143    Noop,
144    /// Ask the destination for its list of known peer repeaters.
145    ///
146    /// `options` is a CoAP-style option block of [`peer_repeaters_request`]
147    /// keys, empty for the first page of an unnonced enumeration. Interpret
148    /// it with [`PeerRepeatersRequestView`].
149    PeerRepeatersRequest {
150        options: &'a [u8],
151    },
152    /// One page of a peer-repeater list.
153    ///
154    /// `body` is the whole payload after the command id: the response's own
155    /// option block, the `0xFF` end marker, then the entries. Interpret it
156    /// with [`PeerRepeatersResponseView`].
157    PeerRepeatersResponse {
158        body: &'a [u8],
159    },
160}
161
162pub fn parse(payload: &[u8]) -> Result<MacCommand<'_>, AppParseError> {
163    let (&command_id, body) = payload
164        .split_first()
165        .ok_or(AppParseError::Core(umsh_core::ParseError::Truncated))?;
166
167    match command_id {
168        1 => {
169            // Validate the option block is structurally well-formed CoAP
170            // options; individual filter values are interpreted (and tolerated)
171            // lazily by IdentityRequestFilters, per receiver tolerance.
172            for item in OptionDecoder::new(body) {
173                item.map_err(AppParseError::Core)?;
174            }
175            Ok(MacCommand::IdentityRequest { options: body })
176        }
177        2 => {
178            if body.is_empty() {
179                Ok(MacCommand::SignalReportRequest)
180            } else {
181                Err(AppParseError::InvalidOptionValue)
182            }
183        }
184        3 => match body {
185            [rssi, snr] => Ok(MacCommand::SignalReportResponse {
186                rssi: *rssi,
187                snr: *snr as i8,
188            }),
189            _ => Err(AppParseError::InvalidLength {
190                expected: 2,
191                actual: body.len(),
192            }),
193        },
194        4 => Ok(MacCommand::EchoRequest { data: body }),
195        5 => Ok(MacCommand::EchoResponse { data: body }),
196        6 => parse_pfs(body, true),
197        7 => parse_pfs(body, false),
198        8 => {
199            if body.is_empty() {
200                Ok(MacCommand::EndPfsSession)
201            } else {
202                Err(AppParseError::InvalidOptionValue)
203            }
204        }
205        // Nothing to read and nothing to do, so a body is nothing to reject
206        // over either.
207        9 => Ok(MacCommand::Noop),
208        10 => {
209            // Structurally well-formed options; values are interpreted
210            // lazily by the view, per receiver tolerance.
211            for item in OptionDecoder::new(body) {
212                item.map_err(AppParseError::Core)?;
213            }
214            Ok(MacCommand::PeerRepeatersRequest { options: body })
215        }
216        // The entry list past the end marker is walked lazily: broken entry
217        // framing ends the walk, keeping the entries decoded before it.
218        11 => Ok(MacCommand::PeerRepeatersResponse { body }),
219        other => Err(AppParseError::InvalidCommandId(other)),
220    }
221}
222
223fn parse_pfs(payload: &[u8], request: bool) -> Result<MacCommand<'_>, AppParseError> {
224    if payload.len() != 34 {
225        return Err(AppParseError::InvalidLength {
226            expected: 34,
227            actual: payload.len(),
228        });
229    }
230    let ephemeral_key = umsh_core::PublicKey(*fixed(&payload[..32])?);
231    let duration_minutes = u16::from_be_bytes(*fixed(&payload[32..34])?);
232    Ok(if request {
233        MacCommand::PfsSessionRequest {
234            ephemeral_key,
235            duration_minutes,
236        }
237    } else {
238        MacCommand::PfsSessionResponse {
239            ephemeral_key,
240            duration_minutes,
241        }
242    })
243}
244
245#[derive(Clone, Debug, PartialEq, Eq)]
246pub enum OwnedMacCommand {
247    IdentityRequest {
248        options: Vec<u8>,
249    },
250    SignalReportRequest,
251    SignalReportResponse {
252        rssi: u8,
253        snr: i8,
254    },
255    EchoRequest {
256        data: Vec<u8>,
257    },
258    EchoResponse {
259        data: Vec<u8>,
260    },
261    PfsSessionRequest {
262        ephemeral_key: PublicKey,
263        duration_minutes: u16,
264    },
265    PfsSessionResponse {
266        ephemeral_key: PublicKey,
267        duration_minutes: u16,
268    },
269    EndPfsSession,
270    Noop,
271    PeerRepeatersRequest {
272        options: Vec<u8>,
273    },
274    PeerRepeatersResponse {
275        body: Vec<u8>,
276    },
277}
278
279impl From<MacCommand<'_>> for OwnedMacCommand {
280    fn from(value: MacCommand<'_>) -> Self {
281        match value {
282            MacCommand::IdentityRequest { options } => Self::IdentityRequest {
283                options: Vec::from(options),
284            },
285            MacCommand::SignalReportRequest => Self::SignalReportRequest,
286            MacCommand::SignalReportResponse { rssi, snr } => {
287                Self::SignalReportResponse { rssi, snr }
288            }
289            MacCommand::EchoRequest { data } => Self::EchoRequest {
290                data: Vec::from(data),
291            },
292            MacCommand::EchoResponse { data } => Self::EchoResponse {
293                data: Vec::from(data),
294            },
295            MacCommand::PfsSessionRequest {
296                ephemeral_key,
297                duration_minutes,
298            } => Self::PfsSessionRequest {
299                ephemeral_key,
300                duration_minutes,
301            },
302            MacCommand::PfsSessionResponse {
303                ephemeral_key,
304                duration_minutes,
305            } => Self::PfsSessionResponse {
306                ephemeral_key,
307                duration_minutes,
308            },
309            MacCommand::EndPfsSession => Self::EndPfsSession,
310            MacCommand::Noop => Self::Noop,
311            MacCommand::PeerRepeatersRequest { options } => Self::PeerRepeatersRequest {
312                options: Vec::from(options),
313            },
314            MacCommand::PeerRepeatersResponse { body } => Self::PeerRepeatersResponse {
315                body: Vec::from(body),
316            },
317        }
318    }
319}
320
321pub fn encode(cmd: &MacCommand<'_>, buf: &mut [u8]) -> Result<usize, AppEncodeError> {
322    let mut pos = 0usize;
323    match cmd {
324        MacCommand::IdentityRequest { options } => {
325            push_byte(buf, &mut pos, CommandId::IdentityRequest as u8)?;
326            copy_into(buf, &mut pos, options)?;
327        }
328        MacCommand::SignalReportRequest => {
329            push_byte(buf, &mut pos, CommandId::SignalReportRequest as u8)?;
330        }
331        MacCommand::SignalReportResponse { rssi, snr } => {
332            push_byte(buf, &mut pos, CommandId::SignalReportResponse as u8)?;
333            push_byte(buf, &mut pos, *rssi)?;
334            push_byte(buf, &mut pos, *snr as u8)?;
335        }
336        MacCommand::EchoRequest { data } => {
337            push_byte(buf, &mut pos, CommandId::EchoRequest as u8)?;
338            copy_into(buf, &mut pos, data)?;
339        }
340        MacCommand::EchoResponse { data } => {
341            push_byte(buf, &mut pos, CommandId::EchoResponse as u8)?;
342            copy_into(buf, &mut pos, data)?;
343        }
344        MacCommand::PfsSessionRequest {
345            ephemeral_key,
346            duration_minutes,
347        } => {
348            push_byte(buf, &mut pos, CommandId::PfsSessionRequest as u8)?;
349            copy_into(buf, &mut pos, &ephemeral_key.0)?;
350            copy_into(buf, &mut pos, &duration_minutes.to_be_bytes())?;
351        }
352        MacCommand::PfsSessionResponse {
353            ephemeral_key,
354            duration_minutes,
355        } => {
356            push_byte(buf, &mut pos, CommandId::PfsSessionResponse as u8)?;
357            copy_into(buf, &mut pos, &ephemeral_key.0)?;
358            copy_into(buf, &mut pos, &duration_minutes.to_be_bytes())?;
359        }
360        MacCommand::EndPfsSession => push_byte(buf, &mut pos, CommandId::EndPfsSession as u8)?,
361        MacCommand::Noop => push_byte(buf, &mut pos, CommandId::Noop as u8)?,
362        MacCommand::PeerRepeatersRequest { options } => {
363            push_byte(buf, &mut pos, CommandId::PeerRepeatersRequest as u8)?;
364            copy_into(buf, &mut pos, options)?;
365        }
366        MacCommand::PeerRepeatersResponse { body } => {
367            push_byte(buf, &mut pos, CommandId::PeerRepeatersResponse as u8)?;
368            copy_into(buf, &mut pos, body)?;
369        }
370    }
371    Ok(pos)
372}
373
374/// Interprets the option block of an [`MacCommand::IdentityRequest`].
375///
376/// Borrows the raw block and decodes its [`identity_filter`] options on demand.
377/// A responder uses [`nonce`](Self::nonce) to obtain the correlation value it
378/// must echo, and [`selects`](Self::selects) to decide whether it is a target
379/// of the request.
380#[derive(Clone, Copy, Debug)]
381pub struct IdentityRequestFilters<'a> {
382    options: &'a [u8],
383}
384
385impl<'a> IdentityRequestFilters<'a> {
386    /// Wrap the option block carried by an Identity Request.
387    pub fn new(options: &'a [u8]) -> Self {
388        Self { options }
389    }
390
391    /// The correlation nonce the responder must echo into its identity's Nonce
392    /// option, or `None` if the request carried no `NONCE` option.
393    ///
394    /// Returns the first `NONCE` option; tolerates minimal (≤4 byte) encodings.
395    pub fn nonce(&self) -> Result<Option<u32>, AppParseError> {
396        for item in OptionDecoder::new(self.options) {
397            let (number, value) = item.map_err(AppParseError::Core)?;
398            if number == identity_filter::NONCE {
399                return parse_be_u32(value).map(Some).map_err(AppParseError::Core);
400            }
401        }
402        Ok(None)
403    }
404
405    /// Whether the request carries at least one `FILTER_NODE_HINT` filter.
406    ///
407    /// A hint filter names one node, or with a partial hint a handful, so such
408    /// a request solicits few replies however far it travels. Without one the
409    /// request selects by role or capability and every node it reaches may
410    /// answer, which is what confines a broadcast or multicast solicitation —
411    /// and its replies — to the requester's own neighborhood.
412    ///
413    /// A malformed option block reads as unfiltered, which is the conservative
414    /// answer: it keeps the strict rules in force.
415    pub fn hint_filtered(&self) -> bool {
416        OptionDecoder::new(self.options)
417            .map_while(Result::ok)
418            .any(|(number, _)| number == identity_filter::FILTER_NODE_HINT)
419    }
420
421    /// Whether this request's hint filters name a node rather than a share of
422    /// the mesh: at least one `FILTER_NODE_HINT` is present and every one of
423    /// them is at least [`UNIQUE_HINT_PREFIX_LEN`] bytes.
424    ///
425    /// This is the question the reply's random hold turns on. The hold exists
426    /// to keep many selected nodes from answering the same frame at once, so a
427    /// request only one node can satisfy has nothing to spread and is answered
428    /// straight away. A one-byte prefix selects a 256th of everything it
429    /// reaches, which is a crowd, so it keeps the hold.
430    ///
431    /// Stricter than [`hint_filtered`](Self::hint_filtered), which asks only
432    /// whether the request is aimed at all. Filters of the same type combine as
433    /// OR, so one short filter widens the whole request and the shortest is
434    /// what decides.
435    ///
436    /// A malformed option block reads as unnarrowed — the conservative answer,
437    /// since it keeps the hold in force.
438    pub fn hint_names_one_node(&self) -> bool {
439        let mut named = false;
440        for item in OptionDecoder::new(self.options) {
441            let Ok((number, value)) = item else {
442                return false;
443            };
444            if number == identity_filter::FILTER_NODE_HINT {
445                if value.len() < UNIQUE_HINT_PREFIX_LEN {
446                    return false;
447                }
448                named = true;
449            }
450        }
451        named
452    }
453
454    /// Whether a node with the given identity is selected by this request.
455    ///
456    /// Filters combine as a logical AND across distinct filter types and a
457    /// logical OR among repeated filters of the same type. An unknown
458    /// **critical** option (odd key) excludes the node; unknown elective
459    /// options are ignored. A well-formed request with no filters (a unicast
460    /// request) selects every node.
461    pub fn selects(
462        &self,
463        role: NodeRole,
464        capabilities: NodeCapabilities,
465        hint: &NodeHint,
466    ) -> Result<bool, AppParseError> {
467        // Per filter type: whether it appeared, and whether any value matched.
468        let mut hint_present = false;
469        let mut hint_match = false;
470        let mut role_present = false;
471        let mut role_match = false;
472        let mut caps_present = false;
473        let mut caps_match = false;
474
475        for item in OptionDecoder::new(self.options) {
476            let (number, value) = item.map_err(AppParseError::Core)?;
477            match number {
478                identity_filter::NONCE => {} // correlation id, not a filter
479                identity_filter::FILTER_NODE_HINT => {
480                    hint_present = true;
481                    // A partial hint matches as a prefix of the node hint. That
482                    // is what lets a two-byte router hint — the only name a
483                    // route gives an intermediate hop — ask that hop to
484                    // identify itself. An empty value matches nothing: it would
485                    // otherwise select every node while `hint_filtered` reported
486                    // the request narrowed to one.
487                    hint_match |=
488                        (1..=hint.0.len()).contains(&value.len()) && hint.0.starts_with(value);
489                }
490                identity_filter::FILTER_NODE_ROLE => {
491                    role_present = true;
492                    role_match |= value == [role.as_byte()];
493                }
494                identity_filter::FILTER_NODE_CAPS => {
495                    caps_present = true;
496                    // Match if the node has every requested bit set.
497                    caps_match |= value.len() == 1 && (capabilities.bits() & value[0]) == value[0];
498                }
499                other if other & 1 == 1 => {
500                    // Unknown critical option: assume we are excluded.
501                    return Ok(false);
502                }
503                _ => {} // unknown elective option: ignore
504            }
505        }
506
507        Ok((!hint_present || hint_match)
508            && (!role_present || role_match)
509            && (!caps_present || caps_match))
510    }
511}
512
513/// Builds the option block for an [`MacCommand::IdentityRequest`].
514///
515/// Options are emitted in ascending key order, so callers must add the nonce
516/// before any filters and add filters in key order. No `0xFF` end marker is
517/// written: an Identity Request payload is options-only, with no trailing data.
518#[derive(Debug, Default)]
519pub struct IdentityRequestBuilder {
520    buf: Vec<u8>,
521    last_number: u16,
522}
523
524impl IdentityRequestBuilder {
525    /// Start an empty builder (a plain unicast request until options are added).
526    pub fn new() -> Self {
527        Self::default()
528    }
529
530    fn put(mut self, number: u16, value: &[u8]) -> Result<Self, AppEncodeError> {
531        // Encode one option into a scratch buffer, continuing the delta chain,
532        // then append. Sized for the header plus the largest filter value.
533        let mut scratch = [0u8; 8 + 4];
534        let mut enc = OptionEncoder::with_last_number(&mut scratch, self.last_number);
535        enc.put(number, value).map_err(AppEncodeError::Core)?;
536        let n = enc.finish();
537        self.buf.extend_from_slice(&scratch[..n]);
538        self.last_number = number;
539        Ok(self)
540    }
541
542    /// Add the `NONCE` correlation option. Add before any filters.
543    pub fn nonce(self, nonce: u32) -> Result<Self, AppEncodeError> {
544        self.put(identity_filter::NONCE, &nonce.to_be_bytes())
545    }
546
547    /// Add a `FILTER_NODE_HINT` filter (repeatable; repeats are OR-combined).
548    pub fn filter_hint(self, hint: &NodeHint) -> Result<Self, AppEncodeError> {
549        self.put(identity_filter::FILTER_NODE_HINT, &hint.0)
550    }
551
552    /// Add a `FILTER_NODE_HINT` filter that matches on a leading part of the
553    /// node hint, for a caller that holds less than the whole of one — a route
554    /// names its intermediate hops by a two-byte
555    /// [router hint](umsh_core::RouterHint) and nothing more.
556    ///
557    /// Rejects an empty prefix, which would select every node, and one longer
558    /// than a node hint, which would select none.
559    pub fn filter_hint_prefix(self, prefix: &[u8]) -> Result<Self, AppEncodeError> {
560        if !(1..=NODE_HINT_LEN).contains(&prefix.len()) {
561            return Err(AppEncodeError::InvalidField);
562        }
563        self.put(identity_filter::FILTER_NODE_HINT, prefix)
564    }
565
566    /// Add a `FILTER_NODE_ROLE` filter (repeatable; repeats are OR-combined).
567    pub fn filter_role(self, role: NodeRole) -> Result<Self, AppEncodeError> {
568        self.put(identity_filter::FILTER_NODE_ROLE, &[role.as_byte()])
569    }
570
571    /// Add a `FILTER_NODE_CAPS` filter (repeatable; repeats are OR-combined).
572    pub fn filter_caps(self, caps: NodeCapabilities) -> Result<Self, AppEncodeError> {
573        self.put(identity_filter::FILTER_NODE_CAPS, &[caps.bits()])
574    }
575
576    /// Finish and return the encoded option block.
577    pub fn build(self) -> Vec<u8> {
578        self.buf
579    }
580}
581
582/// Interprets the option block of a [`MacCommand::PeerRepeatersRequest`].
583///
584/// Borrows the raw block and decodes on demand. Both keys are elective, so an
585/// option this does not understand is skipped rather than refused.
586#[derive(Clone, Copy, Debug)]
587pub struct PeerRepeatersRequestView<'a> {
588    options: &'a [u8],
589}
590
591impl<'a> PeerRepeatersRequestView<'a> {
592    pub fn new(options: &'a [u8]) -> Self {
593        Self { options }
594    }
595
596    /// The correlation nonce to echo, or `None` when the request carried
597    /// none. Tolerates a minimal (≤2 byte) encoding.
598    pub fn nonce(&self) -> Option<u16> {
599        self.find(peer_repeaters_request::NONCE)
600            .and_then(parse_minimal_be_u16)
601    }
602
603    /// The opaque resume token, or `None` on the first request of an
604    /// enumeration.
605    pub fn cursor(&self) -> Option<&'a [u8]> {
606        self.find(peer_repeaters_request::CURSOR)
607    }
608
609    fn find(&self, number: u16) -> Option<&'a [u8]> {
610        OptionDecoder::new(self.options)
611            .map_while(Result::ok)
612            .find(|(key, _)| *key == number)
613            .map(|(_, value)| value)
614    }
615}
616
617/// Interprets the body of a [`MacCommand::PeerRepeatersResponse`]: the page's
618/// own options, then the entries that follow the end marker.
619#[derive(Clone, Copy, Debug)]
620pub struct PeerRepeatersResponseView<'a> {
621    body: &'a [u8],
622}
623
624impl<'a> PeerRepeatersResponseView<'a> {
625    pub fn new(body: &'a [u8]) -> Self {
626        Self { body }
627    }
628
629    /// The nonce copied from the request, present only when the request
630    /// carried one.
631    pub fn nonce(&self) -> Option<u16> {
632        self.find(peer_repeaters_response::NONCE)
633            .and_then(parse_minimal_be_u16)
634    }
635
636    /// The resume token for the next page. Absent on the final page, which
637    /// is what ends an enumeration.
638    pub fn cursor(&self) -> Option<&'a [u8]> {
639        self.find(peer_repeaters_response::CURSOR)
640    }
641
642    /// Entries in the whole list, not this page. Required on the response to
643    /// a cursorless request and optional afterwards.
644    pub fn total(&self) -> Option<u8> {
645        match self.find(peer_repeaters_response::TOTAL) {
646            Some([total]) => Some(*total),
647            _ => None,
648        }
649    }
650
651    /// Walk the entries this page carries.
652    ///
653    /// A malformed entry ends the walk rather than the page: everything
654    /// decoded before it is still an answer.
655    pub fn entries(&self) -> PeerRepeaterEntries<'a> {
656        let mut decoder = OptionDecoder::new(self.body);
657        while decoder.next().is_some() {}
658        PeerRepeaterEntries {
659            rest: decoder.remainder(),
660        }
661    }
662
663    fn find(&self, number: u16) -> Option<&'a [u8]> {
664        OptionDecoder::new(self.body)
665            .map_while(Result::ok)
666            .find(|(key, _)| *key == number)
667            .map(|(_, value)| value)
668    }
669}
670
671/// Iterator over the entries of a [`PeerRepeatersResponseView`].
672#[derive(Clone, Debug)]
673pub struct PeerRepeaterEntries<'a> {
674    rest: &'a [u8],
675}
676
677impl<'a> Iterator for PeerRepeaterEntries<'a> {
678    type Item = PeerRepeaterEntryView<'a>;
679
680    fn next(&mut self) -> Option<Self::Item> {
681        if self.rest.is_empty() {
682            return None;
683        }
684        let mut decoder = OptionDecoder::new(self.rest);
685        while decoder.next().is_some() {}
686        // The decoder stops at this entry's `0xFF`; what it leaves is the
687        // next entry. The final entry may omit its terminator, in which case
688        // the decoder runs to the end and leaves nothing.
689        let consumed = self.rest.len() - decoder.remainder().len();
690        let (entry, rest) = self.rest.split_at(consumed);
691        self.rest = rest;
692        Some(PeerRepeaterEntryView { options: entry })
693    }
694}
695
696/// One peer repeater, as a lazily decoded option list.
697///
698/// Every accessor returns `None` for an option the responder omitted or wrote
699/// in a length this cannot read — a peer that names itself and nothing else is
700/// a legitimate entry, and one bad field is not a reason to drop the rest.
701#[derive(Clone, Copy, Debug)]
702pub struct PeerRepeaterEntryView<'a> {
703    options: &'a [u8],
704}
705
706impl<'a> PeerRepeaterEntryView<'a> {
707    /// The peer's hint: 3 bytes for a whole node hint, 2 for a router hint.
708    /// The only required option; an entry without one names nobody.
709    pub fn hint(&self) -> Option<&'a [u8]> {
710        self.find(peer_repeater_entry::NODE_HINT)
711            .filter(|value| (ROUTER_HINT_LEN..=NODE_HINT_LEN).contains(&value.len()))
712    }
713
714    /// The peer's display name, as learned from its identity.
715    pub fn name(&self) -> Option<&'a str> {
716        self.find(peer_repeater_entry::NODE_NAME)
717            .filter(|value| !value.is_empty() && value.len() <= PEER_REPEATER_NAME_MAX_LEN)
718            .and_then(|value| core::str::from_utf8(value).ok())
719    }
720
721    /// The most recent reception: RSSI in dBm and SNR.
722    pub fn rssi_snr(&self) -> Option<(i16, umsh_hal::Snr)> {
723        match self.find(peer_repeater_entry::RSSI_SNR) {
724            Some([rssi, snr]) => Some((
725                -i16::from(*rssi),
726                umsh_hal::Snr::from_quarter_db_steps(i16::from(*snr as i8)),
727            )),
728            _ => None,
729        }
730    }
731
732    /// Minutes since the responder last heard the peer.
733    pub fn last_heard_min(&self) -> Option<u16> {
734        self.find(peer_repeater_entry::LAST_HEARD)
735            .and_then(parse_minimal_be_u16)
736    }
737
738    /// The peer's position, in the variable-precision location format.
739    pub fn location(&self) -> Option<crate::location::NodeLocation> {
740        self.find(peer_repeater_entry::LOCATION)
741            .filter(|value| !value.is_empty())
742            .map(crate::location::NodeLocation::from_bytes)
743    }
744
745    /// The region codes the peer flood-forwards for.
746    ///
747    /// Codes rather than the strings an identity carries: the entry format is
748    /// tighter on space, and the string form is available from the peer's own
749    /// identity when one is wanted.
750    pub fn regions(&self) -> impl Iterator<Item = [u8; 2]> + 'a {
751        self.find(peer_repeater_entry::REGIONS)
752            .unwrap_or(&[])
753            .chunks_exact(2)
754            .map(|code| [code[0], code[1]])
755    }
756
757    fn find(&self, number: u16) -> Option<&'a [u8]> {
758        OptionDecoder::new(self.options)
759            .map_while(Result::ok)
760            .find(|(key, _)| *key == number)
761            .map(|(_, value)| value)
762    }
763}
764
765/// Decode a minimal big-endian unsigned integer of up to two octets.
766///
767/// The wire form drops leading zero octets, so a value under 256 arrives as
768/// one byte and an absent value as none at all.
769fn parse_minimal_be_u16(value: &[u8]) -> Option<u16> {
770    match value {
771        [] => Some(0),
772        [low] => Some(u16::from(*low)),
773        [high, low] => Some(u16::from_be_bytes([*high, *low])),
774        _ => None,
775    }
776}
777
778/// Builds the option block for a [`MacCommand::PeerRepeatersRequest`].
779///
780/// Options are emitted in ascending key order, so a nonce is added before a
781/// cursor. No `0xFF` marker is written: the payload is options-only.
782#[derive(Debug, Default)]
783pub struct PeerRepeatersRequestBuilder {
784    buf: Vec<u8>,
785    last_number: u16,
786}
787
788impl PeerRepeatersRequestBuilder {
789    /// Start an empty builder — the first page of an unnonced enumeration.
790    pub fn new() -> Self {
791        Self::default()
792    }
793
794    /// Add the correlation nonce the responder echoes.
795    pub fn nonce(mut self, nonce: u16) -> Result<Self, AppEncodeError> {
796        put_option(
797            &mut self.buf,
798            &mut self.last_number,
799            peer_repeaters_request::NONCE,
800            &nonce.to_be_bytes(),
801        )?;
802        Ok(self)
803    }
804
805    /// Resume from a cursor a previous response handed back.
806    pub fn cursor(mut self, cursor: &[u8]) -> Result<Self, AppEncodeError> {
807        put_option(
808            &mut self.buf,
809            &mut self.last_number,
810            peer_repeaters_request::CURSOR,
811            cursor,
812        )?;
813        Ok(self)
814    }
815
816    pub fn build(self) -> Vec<u8> {
817        self.buf
818    }
819}
820
821/// Builds the body of a [`MacCommand::PeerRepeatersResponse`] within a byte
822/// budget, so a responder packs whatever fits and pages the rest.
823///
824/// The page's own options are held as values rather than appended as they
825/// arrive: they are emitted in key order at [`build`](Self::build), so a
826/// responder that learns it needs a cursor only after packing stopped can
827/// still add one.
828#[derive(Debug)]
829pub struct PeerRepeatersResponseBuilder {
830    nonce: Option<u16>,
831    cursor: Option<Vec<u8>>,
832    total: Option<u8>,
833    cursor_reserve: usize,
834    entries: Vec<u8>,
835    budget: usize,
836}
837
838impl PeerRepeatersResponseBuilder {
839    /// Start a response whose whole body must fit `budget` octets.
840    pub fn new(budget: usize) -> Self {
841        Self {
842            nonce: None,
843            cursor: None,
844            total: None,
845            cursor_reserve: 0,
846            entries: Vec::new(),
847            budget,
848        }
849    }
850
851    /// Echo the request's nonce.
852    pub fn nonce(mut self, nonce: u16) -> Self {
853        self.nonce = Some(nonce);
854        self
855    }
856
857    /// Report how many entries the whole list holds.
858    pub fn total(mut self, total: u8) -> Self {
859        self.total = Some(total);
860        self
861    }
862
863    /// Hold back room for a cursor of `octets`, whose value is not known
864    /// until packing stops. Without it a response that fills its budget has
865    /// nowhere left to say where the next page begins.
866    pub fn reserve_cursor(mut self, octets: usize) -> Self {
867        self.cursor_reserve = match octets {
868            0 => 0,
869            // The option header is one octet for a cursor this short.
870            octets => octets + 1,
871        };
872        self
873    }
874
875    /// Name where a follow-up request should resume. Omit on the final page.
876    pub fn cursor(mut self, cursor: &[u8]) -> Self {
877        self.cursor = Some(Vec::from(cursor));
878        self
879    }
880
881    /// Append one entry if it still fits the budget.
882    ///
883    /// Returns whether it was taken, so a responder stops packing and issues
884    /// a cursor at the first refusal rather than at a count it guessed.
885    pub fn try_push(&mut self, entry: &PeerRepeaterEntry<'_>) -> Result<bool, AppEncodeError> {
886        let encoded = entry.encode()?;
887        // Every entry but the last is followed by its terminator, and the
888        // last one may omit it — so an entry fits when it and one separator
889        // do.
890        let separator = usize::from(!self.entries.is_empty());
891        let options = self.encode_options()?.len() + self.cursor_reserve;
892        if options + 1 + self.entries.len() + separator + encoded.len() > self.budget {
893            return Ok(false);
894        }
895        if separator == 1 {
896            self.entries.push(0xFF);
897        }
898        self.entries.extend_from_slice(&encoded);
899        Ok(true)
900    }
901
902    fn encode_options(&self) -> Result<Vec<u8>, AppEncodeError> {
903        let mut buf = Vec::new();
904        let mut last_number = 0u16;
905        if let Some(nonce) = self.nonce {
906            put_option(
907                &mut buf,
908                &mut last_number,
909                peer_repeaters_response::NONCE,
910                &nonce.to_be_bytes(),
911            )?;
912        }
913        if let Some(cursor) = &self.cursor {
914            put_option(
915                &mut buf,
916                &mut last_number,
917                peer_repeaters_response::CURSOR,
918                cursor,
919            )?;
920        }
921        if let Some(total) = self.total {
922            put_option(
923                &mut buf,
924                &mut last_number,
925                peer_repeaters_response::TOTAL,
926                &[total],
927            )?;
928        }
929        Ok(buf)
930    }
931
932    /// Finish the body: options, `0xFF`, then the entries.
933    pub fn build(self) -> Result<Vec<u8>, AppEncodeError> {
934        let mut body = self.encode_options()?;
935        body.push(0xFF);
936        body.extend_from_slice(&self.entries);
937        Ok(body)
938    }
939}
940
941/// One peer repeater to encode into a response, with every field the
942/// responder happens to know.
943#[derive(Clone, Copy, Debug, Default)]
944pub struct PeerRepeaterEntry<'a> {
945    /// 3 bytes when the whole node hint is known, 2 for a router hint.
946    pub hint: &'a [u8],
947    pub name: Option<&'a str>,
948    /// RSSI in dBm and SNR from the most recent reception.
949    pub rssi_snr: Option<(i16, umsh_hal::Snr)>,
950    pub last_heard_min: Option<u16>,
951    pub location: Option<crate::location::NodeLocation>,
952    /// Concatenated 2-octet region codes.
953    pub regions: &'a [u8],
954}
955
956impl PeerRepeaterEntry<'_> {
957    fn encode(&self) -> Result<Vec<u8>, AppEncodeError> {
958        if !(ROUTER_HINT_LEN..=NODE_HINT_LEN).contains(&self.hint.len()) {
959            return Err(AppEncodeError::InvalidField);
960        }
961        let mut buf = Vec::new();
962        let mut last_number = 0u16;
963        put_option(
964            &mut buf,
965            &mut last_number,
966            peer_repeater_entry::NODE_HINT,
967            self.hint,
968        )?;
969        if let Some(name) = self.name {
970            if name.len() > PEER_REPEATER_NAME_MAX_LEN {
971                return Err(AppEncodeError::InvalidField);
972            }
973            put_option(
974                &mut buf,
975                &mut last_number,
976                peer_repeater_entry::NODE_NAME,
977                name.as_bytes(),
978            )?;
979        }
980        if let Some((rssi_dbm, snr)) = self.rssi_snr {
981            // RSSI travels as an unsigned negative-dBm magnitude; a positive
982            // reading is not one a receiver can express, and one past −255
983            // dBm is below any radio's floor.
984            let rssi =
985                u8::try_from(-rssi_dbm.clamp(-255, 0)).map_err(|_| AppEncodeError::InvalidField)?;
986            let snr = i8::try_from(snr.as_quarter_db_steps().clamp(-128, 127))
987                .map_err(|_| AppEncodeError::InvalidField)?;
988            put_option(
989                &mut buf,
990                &mut last_number,
991                peer_repeater_entry::RSSI_SNR,
992                &[rssi, snr as u8],
993            )?;
994        }
995        if let Some(minutes) = self.last_heard_min {
996            let bytes = minutes.to_be_bytes();
997            let minimal = match bytes[0] {
998                0 => &bytes[1..],
999                _ => &bytes[..],
1000            };
1001            put_option(
1002                &mut buf,
1003                &mut last_number,
1004                peer_repeater_entry::LAST_HEARD,
1005                minimal,
1006            )?;
1007        }
1008        if let Some(location) = self.location
1009            && !location.is_unspecified()
1010        {
1011            put_option(
1012                &mut buf,
1013                &mut last_number,
1014                peer_repeater_entry::LOCATION,
1015                location.as_bytes(),
1016            )?;
1017        }
1018        if !self.regions.is_empty() {
1019            if self.regions.len() % 2 != 0 {
1020                return Err(AppEncodeError::InvalidField);
1021            }
1022            put_option(
1023                &mut buf,
1024                &mut last_number,
1025                peer_repeater_entry::REGIONS,
1026                self.regions,
1027            )?;
1028        }
1029        Ok(buf)
1030    }
1031}
1032
1033/// Append one option to a growing block, continuing its delta chain.
1034fn put_option(
1035    buf: &mut Vec<u8>,
1036    last_number: &mut u16,
1037    number: u16,
1038    value: &[u8],
1039) -> Result<(), AppEncodeError> {
1040    let mut scratch = alloc::vec![0u8; value.len() + 8];
1041    let mut encoder = OptionEncoder::with_last_number(&mut scratch, *last_number);
1042    encoder.put(number, value).map_err(AppEncodeError::Core)?;
1043    let written = encoder.finish();
1044    buf.extend_from_slice(&scratch[..written]);
1045    *last_number = number;
1046    Ok(())
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052
1053    fn encode_decode(cmd: MacCommand<'_>) {
1054        let mut buf = [0u8; 64];
1055        let len = encode(&cmd, &mut buf).expect("encode failed");
1056        let decoded = parse(&buf[..len]).expect("parse failed");
1057        assert_eq!(cmd, decoded, "round-trip failed for {cmd:?}");
1058    }
1059
1060    // --- round-trips ---
1061
1062    #[test]
1063    fn identity_request_unicast_no_options() {
1064        encode_decode(MacCommand::IdentityRequest { options: &[] });
1065        let mut buf = [0u8; 4];
1066        let len = encode(&MacCommand::IdentityRequest { options: &[] }, &mut buf).unwrap();
1067        assert_eq!(&buf[..len], &[0x01]);
1068    }
1069
1070    #[test]
1071    fn identity_request_with_options_round_trips() {
1072        let options = IdentityRequestBuilder::new()
1073            .nonce(0x12345678)
1074            .unwrap()
1075            .filter_hint(&NodeHint([0xAA, 0xBB, 0xCC]))
1076            .unwrap()
1077            .filter_role(NodeRole::Repeater)
1078            .unwrap()
1079            .build();
1080        encode_decode(MacCommand::IdentityRequest { options: &options });
1081    }
1082
1083    #[test]
1084    fn identity_request_options_are_appended_verbatim() {
1085        let options = IdentityRequestBuilder::new()
1086            .nonce(0x01020304)
1087            .unwrap()
1088            .build();
1089        let mut buf = [0u8; 16];
1090        let len = encode(&MacCommand::IdentityRequest { options: &options }, &mut buf).unwrap();
1091        assert_eq!(buf[0], 0x01);
1092        assert_eq!(&buf[1..len], options.as_slice());
1093    }
1094
1095    #[test]
1096    fn request_identity_framing_carries_readable_nonce() {
1097        // Mirrors PeerConnection::request_identity: a nonce-only options block
1098        // framed with a leading PayloadType::MacCommand byte. The receiver
1099        // dispatches on payload[0], then parses the command body.
1100        let options = IdentityRequestBuilder::new()
1101            .nonce(0xCAFEF00D)
1102            .unwrap()
1103            .build();
1104        let mut buf = [0u8; 128];
1105        buf[0] = umsh_core::PayloadType::MacCommand as u8;
1106        let n = encode(
1107            &MacCommand::IdentityRequest { options: &options },
1108            &mut buf[1..],
1109        )
1110        .unwrap()
1111            + 1;
1112
1113        assert_eq!(buf[0], umsh_core::PayloadType::MacCommand as u8);
1114        let decoded = parse(&buf[1..n]).expect("command body should parse");
1115        let MacCommand::IdentityRequest { options: body } = decoded else {
1116            panic!("expected IdentityRequest, got {decoded:?}");
1117        };
1118        assert_eq!(
1119            IdentityRequestFilters::new(body).nonce().unwrap(),
1120            Some(0xCAFEF00D)
1121        );
1122    }
1123
1124    #[test]
1125    fn identity_filters_nonce_round_trips() {
1126        let options = IdentityRequestBuilder::new()
1127            .nonce(0xDEADBEEF)
1128            .unwrap()
1129            .build();
1130        let filters = IdentityRequestFilters::new(&options);
1131        assert_eq!(filters.nonce().unwrap(), Some(0xDEADBEEF));
1132
1133        let empty = IdentityRequestFilters::new(&[]);
1134        assert_eq!(empty.nonce().unwrap(), None);
1135    }
1136
1137    #[test]
1138    fn identity_filters_no_filters_selects_everyone() {
1139        let filters = IdentityRequestFilters::new(&[]);
1140        assert!(
1141            filters
1142                .selects(
1143                    NodeRole::Sensor,
1144                    NodeCapabilities::empty(),
1145                    &NodeHint([1, 2, 3])
1146                )
1147                .unwrap()
1148        );
1149    }
1150
1151    #[test]
1152    fn identity_filters_hint_match_and_mismatch() {
1153        let options = IdentityRequestBuilder::new()
1154            .filter_hint(&NodeHint([0xAA, 0xBB, 0xCC]))
1155            .unwrap()
1156            .build();
1157        let filters = IdentityRequestFilters::new(&options);
1158        let caps = NodeCapabilities::empty();
1159        assert!(
1160            filters
1161                .selects(NodeRole::Chat, caps, &NodeHint([0xAA, 0xBB, 0xCC]))
1162                .unwrap()
1163        );
1164        assert!(
1165            !filters
1166                .selects(NodeRole::Chat, caps, &NodeHint([0xAA, 0xBB, 0xCD]))
1167                .unwrap()
1168        );
1169    }
1170
1171    #[test]
1172    fn identity_filters_hint_matches_on_a_prefix() {
1173        let caps = NodeCapabilities::empty();
1174        let selects = |prefix: &[u8], hint: NodeHint| {
1175            let options = IdentityRequestBuilder::new()
1176                .filter_hint_prefix(prefix)
1177                .unwrap()
1178                .build();
1179            IdentityRequestFilters::new(&options)
1180                .selects(NodeRole::Chat, caps, &hint)
1181                .unwrap()
1182        };
1183
1184        // Two bytes is the router hint a route names its hops by.
1185        assert!(selects(&[0xAA, 0xBB], NodeHint([0xAA, 0xBB, 0xCC])));
1186        assert!(!selects(&[0xAA, 0xBC], NodeHint([0xAA, 0xBB, 0xCC])));
1187        assert!(selects(&[0xAA], NodeHint([0xAA, 0xBB, 0xCC])));
1188        assert!(!selects(&[0xAB], NodeHint([0xAA, 0xBB, 0xCC])));
1189
1190        // A prefix is only ever a prefix: it never matches from the middle.
1191        assert!(!selects(&[0xBB, 0xCC], NodeHint([0xAA, 0xBB, 0xCC])));
1192    }
1193
1194    #[test]
1195    fn identity_filters_reject_unusable_hint_lengths() {
1196        // An empty prefix would select every node while `hint_filtered` said
1197        // the request was narrowed, and an over-long one can select nobody.
1198        assert!(
1199            IdentityRequestBuilder::new()
1200                .filter_hint_prefix(&[])
1201                .is_err()
1202        );
1203        assert!(
1204            IdentityRequestBuilder::new()
1205                .filter_hint_prefix(&[0xAA, 0xBB, 0xCC, 0xDD])
1206                .is_err()
1207        );
1208
1209        // Off the wire those lengths are still refused, since nothing stops a
1210        // peer from encoding one by hand.
1211        let caps = NodeCapabilities::empty();
1212        let hint = NodeHint([0xAA, 0xBB, 0xCC]);
1213        for value in [[].as_slice(), &[0xAA, 0xBB, 0xCC, 0xDD]] {
1214            let mut options = Vec::new();
1215            let mut scratch = [0u8; 8];
1216            let mut enc = OptionEncoder::new(&mut scratch);
1217            enc.put(identity_filter::FILTER_NODE_HINT, value).unwrap();
1218            let n = enc.finish();
1219            options.extend_from_slice(&scratch[..n]);
1220            let filters = IdentityRequestFilters::new(&options);
1221            assert!(filters.hint_filtered());
1222            assert!(!filters.selects(NodeRole::Chat, caps, &hint).unwrap());
1223        }
1224    }
1225
1226    #[test]
1227    fn identity_filters_report_whether_a_hint_names_one_node() {
1228        let named = |prefixes: &[&[u8]]| {
1229            let mut builder = IdentityRequestBuilder::new();
1230            for prefix in prefixes {
1231                builder = builder.filter_hint_prefix(prefix).unwrap();
1232            }
1233            IdentityRequestFilters::new(&builder.build()).hint_names_one_node()
1234        };
1235
1236        // Two bytes is one node in 65,536, three is one in 16.7 million.
1237        assert!(named(&[&[0xAA, 0xBB, 0xCC]]));
1238        assert!(named(&[&[0xAA, 0xBB]]));
1239        // One byte is a 256th of the mesh, which is a crowd, not a name.
1240        assert!(!named(&[&[0xAA]]));
1241
1242        // Same-type filters are OR, so the shortest one decides.
1243        assert!(named(&[&[0xAA, 0xBB], &[0xCC, 0xDD]]));
1244        assert!(!named(&[&[0xAA, 0xBB], &[0xCC]]));
1245
1246        // A request with no hint filter at all is narrowed by nothing, however
1247        // else it selects.
1248        let by_role = IdentityRequestBuilder::new()
1249            .filter_role(NodeRole::Chat)
1250            .unwrap()
1251            .build();
1252        assert!(!IdentityRequestFilters::new(&by_role).hint_names_one_node());
1253
1254        // A block that will not decode reads as unnarrowed, so the reply keeps
1255        // its hold rather than racing every other answer on a malformed ask.
1256        // A hint option claiming five bytes of value with none behind it: the
1257        // case where trusting the decode would drop the hold on a filter whose
1258        // length is exactly what could not be read.
1259        let truncated = [0x35u8];
1260        let filters = IdentityRequestFilters::new(&truncated);
1261        assert!(
1262            filters
1263                .selects(NodeRole::Chat, NodeCapabilities::empty(), &NodeHint([0; 3]))
1264                .is_err(),
1265            "the block has to be undecodable for this to test the error path"
1266        );
1267        assert!(!filters.hint_names_one_node());
1268    }
1269
1270    #[test]
1271    fn identity_filters_report_hint_presence() {
1272        let hinted = IdentityRequestBuilder::new()
1273            .nonce(0x0102_0304)
1274            .unwrap()
1275            .filter_hint(&NodeHint([0xAA, 0xBB, 0xCC]))
1276            .unwrap()
1277            .build();
1278        assert!(IdentityRequestFilters::new(&hinted).hint_filtered());
1279
1280        let by_role = IdentityRequestBuilder::new()
1281            .filter_role(NodeRole::Repeater)
1282            .unwrap()
1283            .build();
1284        assert!(!IdentityRequestFilters::new(&by_role).hint_filtered());
1285
1286        // No filters at all, and a truncated block, both read as unfiltered.
1287        assert!(!IdentityRequestFilters::new(&[]).hint_filtered());
1288        assert!(!IdentityRequestFilters::new(&[0x33, 0xAA]).hint_filtered());
1289    }
1290
1291    #[test]
1292    fn identity_filters_repeated_type_is_or() {
1293        let options = IdentityRequestBuilder::new()
1294            .filter_role(NodeRole::Repeater)
1295            .unwrap()
1296            .filter_role(NodeRole::Chat)
1297            .unwrap()
1298            .build();
1299        let filters = IdentityRequestFilters::new(&options);
1300        let caps = NodeCapabilities::empty();
1301        let hint = NodeHint([1, 2, 3]);
1302        assert!(filters.selects(NodeRole::Repeater, caps, &hint).unwrap());
1303        assert!(filters.selects(NodeRole::Chat, caps, &hint).unwrap());
1304        assert!(!filters.selects(NodeRole::Sensor, caps, &hint).unwrap());
1305    }
1306
1307    #[test]
1308    fn identity_filters_distinct_types_are_and() {
1309        let options = IdentityRequestBuilder::new()
1310            .filter_role(NodeRole::Repeater)
1311            .unwrap()
1312            .filter_caps(NodeCapabilities::REPEATER)
1313            .unwrap()
1314            .build();
1315        let filters = IdentityRequestFilters::new(&options);
1316        let hint = NodeHint([1, 2, 3]);
1317        // Both must hold.
1318        assert!(
1319            filters
1320                .selects(NodeRole::Repeater, NodeCapabilities::REPEATER, &hint)
1321                .unwrap()
1322        );
1323        // Role matches but caps don't.
1324        assert!(
1325            !filters
1326                .selects(NodeRole::Repeater, NodeCapabilities::empty(), &hint)
1327                .unwrap()
1328        );
1329    }
1330
1331    #[test]
1332    fn identity_filters_caps_requires_all_requested_bits() {
1333        let options = IdentityRequestBuilder::new()
1334            .filter_caps(NodeCapabilities::REPEATER | NodeCapabilities::TEXT_MESSAGES)
1335            .unwrap()
1336            .build();
1337        let filters = IdentityRequestFilters::new(&options);
1338        let hint = NodeHint([1, 2, 3]);
1339        // Superset matches.
1340        assert!(
1341            filters
1342                .selects(
1343                    NodeRole::Chat,
1344                    NodeCapabilities::REPEATER
1345                        | NodeCapabilities::TEXT_MESSAGES
1346                        | NodeCapabilities::MOBILE,
1347                    &hint,
1348                )
1349                .unwrap()
1350        );
1351        // Missing one requested bit does not match.
1352        assert!(
1353            !filters
1354                .selects(NodeRole::Chat, NodeCapabilities::REPEATER, &hint)
1355                .unwrap()
1356        );
1357    }
1358
1359    #[test]
1360    fn identity_filters_unknown_critical_option_excludes() {
1361        // Key 9 is unknown and critical (odd).
1362        let mut buf = [0u8; 8];
1363        let mut enc = OptionEncoder::new(&mut buf);
1364        enc.put(9, &[0x01]).unwrap();
1365        let n = enc.finish();
1366        let filters = IdentityRequestFilters::new(&buf[..n]);
1367        assert!(
1368            !filters
1369                .selects(
1370                    NodeRole::Chat,
1371                    NodeCapabilities::empty(),
1372                    &NodeHint([1, 2, 3])
1373                )
1374                .unwrap()
1375        );
1376    }
1377
1378    #[test]
1379    fn identity_filters_unknown_elective_option_ignored() {
1380        // Key 8 is unknown and elective (even); alongside a matching role filter.
1381        let mut buf = [0u8; 16];
1382        let mut enc = OptionEncoder::new(&mut buf);
1383        enc.put(5, &[NodeRole::Repeater.as_byte()]).unwrap();
1384        enc.put(8, &[0xFE]).unwrap();
1385        let n = enc.finish();
1386        let filters = IdentityRequestFilters::new(&buf[..n]);
1387        assert!(
1388            filters
1389                .selects(
1390                    NodeRole::Repeater,
1391                    NodeCapabilities::empty(),
1392                    &NodeHint([1, 2, 3])
1393                )
1394                .unwrap()
1395        );
1396    }
1397
1398    #[test]
1399    fn signal_report_request() {
1400        encode_decode(MacCommand::SignalReportRequest);
1401    }
1402
1403    #[test]
1404    fn signal_report_response() {
1405        encode_decode(MacCommand::SignalReportResponse {
1406            rssi: 200,
1407            snr: -10,
1408        });
1409        let mut buf = [0u8; 8];
1410        let len = encode(
1411            &MacCommand::SignalReportResponse {
1412                rssi: 0xAB,
1413                snr: -1,
1414            },
1415            &mut buf,
1416        )
1417        .unwrap();
1418        assert_eq!(&buf[..len], &[0x03, 0xAB, 0xFF]);
1419    }
1420
1421    #[test]
1422    fn echo_request() {
1423        encode_decode(MacCommand::EchoRequest {
1424            data: &[0x01, 0x02, 0x03],
1425        });
1426        encode_decode(MacCommand::EchoRequest { data: &[] });
1427    }
1428
1429    #[test]
1430    fn echo_response() {
1431        encode_decode(MacCommand::EchoResponse {
1432            data: &[0xDE, 0xAD],
1433        });
1434    }
1435
1436    #[test]
1437    fn pfs_session_request() {
1438        let key = PublicKey([0xABu8; 32]);
1439        encode_decode(MacCommand::PfsSessionRequest {
1440            ephemeral_key: key,
1441            duration_minutes: 60,
1442        });
1443        let mut buf = [0u8; 40];
1444        let len = encode(
1445            &MacCommand::PfsSessionRequest {
1446                ephemeral_key: key,
1447                duration_minutes: 0x0102,
1448            },
1449            &mut buf,
1450        )
1451        .unwrap();
1452        assert_eq!(len, 1 + 32 + 2);
1453        assert_eq!(buf[0], 0x06);
1454        assert_eq!(&buf[1..33], &[0xABu8; 32]);
1455        assert_eq!(&buf[33..35], &[0x01, 0x02]);
1456    }
1457
1458    #[test]
1459    fn pfs_session_response() {
1460        let key = PublicKey([0x55u8; 32]);
1461        encode_decode(MacCommand::PfsSessionResponse {
1462            ephemeral_key: key,
1463            duration_minutes: 120,
1464        });
1465    }
1466
1467    #[test]
1468    fn end_pfs_session() {
1469        encode_decode(MacCommand::EndPfsSession);
1470        let mut buf = [0u8; 4];
1471        let len = encode(&MacCommand::EndPfsSession, &mut buf).unwrap();
1472        assert_eq!(&buf[..len], &[0x08]);
1473    }
1474
1475    #[test]
1476    fn noop() {
1477        encode_decode(MacCommand::Noop);
1478        let mut buf = [0u8; 4];
1479        let len = encode(&MacCommand::Noop, &mut buf).unwrap();
1480        assert_eq!(&buf[..len], &[0x09]);
1481    }
1482
1483    #[test]
1484    fn noop_tolerates_a_body_it_has_no_use_for() {
1485        assert_eq!(parse(&[0x09, 0xDE, 0xAD]).unwrap(), MacCommand::Noop);
1486    }
1487
1488    // --- OwnedMacCommand From conversion ---
1489
1490    #[test]
1491    fn owned_from_borrowed_echo() {
1492        let cmd = MacCommand::EchoRequest {
1493            data: &[0x01, 0x02],
1494        };
1495        let owned = OwnedMacCommand::from(cmd);
1496        assert_eq!(
1497            owned,
1498            OwnedMacCommand::EchoRequest {
1499                data: alloc::vec![0x01, 0x02]
1500            }
1501        );
1502    }
1503
1504    // --- parse error cases ---
1505
1506    #[test]
1507    fn parse_empty_returns_truncated() {
1508        assert!(matches!(
1509            parse(&[]),
1510            Err(crate::AppParseError::Core(umsh_core::ParseError::Truncated))
1511        ));
1512    }
1513
1514    #[test]
1515    fn parse_unknown_command_id() {
1516        assert!(matches!(
1517            parse(&[0xFF]),
1518            Err(crate::AppParseError::InvalidCommandId(0xFF))
1519        ));
1520    }
1521
1522    #[test]
1523    fn parse_command_zero_is_unallocated() {
1524        assert!(matches!(
1525            parse(&[0x00]),
1526            Err(crate::AppParseError::InvalidCommandId(0))
1527        ));
1528    }
1529
1530    #[test]
1531    fn parse_identity_request_accepts_option_block() {
1532        // A well-formed option block is accepted as the request payload.
1533        let decoded = parse(&[0x01, 0x00]).expect("valid options should parse");
1534        assert!(matches!(decoded, MacCommand::IdentityRequest { .. }));
1535    }
1536
1537    #[test]
1538    fn parse_identity_request_rejects_malformed_options() {
1539        // 0x41: delta 4, length 1, but no value byte follows -> truncated.
1540        assert!(parse(&[0x01, 0x41]).is_err());
1541    }
1542
1543    #[test]
1544    fn parse_signal_report_response_wrong_length() {
1545        assert!(parse(&[0x03, 0x01]).is_err()); // need exactly 2 body bytes
1546    }
1547
1548    #[test]
1549    fn parse_pfs_request_wrong_length() {
1550        assert!(parse(&[0x06, 0x00]).is_err()); // need exactly 34 body bytes
1551    }
1552
1553    #[test]
1554    fn parse_end_pfs_nonempty_body() {
1555        assert!(parse(&[0x08, 0x00]).is_err());
1556    }
1557
1558    // --- peer repeaters ---
1559
1560    #[test]
1561    fn a_cursorless_peer_repeaters_request_is_one_byte() {
1562        let mut buf = [0u8; 4];
1563        let len = encode(
1564            &MacCommand::PeerRepeatersRequest {
1565                options: &PeerRepeatersRequestBuilder::new().build(),
1566            },
1567            &mut buf,
1568        )
1569        .unwrap();
1570        assert_eq!(&buf[..len], &[0x0A], "options-only, so no end marker");
1571    }
1572
1573    #[test]
1574    fn a_peer_repeaters_request_carries_its_nonce_and_cursor() {
1575        let options = PeerRepeatersRequestBuilder::new()
1576            .nonce(0xBEEF)
1577            .unwrap()
1578            .cursor(&[0x01, 0x02, 0x03])
1579            .unwrap()
1580            .build();
1581        encode_decode(MacCommand::PeerRepeatersRequest { options: &options });
1582
1583        let view = PeerRepeatersRequestView::new(&options);
1584        assert_eq!(view.nonce(), Some(0xBEEF));
1585        assert_eq!(view.cursor(), Some(&[0x01, 0x02, 0x03][..]));
1586
1587        let empty = PeerRepeatersRequestView::new(&[]);
1588        assert_eq!(empty.nonce(), None);
1589        assert_eq!(empty.cursor(), None);
1590    }
1591
1592    /// The entry fields are individually optional, so a page has to read
1593    /// back whatever subset of them the responder happened to know.
1594    #[test]
1595    fn a_peer_repeaters_response_round_trips_every_entry_field() {
1596        let mut builder = PeerRepeatersResponseBuilder::new(200)
1597            .nonce(0xBEEF)
1598            .total(2);
1599        let location = crate::location::NodeLocation::from_lat_lon(44.05, -123.09, 4);
1600        assert!(
1601            builder
1602                .try_push(&PeerRepeaterEntry {
1603                    hint: &[0xAA, 0xBB, 0xCC],
1604                    name: Some("Ridge"),
1605                    rssi_snr: Some((-97, umsh_hal::Snr::from_decibels(-7))),
1606                    last_heard_min: Some(400),
1607                    location: Some(location),
1608                    regions: &[0x78, 0x53, 0x31, 0xD9],
1609                })
1610                .unwrap()
1611        );
1612        // A hint and nothing else is a legitimate entry: an observation
1613        // with no identity behind it names a hop and reports what was
1614        // heard, and here not even that.
1615        assert!(
1616            builder
1617                .try_push(&PeerRepeaterEntry {
1618                    hint: &[0x11, 0x22],
1619                    ..PeerRepeaterEntry::default()
1620                })
1621                .unwrap()
1622        );
1623        let body = builder.build().unwrap();
1624
1625        let view = PeerRepeatersResponseView::new(&body);
1626        assert_eq!(view.nonce(), Some(0xBEEF));
1627        assert_eq!(view.total(), Some(2));
1628        assert_eq!(view.cursor(), None, "the final page names no resume point");
1629
1630        let entries: Vec<_> = view.entries().collect();
1631        assert_eq!(entries.len(), 2);
1632        assert_eq!(entries[0].hint(), Some(&[0xAA, 0xBB, 0xCC][..]));
1633        assert_eq!(entries[0].name(), Some("Ridge"));
1634        assert_eq!(
1635            entries[0].rssi_snr(),
1636            Some((-97, umsh_hal::Snr::from_decibels(-7)))
1637        );
1638        assert_eq!(entries[0].last_heard_min(), Some(400));
1639        assert_eq!(entries[0].location(), Some(location));
1640        assert_eq!(
1641            entries[0].regions().collect::<Vec<_>>(),
1642            [[0x78, 0x53], [0x31, 0xD9]]
1643        );
1644        assert_eq!(entries[1].hint(), Some(&[0x11, 0x22][..]));
1645        assert_eq!(entries[1].name(), None);
1646        assert_eq!(entries[1].rssi_snr(), None);
1647        assert_eq!(entries[1].last_heard_min(), None);
1648        assert_eq!(entries[1].location(), None);
1649        assert_eq!(entries[1].regions().count(), 0);
1650
1651        encode_decode_long(MacCommand::PeerRepeatersResponse { body: &body });
1652    }
1653
1654    /// The last entry may omit its terminator, so the walk cannot rely on
1655    /// one to know an entry ended.
1656    #[test]
1657    fn the_final_entry_needs_no_terminator() {
1658        let mut builder = PeerRepeatersResponseBuilder::new(64);
1659        for hint in [[0x11u8, 0x22], [0x33, 0x44]] {
1660            assert!(
1661                builder
1662                    .try_push(&PeerRepeaterEntry {
1663                        hint: &hint,
1664                        ..PeerRepeaterEntry::default()
1665                    })
1666                    .unwrap()
1667            );
1668        }
1669        let body = builder.build().unwrap();
1670        assert_ne!(
1671            body.last(),
1672            Some(&0xFF),
1673            "nothing follows the last entry, so nothing marks its end"
1674        );
1675        assert_eq!(PeerRepeatersResponseView::new(&body).entries().count(), 2);
1676
1677        // A responder that writes the terminator anyway is read the same
1678        // way rather than as an extra empty entry.
1679        let mut terminated = body.clone();
1680        terminated.push(0xFF);
1681        assert_eq!(
1682            PeerRepeatersResponseView::new(&terminated)
1683                .entries()
1684                .count(),
1685            2
1686        );
1687    }
1688
1689    #[test]
1690    fn a_response_stops_packing_at_its_budget_rather_than_overrunning_it() {
1691        // Options (none) plus the end marker leave four octets, which is
1692        // one four-octet entry and no room for a separator and a second.
1693        let mut builder = PeerRepeatersResponseBuilder::new(5);
1694        let entry = PeerRepeaterEntry {
1695            hint: &[0x11, 0x22, 0x33],
1696            ..PeerRepeaterEntry::default()
1697        };
1698        assert!(builder.try_push(&entry).unwrap());
1699        assert!(!builder.try_push(&entry).unwrap());
1700        let body = builder.build().unwrap();
1701        assert!(body.len() <= 5, "body is {} octets", body.len());
1702        assert_eq!(PeerRepeatersResponseView::new(&body).entries().count(), 1);
1703    }
1704
1705    /// A page that fills its budget is exactly the page that needs a
1706    /// cursor, so the room for one is held back before packing starts.
1707    #[test]
1708    fn a_reserved_cursor_still_fits_after_the_page_is_packed() {
1709        const BUDGET: usize = 16;
1710        let mut builder = PeerRepeatersResponseBuilder::new(BUDGET).reserve_cursor(3);
1711        let entry = PeerRepeaterEntry {
1712            hint: &[0x11, 0x22, 0x33],
1713            ..PeerRepeaterEntry::default()
1714        };
1715        let mut packed = 0;
1716        while builder.try_push(&entry).unwrap() {
1717            packed += 1;
1718        }
1719        assert!(packed > 0);
1720        let body = builder.cursor(&[0x00, 0x01, 0x02]).build().unwrap();
1721        assert!(body.len() <= BUDGET, "body is {} octets", body.len());
1722        let view = PeerRepeatersResponseView::new(&body);
1723        assert_eq!(view.cursor(), Some(&[0x00, 0x01, 0x02][..]));
1724        assert_eq!(view.entries().count(), packed);
1725    }
1726
1727    #[test]
1728    fn a_paged_response_names_where_to_resume() {
1729        let body = PeerRepeatersResponseBuilder::new(64)
1730            .total(9)
1731            .cursor(&[0x00, 0x07, 0x04])
1732            .build()
1733            .unwrap();
1734        let view = PeerRepeatersResponseView::new(&body);
1735        assert_eq!(view.total(), Some(9));
1736        assert_eq!(view.cursor(), Some(&[0x00, 0x07, 0x04][..]));
1737        assert_eq!(view.entries().count(), 0);
1738    }
1739
1740    #[test]
1741    fn an_entry_without_a_usable_hint_names_nobody() {
1742        let mut buf = Vec::new();
1743        let mut last = 0u16;
1744        // One octet is a 256th of the mesh, not a peer.
1745        put_option(&mut buf, &mut last, peer_repeater_entry::NODE_HINT, &[0x11]).unwrap();
1746        let mut body = alloc::vec![0xFFu8];
1747        body.extend_from_slice(&buf);
1748        let entries: Vec<_> = PeerRepeatersResponseView::new(&body).entries().collect();
1749        assert_eq!(entries.len(), 1);
1750        assert_eq!(entries[0].hint(), None);
1751    }
1752
1753    /// Once an entry's framing is broken, the next entry boundary is
1754    /// unknowable — the terminator is only recognizable through correct
1755    /// framing, since values may contain `0xFF`. The walk keeps what it
1756    /// decoded and stops there.
1757    #[test]
1758    fn a_broken_entry_ends_the_walk_but_keeps_what_came_before() {
1759        let mut body = alloc::vec![0xFFu8];
1760        // A whole entry, then one whose second option claims 253 octets it
1761        // does not have, then a well-formed entry stranded behind it.
1762        body.extend_from_slice(&[0x03, 0xAA, 0xBB, 0xCC, 0xFF]);
1763        body.extend_from_slice(&[0x03, 0x11, 0x22, 0x33, 0x0D, 0xF0, 0xFF]);
1764        body.extend_from_slice(&[0x03, 0x44, 0x55, 0x66]);
1765        let entries: Vec<_> = PeerRepeatersResponseView::new(&body).entries().collect();
1766        assert_eq!(entries.len(), 2);
1767        assert_eq!(entries[0].hint(), Some(&[0xAA, 0xBB, 0xCC][..]));
1768        // The broken entry still answers with the options ahead of the
1769        // damage; the entry stranded behind it is the cost.
1770        assert_eq!(entries[1].hint(), Some(&[0x11, 0x22, 0x33][..]));
1771    }
1772
1773    #[test]
1774    fn an_entry_hint_outside_two_or_three_octets_is_refused_at_the_encoder() {
1775        let mut builder = PeerRepeatersResponseBuilder::new(64);
1776        for hint in [&[0x11u8][..], &[0x11, 0x22, 0x33, 0x44][..], &[][..]] {
1777            assert!(
1778                builder
1779                    .try_push(&PeerRepeaterEntry {
1780                        hint,
1781                        ..PeerRepeaterEntry::default()
1782                    })
1783                    .is_err()
1784            );
1785        }
1786    }
1787
1788    #[test]
1789    fn last_heard_uses_the_shortest_encoding_that_holds_it() {
1790        for (minutes, expected) in [(0u16, 1usize), (255, 1), (256, 2), (65535, 2)] {
1791            let mut builder = PeerRepeatersResponseBuilder::new(64);
1792            builder
1793                .try_push(&PeerRepeaterEntry {
1794                    hint: &[0x11, 0x22],
1795                    last_heard_min: Some(minutes),
1796                    ..PeerRepeaterEntry::default()
1797                })
1798                .unwrap();
1799            let body = builder.build().unwrap();
1800            let view = PeerRepeatersResponseView::new(&body);
1801            let entry = view.entries().next().unwrap();
1802            assert_eq!(entry.last_heard_min(), Some(minutes));
1803            assert_eq!(
1804                entry.find(peer_repeater_entry::LAST_HEARD).unwrap().len(),
1805                expected,
1806                "minutes {minutes}"
1807            );
1808        }
1809    }
1810
1811    fn encode_decode_long(cmd: MacCommand<'_>) {
1812        let mut buf = [0u8; 256];
1813        let len = encode(&cmd, &mut buf).expect("encode failed");
1814        assert_eq!(parse(&buf[..len]).expect("parse failed"), cmd);
1815    }
1816}