1use crate::models::*;
39use ipnet::IpNet;
40use std::io::{BufRead, Read};
41use std::net::IpAddr;
42
43type ColumnPositions = (usize, usize, usize, usize, usize);
46
47#[derive(Debug, Clone)]
49pub struct TextDumpHeader {
50 pub table_version: Option<u64>,
52 pub router_id: Option<IpAddr>,
54 pub local_as: Option<u32>,
56 column_positions: Option<ColumnPositions>,
57}
58
59pub fn detect_text_dump<R: Read>(mut reader: R) -> std::io::Result<(bool, Vec<u8>)> {
66 let mut buf = vec![0u8; 256];
67 let n = reader.read(&mut buf)?;
68 buf.truncate(n);
69
70 let is_text = n > 0
71 && buf[0].is_ascii_graphic()
72 && std::str::from_utf8(&buf[..n.min(128)]).is_ok_and(|s| {
73 s.contains("BGP table") || s.contains("Next Hop") || s.contains("Status codes:")
74 });
75
76 Ok((is_text, buf))
77}
78
79fn parse_column_header(line: &str) -> Option<ColumnPositions> {
87 let bytes = line.as_bytes();
89 let mut tokens: Vec<(usize, &str)> = Vec::new();
90 let mut i = 0;
91
92 while i < bytes.len() {
93 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
95 i += 1;
96 }
97 if i >= bytes.len() {
98 break;
99 }
100 let start = i;
101 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
103 i += 1;
104 }
105 let token = std::str::from_utf8(&bytes[start..i]).ok()?;
106 tokens.push((start, token));
107 }
108
109 if tokens.is_empty() {
110 return None;
111 }
112
113 let mut columns: Vec<(usize, String)> = Vec::new();
116 let mut skip = false;
117 for idx in 0..tokens.len() {
118 if skip {
119 skip = false;
120 continue;
121 }
122 let (pos, token) = tokens[idx];
123 if token == "Next" && idx + 1 < tokens.len() && tokens[idx + 1].1 == "Hop" {
124 columns.push((pos, "Next Hop".to_string()));
125 skip = true;
126 } else {
127 columns.push((pos, token.to_string()));
128 }
129 }
130
131 let target_names = ["Next Hop", "Metric", "LocPrf", "Weight", "Path"];
133 let mut positions: [Option<usize>; 5] = [None; 5];
134
135 for (pos, name) in &columns {
136 for (i, target) in target_names.iter().enumerate() {
137 if name == *target {
138 positions[i] = Some(*pos);
139 }
140 }
141 }
142
143 let next_hop = positions[0]?;
144 let metric = positions[1]?;
145 let local_pref = positions[2]?;
146 let weight = positions[3]?;
147 let path = positions[4]?;
148
149 if next_hop < metric && metric < local_pref && local_pref < weight && weight < path {
150 Some((next_hop, metric, local_pref, weight, path))
151 } else {
152 None
153 }
154}
155
156pub fn parse_header<R: BufRead>(reader: &mut R) -> std::io::Result<TextDumpHeader> {
162 let mut header = TextDumpHeader {
163 table_version: None,
164 router_id: None,
165 local_as: None,
166 column_positions: None,
167 };
168 let mut buf = String::new();
169
170 for _ in 0..64 {
171 buf.clear();
172 if reader.read_line(&mut buf)? == 0 {
173 break;
174 }
175 let line = buf.trim_end();
176
177 if line.starts_with("BGP table version") {
178 if let Some(rest) = line.strip_prefix("BGP table version is ") {
179 if let Some((ver_str, rest)) = rest.split_once(',') {
180 header.table_version = ver_str.trim().parse().ok();
181 if let Some(rid_part) = rest.split("local router ID is ").nth(1) {
182 if let Some((rid_str, _)) = rid_part.split_once(',') {
183 header.router_id = rid_str.trim().parse().ok();
184 }
185 }
186 }
187 }
188 }
189
190 if line.starts_with("Default local pref") {
191 if let Some(rest) = line.split("local AS ").nth(1) {
192 header.local_as = rest.trim().parse().ok();
193 }
194 }
195
196 if let Some(column_positions) = parse_column_header(line) {
197 header.column_positions = Some(column_positions);
198 break;
199 }
200 }
201 Ok(header)
202}
203
204#[derive(Debug, Clone)]
208struct RouteEntry {
209 prefix: String,
210 next_hop: String,
211 metric: Option<u32>,
212 local_pref: Option<u32>,
213 as_path: Vec<String>,
214 origin: Option<Origin>,
215}
216
217fn parse_u32_column(line: &str, start: usize, end: usize) -> Option<u32> {
218 line.get(start..end)?.trim().parse().ok()
219}
220
221fn shift_columns_left(columns: ColumnPositions) -> Option<ColumnPositions> {
222 Some((
223 columns.0.checked_sub(1)?,
224 columns.1.checked_sub(1)?,
225 columns.2.checked_sub(1)?,
226 columns.3.checked_sub(1)?,
227 columns.4.checked_sub(1)?,
228 ))
229}
230
231fn parse_wrapped_prefix_line(line: &str, next_hop_start: usize) -> Option<String> {
236 if line.len() > next_hop_start {
239 let rest = line[next_hop_start..].trim();
240 if !rest.is_empty() {
241 return None; }
243 }
244
245 let prefix = if line.len() > next_hop_start {
246 line.get(3..next_hop_start)?
247 } else {
248 line.get(3..)?
249 }
250 .trim();
251 prefix.parse::<IpNet>().ok().map(|_| prefix.to_string())
252}
253
254fn parse_route_line(line: &str, columns: ColumnPositions) -> Option<RouteEntry> {
256 let line = line.trim_end();
257 if line.trim().is_empty() || line.trim_start().starts_with("Displayed") {
258 return None;
259 }
260
261 let (next_hop_start, metric_start, local_pref_start, weight_start, path_start) = columns;
262 let next_hop = line.get(next_hop_start..metric_start)?.trim();
263 if next_hop.parse::<IpAddr>().is_err() {
264 return None;
265 }
266
267 let prefix = line
268 .get(3..next_hop_start)
269 .unwrap_or_default()
270 .trim()
271 .to_string();
272 let metric = parse_u32_column(line, metric_start, local_pref_start);
273 let local_pref = parse_u32_column(line, local_pref_start, weight_start);
274 let _weight = parse_u32_column(line, weight_start, path_start);
276
277 let path_tokens: Vec<&str> = line
278 .get(path_start..)
279 .unwrap_or_default()
280 .split_whitespace()
281 .collect();
282 let origin = match path_tokens.last().copied() {
283 Some("i") => Some(Origin::IGP),
284 Some("e") => Some(Origin::EGP),
285 Some("?") => Some(Origin::INCOMPLETE),
286 _ => None,
287 };
288 let path_end = path_tokens.len() - usize::from(origin.is_some());
289 let as_path = path_tokens[..path_end]
290 .iter()
291 .map(|token| (*token).to_string())
292 .collect();
293
294 Some(RouteEntry {
295 prefix,
296 next_hop: next_hop.to_string(),
297 metric,
298 local_pref,
299 as_path,
300 origin,
301 })
302}
303
304fn as_path_from_tokens(tokens: &[String]) -> AsPath {
316 let mut asns: Vec<Asn> = Vec::new();
317 for token in tokens {
318 if token == "{" || token == "}" {
319 continue;
320 }
321 if let Ok(asn) = token.parse::<u32>() {
322 asns.push(Asn::from(asn));
323 }
324 }
325 if asns.is_empty() {
326 return AsPath {
327 segments: vec![AsPathSegment::AsSequence(Default::default())].into(),
328 };
329 }
330 AsPath {
331 segments: vec![AsPathSegment::AsSequence(asns.into())].into(),
332 }
333}
334
335fn entry_to_elem(
336 entry: &RouteEntry,
337 prefix_str: &str,
338 peer_ip: IpAddr,
339 peer_asn: u32,
340 timestamp: f64,
341) -> Option<BgpElem> {
342 let prefix: IpNet = match prefix_str.parse() {
343 Ok(p) => p,
344 Err(_) => return None,
345 };
346
347 let network_prefix = NetworkPrefix::new(prefix, None);
348 let next_hop: Option<IpAddr> = entry.next_hop.parse().ok();
349 let as_path = Some(as_path_from_tokens(&entry.as_path));
350 let origin = entry.origin;
351 let local_pref = entry.local_pref;
352 let med = entry.metric;
353
354 let origin_asns: Option<Vec<Asn>> = as_path.as_ref().and_then(|ap| {
355 ap.segments
356 .last()
357 .and_then(|seg| match seg {
358 AsPathSegment::AsSequence(asns) => asns.last().copied(),
359 _ => None,
360 })
361 .map(|asn| vec![asn])
362 });
363
364 Some(BgpElem {
365 timestamp,
366 elem_type: ElemType::ANNOUNCE,
367 peer_ip,
368 peer_asn: Asn::from(peer_asn),
369 prefix: network_prefix,
370 next_hop,
371 as_path,
372 origin_asns,
373 origin,
374 local_pref,
375 med,
376 communities: None,
377 atomic: false,
378 aggr_asn: None,
379 aggr_ip: None,
380 only_to_customer: None,
381 unknown: None,
382 deprecated: None,
383 peer_bgp_id: None,
384 })
385}
386
387pub fn infer_timestamp_from_path(path: &str) -> Option<f64> {
397 let bytes = path.as_bytes();
398
399 for (idx, window) in bytes.windows(15).enumerate() {
401 if window[0].is_ascii_digit()
402 && window[1].is_ascii_digit()
403 && window[2].is_ascii_digit()
404 && window[3].is_ascii_digit()
405 && window[4] == b'-'
406 && window[5].is_ascii_digit()
407 && window[6].is_ascii_digit()
408 && window[7] == b'-'
409 && window[8].is_ascii_digit()
410 && window[9].is_ascii_digit()
411 && window[10] == b'-'
412 && window[11].is_ascii_digit()
413 && window[12].is_ascii_digit()
414 && window[13].is_ascii_digit()
415 && window[14].is_ascii_digit()
416 {
417 let y: Option<i32> = std::str::from_utf8(&window[0..4])
418 .ok()
419 .and_then(|s| s.parse().ok());
420 let m: Option<u32> = std::str::from_utf8(&window[5..7])
421 .ok()
422 .and_then(|s| s.parse().ok());
423 let d: Option<u32> = std::str::from_utf8(&window[8..10])
424 .ok()
425 .and_then(|s| s.parse().ok());
426 let hh: Option<u32> = std::str::from_utf8(&window[11..13])
427 .ok()
428 .and_then(|s| s.parse().ok());
429 let mm: Option<u32> = std::str::from_utf8(&window[13..15])
430 .ok()
431 .and_then(|s| s.parse().ok());
432 if idx > 0 && bytes[idx - 1].is_ascii_digit() {
435 continue;
436 }
437 if let (Some(y), Some(m), Some(d), Some(hh), Some(mm)) = (y, m, d, hh, mm) {
438 if let Some(dt) = chrono::NaiveDate::from_ymd_opt(y, m, d)
439 .and_then(|date| date.and_hms_opt(hh, mm, 0))
440 {
441 return Some(dt.and_utc().timestamp() as f64);
442 }
443 }
444 }
445 }
446
447 for window in bytes.windows(10) {
449 if window.len() == 10
450 && window[0].is_ascii_digit()
451 && window[1].is_ascii_digit()
452 && window[2].is_ascii_digit()
453 && window[3].is_ascii_digit()
454 && window[4] == b'.'
455 && window[5].is_ascii_digit()
456 && window[6].is_ascii_digit()
457 && window[7] == b'.'
458 && window[8].is_ascii_digit()
459 && window[9].is_ascii_digit()
460 {
461 let y: i32 = std::str::from_utf8(&window[0..4]).ok()?.parse().ok()?;
462 let m: u32 = std::str::from_utf8(&window[5..7]).ok()?.parse().ok()?;
463 let d: u32 = std::str::from_utf8(&window[8..10]).ok()?.parse().ok()?;
464 let dt = chrono::NaiveDate::from_ymd_opt(y, m, d)?.and_hms_opt(12, 0, 0)?;
466 return Some(dt.and_utc().timestamp() as f64);
467 }
468 }
469 None
470}
471
472pub struct TextDumpElemIterator<R> {
486 reader: R,
487 column_positions: ColumnPositions,
488 wrapped_column_positions: Option<ColumnPositions>,
489 peer_ip: IpAddr,
490 peer_asn: u32,
491 timestamp: f64,
492 current_prefix: String,
493 buf: String,
494}
495
496impl<R: BufRead> TextDumpElemIterator<R> {
497 pub fn new(mut reader: R, timestamp: f64) -> std::io::Result<Self> {
502 let header = parse_header(&mut reader)?;
503 let column_positions = match header.column_positions {
504 Some(positions) => positions,
505 None => {
506 return Err(std::io::Error::new(
507 std::io::ErrorKind::InvalidData,
508 "missing Cisco BGP table column header",
509 ));
510 }
511 };
512 let peer_ip = header
513 .router_id
514 .unwrap_or_else(|| IpAddr::from([0, 0, 0, 0]));
515 let peer_asn = header.local_as.unwrap_or(0);
516
517 Ok(TextDumpElemIterator {
518 reader,
519 column_positions,
520 wrapped_column_positions: shift_columns_left(column_positions),
521 peer_ip,
522 peer_asn,
523 timestamp,
524 current_prefix: String::new(),
525 buf: String::new(),
526 })
527 }
528}
529
530impl<R: BufRead> Iterator for TextDumpElemIterator<R> {
531 type Item = BgpElem;
532
533 fn next(&mut self) -> Option<BgpElem> {
534 loop {
535 self.buf.clear();
536 match self.reader.read_line(&mut self.buf) {
537 Ok(0) => return None, Ok(_) => {}
539 Err(e) => {
540 log::warn!("text-dump read error, stopping iteration: {e}");
547 return None;
548 }
549 }
550
551 let line = self.buf.trim_end();
552 if line.is_empty() {
553 continue;
554 }
555
556 let entry = parse_route_line(line, self.column_positions).or_else(|| {
558 self.wrapped_column_positions
559 .and_then(|positions| parse_route_line(line, positions))
560 });
561 if let Some(entry) = entry {
562 if !entry.prefix.is_empty() {
563 self.current_prefix = entry.prefix.clone();
564 }
565 if self.current_prefix.is_empty() {
566 continue;
567 }
568 if let Some(elem) = entry_to_elem(
569 &entry,
570 &self.current_prefix,
571 self.peer_ip,
572 self.peer_asn,
573 self.timestamp,
574 ) {
575 return Some(elem);
576 }
577 continue;
578 }
579
580 if let Some(prefix) = parse_wrapped_prefix_line(line, self.column_positions.0) {
582 self.current_prefix = prefix;
583 }
584 }
585 }
586}
587
588pub fn parse_text_dump<R: BufRead>(reader: R) -> std::io::Result<Vec<BgpElem>> {
595 parse_text_dump_with_timestamp(reader, 0.0)
596}
597
598pub fn parse_text_dump_with_timestamp<R: BufRead>(
614 reader: R,
615 timestamp: f64,
616) -> std::io::Result<Vec<BgpElem>> {
617 let iter = TextDumpElemIterator::new(reader, timestamp)?;
618 Ok(iter.collect())
619}
620
621#[cfg(test)]
624mod tests {
625 use super::*;
626
627 const TEST_COLUMNS: ColumnPositions = (21, 41, 48, 55, 62);
628 const TABLE_HEADER: &str = " Network Next Hop Metric LocPrf Weight Path";
629
630 fn fixed_width_route(
631 prefix: &str,
632 next_hop: &str,
633 metric: &str,
634 local_pref: &str,
635 weight: &str,
636 path: &str,
637 ) -> String {
638 format!(
639 " *> {:<17}{:<20}{:>7}{:>7}{:>7}{}",
640 prefix, next_hop, metric, local_pref, weight, path
641 )
642 }
643
644 fn parsed_route(line: &str) -> RouteEntry {
645 match parse_route_line(line, TEST_COLUMNS) {
646 Some(entry) => entry,
647 None => panic!("expected a valid fixed-width route line"),
648 }
649 }
650
651 #[test]
652 fn test_detect_text_dump_positive() {
653 let data = b"BGP table version is 123, local router ID is 1.2.3.4, vrf id 0\n";
654 let (is_text, buf) = match detect_text_dump(&data[..]) {
655 Ok(result) => result,
656 Err(error) => panic!("text dump detection failed: {error}"),
657 };
658 assert!(is_text);
659 assert!(!buf.is_empty());
660 }
661
662 #[test]
663 fn test_detect_text_dump_negative() {
664 let data = [0x00, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x01];
665 let (is_text, _buf) = match detect_text_dump(&data[..]) {
666 Ok(result) => result,
667 Err(error) => panic!("text dump detection failed: {error}"),
668 };
669 assert!(!is_text);
670 }
671
672 #[test]
673 fn test_parse_header() {
674 let preamble = "\
675BGP table version is 1350657, local router ID is 45.112.180.132, vrf id 0
676Default local pref 100, local AS 3856
677Status codes: s suppressed, d damped, h history, * valid, > best, = multipath,
678 i internal, r RIB-failure, S Stale, R Removed
679Nexthop codes: @NNN nexthop's vrf id, < announce-nh-self
680Origin codes: i - IGP, e - EGP, ? - incomplete
681RPKI validation codes: V valid, I invalid, N Not found
682
683 Network Next Hop Metric LocPrf Weight Path
684";
685 let header = match parse_header(&mut preamble.as_bytes()) {
686 Ok(header) => header,
687 Err(error) => panic!("header parsing failed: {error}"),
688 };
689 let expected_router_id = match "45.112.180.132".parse() {
690 Ok(router_id) => router_id,
691 Err(error) => panic!("invalid expected router ID: {error}"),
692 };
693 assert_eq!(header.table_version, Some(1350657));
694 assert_eq!(header.router_id, Some(expected_router_id));
695 assert_eq!(header.local_as, Some(3856));
696 assert_eq!(header.column_positions, Some(TEST_COLUMNS));
697 }
698
699 #[test]
700 fn test_parse_route_line_basic() {
701 let line = fixed_width_route("1.0.0.0/24", "103.77.108.11", "0", "", "0", "13335 i");
702 let entry = parsed_route(&line);
703 assert_eq!(entry.prefix, "1.0.0.0/24");
704 assert_eq!(entry.next_hop, "103.77.108.11");
705 assert_eq!(entry.metric, Some(0));
706 assert_eq!(entry.local_pref, None);
707 assert_eq!(entry.origin, Some(Origin::IGP));
708 assert_eq!(entry.as_path, vec!["13335"]);
709 }
710
711 #[test]
712 fn test_parse_route_line_continuation() {
713 let line = fixed_width_route("", "103.77.108.118", "0", "", "0", "13335 i");
714 let entry = parsed_route(&line);
715 assert!(entry.prefix.is_empty());
716 assert_eq!(entry.next_hop, "103.77.108.118");
717 assert_eq!(entry.as_path, vec!["13335"]);
718 }
719
720 #[test]
721 fn test_parse_route_line_uses_fixed_width_attributes() {
722 let line = fixed_width_route("0.0.0.0/0", "103.77.108.116", "0", "", "0", "134942 4755 i");
723 let entry = parsed_route(&line);
724 assert_eq!(entry.metric, Some(0));
725 assert_eq!(entry.local_pref, None);
726 assert_eq!(entry.as_path, vec!["134942", "4755"]);
727 }
728
729 #[test]
730 fn test_parse_route_line_multi_asn() {
731 let line = fixed_width_route(
732 "1.0.0.0/24",
733 "103.77.108.116",
734 "10",
735 "200",
736 "0",
737 "134942 4755 i",
738 );
739 let entry = parsed_route(&line);
740 assert_eq!(entry.metric, Some(10));
741 assert_eq!(entry.local_pref, Some(200));
742 assert_eq!(entry.as_path, vec!["134942", "4755"]);
743 assert_eq!(entry.origin, Some(Origin::IGP));
744 }
745
746 #[test]
747 fn test_parse_route_line_origin_codes() {
748 let prefix = "1.0.0.0/24";
749 let next_hop = "10.0.0.1";
750 assert_eq!(
751 parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 i")).origin,
752 Some(Origin::IGP)
753 );
754 assert_eq!(
755 parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 e")).origin,
756 Some(Origin::EGP)
757 );
758 assert_eq!(
759 parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 ?")).origin,
760 Some(Origin::INCOMPLETE)
761 );
762 }
763
764 #[test]
765 fn test_parse_route_line_no_numeric_attrs() {
766 let line = fixed_width_route("1.0.0.0/24", "10.0.0.1", "", "", "", "100 i");
767 let entry = parsed_route(&line);
768 assert_eq!(entry.next_hop, "10.0.0.1");
769 assert_eq!(entry.as_path, vec!["100"]);
770 assert!(entry.metric.is_none());
771 assert!(entry.local_pref.is_none());
772 }
773
774 #[test]
775 fn test_parse_text_dump_preserves_continuation_prefix() {
776 let first = fixed_width_route("0.0.0.0/0", "103.77.108.116", "0", "", "0", "134942 4755 i");
777 let continuation = fixed_width_route("", "103.77.108.118", "0", "", "0", "13335 i");
778 let dump = format!(
779 "BGP table version is 1350657, local router ID is 45.112.180.132, vrf id 0\nDefault local pref 100, local AS 3856\n\n{TABLE_HEADER}\n{first}\n{continuation}\nDisplayed 1 routes and 2 total paths\n"
780 );
781 let elems = match parse_text_dump(dump.as_bytes()) {
782 Ok(elems) => elems,
783 Err(error) => panic!("text dump parsing failed: {error}"),
784 };
785 assert_eq!(elems.len(), 2);
786 assert_eq!(elems[0].prefix, elems[1].prefix);
787 assert_eq!(elems[0].med, Some(0));
788 assert_eq!(elems[0].local_pref, None);
789 assert_eq!(elems[0].origin_asns, Some(vec![Asn::from(4755u32)]));
790 assert_eq!(elems[1].origin_asns, Some(vec![Asn::from(13335u32)]));
791 }
792
793 #[test]
794 fn test_parse_text_dump_supports_wrapped_prefix() {
795 let dump = format!(
796 "BGP table version is 1350657, local router ID is 45.112.180.132, vrf id 0\nDefault local pref 100, local AS 3856\n\n{TABLE_HEADER}\n * 103.85.157.176/29\n 103.77.108.116 0 0 134942 58715 152125 i\nDisplayed 1 routes and 1 total paths\n"
797 );
798 let elems = match parse_text_dump(dump.as_bytes()) {
799 Ok(elems) => elems,
800 Err(error) => panic!("text dump parsing failed: {error}"),
801 };
802 assert_eq!(elems.len(), 1);
803 assert_eq!(elems[0].origin_asns, Some(vec![Asn::from(152125u32)]));
804 }
805
806 #[test]
807 fn test_entry_to_elem() {
808 let entry = RouteEntry {
809 prefix: "1.0.0.0/24".into(),
810 next_hop: "103.77.108.11".into(),
811 metric: Some(0),
812 local_pref: Some(0),
813 as_path: vec!["13335".into()],
814 origin: Some(Origin::IGP),
815 };
816 let peer_ip = match "45.112.180.132".parse() {
817 Ok(peer_ip) => peer_ip,
818 Err(error) => panic!("invalid expected peer IP: {error}"),
819 };
820 let elem = match entry_to_elem(&entry, "1.0.0.0/24", peer_ip, 3856, 0.0) {
821 Some(elem) => elem,
822 None => panic!("BgpElem conversion failed"),
823 };
824 assert_eq!(elem.peer_ip.to_string(), "45.112.180.132");
825 assert_eq!(u32::from(elem.peer_asn), 3856);
826 assert_eq!(elem.origin, Some(Origin::IGP));
827 assert_eq!(elem.origin_asns, Some(vec![Asn::from(13335u32)]));
828 }
829
830 const ROUTE_VIEWS_HEADER: &str =
831 " Network Next Hop Metric LocPrf Weight Path";
832
833 #[test]
834 fn test_detect_text_dump_route_views() {
835 let data = b"Status codes: s suppressed, d damped, h history, * valid, > best, i - internal,\n r RIB-failure, S Stale\nOrigin codes: i - IGP, e - EGP, ? - incomplete\n";
836 let (is_text, _buf) = match detect_text_dump(&data[..]) {
837 Ok(result) => result,
838 Err(error) => panic!("text dump detection failed: {error}"),
839 };
840 assert!(is_text);
841 }
842
843 #[test]
844 fn test_parse_route_views_dump() {
845 let dump = format!(
846 "Status codes: s suppressed, d damped, h history, * valid, > best, i - internal,\n r RIB-failure, S Stale\nOrigin codes: i - IGP, e - EGP, ? - incomplete\n\n{ROUTE_VIEWS_HEADER}\n* 0.0.0.0/0 147.28.0.3 0 0 0 3130 174 i\n* 1.0.0.0/24 12.0.1.63 0 0 0 7018 13335 i\n* 1.0.0.0/24 129.250.1.71 2001 0 0 2914 13335 i\n"
847 );
848 let elems = match parse_text_dump(dump.as_bytes()) {
849 Ok(elems) => elems,
850 Err(error) => panic!("route-views dump parsing failed: {error}"),
851 };
852 assert_eq!(elems.len(), 3);
853 assert_eq!(elems[0].peer_ip.to_string(), "0.0.0.0");
855 assert_eq!(u32::from(elems[0].peer_asn), 0);
856
857 assert_eq!(elems[0].prefix.prefix.to_string(), "0.0.0.0/0");
858 assert_eq!(elems[0].med, Some(0));
859 assert_eq!(elems[0].origin_asns, Some(vec![Asn::from(174u32)]));
860
861 assert_eq!(elems[2].prefix.prefix.to_string(), "1.0.0.0/24");
862 assert_eq!(elems[2].med, Some(2001));
863 assert_eq!(elems[2].origin, Some(Origin::IGP));
864 assert_eq!(elems[2].origin_asns, Some(vec![Asn::from(13335u32)]));
865 }
866
867 #[test]
868 fn test_infer_timestamp_route_views_path() {
869 let ts = infer_timestamp_from_path(
870 "https://archive.routeviews.org/oix-route-views/2026.07/oix-full-snapshot-2026-07-01-0000.bz2",
871 );
872 assert_eq!(ts, Some(1782864000.0));
874 }
875
876 #[test]
877 fn test_infer_timestamp_pch_path() {
878 let ts = infer_timestamp_from_path(
879 "https://www.pch.net/resources/data/routing-tables/2026/2026.07/rib-ipv4.2026.07.01.gz",
880 );
881 assert_eq!(ts, Some(1782907200.0));
883 }
884
885 #[test]
886 fn test_infer_timestamp_no_date() {
887 assert_eq!(infer_timestamp_from_path("/tmp/some-file.gz"), None);
888 }
889}