diff --git a/CHANGELOG.md b/CHANGELOG.md index 355aa2b..0ce1201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added * **Layered text output format** (`render::text::format_record`, `--format text`): one human-readable, indented block per MRT record — session context (`TIME`/`TYPE`/`FROM`/`TO`), `UPDATE:` sections with withdrawn/announced prefixes (including those carried in MP_REACH/MP_UNREACH) and every path attribute, `OPEN:` capabilities, session states, RIB entries, the peer table, and full legacy type-5 records. RFC 7606 validation findings render under `WARNINGS:` when present. The format is designed around this crate's own models and `Display` vocabulary — inspired by bgpdump's human-readable output, not byte-compatible with it. Rendering is a pure function of the record. In the CLI, `--format text` always uses record-level output (implies `--level records`); the other formats follow `--level` and default to elems. +* **`--hex` flag**: include each record's original wire bytes as hex in record-level output — a `HEX:` line at the end of `--format text` blocks, a `hex` field in `--format json`/`json-pretty` records. Designed as the pipe from filtered CLI output into byte-level tools (e.g. wirescope's hex paste input): `bgpkit-parser -p 8.8.8.0/24 --format text --hex updates.mrt.gz` yields blocks whose `HEX:` line can be pasted straight into a dissector. Bytes come from a new library iterator, `BgpkitParser::into_filtered_raw_record_iter()` — raw MRT records with the same record-level filter semantics as `into_record_iter`, yielding the untouched original bytes (no re-encoding, so attribute-ordering quirks such as BGP-LS hash-map iteration can never alter the output). The flag implies `--level records`, requires text/json/json-pretty formats, is mutually exclusive with `--recover`, and is ignored by count-only runs (`-e`/`-r`), which keep the normal counting pipelines and their per-elem filter semantics. * Record-level filter semantics are now explicit: records that produce no elems (KEEPALIVE, OPEN, NOTIFICATION, state changes) never match elem-oriented filters and are dropped from record iteration while filters are active — a `debug!` line notes each drop. ## v0.21.0 - 2026-08-21 diff --git a/src/bin/main.rs b/src/bin/main.rs index 2d15fe6..be5cd4c 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -69,6 +69,13 @@ struct Opts { #[clap(long)] psv: bool, + /// Include each record's raw bytes as hex: a `HEX:` line in text + /// blocks, a `hex` field in JSON records. Record-level only — implies + /// `--level records`; requires `--format text`, `json`, or + /// `json-pretty`. Count-only runs (-e/-r) ignore this flag. + #[clap(long)] + hex: bool, + /// Count BGP elems #[clap(short, long)] elems_count: bool, @@ -257,6 +264,23 @@ fn main() { opts.format }; + // Count-only runs emit no hex, so the flag is ignored there (and the + // format/recover constraints do not apply). + let counting = opts.elems_count || opts.records_count; + if opts.hex && !counting { + if !matches!( + output_format, + OutputFormat::Json | OutputFormat::JsonPretty | OutputFormat::Text + ) { + eprintln!("Error: --hex requires --format text, json, or json-pretty"); + std::process::exit(1); + } + if opts.recover { + eprintln!("Error: --hex does not support --recover"); + std::process::exit(1); + } + } + let recovery_config = RecoveryConfig::default(); // Element-level runs (element output or counting only elements) use the elem // iterators, which apply filters per element; everything else stays at the record @@ -265,41 +289,50 @@ fn main() { || (!opts.elems_count && !opts.records_count && matches!(opts.level, OutputLevel::Elems))) && output_format != OutputFormat::Text; - let result = match (opts.recover, use_elem_stream) { - (true, true) => run_elems( - parser - .into_recovering_elem_iter(recovery_config) - .map(|event| event.map_err(|error| error.to_string())), - output_format, - opts.elems_count, - true, - ), - (false, true) => run_elems( - parser - .into_elem_iter() - .map(|elem| Ok(RecoveryEvent::Item(elem))), - output_format, - opts.elems_count, - false, - ), - (true, false) => run_records( - parser - .into_recovering_record_iter(recovery_config) - .map(|event| event.map_err(|error| error.to_string())), - output_format, - opts.elems_count, - opts.records_count, - true, - ), - (false, false) => run_records( - parser - .into_record_iter() - .map(|record| Ok(RecoveryEvent::Item(record))), - output_format, - opts.elems_count, - opts.records_count, - false, - ), + // The hex path always works on original wire bytes from the + // raw-record pipeline; it cannot share run_records, which holds + // parsed records. Count-only runs emit no hex, so they keep the + // normal elem/record counting pipelines and their semantics + // (per-elem filtering for -e). + let result = if opts.hex && !counting { + run_hex_records(parser.into_filtered_raw_record_iter(), output_format) + } else { + match (opts.recover, use_elem_stream) { + (true, true) => run_elems( + parser + .into_recovering_elem_iter(recovery_config) + .map(|event| event.map_err(|error| error.to_string())), + output_format, + opts.elems_count, + true, + ), + (false, true) => run_elems( + parser + .into_elem_iter() + .map(|elem| Ok(RecoveryEvent::Item(elem))), + output_format, + opts.elems_count, + false, + ), + (true, false) => run_records( + parser + .into_recovering_record_iter(recovery_config) + .map(|event| event.map_err(|error| error.to_string())), + output_format, + opts.elems_count, + opts.records_count, + true, + ), + (false, false) => run_records( + parser + .into_record_iter() + .map(|record| Ok(RecoveryEvent::Item(record))), + output_format, + opts.elems_count, + opts.records_count, + false, + ), + } }; if let Err(error) = result { eprintln!("{error}"); @@ -425,7 +458,7 @@ where if records_count_requested { continue; } - let output = format_record(&record, output_format); + let output = format_record(&record, output_format, None); if !write_output(&mut stdout, &output)? { return Ok(()); } @@ -446,6 +479,25 @@ where terminal_error.map_or(Ok(()), Err) } +/// Hex-augmented output over the raw-record pipeline: hex comes from the +/// original wire bytes (never a re-encoding), the block/JSON body from the +/// parsed record. +fn run_hex_records( + records: impl Iterator, + output_format: OutputFormat, +) -> Result<(), String> { + let mut stdout = std::io::stdout(); + + for (raw, record) in records { + let hex = bgpkit_parser::render::hex::encode(raw.raw_bytes().as_ref()); + let output = format_record(&record, output_format, Some(&hex)); + if !write_output(&mut stdout, &output)? { + return Ok(()); + } + } + Ok(()) +} + fn write_output(stdout: &mut std::io::Stdout, output: &str) -> Result { if let Err(error) = writeln!(stdout, "{output}") { if error.kind() == std::io::ErrorKind::BrokenPipe { @@ -480,17 +532,30 @@ fn format_elem(elem: &BgpElem, format: OutputFormat, index: usize) -> String { } } -fn format_record(record: &bgpkit_parser::MrtRecord, format: OutputFormat) -> String { +fn format_record( + record: &bgpkit_parser::MrtRecord, + format: OutputFormat, + record_hex: Option<&str>, +) -> String { match format { OutputFormat::Json => { - let val = json!(record); + let mut val = json!(record); + if let (Some(hex), Some(obj)) = (&record_hex, val.as_object_mut()) { + obj.insert("hex".to_string(), json!(hex)); + } val.to_string() } OutputFormat::JsonPretty => { - let val = json!(record); + let mut val = json!(record); + if let (Some(hex), Some(obj)) = (&record_hex, val.as_object_mut()) { + obj.insert("hex".to_string(), json!(hex)); + } serde_json::to_string_pretty(&val).unwrap() } - OutputFormat::Text => bgpkit_parser::render::text::format_record(record), + OutputFormat::Text => match record_hex { + Some(hex) => bgpkit_parser::render::text::format_record_with_hex(record, hex), + None => bgpkit_parser::render::text::format_record(record), + }, OutputFormat::Psv | OutputFormat::Default => { // Use the Display implementation for MrtRecord format!("{}", record) diff --git a/src/parser/iters/default.rs b/src/parser/iters/default.rs index 0edb466..9408f65 100644 --- a/src/parser/iters/default.rs +++ b/src/parser/iters/default.rs @@ -1,12 +1,10 @@ /*! Default iterator implementations that skip errors and return successfully parsed items. */ -use crate::error::ParserError; use crate::models::*; -use crate::parser::iters::{record_matches_filters, write_mrt_core_dump}; +use crate::parser::iters::{handle_record_parse_error, record_matches_filters}; use crate::parser::BgpkitParser; use crate::{Elementor, Filterable}; -use log::{error, warn}; use std::io::Read; /********* @@ -49,52 +47,10 @@ impl Iterator for RecordIterator { } } Err(e) => { - match e.error { - ParserError::TruncatedMsg(err_str) | ParserError::Unsupported(err_str) => { - if self.parser.options.show_warnings { - warn!("parser warn: {}", err_str); - } - write_mrt_core_dump(self.parser.core_dump, e.bytes); - continue; - } - ParserError::ParseError(err_str) => { - error!("parser error: {}", err_str); - write_mrt_core_dump(self.parser.core_dump, e.bytes); - if self.parser.core_dump { - None - } else { - continue; - } - } - ParserError::EofExpected => { - // normal end of file - None - } - ParserError::IoError(err) | ParserError::EofError(err) => { - // when reaching IO error, stop iterating - error!("{:?}", err); - write_mrt_core_dump(self.parser.core_dump, e.bytes); - None - } - #[cfg(feature = "oneio")] - ParserError::OneIoError(_) => None, - ParserError::FilterError(_) => { - // this should not happen at this stage - None - } - // Labeled NLRI parsing errors - treat as malformed and skip - ParserError::InvalidLabeledNlriLength - | ParserError::TruncatedLabeledNlri - | ParserError::TruncatedPrefix - | ParserError::MaxLabelStackDepthExceeded - | ParserError::PeerMaxLabelsExceeded - | ParserError::InvalidPrefix => { - if self.parser.options.show_warnings { - warn!("parser warn: labeled NLRI parsing error: {:?}", e.error); - } - continue; - } + if handle_record_parse_error(&mut self.parser, e.error, e.bytes) { + continue; } + None } }; } diff --git a/src/parser/iters/mod.rs b/src/parser/iters/mod.rs index 927e8d9..ee21eab 100644 --- a/src/parser/iters/mod.rs +++ b/src/parser/iters/mod.rs @@ -25,7 +25,7 @@ pub use diagnostic::{ DissectedDiagnosticEvent, DissectingDiagnosticIterator, }; pub use fallible::{FallibleElemIterator, FallibleRecordIterator}; -pub use raw::RawRecordIterator; +pub use raw::{FilteredRawRecordIterator, RawRecordIterator}; pub use recovery::{ RecoveringElemIterator, RecoveringRecordIterator, RecoveryConfig, RecoveryError, RecoveryEvent, RecoveryEvidence, RecoveryGap, @@ -36,12 +36,13 @@ pub use update::{ UpdateIterator, }; +use crate::error::ParserError; use crate::models::BgpElem; use crate::models::{MrtMessage, MrtRecord, TableDumpV2Message}; use crate::parser::BgpkitParser; use crate::RawMrtRecord; use crate::{Elementor, Filter, Filterable}; -use log::debug; +use log::{debug, error, warn}; use std::io::Read; use std::path::Path; @@ -75,6 +76,63 @@ pub(crate) fn record_matches_filters( elems.iter().any(|element| element.match_filters(filters)) } +/// Shared body-parse error policy for record-producing iterators. +/// +/// Mirrors the historical `RecordIterator` behavior: warnings honor +/// `disable_warnings()`, core dumps are written for recoverable classes, +/// and a fatal `ParseError` with core dumps enabled stops the iterator so +/// a later failure cannot overwrite the dump. Returns `true` to continue +/// iterating, `false` to stop. +pub(crate) fn handle_record_parse_error( + parser: &mut crate::parser::BgpkitParser, + error: ParserError, + bytes: Option>, +) -> bool { + match error { + ParserError::TruncatedMsg(err_str) | ParserError::Unsupported(err_str) => { + if parser.options.show_warnings { + warn!("parser warn: {}", err_str); + } + write_mrt_core_dump(parser.core_dump, bytes); + true + } + ParserError::ParseError(err_str) => { + error!("parser error: {}", err_str); + write_mrt_core_dump(parser.core_dump, bytes); + // stop after writing the dump so later failures don't overwrite it + !parser.core_dump + } + ParserError::EofExpected => { + // normal end of file + false + } + ParserError::IoError(err) | ParserError::EofError(err) => { + // when reaching IO error, stop iterating + error!("{:?}", err); + write_mrt_core_dump(parser.core_dump, bytes); + false + } + #[cfg(feature = "oneio")] + ParserError::OneIoError(_) => false, + ParserError::FilterError(_) => { + // this should not happen at this stage + false + } + // Labeled NLRI parsing errors - treat as malformed and skip + ParserError::InvalidLabeledNlriLength + | ParserError::TruncatedLabeledNlri + | ParserError::TruncatedPrefix + | ParserError::MaxLabelStackDepthExceeded + | ParserError::PeerMaxLabelsExceeded + | ParserError::InvalidPrefix => { + if parser.options.show_warnings { + warn!("parser warn: labeled NLRI parsing error: {:?}", error); + } + true + } + } +} + pub(crate) fn write_mrt_core_dump(enabled: bool, bytes: Option>) { write_mrt_core_dump_to_path(enabled, bytes, "mrt_core_dump"); } @@ -114,6 +172,34 @@ impl BgpkitParser { RawRecordIterator::new(self) } + /// Creates an iterator over raw MRT records with record-level filter + /// semantics applied. + /// + /// Like [`into_raw_record_iter`](Self::into_raw_record_iter), but only + /// records passing the parser's filters are yielded (same semantics as + /// [`into_record_iter`](Self::into_record_iter): filters match on the + /// elem projection, so no-elem records such as KEEPALIVEs are dropped + /// while filters are active, and the `PeerIndexTable` always passes). + /// The yielded records carry their original wire bytes — no + /// re-encoding — which is what byte-exact consumers (hex output, + /// re-dissection) need. Every record body is parsed once inside the + /// iterator and the parsed record is yielded alongside, so consumers + /// do not parse the bytes twice; parse failures follow the same + /// variant-aware diagnostics as the record iterator. + /// + /// # Example + /// ```no_run + /// use bgpkit_parser::BgpkitParser; + /// + /// let parser = BgpkitParser::new("updates.mrt").unwrap(); + /// for (raw, _) in parser.into_filtered_raw_record_iter() { + /// println!("{}", raw.raw_bytes().len()); + /// } + /// ``` + pub fn into_filtered_raw_record_iter(self) -> FilteredRawRecordIterator { + FilteredRawRecordIterator::new(self) + } + /// Creates an opt-in iterator that reports skipped byte ranges while recovering MRT framing. /// /// Recovery never reconstructs a damaged record. It scans for a structurally valid boundary, diff --git a/src/parser/iters/raw.rs b/src/parser/iters/raw.rs index 80ed3a8..de66bb7 100644 --- a/src/parser/iters/raw.rs +++ b/src/parser/iters/raw.rs @@ -11,8 +11,13 @@ when possible and continue processing the remaining data. It also supports confi warning messages and core dump generation for debugging purposes. */ -use crate::parser::iters::write_mrt_core_dump; -use crate::{chunk_mrt_record, BgpkitParser, ParserError, RawMrtRecord}; +use crate::parser::iters::{ + handle_record_parse_error, record_matches_filters, write_mrt_core_dump, +}; +use crate::parser::mrt::mrt_record::raw_record_uses_zebra_compat; +use crate::{ + chunk_mrt_record, BgpkitParser, Elementor, Filter, MrtRecord, ParserError, RawMrtRecord, +}; use log::{error, warn}; use std::io::Read; @@ -89,3 +94,177 @@ impl Iterator for RawRecordIterator { } } } + +/// Iterator over raw MRT records with record-level filter semantics +/// applied. +/// +/// Behaves like [`RawRecordIterator`] for chunking, but only yields the +/// raw bytes of records that pass the parser's filters under the same +/// semantics as [`RecordIterator`](crate::RecordIterator): filters match +/// on the elem projection, so records that produce no elems (KEEPALIVE, +/// OPEN, NOTIFICATION, state changes) are dropped while filters are +/// active, and the `PeerIndexTable` always passes through so RIB peer +/// resolution keeps working. +/// +/// The yielded [`RawMrtRecord`]s carry the *original* wire bytes — no +/// re-encoding is involved — which is what byte-exact outputs (hex dumps, +/// re-dissection) require. Every record body is parsed once inside the +/// iterator (for filter matching and for uniform error diagnostics), and +/// the parsed record is yielded alongside so consumers never parse the +/// bytes twice. Parse failures go through the same variant-aware policy +/// as [`RecordIterator`](crate::RecordIterator) — warnings honor +/// `disable_warnings()`, core dumps are written where applicable, and +/// fatal classes stop the iterator — so this pipeline cannot continue +/// past errors the record pipeline would stop for. +/// +/// Shortened Zebra BGP4MP records are detected and warned about once, +/// matching the record iterator's data-quality diagnostic. +pub struct FilteredRawRecordIterator { + inner: RawRecordIterator, + elementor: Elementor, + filters: Vec, +} + +impl FilteredRawRecordIterator { + pub(crate) fn new(parser: BgpkitParser) -> Self { + let filters = parser.filters.clone(); + FilteredRawRecordIterator { + inner: RawRecordIterator::new(parser), + elementor: Elementor::new(), + filters, + } + } + + /// Zebra detection delegates to the parser's own warn-once state, so + /// `disable_warnings()` is honored and a parser that already warned + /// before conversion does not warn again. + fn warn_zebra_once(&mut self, raw: &RawMrtRecord) { + if raw_record_uses_zebra_compat(raw) { + self.inner.parser.warn_zebra_compat_once(); + } + } +} + +impl Iterator for FilteredRawRecordIterator { + type Item = (RawMrtRecord, MrtRecord); + + fn next(&mut self) -> Option { + loop { + let raw = self.inner.next()?; + self.warn_zebra_once(&raw); + // Body-parse failures share the record iterator's + // variant-aware error policy (warnings vs. errors, core + // dumps, stop-after-dump) — on every path, filtered or not. + let record = match raw.clone().parse() { + Ok(record) => record, + Err(error) => { + if !handle_record_parse_error( + &mut self.inner.parser, + error, + Some(raw.raw_bytes().to_vec()), + ) { + return None; + } + continue; + } + }; + if self.filters.is_empty() + || record_matches_filters(&record, &self.filters, &mut self.elementor) + { + return Some((raw, record)); + } + } + } +} + +#[cfg(test)] +mod filtered_tests { + use super::*; + use crate::models::*; + use std::io::Cursor; + + fn bgp4mp_record(timestamp: u32, bgp_message: BgpMessage) -> Vec { + let body_of = |update: &BgpUpdateMessage| update.encode(AsnLength::Bits32).unwrap(); + let body = match &bgp_message { + BgpMessage::Update(update) => body_of(update), + BgpMessage::KeepAlive => Vec::new().into(), + _ => unreachable!("test builds updates and keepalives only"), + }; + let mut bgp = vec![0xFF; 16]; + bgp.extend_from_slice(&((19 + body.len()) as u16).to_be_bytes()); + bgp.push(bgp_message.msg_type() as u8); + bgp.extend_from_slice(&body); + + let mut mrt_body = Vec::new(); + mrt_body.extend_from_slice(&64496u32.to_be_bytes()); + mrt_body.extend_from_slice(&64497u32.to_be_bytes()); + mrt_body.extend_from_slice(&0u16.to_be_bytes()); + mrt_body.extend_from_slice(&1u16.to_be_bytes()); + mrt_body.extend_from_slice(&[192, 0, 2, 1]); + mrt_body.extend_from_slice(&[192, 0, 2, 2]); + mrt_body.extend_from_slice(&bgp); + + let mut wire = Vec::new(); + wire.extend_from_slice(×tamp.to_be_bytes()); + wire.extend_from_slice(&(EntryType::BGP4MP as u16).to_be_bytes()); + wire.extend_from_slice(&(Bgp4MpType::MessageAs4 as u16).to_be_bytes()); + wire.extend_from_slice(&(mrt_body.len() as u32).to_be_bytes()); + wire.extend_from_slice(&mrt_body); + wire + } + + fn keepalive(timestamp: u32) -> Vec { + bgp4mp_record(timestamp, BgpMessage::KeepAlive) + } + + fn update(timestamp: u32) -> Vec { + let mut attributes = Attributes::default(); + attributes.add_attr(AttributeValue::Origin(Origin::IGP).into()); + attributes.add_attr(AttributeValue::AsPath(AsPath::from_sequence([65000])).into()); + attributes.add_attr(AttributeValue::NextHop("192.0.2.254".parse().unwrap()).into()); + bgp4mp_record( + timestamp, + BgpMessage::Update(BgpUpdateMessage { + withdrawn_prefixes: vec![], + attributes, + announced_prefixes: vec!["198.51.100.0/24".parse().unwrap()], + }), + ) + } + + #[test] + fn no_filters_yield_original_bytes_exactly() { + let mut input = keepalive(1); + let upd = update(2); + input.extend_from_slice(&upd); + + let parser = BgpkitParser::from_reader(Cursor::new(input.clone())); + let yielded: Vec> = parser + .into_filtered_raw_record_iter() + .map(|(raw, _)| raw.raw_bytes().to_vec()) + .collect(); + assert_eq!(yielded.len(), 2); + assert_eq!(yielded[0], input[..input.len() - upd.len()].to_vec()); + assert_eq!(yielded[1], upd); + } + + #[test] + fn filters_drop_no_elem_records_and_preserve_bytes() { + let keep = keepalive(1); + let upd = update(2); + let mut input = keep.clone(); + input.extend_from_slice(&upd); + + let parser = BgpkitParser::from_reader(Cursor::new(input)) + .add_filter("prefix", "198.51.100.0/24") + .unwrap(); + let yielded: Vec> = parser + .into_filtered_raw_record_iter() + .map(|(raw, _)| raw.raw_bytes().to_vec()) + .collect(); + + // the keepalive is dropped; the update passes with byte-exact bytes + assert_eq!(yielded.len(), 1); + assert_eq!(yielded[0], upd); + } +} diff --git a/src/render/hex.rs b/src/render/hex.rs new file mode 100644 index 0000000..e9cd837 --- /dev/null +++ b/src/render/hex.rs @@ -0,0 +1,48 @@ +//! Hex encoding of record bytes. +//! +//! One record, one lowercase hex string — the paste format for +//! byte-level tools (e.g. wirescope's hex input). Encoding operates on +//! the record's *original* bytes (see +//! [`BgpkitParser::into_filtered_raw_record_iter`](crate::BgpkitParser::into_filtered_raw_record_iter)), never on a +//! re-encoding of the parsed model, so attribute ordering quirks (e.g. +//! BGP-LS hash-map iteration order) cannot alter the output. + +/// Encode bytes as a single lowercase hex string (no separators). +/// +/// Writes the two digits directly into the output buffer; no +/// per-byte allocations. +pub fn encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_golden_values() { + assert_eq!(encode(&[]), ""); + assert_eq!(encode(&[0x00]), "00"); + assert_eq!(encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef"); + assert_eq!(encode(&[0xff, 0x10, 0x0a]), "ff100a"); + } + + #[test] + fn encodes_all_byte_values_lowercase() { + let all: Vec = (0..=255u8).collect(); + let hex = encode(&all); + assert_eq!(hex.len(), 512); + assert!(!hex.contains(|c: char| c.is_uppercase())); + // spot checks across the range + assert_eq!(&hex[0..2], "00"); + assert_eq!(&hex[9 * 2..10 * 2], "09"); + assert_eq!(&hex[10 * 2..12 * 2], "0a0b"); + assert_eq!(&hex[510..512], "ff"); + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index 5c599c6..61e798b 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -8,6 +8,11 @@ validation warnings. The format is designed around this crate's own models (reusing the leaf `Display` implementations), not around any external tool's output; it is *inspired by bgpdump's human-readable output*. +The [`hex`] module encodes raw record bytes (as yielded by +[`BgpkitParser::into_filtered_raw_record_iter`](crate::BgpkitParser::into_filtered_raw_record_iter)) +as single hex strings — the paste format for byte-level dissectors such as +[wirescope](https://wirescope.labs.bgpkit.com). + Rendering is a pure function of the record: no iterators, no I/O, no session state. RIB entries reference peers by their table index because the peer table lives in a separate, earlier record. @@ -25,4 +30,5 @@ for record in parser.into_record_iter() { ``` */ +pub mod hex; pub mod text; diff --git a/src/render/text.rs b/src/render/text.rs index de1636a..b6ce197 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -14,6 +14,15 @@ use crate::models::*; const INDENT: &str = " "; +/// Render a record block with a trailing `HEX:` line carrying the raw +/// bytes (e.g. from [`crate::render::hex::encode`]), so the block +/// can be pasted straight into a byte-level dissector. +pub fn format_record_with_hex(record: &MrtRecord, hex: &str) -> String { + let mut out = format_record(record); + out.push_str(&format!("{INDENT}HEX: {hex}\n")); + out +} + /// Render one MRT record as a layered text block. /// /// Pure function of the record; see the [module docs](self) for scope and @@ -468,6 +477,15 @@ mod tests { attributes } + #[test] + fn renders_hex_line_variant() { + let text = format_record_with_hex(&update_record(full_attributes()), "deadbeef"); + assert!(text.trim_end().ends_with(" HEX: deadbeef")); + // everything before the HEX line is the plain block + let plain = format_record(&update_record(full_attributes())); + assert!(text.starts_with(&plain)); + } + #[test] fn renders_layered_update_block() { let text = format_record(&update_record(full_attributes()));