umshctl/command/capture/
decode.rs

1//! Rendering one captured radio frame: the decode summary, the options
2//! that are present, and a hex dump grouped and tinted by field.
3
4use std::fmt::Write as _;
5use std::ops::Range;
6
7use hamaddr::HamAddr;
8use umsh::core::options::OptionDecoder;
9use umsh::core::{
10    OptionNumber, PacketHeader, PacketType, PayloadType, PublicKey, RegionCode, RouterHint,
11    SourceAddrRef,
12};
13
14use crate::output::styled;
15
16/// A header field, used both to color its bytes in the hex dump and to color
17/// the decoded value that came out of those bytes.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Field {
20    Fcf,
21    FloodHops,
22    Dst,
23    Src,
24    Channel,
25    SecInfo,
26    Options,
27    /// Ciphertext covering the addresses of a blind unicast or an encrypted
28    /// multicast, which cannot be attributed to `Dst`/`Src` without the key.
29    EncAddr,
30    Body,
31    /// A MAC ack's correlation handle: the first four bytes of the acked
32    /// packet's MIC. Public, and not an authenticator.
33    AckMic,
34    /// A MAC ack's keyed authenticator, which only the original sender can
35    /// verify.
36    AckTag,
37    Mic,
38}
39
40impl Field {
41    /// SGR parameters for this field. Deliberately limited to the 16 basic
42    /// colors so the output survives minimal terminals.
43    fn sgr(self) -> &'static str {
44        match self {
45            Self::Fcf => "1;97",
46            Self::FloodHops => "93",
47            Self::Dst => "91",
48            Self::Src => "92",
49            Self::Channel => "95",
50            Self::SecInfo => "33",
51            Self::Options => "96",
52            Self::EncAddr => "94",
53            Self::Body => "37",
54            Self::AckMic => "36",
55            Self::AckTag => "35",
56            Self::Mic => "90",
57        }
58    }
59
60    fn label(self) -> &'static str {
61        match self {
62            Self::Fcf => "fcf",
63            Self::FloodHops => "hops",
64            Self::Dst => "dst",
65            Self::Src => "src",
66            Self::Channel => "channel",
67            Self::SecInfo => "secinfo",
68            Self::Options => "options",
69            Self::EncAddr => "enc-addr",
70            Self::Body => "body",
71            Self::AckMic => "ack-mic",
72            Self::AckTag => "ack-tag",
73            Self::Mic => "mic",
74        }
75    }
76}
77
78const LEGEND: [Field; 12] = [
79    Field::Fcf,
80    Field::FloodHops,
81    Field::Dst,
82    Field::Src,
83    Field::Channel,
84    Field::SecInfo,
85    Field::Options,
86    Field::EncAddr,
87    Field::Body,
88    Field::AckMic,
89    Field::AckTag,
90    Field::Mic,
91];
92
93/// Bold white on red. Encryption is the norm, so nothing marks it; it is
94/// cleartext that has to be impossible to scroll past.
95const UNENCRYPTED_SGR: &str = "1;97;41";
96
97/// Wrap `text` in this field's color, or return it unchanged when the output
98/// is not colorized.
99fn tint(text: &str, field: Field, color: bool) -> String {
100    styled(text, field.sgr(), color)
101}
102
103pub fn print_legend() {
104    let chips: Vec<String> = LEGEND
105        .iter()
106        .map(|field| tint(field.label(), *field, true))
107        .collect();
108    println!("fields: {}", chips.join("  "));
109}
110
111pub fn should_display(packet: &[u8], umsh_only: bool) -> bool {
112    !umsh_only || PacketHeader::parse(packet).is_ok()
113}
114
115/// Attribute every byte of `packet` to the field it belongs to.
116///
117/// The layout is reconstructed from the landmarks `PacketHeader` already
118/// computed (options, body, and MIC ranges plus the SECINFO length), so it
119/// stays in step with the parser rather than re-deriving the wire format.
120fn field_map(header: &PacketHeader, len: usize) -> Vec<Option<Field>> {
121    fn paint(map: &mut [Option<Field>], range: Range<usize>, field: Field) {
122        if let Some(slice) = map.get_mut(range) {
123            slice.fill(Some(field));
124        }
125    }
126
127    let mut map = vec![None; len];
128    paint(&mut map, 0..1, Field::Fcf);
129    let mut cursor = 1;
130    if header.flood_hops.is_some() {
131        paint(&mut map, 1..2, Field::FloodHops);
132        cursor = 2;
133    }
134
135    let options = header.options_range.clone();
136    // Painted before the addressing fields: an encrypted multicast places its
137    // ciphertext source at the head of the body range, and must win.
138    paint(&mut map, header.body_range.clone(), Field::Body);
139    paint(&mut map, options.clone(), Field::Options);
140    paint(&mut map, header.mic_range.clone(), Field::Mic);
141
142    let sec_len = header.sec_info.map_or(0, |sec| sec.wire_len());
143    let sec_start = options.start.saturating_sub(sec_len);
144    match header.packet_type() {
145        PacketType::Broadcast => paint(&mut map, cursor..options.start, Field::Src),
146        // A MAC ack has no addressing, and no MIC of its own: its two fixed
147        // fields are body. `PacketHeader` reports the same range as both
148        // `body_range` and `mic_range`, so split it here.
149        PacketType::MacAck => {
150            let ack = header.body_range.start;
151            paint(&mut map, ack..ack + 4, Field::AckMic);
152            paint(&mut map, ack + 4..ack + 8, Field::AckTag);
153        }
154        PacketType::Reserved5 => {}
155        PacketType::Unicast | PacketType::UnicastAckReq => {
156            paint(&mut map, cursor..cursor + 3, Field::Dst);
157            paint(&mut map, cursor + 3..sec_start, Field::Src);
158            paint(&mut map, sec_start..options.start, Field::SecInfo);
159        }
160        PacketType::Multicast => {
161            paint(&mut map, cursor..cursor + 2, Field::Channel);
162            paint(&mut map, cursor + 2..options.start, Field::SecInfo);
163            match header.source {
164                SourceAddrRef::Encrypted { offset, len } => {
165                    paint(&mut map, offset..offset + len, Field::EncAddr)
166                }
167                _ => paint(&mut map, options.end..header.body_range.start, Field::Src),
168            }
169        }
170        PacketType::BlindUnicast | PacketType::BlindUnicastAckReq => {
171            paint(&mut map, cursor..cursor + 2, Field::Channel);
172            paint(&mut map, cursor + 2..options.start, Field::SecInfo);
173            if matches!(header.source, SourceAddrRef::Encrypted { .. }) {
174                paint(
175                    &mut map,
176                    options.end..header.body_range.start,
177                    Field::EncAddr,
178                );
179            } else {
180                paint(&mut map, options.end..options.end + 3, Field::Dst);
181                paint(
182                    &mut map,
183                    options.end + 3..header.body_range.start,
184                    Field::Src,
185                );
186            }
187        }
188    }
189    map
190}
191
192/// Render the hex dump, grouped by field.
193///
194/// Colorized output needs no separators — the color change marks each field
195/// boundary — so the bytes run together and stay compact. Without color the
196/// same grouping is spelled with spaces between fields.
197fn print_hex(packet: &[u8], map: &[Option<Field>], color: bool) {
198    for line in hex_lines(packet, map, color) {
199        println!("  {line}");
200    }
201}
202
203/// Visible hex columns per line, chosen so a full-size LoRa frame wraps but a
204/// typical one stays on a single line.
205const HEX_WIDTH: usize = 96;
206
207fn hex_lines(packet: &[u8], map: &[Option<Field>], color: bool) -> Vec<String> {
208    let field_at = |index: usize| map.get(index).copied().flatten();
209
210    // Split the frame into lines that fit the width budget. Each byte costs
211    // two columns, plus one for the space that separates fields when the
212    // output is not colorized.
213    let mut spans: Vec<Range<usize>> = Vec::new();
214    let mut start = 0;
215    let mut col = 0;
216    for index in 0..packet.len() {
217        let separated = !color && index > start && field_at(index) != field_at(index - 1);
218        let cost = 2 + usize::from(separated);
219        if index > start && col + cost > HEX_WIDTH {
220            spans.push(start..index);
221            start = index;
222            col = 2;
223        } else {
224            col += cost;
225        }
226    }
227    if start < packet.len() {
228        spans.push(start..packet.len());
229    }
230
231    spans
232        .into_iter()
233        .map(|span| {
234            let mut line = String::new();
235            let mut index = span.start;
236            while index < span.end {
237                let field = field_at(index);
238                let mut run = index;
239                while run < span.end && field_at(run) == field {
240                    run += 1;
241                }
242                if !color && index != span.start {
243                    line.push(' ');
244                }
245                let mut hex = String::new();
246                for byte in &packet[index..run] {
247                    let _ = write!(hex, "{byte:02x}");
248                }
249                match field {
250                    Some(field) => line.push_str(&tint(&hex, field, color)),
251                    None => line.push_str(&hex),
252                }
253                index = run;
254            }
255            line
256        })
257        .collect()
258}
259
260/// Print the decode summary, the present options, and the hex dump.
261pub fn print_frame(packet: &[u8], color: bool) {
262    let header = match PacketHeader::parse(packet) {
263        Ok(header) => header,
264        Err(error) => {
265            println!("  not a UMSH packet ({error:?})");
266            print_hex(packet, &vec![None; packet.len()], color);
267            return;
268        }
269    };
270
271    println!("  {}", summary_line(packet, &header, color));
272    if let Some(line) = options_line(packet, &header, color) {
273        println!("  {line}");
274    }
275    print_hex(packet, &field_map(&header, packet.len()), color);
276}
277
278/// Build the one-line decode summary: what it is, who it is between, and the
279/// security and framing state that applies to it.
280fn summary_line(packet: &[u8], header: &PacketHeader, color: bool) -> String {
281    let mut chips: Vec<String> = Vec::new();
282    let packet_type = header.packet_type();
283    let type_text = format!("{packet_type:?}");
284    chips.push(if color {
285        format!("\x1b[1m{type_text}\x1b[0m")
286    } else {
287        type_text
288    });
289
290    // A destination hint names a node; a multicast or an encrypted blind
291    // unicast is addressed by channel instead, so the channel stands in as
292    // the destination and only appears separately when both are present.
293    let dst = match (header.dst, header.channel) {
294        (Some(hint), _) => Some(tint(&hint.to_string(), Field::Dst, color)),
295        (None, Some(channel)) => Some(tint(
296            &format!("ch:{:02x}{:02x}", channel.0[0], channel.0[1]),
297            Field::Channel,
298            color,
299        )),
300        (None, None) if packet_type == PacketType::Broadcast => Some("*".to_owned()),
301        (None, None) => None,
302    };
303    let src = source_text(packet, header).map(|text| tint(&text, Field::Src, color));
304    match (src, dst) {
305        (Some(src), Some(dst)) => chips.push(format!("{src} → {dst}")),
306        (Some(src), None) => chips.push(src),
307        (None, Some(dst)) => chips.push(format!("→ {dst}")),
308        (None, None) => {}
309    }
310    if header.dst.is_some()
311        && let Some(channel) = header.channel
312    {
313        chips.push(tint(
314            &format!("ch:{:02x}{:02x}", channel.0[0], channel.0[1]),
315            Field::Channel,
316            color,
317        ));
318    }
319
320    // Rendered `remaining:accumulated`, matching the order of the two
321    // nibbles in the FHOPS byte itself. `FHOPS_REM` is the flood-forwarding
322    // budget still to spend and `FHOPS_ACC` the flood hops already
323    // traversed; neither is a time-to-live nor a total hop count, since a
324    // source-routed hop moves neither nibble.
325    if let Some(hops) = header.flood_hops {
326        chips.push(tint(
327            &format!("fhops={}:{}", hops.remaining(), hops.accumulated()),
328            Field::FloodHops,
329            color,
330        ));
331    }
332
333    let encrypted = header
334        .sec_info
335        .is_some_and(|security| security.scf.encrypted());
336    if let Some(security) = header.sec_info {
337        // Spelled `fcnt` rather than `ctr`, which would read as the AES-CTR
338        // half of the packet's own encryption.
339        chips.push(tint(
340            &format!("fcnt={}", security.frame_counter),
341            Field::SecInfo,
342            color,
343        ));
344        if let Some(salt) = security.salt {
345            chips.push(tint(&format!("salt={salt:#06x}"), Field::SecInfo, color));
346        }
347        // Encrypted is the expected case and says nothing; a secured packet
348        // type carrying cleartext is the anomaly worth shouting about.
349        if !encrypted {
350            chips.push(styled("UNENC", UNENCRYPTED_SGR, color));
351        }
352    }
353    // `ack_requested()` is derived purely from the packet type, which is
354    // already the first chip on the line, so it gets no chip of its own.
355
356    // A MAC ack's whole content is its two fixed fields, so show them rather
357    // than a length: the ack MIC correlates the ack with the packet it
358    // acknowledges, and the ack tag is what actually authenticates it.
359    if packet_type == PacketType::MacAck {
360        let ack = header.body_range.start;
361        match packet.get(ack..ack + 8) {
362            Some(fields) => {
363                chips.push(tint(
364                    &format!("ack_mic={}", hex_text(&fields[..4])),
365                    Field::AckMic,
366                    color,
367                ));
368                chips.push(tint(
369                    &format!("ack_tag={}", hex_text(&fields[4..])),
370                    Field::AckTag,
371                    color,
372                ));
373            }
374            None => chips.push("<truncated ack>".to_owned()),
375        }
376        return chips.join("  ");
377    }
378
379    // Lengths carry their unit so they cannot be read as the field's value.
380    if header.is_beacon() {
381        chips.push("beacon".to_owned());
382    } else {
383        chips.push(tint(
384            &format!("body={}B", header.body_range.len()),
385            Field::Body,
386            color,
387        ));
388    }
389    // The payload type is the first body byte, and is only readable in the
390    // clear.
391    if !encrypted
392        && let Some(payload) = packet
393            .get(header.body_range.start)
394            .and_then(|byte| PayloadType::from_byte(*byte))
395    {
396        chips.push(tint(&format!("{payload:?}"), Field::Body, color));
397    }
398    if !header.mic_range.is_empty() {
399        chips.push(tint(
400            &format!("mic={}B", header.mic_range.len()),
401            Field::Mic,
402            color,
403        ));
404    }
405    chips.join("  ")
406}
407
408/// Render the source address: a hint, a full key, or an acknowledgement that
409/// it is sealed.
410fn source_text(packet: &[u8], header: &PacketHeader) -> Option<String> {
411    match header.source {
412        SourceAddrRef::Hint(hint) => Some(hint.to_string()),
413        SourceAddrRef::FullKeyAt { offset } => Some(packet.get(offset..offset + 32).map_or_else(
414            || "<truncated>".to_owned(),
415            |bytes| {
416                let mut key = [0u8; 32];
417                key.copy_from_slice(bytes);
418                PublicKey(key).to_string()
419            },
420        )),
421        SourceAddrRef::Encrypted { .. } => Some("<enc>".to_owned()),
422        SourceAddrRef::None => None,
423    }
424}
425
426/// Render the options that are actually present, decoding each one's value.
427///
428/// This walks the option block directly rather than going through
429/// `ParsedOptions`, so options that the MAC has no use for — callsigns, and
430/// anything unrecognized — still show up in a capture.
431fn options_line(packet: &[u8], header: &PacketHeader, color: bool) -> Option<String> {
432    let range = header.options_range.clone();
433    if range.is_empty() {
434        return None;
435    }
436
437    let mut chips: Vec<String> = Vec::new();
438    for entry in OptionDecoder::new(&packet[range]) {
439        let (number, value) = match entry {
440            Ok(entry) => entry,
441            Err(error) => {
442                chips.push(format!("<decode error: {error:?}>"));
443                break;
444            }
445        };
446        chips.push(option_chip(number, value));
447    }
448    if chips.is_empty() {
449        return None;
450    }
451    Some(format!(
452        "{}  {}",
453        tint("opts", Field::Options, color),
454        chips
455            .iter()
456            .map(|chip| tint(chip, Field::Options, color))
457            .collect::<Vec<_>>()
458            .join("  "),
459    ))
460}
461
462fn option_chip(number: u16, value: &[u8]) -> String {
463    match OptionNumber::from(number) {
464        OptionNumber::TraceRoute => format!("trace=[{}]", route_text(value)),
465        OptionNumber::SourceRoute => format!("route=[{}]", route_text(value)),
466        OptionNumber::RegionCode if value.len() == 2 => {
467            format!("region={}", RegionCode::from_bytes([value[0], value[1]]))
468        }
469        // A zero-length threshold selects the receiver's default, which the
470        // frame itself does not name.
471        OptionNumber::MinRssi if value.is_empty() => "min-rssi=default".to_owned(),
472        OptionNumber::MinRssi if value.len() == 1 => format!("min-rssi={}", -i16::from(value[0])),
473        OptionNumber::MinSnr if value.is_empty() => "min-snr=default".to_owned(),
474        OptionNumber::MinSnr if value.len() == 1 => format!("min-snr={}", value[0] as i8),
475        OptionNumber::RouteRetry if value.is_empty() => "retry".to_owned(),
476        OptionNumber::OperatorCallsign => format!("op={}", callsign_text(value)),
477        OptionNumber::StationCallsign => format!("via={}", callsign_text(value)),
478        // Either an unknown option or a known one carrying a malformed value.
479        // Critical options carry an odd number and are marked, since a
480        // receiver that does not understand one has to drop the frame.
481        other => format!(
482            "{}opt{number}={}",
483            if other.is_critical() { "!" } else { "" },
484            hex_text(value),
485        ),
486    }
487}
488
489/// Render a route option as its list of router hints.
490fn route_text(value: &[u8]) -> String {
491    if !value.len().is_multiple_of(2) {
492        return hex_text(value);
493    }
494    value
495        .chunks_exact(2)
496        .map(|hop| RouterHint([hop[0], hop[1]]).to_string())
497        .collect::<Vec<_>>()
498        .join(",")
499}
500
501fn callsign_text(value: &[u8]) -> String {
502    HamAddr::try_from_slice(value).map_or_else(|_| hex_text(value), |addr| addr.to_string())
503}
504
505fn hex_text(value: &[u8]) -> String {
506    if value.is_empty() {
507        return "-".to_owned();
508    }
509    crate::output::hex(value)
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    /// Packet #559 from a live capture: a source-routed encrypted unicast
517    /// with an empty trace-route option, one source-route hop, and flood
518    /// hops still to spend.
519    const SOURCE_ROUTED_UNICAST: [u8; 44] = [
520        0xd1, 0x50, 0xb7, 0xa6, 0x26, 0x45, 0xfe, 0xb2, 0xe0, 0xdf, 0x45, 0xb6, 0xa0, 0x20, 0x12,
521        0xef, 0x24, 0xff, 0xa2, 0x41, 0x4a, 0x15, 0xf0, 0x7c, 0x55, 0x21, 0x51, 0xd6, 0x7d, 0xe6,
522        0x29, 0xbb, 0xf3, 0xa0, 0xee, 0x37, 0x93, 0xa4, 0x22, 0x20, 0x9a, 0x49, 0x8f, 0x72,
523    ];
524
525    /// Packet #560: the same frame after a repeater consumed the source
526    /// route and appended itself to the trace.
527    const FORWARDED_UNICAST: [u8; 44] = [
528        0xd1, 0x41, 0xb7, 0xa6, 0x26, 0x45, 0xfe, 0xb2, 0xe0, 0xdf, 0x45, 0xb6, 0xa0, 0x22, 0xef,
529        0x24, 0x10, 0xff, 0xa2, 0x41, 0x4a, 0x15, 0xf0, 0x7c, 0x55, 0x21, 0x51, 0xd6, 0x7d, 0xe6,
530        0x29, 0xbb, 0xf3, 0xa0, 0xee, 0x37, 0x93, 0xa4, 0x22, 0x20, 0x9a, 0x49, 0x8f, 0x72,
531    ];
532
533    /// Packet #110 from a live capture: a MAC ack with a flood-hop field and
534    /// an empty options block.
535    const MAC_ACK: [u8; 10] = [0xc9, 0x10, 0x8c, 0xb6, 0x8f, 0x5d, 0x97, 0x00, 0xed, 0xe7];
536
537    /// Packet #109 from a live capture: a forwarded ack-requested unicast
538    /// whose source route was consumed by the repeater that carried it.
539    const UNICAST_ACK_REQ: [u8; 40] = [
540        0xd9, 0x41, 0x1e, 0x9d, 0xb8, 0x45, 0xfe, 0xb2, 0xe0, 0xdf, 0x45, 0xb6, 0xa7, 0x30, 0xff,
541        0x94, 0x21, 0xd3, 0x18, 0x09, 0x2e, 0x78, 0xd6, 0x47, 0x8c, 0xb6, 0x8f, 0x5d, 0x0d, 0xcd,
542        0xe2, 0x26, 0x8f, 0xf2, 0x9a, 0x60, 0x75, 0xbd, 0x1b, 0x41,
543    ];
544
545    #[test]
546    fn umsh_only_suppresses_foreign_frames_but_not_valid_umsh() {
547        let valid_umsh_beacon = [0xc0, 0xa1, 0xb2, 0x03];
548        let foreign_frame = [0x15, 0x02, 0x69, 0x26];
549        assert!(should_display(&valid_umsh_beacon, true));
550        assert!(!should_display(&foreign_frame, true));
551        assert!(should_display(&foreign_frame, false));
552    }
553
554    #[test]
555    fn summary_reports_addressing_security_and_framing() {
556        let header = PacketHeader::parse(&SOURCE_ROUTED_UNICAST).unwrap();
557        let summary = summary_line(&SOURCE_ROUTED_UNICAST, &header, false);
558        assert_eq!(
559            summary,
560            "Unicast  5iEP → DMt*  fhops=5:0  fcnt=3745887904  body=10B  mic=16B",
561        );
562        // No `Some(..)`, and absent fields say nothing at all.
563        assert!(!summary.contains("Some"));
564        assert!(!summary.contains("None"));
565
566        // One flood hop later: the repeater spent a hop from the remaining
567        // budget and added it to the accumulated count. Neither nibble is a
568        // total, so both are shown, in the order they appear in the byte.
569        let forwarded = PacketHeader::parse(&FORWARDED_UNICAST).unwrap();
570        assert!(summary_line(&FORWARDED_UNICAST, &forwarded, false).contains("fhops=4:1"),);
571    }
572
573    #[test]
574    fn ack_request_is_stated_once_by_the_packet_type() {
575        let header = PacketHeader::parse(&UNICAST_ACK_REQ).unwrap();
576        assert!(header.ack_requested());
577        let summary = summary_line(&UNICAST_ACK_REQ, &header, false);
578        assert_eq!(
579            summary,
580            "UnicastAckReq  5iEP → 34Wi  fhops=4:1  fcnt=3745887911  body=9B  mic=16B",
581        );
582        // The type name already carries it; nothing repeats it.
583        assert_eq!(summary.matches("Ack").count(), 1);
584        assert!(!summary.contains("ack-req"));
585
586        // A repeater that consumed the final hint leaves the emptied option
587        // behind for provenance, and the capture must still show it.
588        assert_eq!(
589            options_line(&UNICAST_ACK_REQ, &header, false).unwrap(),
590            "opts  route=[]",
591        );
592    }
593
594    #[test]
595    fn mac_ack_reports_its_two_body_fields_not_a_trailer() {
596        let header = PacketHeader::parse(&MAC_ACK).unwrap();
597        assert_eq!(header.packet_type(), PacketType::MacAck);
598        assert_eq!(
599            summary_line(&MAC_ACK, &header, false),
600            "MacAck  fhops=1:0  ack_mic=8cb68f5d  ack_tag=9700ede7",
601        );
602
603        // The two fields are body, and a MAC ack has no MIC of its own —
604        // even though `PacketHeader` reports the same range as `mic_range`.
605        let map = field_map(&header, MAC_ACK.len());
606        assert_eq!(map[0], Some(Field::Fcf));
607        assert_eq!(map[1], Some(Field::FloodHops));
608        for index in 2..6 {
609            assert_eq!(map[index], Some(Field::AckMic), "byte {index}");
610        }
611        for index in 6..10 {
612            assert_eq!(map[index], Some(Field::AckTag), "byte {index}");
613        }
614        assert!(
615            !map.contains(&Some(Field::Mic)),
616            "a MAC ack carries no MIC of its own",
617        );
618
619        assert_eq!(
620            hex_lines(&MAC_ACK, &map, false),
621            vec!["c9 10 8cb68f5d 9700ede7"],
622        );
623    }
624
625    #[test]
626    fn cleartext_is_flagged_loudly_and_encryption_is_silent() {
627        // Encryption is the expected case, so it adds nothing to the line.
628        let header = PacketHeader::parse(&SOURCE_ROUTED_UNICAST).unwrap();
629        assert!(!summary_line(&SOURCE_ROUTED_UNICAST, &header, false).contains("UNENC"));
630
631        // Clearing the SCF encrypted bit leaves a secured packet type
632        // carrying cleartext, which must be impossible to miss.
633        let mut cleartext = SOURCE_ROUTED_UNICAST;
634        cleartext[8] &= 0x7f;
635        let header = PacketHeader::parse(&cleartext).unwrap();
636        assert!(!header.sec_info.unwrap().scf.encrypted());
637
638        let plain = summary_line(&cleartext, &header, false);
639        assert!(plain.contains("UNENC"), "{plain}");
640
641        // Colorized, it is bold white on a red background.
642        let colored = summary_line(&cleartext, &header, true);
643        assert!(colored.contains("\x1b[1;97;41mUNENC\x1b[0m"), "{colored}");
644    }
645
646    #[test]
647    fn options_line_lists_only_present_options_with_decoded_routes() {
648        let header = PacketHeader::parse(&SOURCE_ROUTED_UNICAST).unwrap();
649        let line = options_line(&SOURCE_ROUTED_UNICAST, &header, false).unwrap();
650        // Trace route is present but still empty; the source route names the
651        // next hop rather than a byte range.
652        assert_eq!(line, "opts  trace=[]  route=[H6*]");
653        for absent in ["region", "min-rssi", "min-snr", "retry"] {
654            assert!(!line.contains(absent), "{absent} should not be listed");
655        }
656
657        let forwarded = PacketHeader::parse(&FORWARDED_UNICAST).unwrap();
658        let line = options_line(&FORWARDED_UNICAST, &forwarded, false).unwrap();
659        assert_eq!(line, "opts  trace=[H6*]  route=[]");
660    }
661
662    #[test]
663    fn option_chips_decode_each_known_option() {
664        assert_eq!(option_chip(11, &[0x78, 0x53]), "region=SJC");
665        assert_eq!(option_chip(5, &[130]), "min-rssi=-130");
666        assert_eq!(option_chip(5, &[]), "min-rssi=default");
667        assert_eq!(option_chip(9, &[0xfd]), "min-snr=-3");
668        assert_eq!(option_chip(6, &[]), "retry");
669        assert_eq!(
670            option_chip(
671                4,
672                HamAddr::try_from_callsign("KJ6QOH")
673                    .unwrap()
674                    .as_trimmed_slice()
675            ),
676            "op=KJ6QOH",
677        );
678        assert_eq!(
679            option_chip(
680                7,
681                HamAddr::try_from_callsign("KZ2X")
682                    .unwrap()
683                    .as_trimmed_slice()
684            ),
685            "via=KZ2X",
686        );
687        // Unknown options survive, and the critical ones are marked.
688        assert_eq!(option_chip(20, &[0xaa, 0xbb]), "opt20=aabb");
689        assert_eq!(option_chip(21, &[0xaa]), "!opt21=aa");
690    }
691
692    #[test]
693    fn field_map_attributes_every_byte_of_a_unicast() {
694        let header = PacketHeader::parse(&SOURCE_ROUTED_UNICAST).unwrap();
695        let map = field_map(&header, SOURCE_ROUTED_UNICAST.len());
696        let expected = [
697            (0..1, Field::Fcf),
698            (1..2, Field::FloodHops),
699            (2..5, Field::Dst),
700            (5..8, Field::Src),
701            (8..13, Field::SecInfo),
702            (13..18, Field::Options),
703            (18..28, Field::Body),
704            (28..44, Field::Mic),
705        ];
706        for (range, field) in expected {
707            for index in range.clone() {
708                assert_eq!(map[index], Some(field), "byte {index} of {range:?}");
709            }
710        }
711        assert!(map.iter().all(Option::is_some), "every byte is attributed");
712    }
713
714    #[test]
715    fn hex_dump_groups_by_field_and_drops_spaces_when_colorized() {
716        let header = PacketHeader::parse(&SOURCE_ROUTED_UNICAST).unwrap();
717        let map = field_map(&header, SOURCE_ROUTED_UNICAST.len());
718
719        // Uncolored output separates fields with spaces.
720        let plain = hex_lines(&SOURCE_ROUTED_UNICAST, &map, false);
721        assert_eq!(
722            plain,
723            vec![
724                "d1 50 b7a626 45feb2 e0df45b6a0 2012ef24ff \
725                 a2414a15f07c552151d6 7de629bbf3a0ee3793a422209a498f72",
726            ],
727        );
728
729        // Colorized output needs no separators: the color change is the
730        // boundary. Stripping the escapes recovers a bare hex string.
731        let colored = hex_lines(&SOURCE_ROUTED_UNICAST, &map, true);
732        assert_eq!(colored.len(), 1);
733        assert!(colored[0].contains("\x1b["));
734        assert_eq!(
735            strip_ansi(&colored[0]),
736            SOURCE_ROUTED_UNICAST
737                .iter()
738                .map(|byte| format!("{byte:02x}"))
739                .collect::<String>(),
740        );
741    }
742
743    #[test]
744    fn hex_dump_wraps_a_full_size_frame_without_losing_bytes() {
745        let packet = [0x5au8; 200];
746        let map = vec![Some(Field::Body); packet.len()];
747        let lines = hex_lines(&packet, &map, true);
748        assert!(lines.len() > 1, "a 200-byte frame must wrap");
749        for line in &lines {
750            assert!(strip_ansi(line).len() <= HEX_WIDTH, "line exceeds budget");
751        }
752        let rejoined: String = lines.iter().map(|line| strip_ansi(line)).collect();
753        assert_eq!(rejoined, "5a".repeat(packet.len()));
754    }
755
756    #[test]
757    fn non_umsh_frames_still_dump_their_bytes() {
758        let foreign = [0x15, 0x02, 0x69, 0x26];
759        let map = vec![None; foreign.len()];
760        assert_eq!(hex_lines(&foreign, &map, false), vec!["15026926"]);
761    }
762
763    fn strip_ansi(text: &str) -> String {
764        let mut out = String::new();
765        let mut chars = text.chars();
766        while let Some(ch) = chars.next() {
767            if ch == '\x1b' {
768                for skip in chars.by_ref() {
769                    if skip == 'm' {
770                        break;
771                    }
772                }
773            } else {
774                out.push(ch);
775            }
776        }
777        out
778    }
779
780    #[test]
781    fn beacons_and_foreign_frames_render_without_a_security_header() {
782        let beacon = [0xc0, 0xa1, 0xb2, 0x03];
783        let header = PacketHeader::parse(&beacon).unwrap();
784        assert_eq!(
785            summary_line(&beacon, &header, false),
786            "Broadcast  BtC5 → *  beacon",
787        );
788        assert!(options_line(&beacon, &header, false).is_none());
789
790        assert!(PacketHeader::parse(&[0x15, 0x02, 0x69, 0x26]).is_err());
791    }
792}