Skip to content

feat: --hex flag augments record-level output with raw record bytes - #334

Merged
digizeph merged 6 commits into
mainfrom
feature/hex-format
Aug 21, 2026
Merged

feat: --hex flag augments record-level output with raw record bytes#334
digizeph merged 6 commits into
mainfrom
feature/hex-format

Conversation

@digizeph

@digizeph digizeph commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

Adds a --hex flag that augments record-level output with the record's original wire bytes as hex. Hex is deliberately not a standalone format — the unit is the record and the meaning is "attach the original bytes", so it rides on the formats that already emit records:

  • --format text --hex — a HEX: line closes each block
  • --format json / json-pretty --hex — a hex field is injected into each record object

The intended workflow is the pipe from filtered CLI output into a byte-level dissector (wirescope's hex paste input): copy the HEX: line, paste, get the layered field tree.

$ bgpkit-parser --format text --hex -p 206.224.32.0/19 updates.20000102.2014.gz

TIME: 946844091
TYPE: BGP/1
FROM: 195.211.222.254 AS5409
TO: 193.0.0.1 AS12654
UPDATE:
  ANNOUNCED:
    206.224.32.0/19
  ATTRIBUTES:
    ORIGIN: IGP
    AS_PATH: 5409 6427 6461 568
    NEXT_HOP: 195.211.222.254
    ATOMIC_AGGREGATE
    AGGREGATOR: AS568 by 198.26.118.1
  HEX: 386fb1bb00050001000000381521c3d3defe316ec1000001000000244001010040020a02041521191b193d0238400304c3d3defe400600c007060238c61a760113cee020

Design notes

  • Source of the bytes — original, never re-encoded. A new library iterator, BgpkitParser::into_filtered_raw_record_iter(), yields raw MRT records with the same record-level filter semantics as into_record_iter (no-elem records drop under filters, PeerIndexTable passes through for RIB resolution, empty-filter fast path skips parsing). The CLI hex-encodes RawMrtRecord::raw_bytes() directly. Re-encoding the parsed model was considered and rejected in review: BGP-LS attributes re-encode in HashMap iteration order, which is non-deterministic between runs. Byte-exactness is verified against a real rrc00 fixture: the hex field equals that record's bytes in the decompressed file exactly.
  • Single parse under filters: the iterator yields (raw, Option<MrtRecord>) — the parse performed for filter matching is reused by the renderer; the no-filter fast path skips parsing entirely.
  • Diagnostics parity: body-parse failures are logged and core-dumped like the record iterator; shortened Zebra BGP4MP records are detected and warned once.
  • Semantics: implies --level records; requires text/json/json-pretty; mutually exclusive with --recover; ignored entirely by count-only runs (-e/-r), which keep the normal counting pipelines and per-elem filter semantics (verified: -e -p X and -e --hex -p X report identical totals).
  • Not on the elem pipeline: elems don't carry their source record, and one record fans out to N elems — hex at elem level would be a synthetic reconstruction, not the original bytes.

Testing

  • render::hex: golden encodings, all-256-byte lowercase sweep.
  • FilteredRawRecordIterator: original-bytes-exact yields (no-filter and filtered paths), no-elem record dropping under filters, parse carried alongside when filtering.
  • CLI verified against the real rrc00 fixture: text block ending, JSON field injection, byte-equality with the file contents, count parity with filters, format/recover validation errors.
  • Gates: cargo test --all-features 958 passed / 0 failed, clippy -D warnings clean, --no-default-features build clean, strict rustdoc clean, fmt clean, MSRV 1.87 verified.

Hex is an attribute of existing output formats, not a format of its
own (the unit is the record, the meaning is "attach the original
bytes"):

- --format text --hex: HEX: line at the end of each block
  (render::text::format_record_with_hex)
- --format json/json-pretty --hex: hex field injected into each
  record object
- render::hex::format_record: shared hex source; re-encodes through
  MrtRecord::encode (round-trips byte-identically for standard
  records; round-trip proven in tests against crafted wire bytes)
- unencodable records keep well-formed output + stderr warning
- implies --level records; explicit error for unsupported formats
  (default/psv)

Workflow target: filter the CLI output, paste the HEX line / hex
field straight into wirescope's hex input.
Copilot AI balanced review requested due to automatic review settings August 21, 2026 22:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds record-level hexadecimal output for CLI text and JSON formats.

Changes:

  • Adds an MRT record hex renderer and tests.
  • Introduces --hex validation and output integration.
  • Documents the feature and adds text rendering support.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/render/hex.rs Encodes records as lowercase hexadecimal.
src/render/text.rs Appends HEX: lines to text blocks.
src/render/mod.rs Exposes the hex renderer.
src/bin/main.rs Adds and processes the --hex option.
CHANGELOG.md Documents the new flag.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/render/mod.rs
```
*/

pub mod hex;
Comment thread src/render/hex.rs Outdated
Comment on lines +17 to +18
pub fn format_record(record: &MrtRecord) -> Result<String, EncodingError> {
let bytes = record.encode()?;
Comment thread src/render/hex.rs
Comment on lines +19 to +22
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes.iter() {
out.push_str(&format!("{byte:02x}"));
}
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.93182% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.82%. Comparing base (fe3cd1b) to head (2470b8e).

Files with missing lines Patch % Lines
src/parser/iters/mod.rs 66.66% 12 Missing ⚠️
src/parser/iters/raw.rs 89.21% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #334      +/-   ##
==========================================
- Coverage   90.82%   90.82%   -0.01%     
==========================================
  Files         102      103       +1     
  Lines       25567    25720     +153     
==========================================
+ Hits        23222    23360     +138     
- Misses       2345     2360      +15     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- hex now comes from the record's ORIGINAL wire bytes via a new library
  iterator, BgpkitParser::into_filtered_raw_record_iter(): raw MRT
  records with the same record-level filter semantics as
  into_record_iter (no-elem records drop under filters, PeerIndexTable
  passes, empty-filter fast path skips parsing entirely). Re-encoding
  the parsed model was observable-wrong: BGP-LS attributes re-encode in
  HashMap iteration order, varying between runs.
- render::hex reduced to a pure encode(&[u8]) — no model dependency
  (fixes the --no-default-features build break) and no per-byte
  format! allocations
- CLI: --hex routes through the raw pipeline; --hex + --recover is an
  explicit error (recovering iterators wrap parsed records)
- byte-exactness verified against a real fixture: the hex field equals
  the record's bytes in the decompressed file exactly
Copilot AI review requested due to automatic review settings August 21, 2026 22:58
@digizeph

Copy link
Copy Markdown
Member Author

All 3 review comments addressed in 3d1aca3 — and comment 2 drove a design change, not just a patch:

  1. Feature gating — resolved by making render::hex a pure encode(&[u8]) with no model dependency at all; cargo build --no-default-features is back to green and now part of the local gate run.

  2. Re-encoding ≠ original bytes (the substantive one) — you're right that this was observable-wrong, and BGP-LS hash-map iteration order is a worse case than mere divergence: it's non-deterministic between runs. The fix goes to the source of truth instead of papering over it: a new library iterator, BgpkitParser::into_filtered_raw_record_iter(), which applies the same record-level filter semantics as into_record_iter (no-elem records drop under filters, PeerIndexTable passes through for RIB resolution, empty-filter fast path yields raw chunks without parsing) and yields the untouched original bytes. The CLI's --hex path now feeds render::hex::encode from raw.raw_bytes(). Verified byte-exact against a real rrc00 fixture: the hex field of the first matching record equals that record's bytes in the decompressed file, exactly (checked independently by re-walking the file in Python). Bonus: the re-encode failure path (stderr warning, missing field) is gone entirely — every record has its bytes.

  3. Per-byte allocationencode now writes the two hex digits directly into the preallocated String (const HEX table + two pushes per byte); no format! in the loop.

Also: --hex + --recover is now an explicit error (the recovering iterators wrap parsed records, which is exactly the pairing the raw-bytes pipeline can't honor), and the changelog documents the new iterator API.

Gates: cargo test --all-features 958 passed / 0 failed, clippy -D warnings clean, --no-default-features build clean, strict rustdoc clean, fmt clean, MSRV 1.87 verified.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread src/bin/main.rs Outdated
Comment on lines +500 to +502
if let Ok(record) = raw.parse() {
elems_count += elementor.record_to_elems(record).len();
}
Comment thread src/parser/iters/raw.rs Outdated
Comment on lines +135 to +137
let record = match raw.clone().parse() {
Ok(record) => record,
Err(_) => continue, // skip unparseable, like the record iterator
…iter

- count-only runs (-e/-r) ignore --hex entirely: they emit no hex, so
  they keep the normal elem/record counting pipelines and their per-elem
  filter semantics (verified: -e and -e --hex report identical totals);
  the format/recover validations also skip count-only runs
- FilteredRawRecordIterator mirrors RecordIterator's body-parse failure
  handling under filters: logged via error! and core-dumped when
  enabled, never silently dropped
Copilot AI review requested due to automatic review settings August 21, 2026 23:07
@digizeph

Copy link
Copy Markdown
Member Author

Both follow-up comments addressed in d19b0de:

  1. --hex -e counting semantics — adopted your first suggestion: count-only runs (-e/-r) now ignore --hex entirely, since they emit no hex anyway. They route through the normal elem/record counting pipelines and keep their per-elem filter semantics. Verified on the rrc00 fixture: -e and -e --hex report identical totals (6343). The format/recover validations also skip count-only runs, so -e --hex no longer errors on the default format. run_hex_records shed its counting branches entirely.

  2. Silent parse failures under filtersFilteredRawRecordIterator now mirrors RecordIterator's body-parse failure handling: error! logging plus write_mrt_core_dump when enabled, then continue — instead of the bare Err(_) => continue. The no-filter fast path is unchanged (raw chunks pass through untouched; parse failures surface in the consumer as before).

Gates: 958 tests / 0 failed, clippy -D warnings, --no-default-features build, strict rustdoc, fmt — all clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Comment thread src/bin/main.rs Outdated
Comment thread src/bin/main.rs Outdated

for raw in records {
let hex = bgpkit_parser::render::hex::encode(raw.raw_bytes().as_ref());
let record = match raw.parse() {
Comment thread src/bin/main.rs
// 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)
Comment thread src/render/hex.rs
Comment on lines +5 to +8
//! 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.
- use_elem_stream no longer special-cases --hex: count-only runs keep
  the elem pipeline and per-elem filter semantics (verified parity with
  filters: -e -p and -e --hex -p report identical totals)
- FilteredRawRecordIterator yields (raw, Option<MrtRecord>): the parse
  done for filtering is reused by consumers instead of reparsed; the
  no-filter fast path still skips parsing
- shortened Zebra BGP4MP records are detected and warned once in the
  raw pipeline, matching the record iterator's data-quality diagnostic
Copilot AI review requested due to automatic review settings August 21, 2026 23:31
@digizeph

Copy link
Copy Markdown
Member Author

All 4 comments from the third round addressed in 30a0ade:

  1. Residual !opts.hex in use_elem_stream — removed. Count-only runs now reach the normal elem pipeline even with --hex, restoring per-elem filter semantics for counting. You were right that my earlier parity check missed this: it only exercised the no-filter case. Verified the filtered case now: -e -p 206.224.32.0/19 and -e --hex -p 206.224.32.0/19 both report 10.

  2. Zebra compat diagnostic lost in the raw pipelineFilteredRawRecordIterator now detects shortened Zebra BGP4MP records and warns once with the record iterator's exact message, on both the filtered and no-filter fast paths.

  3. Double parse under filters — the iterator now yields (RawMrtRecord, Option<MrtRecord>): the parse done for filter matching is handed to the consumer (the CLI renders from it directly), the no-filter fast path still skips parsing entirely. The public API carries the pair with None on the fast path, documented on the builder method.

  4. Stale PR description — the "Source of the bytes" section now describes the raw-record design (original bytes via RawMrtRecord::raw_bytes, the BGP-LS HashMap-ordering rationale for rejecting re-encoding, and the file-byte-equality verification) instead of the earlier re-encode/round-trip claim.

Gates: 958 tests / 0 failed, clippy -D warnings, --no-default-features build, strict rustdoc, fmt, MSRV 1.87 — all clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread src/parser/iters/raw.rs Outdated
Comment on lines +121 to +145
warned_zebra_compat: bool,
}

impl<R> FilteredRawRecordIterator<R> {
pub(crate) fn new(parser: BgpkitParser<R>) -> Self {
let filters = parser.filters.clone();
let core_dump = parser.core_dump;
FilteredRawRecordIterator {
inner: RawRecordIterator::new(parser),
elementor: Elementor::new(),
filters,
core_dump,
warned_zebra_compat: false,
}
}

fn warn_zebra_once(&mut self, raw: &RawMrtRecord) {
if !self.warned_zebra_compat && raw_record_uses_zebra_compat(raw) {
warn!(
"recovered shortened Zebra BGP4MP records with missing envelope fields; substituting IPv4 zero addresses and interface index 0 (further occurrences for this parser will not be logged)"
);
self.warned_zebra_compat = true;
}
}
}
Comment thread src/parser/iters/raw.rs
Comment on lines +163 to +168
let record = match raw.clone().parse() {
Ok(record) => record,
Err(error) => {
error!("parser error: {error}");
write_mrt_core_dump(self.core_dump, Some(raw.raw_bytes().to_vec()));
continue;
- FilteredRawRecordIterator's Zebra warning delegates to the parser's
  own warn_zebra_compat_once() state instead of a second iterator-local
  flag: disable_warnings() is honored, and a parser that already warned
  before conversion cannot warn again
- body-parse error handling extracted into a shared variant-aware
  policy (handle_record_parse_error) used by both RecordIterator and
  the filtered raw iterator: TruncatedMsg/Unsupported and labeled-NLRI
  failures warn under show_warnings, ParseError with core dumps enabled
  stops after writing the dump so later failures cannot overwrite it,
  IO errors stop — identical to the historical RecordIterator behavior
Copilot AI review requested due to automatic review settings August 21, 2026 23:40
@digizeph

Copy link
Copy Markdown
Member Author

Both fourth-round comments addressed in 2261364:

  1. Zebra warning state — the iterator-local flag is gone. Detection now calls the parser's own warn_zebra_compat_once() (the parser instance travels inside RawRecordIterator, so the shared state is reachable), which means disable_warnings() is honored and the once-per-parser contract holds even if the parser already warned before conversion.

  2. Error policy parity — the per-class behavior is now extracted into a shared handle_record_parse_error (iters/mod.rs) used by both RecordIterator and FilteredRawRecordIterator: TruncatedMsg/Unsupported warn under show_warnings + core-dump + continue; labeled-NLRI failures warn + continue without dumping; fatal ParseError errors + dumps and stops when core dumps are enabled, so a later failure can't overwrite the dump; IO errors stop. RecordIterator was refactored onto the same helper with identical behavior (all 736 lib tests unchanged), so there is exactly one error policy in the crate, not two approximations of one.

Gates: 958 tests / 0 failed, clippy -D warnings, --no-default-features build, strict rustdoc, fmt, MSRV 1.87 — all clean. Filtered count parity re-verified (-e -p and -e --hex -p: identical totals).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread src/bin/main.rs Outdated
Comment on lines +502 to +506
None => match raw.parse() {
Ok(record) => record,
Err(error) => {
eprintln!("warning: skipping unparseable record: {error}");
continue;
FilteredRawRecordIterator now parses every record body and routes all
parse failures through the shared variant-aware policy, on filtered and
unfiltered paths alike. The CLI hex runner's no-filter fallback (a bare
warn-and-continue eprintln) is deleted along with the Option in the
yielded item: consumers always receive (raw, record), never re-parse,
and the pipeline cannot continue past errors the record pipeline would
stop for (fatal ParseError with core dumps, IO errors).

Note: the first two comments in this round restate the fourth-round
findings (zebra warning state, per-class error policy) that 2261364
already addressed in this file; this change extends the same treatment
to the previously-uncovered no-filter CLI path.
Copilot AI review requested due to automatic review settings August 21, 2026 23:51
@digizeph

Copy link
Copy Markdown
Member Author

Fifth round addressed in 2470b8e:

  • Comment 3 (the new finding) — correct: the CLI's no-filter fallback (parsed == None → raw.parse() with a bare warn-and-continue eprintln) bypassed the shared policy exactly where normal --hex runs live. Adopted your suggested shape: FilteredRawRecordIterator now parses every record body and routes all parse failures — filtered or not — through handle_record_parse_error, so IO errors terminate, fatal ParseError stops after core-dump, and warnings honor disable_warnings() on every path. The Option in the yielded item is gone: the iterator yields (RawMrtRecord, MrtRecord) and the CLI error branch is deleted outright (net −19 lines). The no-filter skip-parse fast path was the hole this fell through; it no longer exists, and the trade is documented on the builder.
  • Comments 1 & 2 — these restate the fourth-round findings (zebra warn-once state, variant-aware error policy) that 2261364 already fixed in this file: warn_zebra_once delegates to parser.warn_zebra_compat_once() (raw.rs:139), and both iterators share handle_record_parse_error (raw.rs:164, default.rs:50). This round's change extends that same treatment to the one path it hadn't covered.

Gates: 958 tests / 0 failed, clippy -D warnings, --no-default-features build, strict rustdoc, fmt, MSRV 1.87 — all clean. Output re-verified against the fixture (HEX lines intact, filtered count parity holds).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/parser/iters/raw.rs:158

  • The advertised empty-filter fast path is missing: the iterator always parses the body before checking self.filters, and its item type cannot represent the promised (raw, Option<MrtRecord>). As a result, unfiltered library consumers pay for full parsing and malformed body records are skipped instead of receiving the original raw record. Return (raw, None) before parsing when filters are empty, and adapt the CLI consumer to parse only when it needs a rendered record (or update the stated API/design if eager parsing is intentional).
    type Item = (RawMrtRecord, MrtRecord);

    fn next(&mut self) -> Option<Self::Item> {
        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() {

@digizeph
digizeph merged commit ea30e27 into main Aug 21, 2026
10 checks passed
@digizeph
digizeph deleted the feature/hex-format branch August 21, 2026 23:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants