From ba38183971035766c9581510edd54fa8718d36c5 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 20 Jul 2026 21:07:41 -0700 Subject: [PATCH 1/2] feat(parse): add Cisco sh ip bgp text dump support - Auto-detect Cisco fixed-width BGP table dumps via preamble - Parse prefix, next-hop, metric, local-pref, weight, AS path, origin - Handle multi-path continuation lines and wrapped-prefix layouts - Support all parse filters, output formats, and MRT export (--mrt-type) - Whitespace-split header column detection (replaces fixed find()) - MrtType::infer() auto-selects Rib for text dumps, Updates for MRT --- CHANGELOG.md | 6 + src/bin/commands/parse.rs | 277 ++++++++++------ src/lens/parse/mod.rs | 148 ++++++--- src/lens/parse/text_dump.rs | 616 ++++++++++++++++++++++++++++++++++++ 4 files changed, 915 insertions(+), 132 deletions(-) create mode 100644 src/lens/parse/text_dump.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcb42d..942885c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ All notable changes to this project will be documented in this file. `rpkiviews`; invalid source/collector combinations now return errors (#134). * Added `--use-cache` / `--cache-dir` MRT-file caching and `--fields` output selection to the `rib` command (#137). +* Added Cisco `sh ip bgp` text dump parsing to the `parse` command. The parser + auto-detects the fixed-width plaintext format from the preamble, extracts + prefix/AS-path/next-hop/metric/local-pref/origin, and supports all standard + parse filters, output formats, and MRT export (`--mrt-type rib|updates`). + Multi-path continuation lines and wrapped-prefix layouts are handled. + Header column detection uses whitespace split rather than fixed positions. ### Performance Improvements diff --git a/src/bin/commands/parse.rs b/src/bin/commands/parse.rs index 248b3f4..ac8fb47 100644 --- a/src/bin/commands/parse.rs +++ b/src/bin/commands/parse.rs @@ -1,12 +1,14 @@ use std::io::Write; use std::path::PathBuf; -use bgpkit_parser::encoder::MrtUpdatesEncoder; +use bgpkit_parser::encoder::{MrtRibEncoder, MrtUpdatesEncoder}; +use bgpkit_parser::parser::filter::Filterable; use bgpkit_parser::BgpElem; use clap::Args; use monocle::lens::parse::filter_file::{load_prefix_file, merge_prefix_file, FilterFile}; -use monocle::lens::parse::{ParseFilters, ParseLens}; +use monocle::lens::parse::text_dump; +use monocle::lens::parse::{MrtType, ParseFilters, ParseLens}; use monocle::utils::{OrderByField, OrderDirection, OutputFormat, TimestampFormat}; use super::elem_format::{ @@ -16,7 +18,8 @@ use super::elem_format::{ /// Arguments for the Parse command #[derive(Args)] pub(crate) struct ParseArgs { - /// File path to an MRT file, local or remote. + /// File path to an MRT file or Cisco `sh ip bgp` text dump, local or remote. + /// Format is auto-detected: binary MRT vs plaintext BGP table dump. #[clap(name = "FILE")] pub file_path: PathBuf, @@ -28,6 +31,11 @@ pub(crate) struct ParseArgs { #[clap(long, short = 'M')] pub mrt_path: Option, + /// MRT output subtype: `rib` for TABLE_DUMP_V2 RIB entries, `updates` for BGP4MP messages. + /// Default: auto-inferred (`rib` for text dumps, `updates` for MRT files). + #[clap(long, value_enum, value_name = "TYPE")] + pub mrt_type: Option, + /// Comma-separated list of fields to output #[clap(long, short = 'f', value_name = "FIELDS", help = available_fields_help())] pub fields: Option, @@ -64,6 +72,7 @@ pub fn run(args: ParseArgs, output_format: OutputFormat) { file_path, pretty, mrt_path, + mrt_type, fields: fields_arg, order_by, order, @@ -106,136 +115,212 @@ pub fn run(args: ParseArgs, output_format: OutputFormat) { return; } - let file_path = match file_path.to_str() { + let file_path_str = match file_path.to_str() { Some(path) => path, None => { eprintln!("Invalid file path"); std::process::exit(1); } }; - let parser = match lens.create_parser(&filters, file_path) { - Ok(p) => p, + + // ── Format detection ────────────────────────────────────────── + let reader = match oneio::get_reader(file_path_str) { + Ok(r) => r, Err(e) => { - eprintln!("Failed to create parser for {}: {}", file_path, e); + eprintln!("Failed to open {}: {}", file_path_str, e); std::process::exit(1); } }; - let mut stdout = std::io::stdout(); + let (is_text, _peeked) = match text_dump::detect_text_dump(reader) { + Ok(result) => result, + Err(e) => { + eprintln!("Failed to read {}: {}", file_path_str, e); + std::process::exit(1); + } + }; + + // Determine MRT type: explicit flag takes precedence, otherwise infer. + let mrt_type = mrt_type.unwrap_or_else(|| MrtType::infer(is_text)); - // Adjust output format for pretty flag + // Preserve streaming output for ordinary MRT parsing. Text dumps and MRT + // exports require materialization for their respective encoders/parsers. let output_format = if pretty && output_format == OutputFormat::Json { OutputFormat::JsonPretty } else { output_format }; - - // Determine if we need to buffer (for Table format or when ordering is requested) let needs_buffering = output_format == OutputFormat::Table || order_by.is_some(); - - match mrt_path { - None => { - // If buffering is needed (Table format or ordering requested), collect and sort - if needs_buffering { - let mut elems: Vec<(BgpElem, Option)> = - parser.into_iter().map(|elem| (elem, None)).collect(); - - // Sort if ordering is requested - if let Some(order_field) = order_by { - sort_elems(&mut elems, order_field, order); + if !is_text && mrt_path.is_none() && !needs_buffering { + let parser = match lens.create_parser(&filters, file_path_str) { + Ok(parser) => parser, + Err(error) => { + eprintln!("Failed to create parser for {}: {}", file_path_str, error); + std::process::exit(1); + } + }; + let mut stdout = std::io::stdout(); + if let Some(header) = get_header(output_format, &fields) { + if let Err(error) = writeln!(stdout, "{}", header) { + if error.kind() != std::io::ErrorKind::BrokenPipe { + eprintln!("ERROR: {error}"); } - - if elems.is_empty() { - return; + std::process::exit(1); + } + } + for elem in parser { + if let Some(output) = format_elem(&elem, output_format, &fields, None, time_format) { + if let Err(error) = writeln!(stdout, "{}", output) { + if error.kind() != std::io::ErrorKind::BrokenPipe { + eprintln!("ERROR: {error}"); + } + std::process::exit(1); } + } + } + return; + } - // Output based on format - if output_format == OutputFormat::Table { - println!("{}", format_elems_table(&elems, &fields, time_format)); - } else { - // Print header for markdown format - if let Some(header) = get_header(output_format, &fields) { - if let Err(e) = writeln!(stdout, "{}", header) { - if e.kind() != std::io::ErrorKind::BrokenPipe { - eprintln!("ERROR: {e}"); - } - std::process::exit(1); - } - } + // ── Parse ───────────────────────────────────────────────────── + let elems: Vec<(BgpElem, Option)> = if is_text { + // Re-open the file for full parsing (detect_text_dump consumed the reader) + let reader = match oneio::get_reader(file_path_str) { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to re-open {}: {}", file_path_str, e); + std::process::exit(1); + } + }; + let buf_reader = std::io::BufReader::new(reader); + let text_filters = match filters.to_filters() { + Ok(filters) => filters, + Err(e) => { + eprintln!("Failed to build text dump filters: {e}"); + std::process::exit(1); + } + }; + match text_dump::parse_text_dump(buf_reader) { + Ok(elems) => elems + .into_iter() + .filter(|elem| elem.match_filters(&text_filters)) + .map(|elem| (elem, None)) + .collect(), + Err(e) => { + eprintln!("Failed to parse text dump {}: {}", file_path_str, e); + std::process::exit(1); + } + } + } else { + // Standard MRT parse: re-open since detect_text_dump consumed the reader + let parser = match lens.create_parser(&filters, file_path_str) { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to create parser for {}: {}", file_path_str, e); + std::process::exit(1); + } + }; + parser.into_iter().map(|elem| (elem, None)).collect() + }; - // Output sorted elements - for (elem, collector) in &elems { - if let Some(output_str) = format_elem( - elem, - output_format, - &fields, - collector.as_deref(), - time_format, - ) { - if let Err(e) = writeln!(stdout, "{}", output_str) { - if e.kind() != std::io::ErrorKind::BrokenPipe { - eprintln!("ERROR: {e}"); - } - std::process::exit(1); - } - } - } - } - return; + if elems.is_empty() { + return; + } + + let mut stdout = std::io::stdout(); + + // ── MRT output ───────────────────────────────────────────────── + if let Some(ref mrt_out_path) = mrt_path { + let mrt_out_str = match mrt_out_path.to_str() { + Some(s) => s, + None => { + eprintln!("Invalid MRT output path"); + std::process::exit(1); } + }; - // Streaming output (no buffering needed) - // Print header for markdown format before first element - if let Some(header) = get_header(output_format, &fields) { - if let Err(e) = writeln!(stdout, "{}", header) { - if e.kind() != std::io::ErrorKind::BrokenPipe { - eprintln!("ERROR: {e}"); + eprintln!("writing MRT ({mrt_type:?}) to {mrt_out_str}..."); + + match mrt_type { + MrtType::Rib => { + let mut encoder = MrtRibEncoder::new(); + for (elem, _) in &elems { + encoder.process_elem(elem); + } + let bytes = encoder.export_bytes(); + match oneio::get_writer(mrt_out_str) { + Ok(mut w) => { + if let Err(e) = w.write_all(&bytes) { + eprintln!("Failed to write MRT data: {}", e); + } + } + Err(e) => { + eprintln!("Failed to create MRT writer: {}", e); + std::process::exit(1); } - std::process::exit(1); } } - - for elem in parser { - // output to stdout based on format - if let Some(output_str) = - format_elem(&elem, output_format, &fields, None, time_format) - { - if let Err(e) = writeln!(stdout, "{}", output_str) { - if e.kind() != std::io::ErrorKind::BrokenPipe { - eprintln!("ERROR: {e}"); + MrtType::Updates => { + let mut encoder = MrtUpdatesEncoder::new(); + for (elem, _) in &elems { + encoder.process_elem(elem); + } + let bytes = encoder.export_bytes(); + match oneio::get_writer(mrt_out_str) { + Ok(mut w) => { + if let Err(e) = w.write_all(&bytes) { + eprintln!("Failed to write MRT data: {}", e); } + } + Err(e) => { + eprintln!("Failed to create MRT writer: {}", e); std::process::exit(1); } } } } - Some(p) => { - let path = match p.to_str() { - Some(path) => path.to_string(), - None => { - eprintln!("Invalid MRT path"); - std::process::exit(1); - } - }; - eprintln!("processing. filtered messages output to {}...", path); - let mut encoder = MrtUpdatesEncoder::new(); - let mut writer = match oneio::get_writer(&path) { - Ok(w) => w, - Err(e) => { + + eprintln!("done. total of {} messages written", elems.len()); + return; + } + + // ── Text output ──────────────────────────────────────────────── + + // Sort if ordering is requested + let mut elems = elems; + if let Some(order_field) = order_by { + sort_elems(&mut elems, order_field, order); + } + + // Output based on format + if output_format == OutputFormat::Table { + println!("{}", format_elems_table(&elems, &fields, time_format)); + } else { + // Print header for markdown format + if let Some(header) = get_header(output_format, &fields) { + if let Err(e) = writeln!(stdout, "{}", header) { + if e.kind() != std::io::ErrorKind::BrokenPipe { eprintln!("ERROR: {e}"); - std::process::exit(1); } - }; - let mut total_count = 0; - for elem in parser { - total_count += 1; - encoder.process_elem(&elem); + std::process::exit(1); } - if let Err(e) = writer.write_all(&encoder.export_bytes()) { - eprintln!("Failed to write MRT data: {}", e); + } + + // Output elements + for (elem, collector) in &elems { + if let Some(output_str) = format_elem( + elem, + output_format, + &fields, + collector.as_deref(), + time_format, + ) { + if let Err(e) = writeln!(stdout, "{}", output_str) { + if e.kind() != std::io::ErrorKind::BrokenPipe { + eprintln!("ERROR: {e}"); + } + std::process::exit(1); + } } - drop(writer); - eprintln!("done. total of {} message wrote", total_count); } } } diff --git a/src/lens/parse/mod.rs b/src/lens/parse/mod.rs index f9705c7..9451e8e 100644 --- a/src/lens/parse/mod.rs +++ b/src/lens/parse/mod.rs @@ -65,10 +65,12 @@ //! ``` pub mod filter_file; +pub mod text_dump; use crate::lens::time::TimeLens; use anyhow::anyhow; use anyhow::Result; +use bgpkit_parser::parser::filter::Filter; use bgpkit_parser::BgpElem; use bgpkit_parser::BgpkitParser; use ipnet::IpNet; @@ -149,6 +151,34 @@ impl Display for ParseElemType { } } +/// MRT output subtype for `--mrt-type`. +/// +/// Controls whether the MRT export produces TABLE_DUMP_V2 RIB entries +/// or BGP4MP update messages. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "cli", derive(clap::ValueEnum))] +pub enum MrtType { + /// TABLE_DUMP_V2 RIB entries (peer-index-table + per-prefix RIB) + Rib, + /// BGP4MP individual update messages + Updates, +} + +impl MrtType { + /// Infer the appropriate MRT type from the input format. + /// + /// Text dumps are always RIB snapshots → `Rib`. + /// MRT files default to `Updates` (the existing behavior for + /// BGP4MP/UPDATE streams). + pub fn infer(is_text_dump: bool) -> Self { + if is_text_dump { + MrtType::Rib + } else { + MrtType::Updates + } + } +} + // ============================================================================= // Args // ============================================================================= @@ -249,6 +279,8 @@ pub struct ParseFilters { pub as_path: Option, } +type FilterSpec = (&'static str, String); + impl ParseFilters { /// Parse start and end time strings into Unix timestamps pub fn parse_start_end_strings(&self) -> Result<(i64, i64)> { @@ -494,79 +526,90 @@ impl ParseFilters { Ok(()) } - /// Convert filters to a BgpkitParser - /// - /// This method creates a parser with all filters applied. Multi-value filters - /// use OR logic (matches ANY of the specified values). Negated values (prefixed - /// with `!`) exclude matching elements. - pub fn to_parser(&self, file_path: &str) -> Result>> { - let mut parser = BgpkitParser::new(file_path)?.disable_warnings(); + fn filter_specs(&self) -> Result> { + let mut specs = Vec::new(); - if let Some(v) = &self.as_path { - parser = parser.add_filter("as_path", v.to_string().as_str())?; + if let Some(value) = &self.as_path { + specs.push(("as_path", value.clone())); } - // Origin ASN filter - always use plural filter key for consistency + // Origin ASN filter - always use plural filter key for consistency. if !self.origin_asn.is_empty() { - let value = self.origin_asn.join(","); - parser = parser.add_filter("origin_asns", &value)?; + specs.push(("origin_asns", self.origin_asn.join(","))); } - // Prefix filter - always use plural filter keys + // Prefix filter - always use plural filter keys. if !self.prefix.is_empty() { - let value = self.prefix.join(","); let filter_key = match (self.include_super, self.include_sub) { (false, false) => "prefixes", (true, false) => "prefixes_super", (false, true) => "prefixes_sub", (true, true) => "prefixes_super_sub", }; - parser = parser.add_filter(filter_key, &value)?; + specs.push((filter_key, self.prefix.join(","))); } - // Peer IPs filter if !self.peer_ip.is_empty() { - let v = self.peer_ip.iter().map(|p| p.to_string()).join(","); - parser = parser.add_filter("peer_ips", v.as_str())?; + let value = self.peer_ip.iter().map(ToString::to_string).join(","); + specs.push(("peer_ips", value)); } - // Peer ASN filter - always use plural filter key for consistency + // Peer ASN filter - always use plural filter key for consistency. if !self.peer_asn.is_empty() { - let value = self.peer_asn.join(","); - parser = parser.add_filter("peer_asns", &value)?; + specs.push(("peer_asns", self.peer_asn.join(","))); } // Community filter - bgpkit-parser uses singular filter key name. if let Some(value) = self.build_community_filter_value()? { - parser = parser.add_filter("community", &value)?; + specs.push(("community", value)); } - if let Some(v) = &self.elem_type { - parser = parser.add_filter("type", v.to_string().as_str())?; + if let Some(value) = &self.elem_type { + specs.push(("type", value.to_string())); } match self.parse_start_end_strings() { Ok((start_ts, end_ts)) => { - // in case we have full start_ts and end_ts, like in `monocle search` command input, - // we will use the parsed start_ts and end_ts. - parser = parser.add_filter("start_ts", start_ts.to_string().as_str())?; - parser = parser.add_filter("end_ts", end_ts.to_string().as_str())?; + // Full start/end inputs, such as those from `monocle search`. + specs.push(("start_ts", start_ts.to_string())); + specs.push(("end_ts", end_ts.to_string())); } Err(_) => { - // we could also likely not have any time filters, in this case, add filters - // as we see them, and no modification is needed. + // No complete time window: retain any individually supplied boundary. let time_lens = TimeLens::new(); - if let Some(v) = &self.start_ts { - let ts = time_lens.parse_time_string(v.as_str())?.timestamp(); - parser = parser.add_filter("start_ts", ts.to_string().as_str())?; + if let Some(value) = &self.start_ts { + let timestamp = time_lens.parse_time_string(value)?.timestamp(); + specs.push(("start_ts", timestamp.to_string())); } - if let Some(v) = &self.end_ts { - let ts = time_lens.parse_time_string(v.as_str())?.timestamp(); - parser = parser.add_filter("end_ts", ts.to_string().as_str())?; + if let Some(value) = &self.end_ts { + let timestamp = time_lens.parse_time_string(value)?.timestamp(); + specs.push(("end_ts", timestamp.to_string())); } } } + Ok(specs) + } + + /// Convert filters into BgpElem predicates using bgpkit-parser's canonical semantics. + pub fn to_filters(&self) -> Result> { + let mut filters = Vec::new(); + for (filter_type, filter_value) in self.filter_specs()? { + filters.push(Filter::new(filter_type, &filter_value)?); + } + Ok(filters) + } + + /// Convert filters to a BgpkitParser. + /// + /// This method creates a parser with all filters applied. Multi-value filters + /// use OR logic (matches ANY of the specified values). Negated values (prefixed + /// with `!`) exclude matching elements. + pub fn to_parser(&self, file_path: &str) -> Result>> { + let mut parser = BgpkitParser::new(file_path)?.disable_warnings(); + for (filter_type, filter_value) in self.filter_specs()? { + parser = parser.add_filter(filter_type, &filter_value)?; + } Ok(parser) } } @@ -845,6 +888,12 @@ impl Default for ParseLens { mod tests { use super::*; + #[test] + fn test_mrt_type_inference() { + assert_eq!(MrtType::infer(true), MrtType::Rib); + assert_eq!(MrtType::infer(false), MrtType::Updates); + } + #[test] fn test_parse_progress_serialization() { // Test that progress types can be serialized for GUI communication @@ -1014,6 +1063,33 @@ mod tests { assert!(ParseFilters::check_negation_consistency(&values, "test").is_err()); } + #[test] + fn test_to_filters_uses_canonical_filter_specs() { + let filters = ParseFilters { + as_path: Some("^13335 ".to_string()), + origin_asn: vec!["13335".to_string(), "15169".to_string()], + prefix: vec!["0.0.0.0/0".to_string()], + ..Default::default() + }; + let actual = match filters.to_filters() { + Ok(filters) => filters, + Err(error) => panic!("filter conversion failed: {error}"), + }; + + for (filter_type, filter_value) in [ + ("as_path", "^13335 "), + ("origin_asns", "13335,15169"), + ("prefixes", "0.0.0.0/0"), + ] { + let expected = match Filter::new(filter_type, filter_value) { + Ok(filter) => filter, + Err(error) => panic!("invalid expected filter: {error}"), + }; + assert!(actual.contains(&expected)); + } + assert_eq!(actual.len(), 3); + } + #[test] fn test_parse_filters_validate() { // Valid filters diff --git a/src/lens/parse/text_dump.rs b/src/lens/parse/text_dump.rs new file mode 100644 index 0000000..92783fd --- /dev/null +++ b/src/lens/parse/text_dump.rs @@ -0,0 +1,616 @@ +//! Cisco `sh ip bgp` text dump parser. +//! +//! Parses the fixed-width column format produced by Cisco IOS routers. +//! Extracts field boundaries from the table header so numeric attributes do not +//! become indistinguishable from numeric AS-path segments. +//! +//! # Format +//! +//! ```text +//! BGP table version is N, local router ID is X.X.X.X, vrf id 0 +//! Default local pref 100, local AS NNNN +//! ... +//! Network Next Hop Metric LocPrf Weight Path +//! *> 1.0.0.0/24 103.77.108.118 0 0 13335 i +//! *= 103.77.108.11 0 0 13335 i +//! ``` + +use bgpkit_parser::models::*; +use ipnet::IpNet; +use std::io::{BufRead, Read}; +use std::net::IpAddr; + +/// Byte offsets for the Cisco `Next Hop`, `Metric`, `LocPrf`, `Weight`, and +/// `Path` columns, respectively. +type ColumnPositions = (usize, usize, usize, usize, usize); + +/// Header metadata extracted from the preamble. +#[derive(Debug, Clone)] +pub struct TextDumpHeader { + pub table_version: Option, + pub router_id: Option, + pub local_as: Option, + column_positions: Option, +} + +// ── Detection ────────────────────────────────────────────────────── + +/// Detect whether a reader contains a Cisco `sh ip bgp` text dump. +pub fn detect_text_dump(mut reader: R) -> std::io::Result<(bool, Vec)> { + let mut buf = vec![0u8; 256]; + let n = reader.read(&mut buf)?; + buf.truncate(n); + + let is_text = n > 0 + && buf[0].is_ascii_graphic() + && std::str::from_utf8(&buf[..n.min(128)]) + .is_ok_and(|s| s.contains("BGP table") || s.contains("Next Hop")); + + Ok((is_text, buf)) +} + +// ── Header parsing ───────────────────────────────────────────────── + +/// Extract column offsets from the Cisco table header using whitespace split. +/// +/// Splits the header line into whitespace-delimited tokens, merges known +/// multi-word field names (e.g. "Next" + "Hop" → "Next Hop"), and records +/// the byte position of each column in the original header line. +fn parse_column_header(line: &str) -> Option { + // Tokenize by whitespace: record (byte_position, token) for each word. + let bytes = line.as_bytes(); + let mut tokens: Vec<(usize, &str)> = Vec::new(); + let mut i = 0; + + while i < bytes.len() { + // Skip leading whitespace. + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() { + break; + } + let start = i; + // Consume non-whitespace characters. + while i < bytes.len() && !bytes[i].is_ascii_whitespace() { + i += 1; + } + let token = std::str::from_utf8(&bytes[start..i]).ok()?; + tokens.push((start, token)); + } + + if tokens.is_empty() { + return None; + } + + // Merge known multi-word field names. + // "Next" immediately followed by "Hop" → "Next Hop" starting at "Next". + let mut columns: Vec<(usize, String)> = Vec::new(); + let mut skip = false; + for idx in 0..tokens.len() { + if skip { + skip = false; + continue; + } + let (pos, token) = tokens[idx]; + if token == "Next" && idx + 1 < tokens.len() && tokens[idx + 1].1 == "Hop" { + columns.push((pos, "Next Hop".to_string())); + skip = true; + } else { + columns.push((pos, token.to_string())); + } + } + + // Extract positions for the five columns we depend on. + let target_names = ["Next Hop", "Metric", "LocPrf", "Weight", "Path"]; + let mut positions: [Option; 5] = [None; 5]; + + for (pos, name) in &columns { + for (i, target) in target_names.iter().enumerate() { + if name == *target { + positions[i] = Some(*pos); + } + } + } + + let next_hop = positions[0]?; + let metric = positions[1]?; + let local_pref = positions[2]?; + let weight = positions[3]?; + let path = positions[4]?; + + if next_hop < metric && metric < local_pref && local_pref < weight && weight < path { + Some((next_hop, metric, local_pref, weight, path)) + } else { + None + } +} + +/// Parse the preamble to extract router metadata and fixed-width column offsets. +pub fn parse_header(reader: &mut R) -> std::io::Result { + let mut header = TextDumpHeader { + table_version: None, + router_id: None, + local_as: None, + column_positions: None, + }; + let mut buf = String::new(); + + for _ in 0..64 { + buf.clear(); + if reader.read_line(&mut buf)? == 0 { + break; + } + let line = buf.trim_end(); + + if line.starts_with("BGP table version") { + if let Some(rest) = line.strip_prefix("BGP table version is ") { + if let Some((ver_str, rest)) = rest.split_once(',') { + header.table_version = ver_str.trim().parse().ok(); + if let Some(rid_part) = rest.split("local router ID is ").nth(1) { + if let Some((rid_str, _)) = rid_part.split_once(',') { + header.router_id = rid_str.trim().parse().ok(); + } + } + } + } + } + + if line.starts_with("Default local pref") { + if let Some(rest) = line.split("local AS ").nth(1) { + header.local_as = rest.trim().parse().ok(); + } + } + + if let Some(column_positions) = parse_column_header(line) { + header.column_positions = Some(column_positions); + break; + } + } + Ok(header) +} + +// ── Fixed-width route parsing ────────────────────────────────────── + +/// A parsed route entry from a single line. +#[derive(Debug, Clone)] +struct RouteEntry { + prefix: String, + next_hop: String, + metric: Option, + local_pref: Option, + as_path: Vec, + origin: Option, +} + +fn parse_u32_column(line: &str, start: usize, end: usize) -> Option { + line.get(start..end)?.trim().parse().ok() +} + +fn shift_columns_left(columns: ColumnPositions) -> Option { + Some(( + columns.0.checked_sub(1)?, + columns.1.checked_sub(1)?, + columns.2.checked_sub(1)?, + columns.3.checked_sub(1)?, + columns.4.checked_sub(1)?, + )) +} + +fn parse_wrapped_prefix_line(line: &str, next_hop_start: usize) -> Option { + if line.len() > next_hop_start { + return None; + } + + let prefix = line.get(3..next_hop_start)?.trim(); + prefix.parse::().ok().map(|_| prefix.to_string()) +} + +/// Parse a single Cisco route line using positions extracted from its column header. +fn parse_route_line(line: &str, columns: ColumnPositions) -> Option { + let line = line.trim_end(); + if line.trim().is_empty() || line.trim_start().starts_with("Displayed") { + return None; + } + + let (next_hop_start, metric_start, local_pref_start, weight_start, path_start) = columns; + let next_hop = line.get(next_hop_start..metric_start)?.trim(); + if next_hop.parse::().is_err() { + return None; + } + + let prefix = line + .get(3..next_hop_start) + .unwrap_or_default() + .trim() + .to_string(); + let metric = parse_u32_column(line, metric_start, local_pref_start); + let local_pref = parse_u32_column(line, local_pref_start, weight_start); + // Cisco weight is a router-local attribute with no `BgpElem` representation. + let _weight = parse_u32_column(line, weight_start, path_start); + + let path_tokens: Vec<&str> = line + .get(path_start..) + .unwrap_or_default() + .split_whitespace() + .collect(); + let origin = match path_tokens.last().copied() { + Some("i") => Some(Origin::IGP), + Some("e") => Some(Origin::EGP), + Some("?") => Some(Origin::INCOMPLETE), + _ => None, + }; + let path_end = path_tokens.len() - usize::from(origin.is_some()); + let as_path = path_tokens[..path_end] + .iter() + .map(|token| (*token).to_string()) + .collect(); + + Some(RouteEntry { + prefix, + next_hop: next_hop.to_string(), + metric, + local_pref, + as_path, + origin, + }) +} + +// ── BgpElem construction ─────────────────────────────────────────── + +fn as_path_from_tokens(tokens: &[String]) -> AsPath { + let mut asns: Vec = Vec::new(); + for token in tokens { + if token == "{" || token == "}" { + continue; + } + if let Ok(asn) = token.parse::() { + asns.push(Asn::from(asn)); + } + } + if asns.is_empty() { + return AsPath { + segments: vec![AsPathSegment::AsSequence(Vec::new())], + }; + } + AsPath { + segments: vec![AsPathSegment::AsSequence(asns)], + } +} + +fn entry_to_elem( + entry: &RouteEntry, + prefix_str: &str, + peer_ip: IpAddr, + peer_asn: u32, + timestamp: f64, +) -> Option { + let prefix: IpNet = match prefix_str.parse() { + Ok(p) => p, + Err(_) => return None, + }; + + let network_prefix = NetworkPrefix::new(prefix, None); + let next_hop: Option = entry.next_hop.parse().ok(); + let as_path = Some(as_path_from_tokens(&entry.as_path)); + let origin = entry.origin; + let local_pref = entry.local_pref; + let med = entry.metric; + + let origin_asns: Option> = as_path.as_ref().and_then(|ap| { + ap.segments + .last() + .and_then(|seg| match seg { + AsPathSegment::AsSequence(asns) => asns.last().copied(), + _ => None, + }) + .map(|asn| vec![asn]) + }); + + Some(BgpElem { + timestamp, + elem_type: ElemType::ANNOUNCE, + peer_ip, + peer_asn: Asn::from(peer_asn), + prefix: network_prefix, + next_hop, + as_path, + origin_asns, + origin, + local_pref, + med, + communities: None, + atomic: false, + aggr_asn: None, + aggr_ip: None, + only_to_customer: None, + unknown: None, + deprecated: None, + }) +} + +// ── Top-level parse ──────────────────────────────────────────────── + +pub fn parse_text_dump(mut reader: R) -> std::io::Result> { + let header = parse_header(&mut reader)?; + let column_positions = match header.column_positions { + Some(positions) => positions, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "missing Cisco BGP table column header", + )); + } + }; + let peer_ip = match header.router_id { + Some(ip) => ip, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "missing router ID in text dump header", + )); + } + }; + let peer_asn = match header.local_as { + Some(asn) => asn, + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "missing local AS in text dump header", + )); + } + }; + + let timestamp = 0.0_f64; + let wrapped_column_positions = shift_columns_left(column_positions); + + let mut buf = String::new(); + let mut current_prefix = String::new(); + let mut entries: Vec<(String, RouteEntry)> = Vec::new(); + + while reader.read_line(&mut buf)? > 0 { + let line = buf.trim_end().to_string(); + buf.clear(); + + if line.is_empty() { + continue; + } + + if let Some(prefix) = parse_wrapped_prefix_line(&line, column_positions.0) { + current_prefix = prefix; + continue; + } + + let entry = parse_route_line(&line, column_positions).or_else(|| { + wrapped_column_positions.and_then(|positions| parse_route_line(&line, positions)) + }); + if let Some(entry) = entry { + if !entry.prefix.is_empty() { + current_prefix = entry.prefix.clone(); + } + entries.push((current_prefix.clone(), entry)); + } + } + + let mut elems: Vec = Vec::with_capacity(entries.len()); + for (prefix, entry) in &entries { + if prefix.is_empty() { + continue; + } + if let Some(elem) = entry_to_elem(entry, prefix, peer_ip, peer_asn, timestamp) { + elems.push(elem); + } + } + + Ok(elems) +} + +// ── Tests ────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_COLUMNS: ColumnPositions = (21, 41, 48, 55, 62); + const TABLE_HEADER: &str = " Network Next Hop Metric LocPrf Weight Path"; + + fn fixed_width_route( + prefix: &str, + next_hop: &str, + metric: &str, + local_pref: &str, + weight: &str, + path: &str, + ) -> String { + format!( + " *> {:<17}{:<20}{:>7}{:>7}{:>7}{}", + prefix, next_hop, metric, local_pref, weight, path + ) + } + + fn parsed_route(line: &str) -> RouteEntry { + match parse_route_line(line, TEST_COLUMNS) { + Some(entry) => entry, + None => panic!("expected a valid fixed-width route line"), + } + } + + #[test] + fn test_detect_text_dump_positive() { + let data = b"BGP table version is 123, local router ID is 1.2.3.4, vrf id 0\n"; + let (is_text, buf) = match detect_text_dump(&data[..]) { + Ok(result) => result, + Err(error) => panic!("text dump detection failed: {error}"), + }; + assert!(is_text); + assert!(!buf.is_empty()); + } + + #[test] + fn test_detect_text_dump_negative() { + let data = vec![0x00, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x01]; + let (is_text, _buf) = match detect_text_dump(&data[..]) { + Ok(result) => result, + Err(error) => panic!("text dump detection failed: {error}"), + }; + assert!(!is_text); + } + + #[test] + fn test_parse_header() { + let preamble = "\ +BGP table version is 1350657, local router ID is 45.112.180.132, vrf id 0 +Default local pref 100, local AS 3856 +Status codes: s suppressed, d damped, h history, * valid, > best, = multipath, + i internal, r RIB-failure, S Stale, R Removed +Nexthop codes: @NNN nexthop's vrf id, < announce-nh-self +Origin codes: i - IGP, e - EGP, ? - incomplete +RPKI validation codes: V valid, I invalid, N Not found + + Network Next Hop Metric LocPrf Weight Path +"; + let header = match parse_header(&mut preamble.as_bytes()) { + Ok(header) => header, + Err(error) => panic!("header parsing failed: {error}"), + }; + let expected_router_id = match "45.112.180.132".parse() { + Ok(router_id) => router_id, + Err(error) => panic!("invalid expected router ID: {error}"), + }; + assert_eq!(header.table_version, Some(1350657)); + assert_eq!(header.router_id, Some(expected_router_id)); + assert_eq!(header.local_as, Some(3856)); + assert_eq!(header.column_positions, Some(TEST_COLUMNS)); + } + + #[test] + fn test_parse_route_line_basic() { + let line = fixed_width_route("1.0.0.0/24", "103.77.108.11", "0", "", "0", "13335 i"); + let entry = parsed_route(&line); + assert_eq!(entry.prefix, "1.0.0.0/24"); + assert_eq!(entry.next_hop, "103.77.108.11"); + assert_eq!(entry.metric, Some(0)); + assert_eq!(entry.local_pref, None); + assert_eq!(entry.origin, Some(Origin::IGP)); + assert_eq!(entry.as_path, vec!["13335"]); + } + + #[test] + fn test_parse_route_line_continuation() { + let line = fixed_width_route("", "103.77.108.118", "0", "", "0", "13335 i"); + let entry = parsed_route(&line); + assert!(entry.prefix.is_empty()); + assert_eq!(entry.next_hop, "103.77.108.118"); + assert_eq!(entry.as_path, vec!["13335"]); + } + + #[test] + fn test_parse_route_line_uses_fixed_width_attributes() { + let line = fixed_width_route("0.0.0.0/0", "103.77.108.116", "0", "", "0", "134942 4755 i"); + let entry = parsed_route(&line); + assert_eq!(entry.metric, Some(0)); + assert_eq!(entry.local_pref, None); + assert_eq!(entry.as_path, vec!["134942", "4755"]); + } + + #[test] + fn test_parse_route_line_multi_asn() { + let line = fixed_width_route( + "1.0.0.0/24", + "103.77.108.116", + "10", + "200", + "0", + "134942 4755 i", + ); + let entry = parsed_route(&line); + assert_eq!(entry.metric, Some(10)); + assert_eq!(entry.local_pref, Some(200)); + assert_eq!(entry.as_path, vec!["134942", "4755"]); + assert_eq!(entry.origin, Some(Origin::IGP)); + } + + #[test] + fn test_parse_route_line_origin_codes() { + let prefix = "1.0.0.0/24"; + let next_hop = "10.0.0.1"; + assert_eq!( + parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 i")).origin, + Some(Origin::IGP) + ); + assert_eq!( + parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 e")).origin, + Some(Origin::EGP) + ); + assert_eq!( + parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 ?")).origin, + Some(Origin::INCOMPLETE) + ); + } + + #[test] + fn test_parse_route_line_no_numeric_attrs() { + let line = fixed_width_route("1.0.0.0/24", "10.0.0.1", "", "", "", "100 i"); + let entry = parsed_route(&line); + assert_eq!(entry.next_hop, "10.0.0.1"); + assert_eq!(entry.as_path, vec!["100"]); + assert!(entry.metric.is_none()); + assert!(entry.local_pref.is_none()); + } + + #[test] + fn test_parse_text_dump_preserves_continuation_prefix() { + let first = fixed_width_route("0.0.0.0/0", "103.77.108.116", "0", "", "0", "134942 4755 i"); + let continuation = fixed_width_route("", "103.77.108.118", "0", "", "0", "13335 i"); + let dump = format!( + "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" + ); + let elems = match parse_text_dump(dump.as_bytes()) { + Ok(elems) => elems, + Err(error) => panic!("text dump parsing failed: {error}"), + }; + assert_eq!(elems.len(), 2); + assert_eq!(elems[0].prefix, elems[1].prefix); + assert_eq!(elems[0].med, Some(0)); + assert_eq!(elems[0].local_pref, None); + assert_eq!(elems[0].origin_asns, Some(vec![Asn::from(4755u32)])); + assert_eq!(elems[1].origin_asns, Some(vec![Asn::from(13335u32)])); + } + + #[test] + fn test_parse_text_dump_supports_wrapped_prefix() { + let dump = format!( + "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" + ); + let elems = match parse_text_dump(dump.as_bytes()) { + Ok(elems) => elems, + Err(error) => panic!("text dump parsing failed: {error}"), + }; + assert_eq!(elems.len(), 1); + assert_eq!(elems[0].origin_asns, Some(vec![Asn::from(152125u32)])); + } + + #[test] + fn test_entry_to_elem() { + let entry = RouteEntry { + prefix: "1.0.0.0/24".into(), + next_hop: "103.77.108.11".into(), + metric: Some(0), + local_pref: Some(0), + as_path: vec!["13335".into()], + origin: Some(Origin::IGP), + }; + let peer_ip = match "45.112.180.132".parse() { + Ok(peer_ip) => peer_ip, + Err(error) => panic!("invalid expected peer IP: {error}"), + }; + let elem = match entry_to_elem(&entry, "1.0.0.0/24", peer_ip, 3856, 0.0) { + Some(elem) => elem, + None => panic!("BgpElem conversion failed"), + }; + assert_eq!(elem.peer_ip.to_string(), "45.112.180.132"); + assert_eq!(u32::from(elem.peer_asn), 3856); + assert_eq!(elem.origin, Some(Origin::IGP)); + assert_eq!(elem.origin_asns, Some(vec![Asn::from(13335u32)])); + } +} From 820002366b713513cc103f645d494ceb21f29b80 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Tue, 21 Jul 2026 04:51:49 -0700 Subject: [PATCH 2/2] fix(parse): timestamp inference, IPv6 wrapped prefixes, AS-set docs - Fix parse_wrapped_prefix_line to handle IPv6 and long prefixes by checking whether route data exists after the next-hop column instead of using a line-length heuristic; swap loop order so route-line parsing is tried before wrapped-prefix fallback - Add infer_timestamp_from_path() that extracts YYYY.MM.DD from PCH-style filenames/URLs and converts to Unix timestamp - Add parse_text_dump_with_timestamp() to accept an explicit timestamp; CLI passes inferred timestamp, falls back to 0.0 when undetectable - Document AS-set {/} flattening in as_path_from_tokens --- src/bin/commands/parse.rs | 3 +- src/lens/parse/text_dump.rs | 78 ++++++++++++++++++++++++++++++++----- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/bin/commands/parse.rs b/src/bin/commands/parse.rs index ac8fb47..9f17099 100644 --- a/src/bin/commands/parse.rs +++ b/src/bin/commands/parse.rs @@ -199,7 +199,8 @@ pub fn run(args: ParseArgs, output_format: OutputFormat) { std::process::exit(1); } }; - match text_dump::parse_text_dump(buf_reader) { + let timestamp = text_dump::infer_timestamp_from_path(file_path_str).unwrap_or(0.0); + match text_dump::parse_text_dump_with_timestamp(buf_reader, timestamp) { Ok(elems) => elems .into_iter() .filter(|elem| elem.match_filters(&text_filters)) diff --git a/src/lens/parse/text_dump.rs b/src/lens/parse/text_dump.rs index 92783fd..f314622 100644 --- a/src/lens/parse/text_dump.rs +++ b/src/lens/parse/text_dump.rs @@ -197,12 +197,26 @@ fn shift_columns_left(columns: ColumnPositions) -> Option { )) } +/// Detect a "wrapped" prefix-only line (no route data, just a prefix after +/// the status flags). The next-hop column position is used to distinguish +/// prefix-only lines from full route lines: if the line segment at the +/// next-hop position is empty, the line carries only a prefix. fn parse_wrapped_prefix_line(line: &str, next_hop_start: usize) -> Option { + // If the line extends into the next-hop column, check whether the + // characters there form route data (digits/IP) or are just whitespace. if line.len() > next_hop_start { - return None; + let rest = line[next_hop_start..].trim(); + if !rest.is_empty() { + return None; // has route data → regular line, not a wrapped prefix + } } - let prefix = line.get(3..next_hop_start)?.trim(); + let prefix = if line.len() > next_hop_start { + line.get(3..next_hop_start)? + } else { + line.get(3..)? + } + .trim(); prefix.parse::().ok().map(|_| prefix.to_string()) } @@ -258,6 +272,11 @@ fn parse_route_line(line: &str, columns: ColumnPositions) -> Option // ── BgpElem construction ─────────────────────────────────────────── +/// Convert path tokens into an AsPath. +/// +/// AS-set delimiters (`{` / `}`) are silently dropped, flattening AS-sets +/// into plain AS-sequences. This is intentional: the parser aims to recover +/// the AS-level propagation path, and set membership is not preserved. fn as_path_from_tokens(tokens: &[String]) -> AsPath { let mut asns: Vec = Vec::new(); for token in tokens { @@ -329,9 +348,50 @@ fn entry_to_elem( }) } +// ── Timestamp inference ──────────────────────────────────────────── + +/// Try to extract a Unix timestamp from a file path or URL. +/// +/// Recognises PCH-style date components like `...2026.07.01.gz` or +/// `...YYYY.MM.DD...` and converts them to seconds since the Unix epoch. +/// Returns `None` when no recognisable date is found. +pub fn infer_timestamp_from_path(path: &str) -> Option { + // Scan for YYYY.MM.DD pattern without pulling in the regex crate. + let bytes = path.as_bytes(); + for window in bytes.windows(10) { + if window.len() == 10 + && window[0].is_ascii_digit() + && window[1].is_ascii_digit() + && window[2].is_ascii_digit() + && window[3].is_ascii_digit() + && window[4] == b'.' + && window[5].is_ascii_digit() + && window[6].is_ascii_digit() + && window[7] == b'.' + && window[8].is_ascii_digit() + && window[9].is_ascii_digit() + { + let y: i32 = std::str::from_utf8(&window[0..4]).ok()?.parse().ok()?; + let m: u32 = std::str::from_utf8(&window[5..7]).ok()?.parse().ok()?; + let d: u32 = std::str::from_utf8(&window[8..10]).ok()?.parse().ok()?; + // Use noon UTC to avoid DST boundary issues. + let dt = chrono::NaiveDate::from_ymd_opt(y, m, d)?.and_hms_opt(12, 0, 0)?; + return Some(dt.and_utc().timestamp() as f64); + } + } + None +} + // ── Top-level parse ──────────────────────────────────────────────── -pub fn parse_text_dump(mut reader: R) -> std::io::Result> { +pub fn parse_text_dump(reader: R) -> std::io::Result> { + parse_text_dump_with_timestamp(reader, 0.0) +} + +pub fn parse_text_dump_with_timestamp( + mut reader: R, + timestamp: f64, +) -> std::io::Result> { let header = parse_header(&mut reader)?; let column_positions = match header.column_positions { Some(positions) => positions, @@ -361,7 +421,6 @@ pub fn parse_text_dump(mut reader: R) -> std::io::Result(mut reader: R) -> std::io::Result(mut reader: R) -> std::io::Result