1use crate::models::*;
18
19const INDENT: &str = " ";
20
21pub fn format_record_with_hex(record: &MrtRecord, hex: &str) -> String {
25 let mut out = format_record(record);
26 out.push_str(&format!("{INDENT}HEX: {hex}\n"));
27 out
28}
29
30pub fn format_record(record: &MrtRecord) -> String {
35 let mut out = String::with_capacity(512);
36 let ts = match record.common_header.microsecond_timestamp {
37 Some(us) => format!("{}.{:06}", record.common_header.timestamp, us),
38 None => record.common_header.timestamp.to_string(),
39 };
40 out.push_str(&format!("TIME: {ts}\n"));
41 out.push_str(&format!(
42 "TYPE: {:?}/{}\n",
43 record.common_header.entry_type,
44 entry_subtype_name(record)
45 ));
46
47 match &record.message {
48 MrtMessage::Bgp4Mp(msg) => render_bgp4mp(&mut out, msg),
49 MrtMessage::LegacyBgp(msg) => render_legacy_bgp(&mut out, msg),
50 MrtMessage::TableDumpMessage(msg) => render_table_dump(&mut out, msg),
51 MrtMessage::TableDumpMessageBatch(messages) => {
52 out.push_str(&format!("TABLE_DUMP: {} entries\n", messages.len()));
53 for msg in messages {
54 render_table_dump(&mut out, msg);
55 }
56 }
57 MrtMessage::TableDumpV2Message(msg) => render_table_dump_v2(&mut out, msg),
58 }
59 out
60}
61
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67pub struct Style {
68 ansi: bool,
69}
70
71impl Style {
72 pub const fn plain() -> Self {
74 Self { ansi: false }
75 }
76
77 pub const fn ansi() -> Self {
79 Self { ansi: true }
80 }
81}
82
83const ANSI_LABEL: &str = "\x1b[1;34m";
84const ANSI_SECTION: &str = "\x1b[1;35m";
85const ANSI_PREFIX: &str = "\x1b[32m";
86const ANSI_NEXT_HOP: &str = "\x1b[33m";
87const ANSI_RESET: &str = "\x1b[0m";
88
89const PREFIX_SECTIONS: [&str; 3] = ["WITHDRAWN", "ANNOUNCED", "ANNOUNCED (labeled)"];
91const SECTION_LABELS: [&str; 17] = [
93 "UPDATE",
94 "OPEN",
95 "KEEPALIVE",
96 "NOTIFICATION",
97 "ROUTE_REFRESH",
98 "STATE_CHANGE",
99 "ATTRIBUTES",
100 "WARNINGS",
101 "CAPABILITIES",
102 "LINK_STATE_NLRI",
103 "FLOWSPEC_NLRI",
104 "TABLE_DUMP",
105 "PEER_INDEX_TABLE",
106 "GEO_PEER_TABLE",
107 "RIB_ENTRY",
108 "RIB_AFI",
109 "RIB_GENERIC",
110];
111const NEXT_HOP_LABELS: [&str; 2] = ["NEXT_HOP", "MP_REACH_NLRI"];
113const PREFIX_LABELS: [&str; 1] = ["PREFIX"];
115const BARE_LABELS: [&str; 1] = ["ATOMIC_AGGREGATE"];
117const SPACED_LABEL_PREFIXES: [&str; 3] =
119 ["RAW ATTRIBUTE (type", "DEPRECATED (type", "UNKNOWN (type"];
120
121pub fn format_record_with_style(record: &MrtRecord, style: Style) -> String {
126 let plain = format_record(record);
127 if style.ansi {
128 colorize(&plain)
129 } else {
130 plain
131 }
132}
133
134pub fn format_record_with_hex_and_style(record: &MrtRecord, hex: &str, style: Style) -> String {
137 let plain = format_record_with_hex(record, hex);
138 if style.ansi {
139 colorize(&plain)
140 } else {
141 plain
142 }
143}
144
145fn colorize(plain: &str) -> String {
150 let mut out = String::with_capacity(plain.len() + plain.len() / 4);
151 let mut prefix_section = false;
152 for line in plain.split_inclusive('\n') {
153 let (content, newline) = match line.strip_suffix('\n') {
154 Some(content) => (content, "\n"),
155 None => (line, ""),
156 };
157 let trimmed = content.trim_start();
158 let indent = content.len() - trimmed.len();
159 let painted = if trimmed.is_empty() {
160 None
161 } else if indent >= 4 && prefix_section {
162 let (prefix, rest) = trimmed.split_once(' ').unwrap_or((trimmed, ""));
166 let rest = match rest.is_empty() {
167 true => String::new(),
168 false => format!(" {rest}"),
169 };
170 Some(format!("{ANSI_PREFIX}{prefix}{ANSI_RESET}{rest}"))
171 } else if let Some((label, value)) = trimmed.split_once(':') {
172 let label = label.trim_end();
173 let value = value.strip_prefix(' ').unwrap_or(value);
174 if value.is_empty() {
175 prefix_section = PREFIX_SECTIONS.contains(&label);
176 let accent = if prefix_section || SECTION_LABELS.contains(&label) {
177 ANSI_SECTION
178 } else {
179 ANSI_LABEL
180 };
181 Some(format!("{accent}{label}:{ANSI_RESET}"))
182 } else if SECTION_LABELS.contains(&label) {
183 Some(match value.split_once("PREFIX: ") {
185 Some((head, tail)) => {
186 let (prefix, rest) = tail.split_once(' ').unwrap_or((tail, ""));
187 let rest = match rest.is_empty() {
188 true => String::new(),
189 false => format!(" {rest}"),
190 };
191 format!(
192 "{ANSI_SECTION}{label}:{ANSI_RESET} {head}PREFIX: {ANSI_PREFIX}{prefix}{ANSI_RESET}{rest}"
193 )
194 }
195 None => format!("{ANSI_SECTION}{label}:{ANSI_RESET} {value}"),
196 })
197 } else if PREFIX_LABELS.contains(&label) {
198 Some(format!(
199 "{ANSI_LABEL}{label}:{ANSI_RESET} {ANSI_PREFIX}{value}{ANSI_RESET}"
200 ))
201 } else if NEXT_HOP_LABELS.contains(&label) {
202 Some(match value.split_once(" next-hop ") {
206 Some((family, address)) => format!(
207 "{ANSI_LABEL}{label}:{ANSI_RESET} {family} next-hop {ANSI_NEXT_HOP}{address}{ANSI_RESET}"
208 ),
209 None if label == "NEXT_HOP" => {
210 format!("{ANSI_LABEL}{label}:{ANSI_RESET} {ANSI_NEXT_HOP}{value}{ANSI_RESET}")
211 }
212 None => format!("{ANSI_LABEL}{label}:{ANSI_RESET} {value}"),
213 })
214 } else if !label.contains(' ')
215 || SPACED_LABEL_PREFIXES
216 .iter()
217 .any(|prefix| label.starts_with(prefix))
218 {
219 Some(format!("{ANSI_LABEL}{label}:{ANSI_RESET} {value}"))
220 } else {
221 None
223 }
224 } else if BARE_LABELS.contains(&trimmed) {
225 Some(format!("{ANSI_LABEL}{trimmed}{ANSI_RESET}"))
226 } else {
227 None
228 };
229 match painted {
230 Some(painted) => {
231 out.push_str(&" ".repeat(indent));
232 out.push_str(&painted);
233 }
234 None => out.push_str(content),
235 }
236 out.push_str(newline);
237 }
238 out
239}
240
241fn render_legacy_bgp(out: &mut String, msg: &LegacyBgp) {
242 match msg {
245 LegacyBgp::Message(m) => {
246 out.push_str(&format!(
247 "PEER: {} AS{}\nLOCAL: {} AS{}\n",
248 m.peer_ip, m.peer_asn, m.local_ip, m.local_asn
249 ));
250 render_bgp_message(out, &m.bgp_message);
251 }
252 LegacyBgp::StateChange(m) => {
253 out.push_str(&format!("PEER: {} AS{}\n", m.peer_ip, m.peer_asn));
254 out.push_str("STATE_CHANGE:\n");
255 out.push_str(&format!(
256 "{INDENT}OLD_STATE: {:?}\n{INDENT}NEW_STATE: {:?}\n",
257 m.old_state, m.new_state
258 ));
259 }
260 }
261}
262
263fn entry_subtype_name(record: &MrtRecord) -> String {
264 match &record.message {
265 MrtMessage::Bgp4Mp(msg) => format!("{:?}", msg.msg_type()),
266 _ => record.common_header.entry_subtype.to_string(),
267 }
268}
269
270fn render_bgp4mp(out: &mut String, msg: &Bgp4MpEnum) {
271 match msg {
272 Bgp4MpEnum::StateChange(m) => {
273 out.push_str(&format!(
274 "PEER: {} AS{}\nLOCAL: {} AS{}\n",
275 m.peer_ip, m.peer_asn, m.local_addr, m.local_asn
276 ));
277 out.push_str("STATE_CHANGE:\n");
278 out.push_str(&format!(
279 "{INDENT}OLD_STATE: {:?}\n{INDENT}NEW_STATE: {:?}\n",
280 m.old_state, m.new_state
281 ));
282 }
283 Bgp4MpEnum::Message(m) => {
284 out.push_str(&format!(
285 "PEER: {} AS{}\nLOCAL: {} AS{}\n",
286 m.peer_ip, m.peer_asn, m.local_ip, m.local_asn
287 ));
288 render_bgp_message(out, &m.bgp_message);
289 }
290 }
291}
292
293fn render_bgp_message(out: &mut String, msg: &BgpMessage) {
294 match msg {
295 BgpMessage::Open(open) => {
296 out.push_str("OPEN:\n");
297 out.push_str(&format!(
298 "{INDENT}VERSION: {}\n{INDENT}MY_AS: {}\n{INDENT}HOLD_TIME: {}s\n{INDENT}BGP_ID: {}\n",
299 open.version, open.asn, open.hold_time, open.bgp_identifier
300 ));
301 if open.opt_params.is_empty() {
302 return;
303 }
304 out.push_str(&format!("{INDENT}CAPABILITIES:\n"));
305 for param in &open.opt_params {
306 if let ParamValue::Capacities(caps) = ¶m.param_value {
307 for cap in caps {
308 out.push_str(&format!("{INDENT}{INDENT}{}\n", capability_summary(cap)));
309 }
310 }
311 }
312 }
313 BgpMessage::Update(update) => {
314 out.push_str("UPDATE:\n");
315 render_update(out, update);
316 }
317 BgpMessage::Notification(n) => {
318 out.push_str("NOTIFICATION:\n");
319 out.push_str(&format!(
320 "{INDENT}ERROR: {:?} ({} bytes of data)\n",
321 n.error,
322 n.data.len()
323 ));
324 }
325 BgpMessage::KeepAlive => {
326 out.push_str("KEEPALIVE:\n");
327 }
328 BgpMessage::RouteRefresh(r) => {
329 out.push_str("ROUTE_REFRESH:\n");
330 out.push_str(&format!(
331 "{INDENT}AFI: {} SAFI: {} SUBTYPE: {}\n",
332 r.afi, r.safi, r.subtype
333 ));
334 }
335 }
336}
337
338fn capability_summary(cap: &Capability) -> String {
339 match &cap.value {
340 CapabilityValue::MultiprotocolExtensions(mp) => {
341 format!("{:?}: {:?}/{:?}", cap.ty, mp.afi, mp.safi)
342 }
343 CapabilityValue::FourOctetAs(foa) => format!("{:?}: AS{}", cap.ty, foa.asn),
344 CapabilityValue::AddPath(ap) => format!(
345 "{:?}: {} address families",
346 cap.ty,
347 ap.address_families.len()
348 ),
349 _ => format!("{:?}", cap.ty),
350 }
351}
352
353fn render_update(out: &mut String, update: &BgpUpdateMessage) {
354 let mut withdrawn: Vec<&NetworkPrefix> = update.withdrawn_prefixes.iter().collect();
359 let mut announced: Vec<&NetworkPrefix> = update.announced_prefixes.iter().collect();
360 let mut labeled_announced: Vec<&LabeledNetworkPrefix> = Vec::new();
361 let mut link_state_count = 0usize;
362 let mut flowspec_count = 0usize;
363 for attr in update.attributes.iter() {
364 match attr {
365 AttributeValue::MpUnreachNlri(nlri) => {
366 withdrawn.extend(nlri.prefixes.iter());
367 link_state_count += nlri.link_state_nlris.as_ref().map_or(0, Vec::len);
368 flowspec_count += nlri.flowspec_nlris.as_ref().map_or(0, Vec::len);
369 }
370 AttributeValue::MpReachNlri(nlri) => {
371 announced.extend(nlri.prefixes.iter());
372 labeled_announced.extend(nlri.labeled_prefixes.iter().flatten());
373 link_state_count += nlri.link_state_nlris.as_ref().map_or(0, Vec::len);
374 flowspec_count += nlri.flowspec_nlris.as_ref().map_or(0, Vec::len);
375 }
376 _ => {}
377 }
378 }
379
380 if !withdrawn.is_empty() {
381 out.push_str(&format!("{INDENT}WITHDRAWN:\n"));
382 for prefix in withdrawn {
383 out.push_str(&format!("{INDENT}{INDENT}{prefix}\n"));
384 }
385 }
386 if !announced.is_empty() {
387 out.push_str(&format!("{INDENT}ANNOUNCED:\n"));
388 for prefix in announced {
389 out.push_str(&format!("{INDENT}{INDENT}{prefix}\n"));
390 }
391 }
392 if !labeled_announced.is_empty() {
393 out.push_str(&format!("{INDENT}ANNOUNCED (labeled):\n"));
396 for labeled in labeled_announced {
397 let labels: Vec<String> = labeled
398 .labels
399 .iter()
400 .map(|label| label.value().to_string())
401 .collect();
402 let path_id = labeled
403 .path_id
404 .map_or_else(String::new, |id| format!(" path-id {id}"));
405 out.push_str(&format!(
406 "{INDENT}{INDENT}{} labels=[{}]{path_id}\n",
407 labeled.prefix,
408 labels.join(", ")
409 ));
410 }
411 }
412 if link_state_count > 0 {
413 out.push_str(&format!(
414 "{INDENT}LINK_STATE_NLRI: {link_state_count} entries (BGP-LS)\n"
415 ));
416 }
417 if flowspec_count > 0 {
418 out.push_str(&format!(
419 "{INDENT}FLOWSPEC_NLRI: {flowspec_count} rules (RFC 8955)\n"
420 ));
421 }
422
423 let mut attrs = update.attributes.iter().peekable();
424 if attrs.peek().is_some() {
425 out.push_str(&format!("{INDENT}ATTRIBUTES:\n"));
426 for attr in attrs {
427 if let Some(line) = render_attribute(attr) {
428 out.push_str(&format!("{INDENT}{INDENT}{line}\n"));
429 }
430 }
431 }
432
433 let warnings = update.attributes.validation_warnings();
434 if !warnings.is_empty() {
435 out.push_str(&format!("{INDENT}WARNINGS:\n"));
436 for warning in warnings {
437 out.push_str(&format!("{INDENT}{INDENT}{warning}\n"));
438 }
439 }
440}
441
442fn render_attribute(value: &AttributeValue) -> Option<String> {
446 let line = match value {
447 AttributeValue::Origin(v) => format!("ORIGIN: {v}"),
448 AttributeValue::AsPath(v) => format!("AS_PATH: {v}"),
449 AttributeValue::As4Path(v) => format!("AS4_PATH: {v}"),
450 AttributeValue::NextHop(v) => format!("NEXT_HOP: {v}"),
451 AttributeValue::MultiExitDiscriminator(v) => format!("MULTI_EXIT_DISC: {v}"),
452 AttributeValue::LocalPreference(v) => format!("LOCAL_PREF: {v}"),
453 AttributeValue::OnlyToCustomer(v) => format!("ONLY_TO_CUSTOMER: {v}"),
454 AttributeValue::AtomicAggregate => "ATOMIC_AGGREGATE".to_string(),
455 AttributeValue::Aggregator { asn, id } => format!("AGGREGATOR: AS{asn} by {id}"),
456 AttributeValue::As4Aggregator { asn, id } => {
457 format!("AS4_AGGREGATOR: AS{asn} by {id}")
458 }
459 AttributeValue::Communities(v) => {
460 let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
461 format!("COMMUNITIES: {}", rendered.join(" "))
462 }
463 AttributeValue::LargeCommunities(v) => {
464 let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
465 format!("LARGE_COMMUNITIES: {}", rendered.join(" "))
466 }
467 AttributeValue::ExtendedCommunities(v) => {
468 let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
469 format!("EXTENDED_COMMUNITIES: {}", rendered.join(" "))
470 }
471 AttributeValue::Ipv6AddressSpecificExtendedCommunities(v) => {
472 let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
473 format!("IPV6_EXTENDED_COMMUNITIES: {}", rendered.join(" "))
474 }
475 AttributeValue::OriginatorId(v) => format!("ORIGINATOR_ID: {v}"),
476 AttributeValue::Clusters(v) => {
477 let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
478 format!("CLUSTER_LIST: {}", rendered.join(" "))
479 }
480 AttributeValue::MpReachNlri(nlri) => {
481 let next_hop = match &nlri.next_hop {
482 Some(nh) => format!(" next-hop {nh}"),
483 None => String::new(),
484 };
485 format!("MP_REACH_NLRI: {:?}/{:?}{next_hop}", nlri.afi, nlri.safi)
486 }
487 AttributeValue::MpUnreachNlri(nlri) => {
488 format!("MP_UNREACH_NLRI: {:?}/{:?}", nlri.afi, nlri.safi)
489 }
490 AttributeValue::Aigp(v) => format!("AIGP: {v:?}"),
491 AttributeValue::BfdDiscriminator(v) => format!("BFD_DISCRIMINATOR: {v:?}"),
492 AttributeValue::DomainPath(v) => format!("BGP_DOMAIN_PATH: {v:?}"),
493 AttributeValue::TrafficEngineering(v) => format!("TRAFFIC_ENGINEERING: {v:?}"),
494 AttributeValue::TunnelEncapsulation(v) => format!("TUNNEL_ENCAPSULATION: {v:?}"),
495 AttributeValue::LinkState(v) => format!("BGP_LS: {v:?}"),
496 AttributeValue::BgpPrefixSid(v) => format!("BGP_PREFIX_SID: {v:?}"),
497 AttributeValue::Bier(v) => format!("BIER: {v:?}"),
498 AttributeValue::Sfp(v) => format!("SFP: {v:?}"),
499 AttributeValue::AttrSet(v) => {
500 format!("ATTR_SET: attributes of AS{}", v.origin_as)
501 }
502 AttributeValue::Development(v) => format!("DEVELOPMENT: {} bytes", v.len()),
503 AttributeValue::Raw(v) => {
504 format!("RAW ATTRIBUTE (type {}): {} bytes", v.code, v.bytes.len())
505 }
506 AttributeValue::Deprecated(v) => {
507 format!("DEPRECATED (type {}): {} bytes", v.code, v.bytes.len())
508 }
509 AttributeValue::Unknown(v) => {
510 format!("UNKNOWN (type {}): {} bytes", v.code, v.bytes.len())
511 }
512 };
513 Some(line)
514}
515
516fn render_table_dump(out: &mut String, msg: &TableDumpMessage) {
517 out.push_str(&format!(
518 "RIB_ENTRY:\n{INDENT}PREFIX: {}\n{INDENT}PEER: {} AS{}\n{INDENT}ORIGINATED: {}\n",
519 msg.prefix, msg.peer_ip, msg.peer_asn, msg.originated_time
520 ));
521 render_attributes_block(out, &msg.attributes, 1);
522}
523
524fn render_table_dump_v2(out: &mut String, msg: &TableDumpV2Message) {
525 match msg {
526 TableDumpV2Message::PeerIndexTable(pit) => {
527 out.push_str(&format!(
528 "PEER_INDEX_TABLE: {} peers\n",
529 pit.id_peer_map.len()
530 ));
531 let mut peers: Vec<_> = pit.id_peer_map.iter().collect();
533 peers.sort_by_key(|(id, _)| **id);
534 for (id, peer) in peers {
535 out.push_str(&format!(
536 "{INDENT}PEER[{id}]: {} AS{}\n",
537 peer.peer_ip, peer.peer_asn
538 ));
539 }
540 }
541 TableDumpV2Message::RibAfi(rib) => {
542 out.push_str(&format!(
543 "RIB_AFI: {:?} PREFIX: {} ({} entries)\n",
544 rib.rib_type,
545 rib.prefix,
546 rib.rib_entries.len()
547 ));
548 for entry in &rib.rib_entries {
549 out.push_str(&format!(
550 "{INDENT}RIB_ENTRY:\n{INDENT}{INDENT}PEER_INDEX: {}\n{INDENT}{INDENT}ORIGINATED: {}\n",
551 entry.peer_index, entry.originated_time
552 ));
553 render_attributes_block(out, &entry.attributes, 2);
554 }
555 }
556 TableDumpV2Message::RibGeneric(rib) => {
557 out.push_str(&format!(
558 "RIB_GENERIC: {:?}/{:?} PREFIX: {} ({} entries)\n",
559 rib.afi,
560 rib.safi,
561 rib.nlri,
562 rib.rib_entries.len()
563 ));
564 for entry in &rib.rib_entries {
565 out.push_str(&format!(
566 "{INDENT}RIB_ENTRY:\n{INDENT}{INDENT}PEER_INDEX: {}\n{INDENT}{INDENT}ORIGINATED: {}\n",
567 entry.peer_index, entry.originated_time
568 ));
569 render_attributes_block(out, &entry.attributes, 2);
570 }
571 }
572 TableDumpV2Message::GeoPeerTable(gpt) => {
573 out.push_str(&format!("GEO_PEER_TABLE: {} peers\n", gpt.geo_peers.len()));
574 }
575 }
576}
577
578fn render_attributes_block(out: &mut String, attributes: &Attributes, depth: usize) {
579 let pad = INDENT.repeat(depth + 1);
580 let mut iter = attributes.iter().peekable();
581 if iter.peek().is_some() {
585 out.push_str(&format!("{}ATTRIBUTES:\n", INDENT.repeat(depth)));
586 }
587 for attr in iter {
588 if let Some(line) = render_attribute(attr) {
589 out.push_str(&format!("{pad}{line}\n"));
590 }
591 }
592 let warnings = attributes.validation_warnings();
593 if !warnings.is_empty() {
594 out.push_str(&format!("{}WARNINGS:\n", INDENT.repeat(depth)));
595 for warning in warnings {
596 out.push_str(&format!("{pad}{warning}\n"));
597 }
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use crate::error::BgpValidationWarning;
605 use crate::models::capabilities::{
606 BgpCapabilityType, FourOctetAsCapability, MultiprotocolExtensionsCapability,
607 };
608 use std::net::IpAddr;
609 use std::net::Ipv4Addr;
610 use std::str::FromStr;
611
612 fn update_record(attributes: Attributes) -> MrtRecord {
613 MrtRecord {
614 common_header: CommonHeader {
615 timestamp: 1_666_542_810,
616 microsecond_timestamp: Some(970_000),
617 entry_type: EntryType::BGP4MP,
618 entry_subtype: Bgp4MpType::MessageAs4 as u16,
619 length: 0,
620 },
621 message: MrtMessage::Bgp4Mp(Bgp4MpEnum::Message(Bgp4MpMessage {
622 msg_type: Bgp4MpType::MessageAs4,
623 peer_asn: Asn::new_32bit(64496),
624 local_asn: Asn::new_32bit(64497),
625 interface_index: 0,
626 peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
627 local_ip: IpAddr::from_str("192.0.2.2").unwrap(),
628 bgp_message: BgpMessage::Update(BgpUpdateMessage {
629 withdrawn_prefixes: vec![NetworkPrefix::from_str("203.0.113.0/24").unwrap()],
630 attributes,
631 announced_prefixes: vec![NetworkPrefix::from_str("198.51.100.0/24").unwrap()],
632 }),
633 })),
634 }
635 }
636
637 fn full_attributes() -> Attributes {
638 let mut attributes = Attributes::default();
639 attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
640 attributes.add_attr(
641 AttributeValue::AsPath(AsPath::from_segments(vec![
642 AsPathSegment::sequence([65001, 65002]),
643 AsPathSegment::set([65010, 65011]),
644 ]))
645 .into(),
646 );
647 attributes
648 .add_attr(AttributeValue::NextHop(IpAddr::from_str("192.0.2.254").unwrap()).into());
649 attributes.add_attr(AttributeValue::LocalPreference(100).into());
650 attributes.add_attr(AttributeValue::OnlyToCustomer(64512.into()).into());
651 attributes.add_attr(
652 AttributeValue::Communities(vec![
653 Community::Custom(Asn::new_32bit(64512), 100),
654 Community::NoExport,
655 ])
656 .into(),
657 );
658 attributes.add_attr(
659 AttributeValue::LargeCommunities(vec![LargeCommunity::new(64496, [1, 2])]).into(),
660 );
661 attributes
662 }
663
664 #[test]
665 fn renders_hex_line_variant() {
666 let text = format_record_with_hex(&update_record(full_attributes()), "deadbeef");
667 assert!(text.trim_end().ends_with(" HEX: deadbeef"));
668 let plain = format_record(&update_record(full_attributes()));
670 assert!(text.starts_with(&plain));
671 }
672
673 #[test]
674 fn renders_layered_update_block() {
675 let text = format_record(&update_record(full_attributes()));
676 let expected = "\
677TIME: 1666542810.970000
678TYPE: BGP4MP/MessageAs4
679PEER: 192.0.2.1 AS64496
680LOCAL: 192.0.2.2 AS64497
681UPDATE:
682 WITHDRAWN:
683 203.0.113.0/24
684 ANNOUNCED:
685 198.51.100.0/24
686 ATTRIBUTES:
687 ORIGIN: IGP
688 AS_PATH: 65001 65002 {65010,65011}
689 NEXT_HOP: 192.0.2.254
690 LOCAL_PREF: 100
691 ONLY_TO_CUSTOMER: 64512
692 COMMUNITIES: 64512:100 no-export
693 LARGE_COMMUNITIES: 64496:1:2
694";
695 assert_eq!(text, expected);
696 }
697
698 #[test]
699 fn renders_warnings_section() {
700 let mut attributes = full_attributes();
701 attributes.add_validation_warning(BgpValidationWarning::DuplicateAttribute {
702 attr_type: AttrType::ORIGIN,
703 });
704 let text = format_record(&update_record(attributes));
705 assert!(text.contains(" WARNINGS:\n"));
706 assert!(text.contains("Duplicate attribute: ORIGIN"));
707 let attr_pos = text.find("LARGE_COMMUNITIES").unwrap();
709 let warn_pos = text.find("WARNINGS").unwrap();
710 assert!(warn_pos > attr_pos);
711 }
712
713 #[test]
714 fn renders_mp_reach_prefixes_in_sections() {
715 let mut attributes = Attributes::default();
716 attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
717 attributes.add_attr(
718 AttributeValue::MpReachNlri(Nlri {
719 afi: Afi::Ipv6,
720 safi: Safi::Unicast,
721 next_hop: Some(NextHopAddress::Ipv6("2001:db8::1".parse().unwrap())),
722 prefixes: vec![NetworkPrefix::from_str("2001:db8::/32").unwrap()],
723 labeled_prefixes: None,
724 link_state_nlris: None,
725 flowspec_nlris: None,
726 })
727 .into(),
728 );
729 let text = format_record(&update_record(attributes));
730 assert!(text.contains(" 198.51.100.0/24\n 2001:db8::/32\n"));
731 assert!(text.contains("MP_REACH_NLRI: Ipv6/Unicast next-hop 2001:db8::1"));
732 }
733
734 fn strip_sgr(text: &str) -> String {
736 let mut out = String::with_capacity(text.len());
737 let mut rest = text;
738 while let Some(start) = rest.find('\x1b') {
739 out.push_str(&rest[..start]);
740 match rest[start..].find('m') {
741 Some(end) => rest = &rest[start + end + 1..],
742 None => return out,
743 }
744 }
745 out.push_str(rest);
746 out
747 }
748
749 #[test]
750 fn style_plain_is_byte_identical() {
751 let record = update_record(full_attributes());
752 assert_eq!(
753 format_record_with_style(&record, Style::plain()),
754 format_record(&record)
755 );
756 assert_eq!(
757 format_record_with_hex_and_style(&record, "deadbeef", Style::plain()),
758 format_record_with_hex(&record, "deadbeef")
759 );
760 }
761
762 #[test]
763 fn style_accents_labels_sections_prefixes_and_next_hops() {
764 let mut attributes = full_attributes();
765 attributes.add_attr(
766 AttributeValue::MpReachNlri(Nlri {
767 afi: Afi::Ipv6,
768 safi: Safi::Unicast,
769 next_hop: Some(NextHopAddress::Ipv6LinkLocal(
771 "2001:db8::1".parse().unwrap(),
772 "fe80::1".parse().unwrap(),
773 )),
774 prefixes: vec![NetworkPrefix::from_str("2001:db8:1::/48").unwrap()],
775 labeled_prefixes: None,
776 link_state_nlris: None,
777 flowspec_nlris: None,
778 })
779 .into(),
780 );
781 let record = update_record(attributes);
782 let plain = format_record(&record);
783 let styled = format_record_with_style(&record, Style::ansi());
784
785 assert_ne!(styled, plain);
787 assert_eq!(strip_sgr(&styled), plain);
788
789 assert!(styled.contains(&format!("{ANSI_LABEL}TIME:{ANSI_RESET} 1666542810.970000")));
790 assert!(styled.contains(&format!("{ANSI_LABEL}PEER:{ANSI_RESET} 192.0.2.1 AS64496")));
791 assert!(styled.contains(&format!("{ANSI_SECTION}UPDATE:{ANSI_RESET}")));
792 assert!(styled.contains(&format!("{ANSI_SECTION}ANNOUNCED:{ANSI_RESET}")));
793 assert!(styled.contains(&format!("{ANSI_SECTION}ATTRIBUTES:{ANSI_RESET}")));
794 assert!(styled.contains(&format!(" {ANSI_PREFIX}198.51.100.0/24{ANSI_RESET}")));
795 assert!(styled.contains(&format!("{ANSI_LABEL}ORIGIN:{ANSI_RESET} IGP")));
796 assert!(styled.contains(&format!(
797 "{ANSI_LABEL}MP_REACH_NLRI:{ANSI_RESET} Ipv6/Unicast next-hop {ANSI_NEXT_HOP}2001:db8::1,fe80::1{ANSI_RESET}"
798 )));
799 }
800
801 #[test]
802 fn style_accents_table_dump_prefixes() {
803 let mut attributes = Attributes::default();
804 attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
805 let rib = MrtRecord {
806 common_header: CommonHeader {
807 timestamp: 6,
808 microsecond_timestamp: None,
809 entry_type: EntryType::TABLE_DUMP_V2,
810 entry_subtype: 2,
811 length: 0,
812 },
813 message: MrtMessage::TableDumpV2Message(TableDumpV2Message::RibAfi(RibAfiEntries {
814 rib_type: TableDumpV2Type::RibIpv4Unicast,
815 sequence_number: 1,
816 prefix: NetworkPrefix::from_str("198.51.100.0/24").unwrap(),
817 rib_entries: vec![RibEntry {
818 peer_index: 0,
819 originated_time: 1_666_542_000,
820 path_id: None,
821 attributes,
822 }],
823 })),
824 };
825 let styled = format_record_with_style(&rib, Style::ansi());
826 assert!(styled.contains(&format!(
828 "PREFIX: {ANSI_PREFIX}198.51.100.0/24{ANSI_RESET} (1 entries)"
829 )));
830
831 let entry = MrtRecord {
833 common_header: CommonHeader {
834 timestamp: 6,
835 microsecond_timestamp: None,
836 entry_type: EntryType::TABLE_DUMP,
837 entry_subtype: 12,
838 length: 0,
839 },
840 message: MrtMessage::TableDumpMessage(TableDumpMessage {
841 view_number: 0,
842 sequence_number: 1,
843 prefix: NetworkPrefix::from_str("198.51.100.0/24").unwrap(),
844 status: 1,
845 originated_time: 1_666_542_000,
846 peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
847 peer_asn: Asn::new_16bit(64496),
848 attributes: Attributes::default(),
849 }),
850 };
851 let styled = format_record_with_style(&entry, Style::ansi());
852 assert!(styled.contains(&format!(
853 "{ANSI_LABEL}PREFIX:{ANSI_RESET} {ANSI_PREFIX}198.51.100.0/24{ANSI_RESET}\n"
854 )));
855 }
856
857 #[test]
858 fn style_accents_bare_and_spaced_property_labels() {
859 let plain = "UPDATE:\n ATTRIBUTES:\n ATOMIC_AGGREGATE\n RAW ATTRIBUTE (type 99): 4 bytes\n Duplicate attribute: ORIGIN\n";
860 let styled = colorize(plain);
861 assert!(styled.contains(&format!("{ANSI_LABEL}ATOMIC_AGGREGATE{ANSI_RESET}")));
862 assert!(styled.contains(&format!(
863 "{ANSI_LABEL}RAW ATTRIBUTE (type 99):{ANSI_RESET} 4 bytes"
864 )));
865 assert!(styled.contains(" Duplicate attribute: ORIGIN\n"));
867 assert_eq!(strip_sgr(&styled), plain);
868 }
869
870 #[test]
871 fn style_accents_only_the_prefix_in_labeled_entries() {
872 let plain = "UPDATE:\n ANNOUNCED (labeled):\n 2001:db8::/32 labels=[16] path-id 3\n";
873 let styled = colorize(plain);
874 assert!(styled.contains(&format!(
875 " {ANSI_PREFIX}2001:db8::/32{ANSI_RESET} labels=[16] path-id 3"
876 )));
877 assert_eq!(strip_sgr(&styled), plain);
878 }
879
880 #[test]
881 fn style_leaves_sentence_lines_plain() {
882 let mut attributes = full_attributes();
883 attributes.add_validation_warning(BgpValidationWarning::DuplicateAttribute {
884 attr_type: AttrType::ORIGIN,
885 });
886 let styled = format_record_with_style(&update_record(attributes), Style::ansi());
887 assert!(styled.contains(" Duplicate attribute: ORIGIN\n"));
889 }
890
891 #[test]
892 fn renders_state_change_open_and_keepalive() {
893 let state = MrtRecord {
894 common_header: CommonHeader {
895 timestamp: 2,
896 microsecond_timestamp: None,
897 entry_type: EntryType::BGP4MP,
898 entry_subtype: Bgp4MpType::StateChange as u16,
899 length: 0,
900 },
901 message: MrtMessage::Bgp4Mp(Bgp4MpEnum::StateChange(Bgp4MpStateChange {
902 msg_type: Bgp4MpType::StateChange,
903 peer_asn: Asn::new_16bit(64496),
904 local_asn: Asn::new_16bit(64497),
905 interface_index: 0,
906 peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
907 local_addr: IpAddr::from_str("192.0.2.2").unwrap(),
908 old_state: BgpState::Idle,
909 new_state: BgpState::Established,
910 })),
911 };
912 let text = format_record(&state);
913 assert!(text.contains("PEER: 192.0.2.1 AS64496"));
914 assert!(text.contains("LOCAL: 192.0.2.2 AS64497"));
915 assert!(text.contains("STATE_CHANGE:"));
916 assert!(text.contains("OLD_STATE: Idle"));
917 assert!(text.contains("NEW_STATE: Established"));
918
919 let keepalive = MrtRecord {
920 common_header: CommonHeader {
921 timestamp: 3,
922 microsecond_timestamp: None,
923 entry_type: EntryType::BGP4MP,
924 entry_subtype: Bgp4MpType::MessageAs4 as u16,
925 length: 0,
926 },
927 message: MrtMessage::Bgp4Mp(Bgp4MpEnum::Message(Bgp4MpMessage {
928 msg_type: Bgp4MpType::MessageAs4,
929 peer_asn: Asn::new_32bit(64496),
930 local_asn: Asn::new_32bit(64497),
931 interface_index: 0,
932 peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
933 local_ip: IpAddr::from_str("192.0.2.2").unwrap(),
934 bgp_message: BgpMessage::KeepAlive,
935 })),
936 };
937 assert!(format_record(&keepalive).contains("KEEPALIVE:"));
938
939 let open = MrtRecord {
940 common_header: CommonHeader {
941 timestamp: 4,
942 microsecond_timestamp: None,
943 entry_type: EntryType::BGP4MP,
944 entry_subtype: Bgp4MpType::MessageAs4 as u16,
945 length: 0,
946 },
947 message: MrtMessage::Bgp4Mp(Bgp4MpEnum::Message(Bgp4MpMessage {
948 msg_type: Bgp4MpType::MessageAs4,
949 peer_asn: Asn::new_32bit(64496),
950 local_asn: Asn::new_32bit(64497),
951 interface_index: 0,
952 peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
953 local_ip: IpAddr::from_str("192.0.2.2").unwrap(),
954 bgp_message: BgpMessage::Open(BgpOpenMessage {
955 version: 4,
956 asn: Asn::new_16bit(64496),
957 hold_time: 180,
958 bgp_identifier: Ipv4Addr::new(192, 0, 2, 1),
959 extended_length: false,
960 opt_params: vec![OptParam {
961 param_type: 2,
962 param_value: ParamValue::Capacities(vec![
963 Capability {
964 ty: BgpCapabilityType::MULTIPROTOCOL_EXTENSIONS_FOR_BGP_4,
965 value: CapabilityValue::MultiprotocolExtensions(
966 MultiprotocolExtensionsCapability::new(
967 Afi::Ipv4,
968 Safi::Unicast,
969 ),
970 ),
971 },
972 Capability {
973 ty: BgpCapabilityType::SUPPORT_FOR_4_OCTET_AS_NUMBER_CAPABILITY,
974 value: CapabilityValue::FourOctetAs(FourOctetAsCapability::new(
975 64496,
976 )),
977 },
978 ]),
979 }],
980 }),
981 })),
982 };
983 let text = format_record(&open);
984 assert!(text.contains("OPEN:"));
985 assert!(text.contains("MY_AS: 64496"));
986 assert!(text.contains("MULTIPROTOCOL_EXTENSIONS_FOR_BGP_4: Ipv4/Unicast"));
987 assert!(text.contains("SUPPORT_FOR_4_OCTET_AS_NUMBER_CAPABILITY: AS64496"));
988 }
989
990 #[test]
991 fn renders_labeled_linkstate_and_flowspec_sections() {
992 use crate::models::{LabeledNetworkPrefix, MplsLabel};
993
994 let labeled = LabeledNetworkPrefix {
995 prefix: "192.0.2.0/24".parse().unwrap(),
996 labels: smallvec::SmallVec::from_vec(vec![
997 MplsLabel::try_new(24001).unwrap(),
998 MplsLabel::try_new(16).unwrap(),
999 ]),
1000 path_id: Some(7),
1001 };
1002 let mut attributes = Attributes::default();
1003 attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
1004 attributes.add_attr(
1005 AttributeValue::MpReachNlri(Nlri {
1006 afi: Afi::Ipv4,
1007 safi: Safi::MplsLabel,
1008 next_hop: Some(NextHopAddress::Ipv4("192.0.2.254".parse().unwrap())),
1009 prefixes: vec![],
1010 labeled_prefixes: Some(vec![labeled]),
1011 link_state_nlris: Some(vec![]),
1012 flowspec_nlris: Some(vec![]),
1013 })
1014 .into(),
1015 );
1016 let text = format_record(&update_record(attributes));
1017 assert!(text.contains(" ANNOUNCED (labeled):\n"));
1018 assert!(text.contains(" 192.0.2.0/24 labels=[24001, 16] path-id 7\n"));
1019 assert!(!text.contains("LINK_STATE_NLRI"));
1021 assert!(!text.contains("FLOWSPEC_NLRI"));
1022 }
1023
1024 #[test]
1025 fn renders_rib_warnings_with_empty_attributes() {
1026 let mut empty_with_warnings = Attributes::default();
1032 empty_with_warnings.add_validation_warning(
1033 BgpValidationWarning::MissingWellKnownAttribute {
1034 attr_type: AttrType::ORIGIN,
1035 },
1036 );
1037 let rib = MrtRecord {
1038 common_header: CommonHeader {
1039 timestamp: 9,
1040 microsecond_timestamp: None,
1041 entry_type: EntryType::TABLE_DUMP_V2,
1042 entry_subtype: 2,
1043 length: 0,
1044 },
1045 message: MrtMessage::TableDumpV2Message(TableDumpV2Message::RibAfi(RibAfiEntries {
1046 rib_type: TableDumpV2Type::RibIpv4Unicast,
1047 sequence_number: 1,
1048 prefix: NetworkPrefix::from_str("198.51.100.0/24").unwrap(),
1049 rib_entries: vec![RibEntry {
1050 peer_index: 0,
1051 originated_time: 1,
1052 path_id: None,
1053 attributes: empty_with_warnings,
1054 }],
1055 })),
1056 };
1057 let text = format_record(&rib);
1058 assert!(text.contains("WARNINGS:"));
1059 assert!(text.contains("Missing well-known mandatory attribute: ORIGIN"));
1060 assert!(!text.contains("ATTRIBUTES:"));
1061 }
1062
1063 #[test]
1064 fn renders_legacy_bgp_records() {
1065 let legacy = MrtRecord {
1066 common_header: CommonHeader {
1067 timestamp: 7,
1068 microsecond_timestamp: None,
1069 entry_type: EntryType::BGP,
1070 entry_subtype: 5,
1071 length: 0,
1072 },
1073 message: MrtMessage::LegacyBgp(LegacyBgp::Message(LegacyBgpMessage {
1074 peer_asn: Asn::new_16bit(5409),
1075 peer_ip: IpAddr::from_str("195.211.222.254").unwrap(),
1076 local_asn: Asn::new_16bit(12654),
1077 local_ip: IpAddr::from_str("193.0.0.1").unwrap(),
1078 bgp_message: BgpMessage::KeepAlive,
1079 })),
1080 };
1081 let text = format_record(&legacy);
1082 assert!(text.contains("PEER: 195.211.222.254 AS5409"));
1083 assert!(text.contains("LOCAL: 193.0.0.1 AS12654"));
1084 assert!(text.contains("KEEPALIVE:"));
1085
1086 let state = MrtRecord {
1087 common_header: CommonHeader {
1088 timestamp: 8,
1089 microsecond_timestamp: None,
1090 entry_type: EntryType::BGP,
1091 entry_subtype: 6,
1092 length: 0,
1093 },
1094 message: MrtMessage::LegacyBgp(LegacyBgp::StateChange(LegacyBgpStateChange {
1095 peer_asn: Asn::new_16bit(5409),
1096 peer_ip: IpAddr::from_str("195.211.222.254").unwrap(),
1097 old_state: BgpState::Established,
1098 new_state: BgpState::Idle,
1099 })),
1100 };
1101 let text = format_record(&state);
1102 assert!(text.contains("PEER: 195.211.222.254 AS5409"));
1103 assert!(!text.contains("LOCAL:"));
1105 assert!(text.contains("STATE_CHANGE:"));
1106 assert!(text.contains("OLD_STATE: Established"));
1107 assert!(text.contains("NEW_STATE: Idle"));
1108 }
1109
1110 #[test]
1111 fn renders_peer_index_table_and_rib_entries() {
1112 let mut peers = std::collections::HashMap::new();
1113 peers.insert(
1114 0u16,
1115 Peer::new(
1116 Ipv4Addr::new(10, 0, 0, 1),
1117 IpAddr::from_str("192.0.2.1").unwrap(),
1118 Asn::new_32bit(64496),
1119 ),
1120 );
1121 let pit = MrtRecord {
1122 common_header: CommonHeader {
1123 timestamp: 5,
1124 microsecond_timestamp: None,
1125 entry_type: EntryType::TABLE_DUMP_V2,
1126 entry_subtype: 1,
1127 length: 0,
1128 },
1129 message: MrtMessage::TableDumpV2Message(TableDumpV2Message::PeerIndexTable(
1130 PeerIndexTable {
1131 collector_bgp_id: Ipv4Addr::new(10, 0, 0, 254),
1132 view_name: "".to_string(),
1133 id_peer_map: peers,
1134 peer_ip_id_map: std::collections::HashMap::new(),
1135 },
1136 )),
1137 };
1138 let text = format_record(&pit);
1139 assert!(text.contains("PEER_INDEX_TABLE: 1 peers"));
1140 assert!(text.contains("PEER[0]: 192.0.2.1 AS64496"));
1141
1142 let mut attributes = Attributes::default();
1143 attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
1144 attributes.add_attr(AttributeValue::AsPath(AsPath::from_sequence([64500])).into());
1145 let rib = MrtRecord {
1146 common_header: CommonHeader {
1147 timestamp: 6,
1148 microsecond_timestamp: None,
1149 entry_type: EntryType::TABLE_DUMP_V2,
1150 entry_subtype: 2,
1151 length: 0,
1152 },
1153 message: MrtMessage::TableDumpV2Message(TableDumpV2Message::RibAfi(RibAfiEntries {
1154 rib_type: TableDumpV2Type::RibIpv4Unicast,
1155 sequence_number: 1,
1156 prefix: NetworkPrefix::from_str("198.51.100.0/24").unwrap(),
1157 rib_entries: vec![RibEntry {
1158 peer_index: 0,
1159 originated_time: 1_666_542_000,
1160 path_id: None,
1161 attributes,
1162 }],
1163 })),
1164 };
1165 let text = format_record(&rib);
1166 assert!(text.contains("PREFIX: 198.51.100.0/24 (1 entries)"));
1167 assert!(text.contains("PEER_INDEX: 0"));
1168 assert!(text.contains("AS_PATH: 64500"));
1169 }
1170}