1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Field {
20 Fcf,
21 FloodHops,
22 Dst,
23 Src,
24 Channel,
25 SecInfo,
26 Options,
27 EncAddr,
30 Body,
31 AckMic,
34 AckTag,
37 Mic,
38}
39
40impl Field {
41 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
93const UNENCRYPTED_SGR: &str = "1;97;41";
96
97fn 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
115fn 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 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 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
192fn print_hex(packet: &[u8], map: &[Option<Field>], color: bool) {
198 for line in hex_lines(packet, map, color) {
199 println!(" {line}");
200 }
201}
202
203const 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 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
260pub 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
278fn 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 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 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 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 if !encrypted {
350 chips.push(styled("UNENC", UNENCRYPTED_SGR, color));
351 }
352 }
353 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 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 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
408fn 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
426fn 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 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 other => format!(
482 "{}opt{number}={}",
483 if other.is_critical() { "!" } else { "" },
484 hex_text(value),
485 ),
486 }
487}
488
489fn 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 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 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 const MAC_ACK: [u8; 10] = [0xc9, 0x10, 0x8c, 0xb6, 0x8f, 0x5d, 0x97, 0x00, 0xed, 0xe7];
536
537 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 assert!(!summary.contains("Some"));
564 assert!(!summary.contains("None"));
565
566 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 assert_eq!(summary.matches("Ack").count(), 1);
584 assert!(!summary.contains("ack-req"));
585
586 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 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 let header = PacketHeader::parse(&SOURCE_ROUTED_UNICAST).unwrap();
629 assert!(!summary_line(&SOURCE_ROUTED_UNICAST, &header, false).contains("UNENC"));
630
631 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 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 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 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 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 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}