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) equals this
21    /// value. 3 bytes.
22    pub const FILTER_NODE_HINT: u16 = 3;
23    /// Match only nodes whose primary role equals this value. 1 byte.
24    pub const FILTER_NODE_ROLE: u16 = 5;
25    /// Match only nodes whose capability bitmap has every bit set that is set
26    /// in this value. 1 byte.
27    pub const FILTER_NODE_CAPS: u16 = 7;
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31#[repr(u8)]
32pub enum CommandId {
33    IdentityRequest = 1,
34    SignalReportRequest = 2,
35    SignalReportResponse = 3,
36    EchoRequest = 4,
37    EchoResponse = 5,
38    PfsSessionRequest = 6,
39    PfsSessionResponse = 7,
40    EndPfsSession = 8,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum MacCommand<'a> {
45    /// Request that the destination respond with its node identity.
46    ///
47    /// `options` is a CoAP-style option block of [`identity_filter`] keys,
48    /// empty for a plain unicast request. Interpret it with
49    /// [`IdentityRequestFilters`].
50    IdentityRequest {
51        options: &'a [u8],
52    },
53    SignalReportRequest,
54    SignalReportResponse {
55        rssi: u8,
56        snr: i8,
57    },
58    EchoRequest {
59        data: &'a [u8],
60    },
61    EchoResponse {
62        data: &'a [u8],
63    },
64    PfsSessionRequest {
65        ephemeral_key: umsh_core::PublicKey,
66        duration_minutes: u16,
67    },
68    PfsSessionResponse {
69        ephemeral_key: umsh_core::PublicKey,
70        duration_minutes: u16,
71    },
72    EndPfsSession,
73}
74
75pub fn parse(payload: &[u8]) -> Result<MacCommand<'_>, AppParseError> {
76    let (&command_id, body) = payload
77        .split_first()
78        .ok_or(AppParseError::Core(umsh_core::ParseError::Truncated))?;
79
80    match command_id {
81        1 => {
82            // Validate the option block is structurally well-formed CoAP
83            // options; individual filter values are interpreted (and tolerated)
84            // lazily by IdentityRequestFilters, per receiver tolerance.
85            for item in OptionDecoder::new(body) {
86                item.map_err(AppParseError::Core)?;
87            }
88            Ok(MacCommand::IdentityRequest { options: body })
89        }
90        2 => {
91            if body.is_empty() {
92                Ok(MacCommand::SignalReportRequest)
93            } else {
94                Err(AppParseError::InvalidOptionValue)
95            }
96        }
97        3 => match body {
98            [rssi, snr] => Ok(MacCommand::SignalReportResponse {
99                rssi: *rssi,
100                snr: *snr as i8,
101            }),
102            _ => Err(AppParseError::InvalidLength {
103                expected: 2,
104                actual: body.len(),
105            }),
106        },
107        4 => Ok(MacCommand::EchoRequest { data: body }),
108        5 => Ok(MacCommand::EchoResponse { data: body }),
109        6 => parse_pfs(body, true),
110        7 => parse_pfs(body, false),
111        8 => {
112            if body.is_empty() {
113                Ok(MacCommand::EndPfsSession)
114            } else {
115                Err(AppParseError::InvalidOptionValue)
116            }
117        }
118        other => Err(AppParseError::InvalidCommandId(other)),
119    }
120}
121
122fn parse_pfs(payload: &[u8], request: bool) -> Result<MacCommand<'_>, AppParseError> {
123    if payload.len() != 34 {
124        return Err(AppParseError::InvalidLength {
125            expected: 34,
126            actual: payload.len(),
127        });
128    }
129    let ephemeral_key = umsh_core::PublicKey(*fixed(&payload[..32])?);
130    let duration_minutes = u16::from_be_bytes(*fixed(&payload[32..34])?);
131    Ok(if request {
132        MacCommand::PfsSessionRequest {
133            ephemeral_key,
134            duration_minutes,
135        }
136    } else {
137        MacCommand::PfsSessionResponse {
138            ephemeral_key,
139            duration_minutes,
140        }
141    })
142}
143
144#[derive(Clone, Debug, PartialEq, Eq)]
145pub enum OwnedMacCommand {
146    IdentityRequest {
147        options: Vec<u8>,
148    },
149    SignalReportRequest,
150    SignalReportResponse {
151        rssi: u8,
152        snr: i8,
153    },
154    EchoRequest {
155        data: Vec<u8>,
156    },
157    EchoResponse {
158        data: Vec<u8>,
159    },
160    PfsSessionRequest {
161        ephemeral_key: PublicKey,
162        duration_minutes: u16,
163    },
164    PfsSessionResponse {
165        ephemeral_key: PublicKey,
166        duration_minutes: u16,
167    },
168    EndPfsSession,
169}
170
171impl From<MacCommand<'_>> for OwnedMacCommand {
172    fn from(value: MacCommand<'_>) -> Self {
173        match value {
174            MacCommand::IdentityRequest { options } => Self::IdentityRequest {
175                options: Vec::from(options),
176            },
177            MacCommand::SignalReportRequest => Self::SignalReportRequest,
178            MacCommand::SignalReportResponse { rssi, snr } => {
179                Self::SignalReportResponse { rssi, snr }
180            }
181            MacCommand::EchoRequest { data } => Self::EchoRequest {
182                data: Vec::from(data),
183            },
184            MacCommand::EchoResponse { data } => Self::EchoResponse {
185                data: Vec::from(data),
186            },
187            MacCommand::PfsSessionRequest {
188                ephemeral_key,
189                duration_minutes,
190            } => Self::PfsSessionRequest {
191                ephemeral_key,
192                duration_minutes,
193            },
194            MacCommand::PfsSessionResponse {
195                ephemeral_key,
196                duration_minutes,
197            } => Self::PfsSessionResponse {
198                ephemeral_key,
199                duration_minutes,
200            },
201            MacCommand::EndPfsSession => Self::EndPfsSession,
202        }
203    }
204}
205
206pub fn encode(cmd: &MacCommand<'_>, buf: &mut [u8]) -> Result<usize, AppEncodeError> {
207    let mut pos = 0usize;
208    match cmd {
209        MacCommand::IdentityRequest { options } => {
210            push_byte(buf, &mut pos, CommandId::IdentityRequest as u8)?;
211            copy_into(buf, &mut pos, options)?;
212        }
213        MacCommand::SignalReportRequest => {
214            push_byte(buf, &mut pos, CommandId::SignalReportRequest as u8)?;
215        }
216        MacCommand::SignalReportResponse { rssi, snr } => {
217            push_byte(buf, &mut pos, CommandId::SignalReportResponse as u8)?;
218            push_byte(buf, &mut pos, *rssi)?;
219            push_byte(buf, &mut pos, *snr as u8)?;
220        }
221        MacCommand::EchoRequest { data } => {
222            push_byte(buf, &mut pos, CommandId::EchoRequest as u8)?;
223            copy_into(buf, &mut pos, data)?;
224        }
225        MacCommand::EchoResponse { data } => {
226            push_byte(buf, &mut pos, CommandId::EchoResponse as u8)?;
227            copy_into(buf, &mut pos, data)?;
228        }
229        MacCommand::PfsSessionRequest {
230            ephemeral_key,
231            duration_minutes,
232        } => {
233            push_byte(buf, &mut pos, CommandId::PfsSessionRequest as u8)?;
234            copy_into(buf, &mut pos, &ephemeral_key.0)?;
235            copy_into(buf, &mut pos, &duration_minutes.to_be_bytes())?;
236        }
237        MacCommand::PfsSessionResponse {
238            ephemeral_key,
239            duration_minutes,
240        } => {
241            push_byte(buf, &mut pos, CommandId::PfsSessionResponse as u8)?;
242            copy_into(buf, &mut pos, &ephemeral_key.0)?;
243            copy_into(buf, &mut pos, &duration_minutes.to_be_bytes())?;
244        }
245        MacCommand::EndPfsSession => push_byte(buf, &mut pos, CommandId::EndPfsSession as u8)?,
246    }
247    Ok(pos)
248}
249
250/// Interprets the option block of an [`MacCommand::IdentityRequest`].
251///
252/// Borrows the raw block and decodes its [`identity_filter`] options on demand.
253/// A responder uses [`nonce`](Self::nonce) to obtain the correlation value it
254/// must echo, and [`selects`](Self::selects) to decide whether it is a target
255/// of the request.
256#[derive(Clone, Copy, Debug)]
257pub struct IdentityRequestFilters<'a> {
258    options: &'a [u8],
259}
260
261impl<'a> IdentityRequestFilters<'a> {
262    /// Wrap the option block carried by an Identity Request.
263    pub fn new(options: &'a [u8]) -> Self {
264        Self { options }
265    }
266
267    /// The correlation nonce the responder must echo into its identity's Nonce
268    /// option, or `None` if the request carried no `NONCE` option.
269    ///
270    /// Returns the first `NONCE` option; tolerates minimal (≤4 byte) encodings.
271    pub fn nonce(&self) -> Result<Option<u32>, AppParseError> {
272        for item in OptionDecoder::new(self.options) {
273            let (number, value) = item.map_err(AppParseError::Core)?;
274            if number == identity_filter::NONCE {
275                return parse_be_u32(value).map(Some).map_err(AppParseError::Core);
276            }
277        }
278        Ok(None)
279    }
280
281    /// Whether the request carries at least one `FILTER_NODE_HINT` filter.
282    ///
283    /// A hint filter names a single node, so such a request solicits one reply
284    /// however far it travels. Without one the request selects by role or
285    /// capability and every node it reaches may answer, which is what confines
286    /// a broadcast or multicast solicitation — and its replies — to the
287    /// requester's own neighbourhood.
288    ///
289    /// A malformed option block reads as unfiltered, which is the conservative
290    /// answer: it keeps the strict rules in force.
291    pub fn hint_filtered(&self) -> bool {
292        OptionDecoder::new(self.options)
293            .map_while(Result::ok)
294            .any(|(number, _)| number == identity_filter::FILTER_NODE_HINT)
295    }
296
297    /// Whether a node with the given identity is selected by this request.
298    ///
299    /// Filters combine as a logical AND across distinct filter types and a
300    /// logical OR among repeated filters of the same type. An unknown
301    /// **critical** option (odd key) excludes the node; unknown elective
302    /// options are ignored. A well-formed request with no filters (a unicast
303    /// request) selects every node.
304    pub fn selects(
305        &self,
306        role: NodeRole,
307        capabilities: NodeCapabilities,
308        hint: &NodeHint,
309    ) -> Result<bool, AppParseError> {
310        // Per filter type: whether it appeared, and whether any value matched.
311        let mut hint_present = false;
312        let mut hint_match = false;
313        let mut role_present = false;
314        let mut role_match = false;
315        let mut caps_present = false;
316        let mut caps_match = false;
317
318        for item in OptionDecoder::new(self.options) {
319            let (number, value) = item.map_err(AppParseError::Core)?;
320            match number {
321                identity_filter::NONCE => {} // correlation id, not a filter
322                identity_filter::FILTER_NODE_HINT => {
323                    hint_present = true;
324                    hint_match |= value == hint.0.as_slice();
325                }
326                identity_filter::FILTER_NODE_ROLE => {
327                    role_present = true;
328                    role_match |= value == [role.as_byte()];
329                }
330                identity_filter::FILTER_NODE_CAPS => {
331                    caps_present = true;
332                    // Match if the node has every requested bit set.
333                    caps_match |= value.len() == 1 && (capabilities.bits() & value[0]) == value[0];
334                }
335                other if other & 1 == 1 => {
336                    // Unknown critical option: assume we are excluded.
337                    return Ok(false);
338                }
339                _ => {} // unknown elective option: ignore
340            }
341        }
342
343        Ok((!hint_present || hint_match)
344            && (!role_present || role_match)
345            && (!caps_present || caps_match))
346    }
347}
348
349/// Builds the option block for an [`MacCommand::IdentityRequest`].
350///
351/// Options are emitted in ascending key order, so callers must add the nonce
352/// before any filters and add filters in key order. No `0xFF` end marker is
353/// written: an Identity Request payload is options-only, with no trailing data.
354#[derive(Debug, Default)]
355pub struct IdentityRequestBuilder {
356    buf: Vec<u8>,
357    last_number: u16,
358}
359
360impl IdentityRequestBuilder {
361    /// Start an empty builder (a plain unicast request until options are added).
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    fn put(mut self, number: u16, value: &[u8]) -> Result<Self, AppEncodeError> {
367        // Encode one option into a scratch buffer, continuing the delta chain,
368        // then append. Sized for the header plus the largest filter value.
369        let mut scratch = [0u8; 8 + 4];
370        let mut enc = OptionEncoder::with_last_number(&mut scratch, self.last_number);
371        enc.put(number, value).map_err(AppEncodeError::Core)?;
372        let n = enc.finish();
373        self.buf.extend_from_slice(&scratch[..n]);
374        self.last_number = number;
375        Ok(self)
376    }
377
378    /// Add the `NONCE` correlation option. Add before any filters.
379    pub fn nonce(self, nonce: u32) -> Result<Self, AppEncodeError> {
380        self.put(identity_filter::NONCE, &nonce.to_be_bytes())
381    }
382
383    /// Add a `FILTER_NODE_HINT` filter (repeatable; repeats are OR-combined).
384    pub fn filter_hint(self, hint: &NodeHint) -> Result<Self, AppEncodeError> {
385        self.put(identity_filter::FILTER_NODE_HINT, &hint.0)
386    }
387
388    /// Add a `FILTER_NODE_ROLE` filter (repeatable; repeats are OR-combined).
389    pub fn filter_role(self, role: NodeRole) -> Result<Self, AppEncodeError> {
390        self.put(identity_filter::FILTER_NODE_ROLE, &[role.as_byte()])
391    }
392
393    /// Add a `FILTER_NODE_CAPS` filter (repeatable; repeats are OR-combined).
394    pub fn filter_caps(self, caps: NodeCapabilities) -> Result<Self, AppEncodeError> {
395        self.put(identity_filter::FILTER_NODE_CAPS, &[caps.bits()])
396    }
397
398    /// Finish and return the encoded option block.
399    pub fn build(self) -> Vec<u8> {
400        self.buf
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn encode_decode(cmd: MacCommand<'_>) {
409        let mut buf = [0u8; 64];
410        let len = encode(&cmd, &mut buf).expect("encode failed");
411        let decoded = parse(&buf[..len]).expect("parse failed");
412        assert_eq!(cmd, decoded, "round-trip failed for {cmd:?}");
413    }
414
415    // --- round-trips ---
416
417    #[test]
418    fn identity_request_unicast_no_options() {
419        encode_decode(MacCommand::IdentityRequest { options: &[] });
420        let mut buf = [0u8; 4];
421        let len = encode(&MacCommand::IdentityRequest { options: &[] }, &mut buf).unwrap();
422        assert_eq!(&buf[..len], &[0x01]);
423    }
424
425    #[test]
426    fn identity_request_with_options_round_trips() {
427        let options = IdentityRequestBuilder::new()
428            .nonce(0x12345678)
429            .unwrap()
430            .filter_hint(&NodeHint([0xAA, 0xBB, 0xCC]))
431            .unwrap()
432            .filter_role(NodeRole::Repeater)
433            .unwrap()
434            .build();
435        encode_decode(MacCommand::IdentityRequest { options: &options });
436    }
437
438    #[test]
439    fn identity_request_options_are_appended_verbatim() {
440        let options = IdentityRequestBuilder::new()
441            .nonce(0x01020304)
442            .unwrap()
443            .build();
444        let mut buf = [0u8; 16];
445        let len = encode(&MacCommand::IdentityRequest { options: &options }, &mut buf).unwrap();
446        assert_eq!(buf[0], 0x01);
447        assert_eq!(&buf[1..len], options.as_slice());
448    }
449
450    #[test]
451    fn request_identity_framing_carries_readable_nonce() {
452        // Mirrors PeerConnection::request_identity: a nonce-only options block
453        // framed with a leading PayloadType::MacCommand byte. The receiver
454        // dispatches on payload[0], then parses the command body.
455        let options = IdentityRequestBuilder::new()
456            .nonce(0xCAFEF00D)
457            .unwrap()
458            .build();
459        let mut buf = [0u8; 128];
460        buf[0] = umsh_core::PayloadType::MacCommand as u8;
461        let n = encode(
462            &MacCommand::IdentityRequest { options: &options },
463            &mut buf[1..],
464        )
465        .unwrap()
466            + 1;
467
468        assert_eq!(buf[0], umsh_core::PayloadType::MacCommand as u8);
469        let decoded = parse(&buf[1..n]).expect("command body should parse");
470        let MacCommand::IdentityRequest { options: body } = decoded else {
471            panic!("expected IdentityRequest, got {decoded:?}");
472        };
473        assert_eq!(
474            IdentityRequestFilters::new(body).nonce().unwrap(),
475            Some(0xCAFEF00D)
476        );
477    }
478
479    #[test]
480    fn identity_filters_nonce_round_trips() {
481        let options = IdentityRequestBuilder::new()
482            .nonce(0xDEADBEEF)
483            .unwrap()
484            .build();
485        let filters = IdentityRequestFilters::new(&options);
486        assert_eq!(filters.nonce().unwrap(), Some(0xDEADBEEF));
487
488        let empty = IdentityRequestFilters::new(&[]);
489        assert_eq!(empty.nonce().unwrap(), None);
490    }
491
492    #[test]
493    fn identity_filters_no_filters_selects_everyone() {
494        let filters = IdentityRequestFilters::new(&[]);
495        assert!(
496            filters
497                .selects(
498                    NodeRole::Sensor,
499                    NodeCapabilities::empty(),
500                    &NodeHint([1, 2, 3])
501                )
502                .unwrap()
503        );
504    }
505
506    #[test]
507    fn identity_filters_hint_match_and_mismatch() {
508        let options = IdentityRequestBuilder::new()
509            .filter_hint(&NodeHint([0xAA, 0xBB, 0xCC]))
510            .unwrap()
511            .build();
512        let filters = IdentityRequestFilters::new(&options);
513        let caps = NodeCapabilities::empty();
514        assert!(
515            filters
516                .selects(NodeRole::Chat, caps, &NodeHint([0xAA, 0xBB, 0xCC]))
517                .unwrap()
518        );
519        assert!(
520            !filters
521                .selects(NodeRole::Chat, caps, &NodeHint([0xAA, 0xBB, 0xCD]))
522                .unwrap()
523        );
524    }
525
526    #[test]
527    fn identity_filters_report_hint_presence() {
528        let hinted = IdentityRequestBuilder::new()
529            .nonce(0x0102_0304)
530            .unwrap()
531            .filter_hint(&NodeHint([0xAA, 0xBB, 0xCC]))
532            .unwrap()
533            .build();
534        assert!(IdentityRequestFilters::new(&hinted).hint_filtered());
535
536        let by_role = IdentityRequestBuilder::new()
537            .filter_role(NodeRole::Repeater)
538            .unwrap()
539            .build();
540        assert!(!IdentityRequestFilters::new(&by_role).hint_filtered());
541
542        // No filters at all, and a truncated block, both read as unfiltered.
543        assert!(!IdentityRequestFilters::new(&[]).hint_filtered());
544        assert!(!IdentityRequestFilters::new(&[0x33, 0xAA]).hint_filtered());
545    }
546
547    #[test]
548    fn identity_filters_repeated_type_is_or() {
549        let options = IdentityRequestBuilder::new()
550            .filter_role(NodeRole::Repeater)
551            .unwrap()
552            .filter_role(NodeRole::Chat)
553            .unwrap()
554            .build();
555        let filters = IdentityRequestFilters::new(&options);
556        let caps = NodeCapabilities::empty();
557        let hint = NodeHint([1, 2, 3]);
558        assert!(filters.selects(NodeRole::Repeater, caps, &hint).unwrap());
559        assert!(filters.selects(NodeRole::Chat, caps, &hint).unwrap());
560        assert!(!filters.selects(NodeRole::Sensor, caps, &hint).unwrap());
561    }
562
563    #[test]
564    fn identity_filters_distinct_types_are_and() {
565        let options = IdentityRequestBuilder::new()
566            .filter_role(NodeRole::Repeater)
567            .unwrap()
568            .filter_caps(NodeCapabilities::REPEATER)
569            .unwrap()
570            .build();
571        let filters = IdentityRequestFilters::new(&options);
572        let hint = NodeHint([1, 2, 3]);
573        // Both must hold.
574        assert!(
575            filters
576                .selects(NodeRole::Repeater, NodeCapabilities::REPEATER, &hint)
577                .unwrap()
578        );
579        // Role matches but caps don't.
580        assert!(
581            !filters
582                .selects(NodeRole::Repeater, NodeCapabilities::empty(), &hint)
583                .unwrap()
584        );
585    }
586
587    #[test]
588    fn identity_filters_caps_requires_all_requested_bits() {
589        let options = IdentityRequestBuilder::new()
590            .filter_caps(NodeCapabilities::REPEATER | NodeCapabilities::TEXT_MESSAGES)
591            .unwrap()
592            .build();
593        let filters = IdentityRequestFilters::new(&options);
594        let hint = NodeHint([1, 2, 3]);
595        // Superset matches.
596        assert!(
597            filters
598                .selects(
599                    NodeRole::Chat,
600                    NodeCapabilities::REPEATER
601                        | NodeCapabilities::TEXT_MESSAGES
602                        | NodeCapabilities::MOBILE,
603                    &hint,
604                )
605                .unwrap()
606        );
607        // Missing one requested bit does not match.
608        assert!(
609            !filters
610                .selects(NodeRole::Chat, NodeCapabilities::REPEATER, &hint)
611                .unwrap()
612        );
613    }
614
615    #[test]
616    fn identity_filters_unknown_critical_option_excludes() {
617        // Key 9 is unknown and critical (odd).
618        let mut buf = [0u8; 8];
619        let mut enc = OptionEncoder::new(&mut buf);
620        enc.put(9, &[0x01]).unwrap();
621        let n = enc.finish();
622        let filters = IdentityRequestFilters::new(&buf[..n]);
623        assert!(
624            !filters
625                .selects(
626                    NodeRole::Chat,
627                    NodeCapabilities::empty(),
628                    &NodeHint([1, 2, 3])
629                )
630                .unwrap()
631        );
632    }
633
634    #[test]
635    fn identity_filters_unknown_elective_option_ignored() {
636        // Key 8 is unknown and elective (even); alongside a matching role filter.
637        let mut buf = [0u8; 16];
638        let mut enc = OptionEncoder::new(&mut buf);
639        enc.put(5, &[NodeRole::Repeater.as_byte()]).unwrap();
640        enc.put(8, &[0xFE]).unwrap();
641        let n = enc.finish();
642        let filters = IdentityRequestFilters::new(&buf[..n]);
643        assert!(
644            filters
645                .selects(
646                    NodeRole::Repeater,
647                    NodeCapabilities::empty(),
648                    &NodeHint([1, 2, 3])
649                )
650                .unwrap()
651        );
652    }
653
654    #[test]
655    fn signal_report_request() {
656        encode_decode(MacCommand::SignalReportRequest);
657    }
658
659    #[test]
660    fn signal_report_response() {
661        encode_decode(MacCommand::SignalReportResponse {
662            rssi: 200,
663            snr: -10,
664        });
665        let mut buf = [0u8; 8];
666        let len = encode(
667            &MacCommand::SignalReportResponse {
668                rssi: 0xAB,
669                snr: -1,
670            },
671            &mut buf,
672        )
673        .unwrap();
674        assert_eq!(&buf[..len], &[0x03, 0xAB, 0xFF]);
675    }
676
677    #[test]
678    fn echo_request() {
679        encode_decode(MacCommand::EchoRequest {
680            data: &[0x01, 0x02, 0x03],
681        });
682        encode_decode(MacCommand::EchoRequest { data: &[] });
683    }
684
685    #[test]
686    fn echo_response() {
687        encode_decode(MacCommand::EchoResponse {
688            data: &[0xDE, 0xAD],
689        });
690    }
691
692    #[test]
693    fn pfs_session_request() {
694        let key = PublicKey([0xABu8; 32]);
695        encode_decode(MacCommand::PfsSessionRequest {
696            ephemeral_key: key,
697            duration_minutes: 60,
698        });
699        let mut buf = [0u8; 40];
700        let len = encode(
701            &MacCommand::PfsSessionRequest {
702                ephemeral_key: key,
703                duration_minutes: 0x0102,
704            },
705            &mut buf,
706        )
707        .unwrap();
708        assert_eq!(len, 1 + 32 + 2);
709        assert_eq!(buf[0], 0x06);
710        assert_eq!(&buf[1..33], &[0xABu8; 32]);
711        assert_eq!(&buf[33..35], &[0x01, 0x02]);
712    }
713
714    #[test]
715    fn pfs_session_response() {
716        let key = PublicKey([0x55u8; 32]);
717        encode_decode(MacCommand::PfsSessionResponse {
718            ephemeral_key: key,
719            duration_minutes: 120,
720        });
721    }
722
723    #[test]
724    fn end_pfs_session() {
725        encode_decode(MacCommand::EndPfsSession);
726        let mut buf = [0u8; 4];
727        let len = encode(&MacCommand::EndPfsSession, &mut buf).unwrap();
728        assert_eq!(&buf[..len], &[0x08]);
729    }
730
731    // --- OwnedMacCommand From conversion ---
732
733    #[test]
734    fn owned_from_borrowed_echo() {
735        let cmd = MacCommand::EchoRequest {
736            data: &[0x01, 0x02],
737        };
738        let owned = OwnedMacCommand::from(cmd);
739        assert_eq!(
740            owned,
741            OwnedMacCommand::EchoRequest {
742                data: alloc::vec![0x01, 0x02]
743            }
744        );
745    }
746
747    // --- parse error cases ---
748
749    #[test]
750    fn parse_empty_returns_truncated() {
751        assert!(matches!(
752            parse(&[]),
753            Err(crate::AppParseError::Core(umsh_core::ParseError::Truncated))
754        ));
755    }
756
757    #[test]
758    fn parse_unknown_command_id() {
759        assert!(matches!(
760            parse(&[0xFF]),
761            Err(crate::AppParseError::InvalidCommandId(0xFF))
762        ));
763    }
764
765    #[test]
766    fn parse_command_zero_is_unallocated() {
767        assert!(matches!(
768            parse(&[0x00]),
769            Err(crate::AppParseError::InvalidCommandId(0))
770        ));
771    }
772
773    #[test]
774    fn parse_identity_request_accepts_option_block() {
775        // A well-formed option block is accepted as the request payload.
776        let decoded = parse(&[0x01, 0x00]).expect("valid options should parse");
777        assert!(matches!(decoded, MacCommand::IdentityRequest { .. }));
778    }
779
780    #[test]
781    fn parse_identity_request_rejects_malformed_options() {
782        // 0x41: delta 4, length 1, but no value byte follows -> truncated.
783        assert!(parse(&[0x01, 0x41]).is_err());
784    }
785
786    #[test]
787    fn parse_signal_report_response_wrong_length() {
788        assert!(parse(&[0x03, 0x01]).is_err()); // need exactly 2 body bytes
789    }
790
791    #[test]
792    fn parse_pfs_request_wrong_length() {
793        assert!(parse(&[0x06, 0x00]).is_err()); // need exactly 34 body bytes
794    }
795
796    #[test]
797    fn parse_end_pfs_nonempty_body() {
798        assert!(parse(&[0x08, 0x00]).is_err());
799    }
800}