fix(encoding): add fallible try_encode() API, remove panics and silent truncation - #314
fix(encoding): add fallible try_encode() API, remove panics and silent truncation#314digizeph wants to merge 7 commits into
Conversation
…t truncation The encoding layer previously had three classes of bugs that let arbitrary input crash the process or silently corrupt wire output: 1. Hard panics: .expect() in encode_bgp_open_param_value would crash on oversized capability values (introduced in PR #312). 2. Silent truncation: unchecked / casts throughout encoding code silently discarded high bits, producing wrong length/count fields on the wire for any value exceeding the field capacity. 3. Error swallowing: MP_REACH/MP_UNREACH NLRI encoding failures were silently replaced with empty bytes. This commit adds: - EncodingError type in error.rs - try_encode() methods on BgpOpenMessage, BgpUpdateMessage, BgpMessage, Attribute, and Attributes that return Result<Bytes, EncodingError> - Checked conversions (u8::try_from / u16::try_from) replacing all truncating casts in the primary encoding paths - encode_as_path now returns Result<Bytes, EncodingError> - Defensive .min() clamps in secondary attribute encoders - Backwards-compatible encode() methods retained as panic-on-error wrappers - 5 regression tests covering each crash/corruption vector Closes #313
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #314 +/- ##
==========================================
+ Coverage 90.31% 90.48% +0.17%
==========================================
Files 91 92 +1
Lines 19306 19887 +581
==========================================
+ Hits 17436 17995 +559
- Misses 1870 1892 +22 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
Pull request overview
This PR hardens the BGP/MRT encoding layer by introducing a fallible encoding API (try_encode) backed by a new EncodingError type, aiming to eliminate panics, silent integer truncation, and swallowed encoding failures when serializing potentially-untrusted parsed data back to wire format.
Changes:
- Added
EncodingErrorand exposed it viabgpkit_parser::error::EncodingErrorandbgpkit_parser::EncodingError. - Introduced
try_encode()methods on core BGP message/attribute types and propagated checked length conversions (u8::try_from/u16::try_from) instead of panicking/truncating. - Reworked
encode_as_path()to returnResult<Bytes, EncodingError>and added regression tests for oversized encodings.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| CHANGELOG.md | Documents the new fallible encoding API and the encoding hardening. |
| src/error.rs | Adds EncodingError enum + Display/Error impls. |
| src/parser/mod.rs | Re-exports EncodingError from the parser module API surface. |
| src/parser/bgp/messages.rs | Adds try_encode() for OPEN/UPDATE/BgpMessage and replaces panics/truncation with checked conversions + propagation. |
| src/parser/bgp/attributes/mod.rs | Adds try_encode() for Attribute/Attributes and propagates encoding errors (including AS_PATH). |
| src/parser/bgp/attributes/attr_02_17_as_path.rs | Makes AS_PATH encoding fallible and checks segment-count overflow. |
| src/parser/bgp/attributes/attr_23_tunnel_encap.rs | Changes length encoding to clamped writes (but currently still risks wire-length/value mismatches). |
| src/parser/bgp/attributes/attr_29_linkstate.rs | Changes TLV length encoding to clamped writes (but currently still risks wire-length/value mismatches). |
| src/parser/bgp/attributes/attr_37_sfp.rs | Clamps TLV length fields (but currently still risks wire-length/value mismatches). |
| src/parser/bgp/attributes/attr_38_bfd_discriminator.rs | Clamps TLV length fields (but currently still risks wire-length/value mismatches). |
| src/parser/bgp/attributes/attr_40_bgp_prefix_sid.rs | Clamps TLV length fields (but currently still risks wire-length/value mismatches). |
| src/parser/bgp/attributes/attr_41_bier.rs | Clamps TLV length fields (but currently still risks wire-length/value mismatches). |
| src/parser/mrt/messages/table_dump.rs | Clamps attribute-length field (but currently still risks length/payload mismatches). |
| src/parser/mrt/messages/table_dump_v2/rib_afi_entries.rs | Clamps attribute-length field (but currently still risks length/payload mismatches). |
| src/parser/mrt/messages/table_dump_v2/peer_index_table.rs | Clamps peer IDs/counts/lengths (but currently can corrupt the table and/or emit inconsistent wire format). |
| src/parser/mrt/messages/table_dump_v2/geo_peer_table.rs | Clamps view-name length and peer count (but currently can emit inconsistent wire format). |
| src/models/bgp/tunnel_encap.rs | Clamps Sub-TLV length() helper. |
| src/models/bgp/linkstate.rs | Clamps Link-State TLV length() helper. |
| src/models/bgp/flowspec/nlri.rs | Clamps FlowSpec NLRI length (but must respect RFC’s 12-bit length encoding semantics). |
Comments suppressed due to low confidence (2)
src/parser/bgp/attributes/attr_23_tunnel_encap.rs:113
- The tunnel-length field is clamped, but the full
sub_tlv_bytesblock is still appended. If the sub-TLV block exceedsu16::MAX, the declared tunnel length won’t match the bytes written, making the attribute invalid.
If clamping is intended, also truncate the emitted sub-TLV bytes to the clamped tunnel length.
// Encode tunnel length
bytes.put_u16(sub_tlv_bytes.len().min(u16::MAX as usize) as u16);
// Append sub-TLV data
bytes.extend_from_slice(&sub_tlv_bytes);
src/parser/bgp/attributes/attr_29_linkstate.rs:460
- The unknown-attributes TLV encoder writes
tlv.length()(now clamped) but still appends the fulltlv.value. Iftlv.value.len() > u16::MAX, the encoded TLV becomes invalid because the declared length won’t match the bytes written.
To keep the wire format self-consistent under the new clamping approach, truncate the emitted value bytes to the encoded length (or make the encoder fallible and return an error).
// Encode prefix attributes
for (attr_type, value) in &attr.prefix_attributes {
let type_code = u16::from(*attr_type);
bytes.put_u16(type_code);
bytes.put_u16(value.len().min(u16::MAX as usize) as u16);
bytes.extend_from_slice(value);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… Copilot comments The .min() clamp approach clamped the length field but still wrote the full payload, producing wire-inconsistent output (declared length != actual bytes). Changes: - Made 6 BGP attribute encoders fallible (tunnel_encap, linkstate, bfd, prefix_sid, bier, sfp) — now return Result<Bytes, EncodingError> - Made encode_flowspec_nlri fallible — now returns Result<Vec<u8>, EncodingError> - Added try_encode() to TableDumpMessage, RibEntry, PeerIndexTable, GeoPeerTable with checked u16 conversions for all length/count fields - Replaced .min() in model length() methods with u16::try_from - Added #[non_exhaustive] to EncodingError for future extensibility - Added debug_assert! to the safe-by-logic non-extended OPEN param cast - Updated all test call sites with .unwrap() for the now-Result APIs Addresses all 12 Copilot review comments on PR #314.
…ethods The Tlv::length() and SubTlv::length() methods use saturating casts (.min(u16::MAX)) but are only called from test code — the actual encode paths use u16::try_from with ? propagation. Added clear documentation.
…ooLarge arms 13 new tests covering every map_err closure that was previously uncovered: - tunnel_encap: u8 sub-TLV overflow, u16 sub-TLV overflow, tunnel total overflow - linkstate: node/link/prefix/unknown attribute value overflow - bfd_discriminator, bgp_prefix_sid, bier, sfp: TLV value overflow - flowspec_nlri: total NLRI length overflow - MRT: table_dump, rib_entry, peer_index_table, geo_peer_table overflow Fixes code coverage report on PR #314.
|
AI review: [0] src/parser/mrt/messages/table_dump_v2/rib_afi_entries.rs:179 (CONFIRMED)RibAfiEntries::encode() silently drops any RIB entry whose encoding fails: [1] src/parser/mrt/messages/table_dump_v2/rib_afi_entries.rs:179 (CONFIRMED)RibAfiEntries::encode silently drops any RIB entry whose encoding fails: [2] src/parser/mrt/messages/table_dump_v2/rib_afi_entries.rs:179 (CONFIRMED)RibAfiEntries::encode silently drops any RIB entry whose encoding fails: encode_for_rib_type now returns Result<Bytes, EncodingError>, and [3] src/parser/bgp/attributes/mod.rs:547 (CONFIRMED)Attribute::try_encode() swallows MP_REACH_NLRI/MP_UNREACH_NLRI encoding failures via [4] src/parser/bgp/attributes/mod.rs:547 (CONFIRMED)The new fallible Attribute::try_encode still swallows encode_nlri errors in the MpReachNlri/MpUnreachNlri arms (unwrap_or_else -> log::warn + Bytes::new()), so it returns Ok with a zero-length attribute value instead of Err, violating the contract the PR establishes for every other arm (LinkState, TunnelEncap, BFD, PrefixSid, Bier, Sfp all use [5] src/parser/mrt/messages/table_dump_v2/peer_index_table.rs:74 (CONFIRMED)PeerIndexTable::add_peer clamps the new peer id with [6] src/parser/mrt/messages/table_dump_v2/peer_index_table.rs:74 (CONFIRMED)PeerIndexTable::add_peer replaces the old wrapping cast with a silent saturating clamp [7] src/parser/mrt/messages/table_dump_v2/peer_index_table.rs:74 (CONFIRMED)PeerIndexTable::add_peer's new [8] src/parser/bgp/messages.rs:471 (CONFIRMED)BgpOpenMessage::try_encode writes param.param_type unconditionally, so a first optional parameter with param_type 255 emitted in non-extended framing is indistinguishable from the RFC 9072 extended-length marker and is mis-parsed on round trip. [9] src/parser/bgp/attributes/mod.rs:570 (CONFIRMED)Attribute::try_encode returns Ok with an empty value for AttributeValue::AttrSet (encoding "not yet implemented"), silently emitting a zero-length ATTR_SET attribute instead of an EncodingError. [10] src/parser/bgp/messages.rs:720 (CONFIRMED)The retained infallible encode() wrappers now panic via .expect() where the old code never panicked (it truncated), and higher-level unchanged APIs with no try_ alternative — Bgp4MpMessage::encode (bgp4mp.rs:182 calls self.bgp_message.encode), MrtMessage::encode, MrtRibEncoder::export_bytes — inherit the panic with no fallible escape hatch. [11] src/parser/bgp/messages.rs:432 (CONFIRMED)BgpOpenMessage::try_encode silently switches to RFC 9072 extended framing when extended_length is false but non-extended params exceed 255 bytes, instead of returning an EncodingError — a silent wire-format change replacing the old silent truncation rather than the fallible behavior the branch promises. [12] src/parser/mrt/messages/table_dump_v2/rib_afi_entries.rs:176 (CONFIRMED)RibAfiEntries::encode still writes the entry count with an unchecked [13] src/parser/mrt/messages/table_dump_v2/peer_index_table.rs:74 (CONFIRMED, cleanup)add_peer still uses a silent [14] src/models/bgp/flowspec/nlri.rs:97 (CONFIRMED, cleanup)FlowSpec NLRI length check uses u16::MAX (65535) as the bound, but the wire length field is 12-bit (max 4095): encode_length does [15] src/parser/bgp/messages.rs:470 (CONFIRMED, cleanup)Non-extended OPEN encoding emits a user-supplied param_type of 255 as the first parameter byte, which this crate's own parser (and any RFC 9072 peer) interprets as the extended-format marker [16] src/parser/mrt/messages/table_dump.rs:151 (CONFIRMED, cleanup)TableDumpMessage::try_encode hand-rolls a loop over self.attributes.inner that duplicates the existing Attributes::try_encode helper (which rib_afi_entries.rs already calls) [17] src/error.rs:56 (CONFIRMED, cleanup)~22 near-identical [18] src/models/bgp/linkstate.rs:191 (CONFIRMED, cleanup)Tlv::length() (and SubTlv::length() in tunnel_encap.rs:132) now silently saturates at u16::MAX while the encode path bypasses it and recomputes u16::try_from(tlv.value.len()) — the method is dead on all production paths (only tests call it) and is a footgun for downstream users [19] src/parser/bgp/messages.rs:474 (CONFIRMED, cleanup)The per-parameter u16::try_from(value.len()) error arm in the extended OPEN path is unreachable: the aggregate agg_len check at line ~430 already bounds encoded_params_len (= 3*count + values_len >= value.len() + 3) to u16::MAX [20] src/parser/bgp/messages.rs:491 (CONFIRMED, cleanup)Doc comment on BgpOpenMessage::encode reads "Infinitely convenient infallible encoding wrapper." — a nonsense phrase (presumably meant "Infallible convenience wrapper") [21] src/parser/bgp/messages.rs:1346 (CONFIRMED, cleanup)New tests construct Attributes via struct literal (inner/validation_warnings/attr_mask: [0; 4]) in four places instead of using the existing From<Vec> impl (models/bgp/attributes/mod.rs:400) [22] src/parser/mrt/messages/table_dump_v2/geo_peer_table.rs:229 (CONFIRMED, cleanup)Test fixtures add pointless [23] src/parser/bgp/attributes/attr_23_tunnel_encap.rs:100 (CONFIRMED, cleanup)Both branches of the |
…eview
Critical fixes (silent corruption despite fallible API):
1. rib_afi_entries:179 — Result silently swallowed by bytes.extend(Result)
(regression from this branch). Now propagates ? and makes RibAfiEntries
fallible with try_encode().
2. peer_index_table:74 — add_peer no longer clamps with .min(u16::MAX).
Returns Option<u16>, None when table exceeds u16 capacity.
3. MP_REACH/MP_UNREACH NLRI encoding failures no longer silently swallowed
with unwrap_or_else(Bytes::new()). Now returns EncodingError::InvalidInput.
4. rib_afi_entries entry_count now checked with u16::try_from.
5. OPEN param_type 255 in non-extended mode now returns InvalidInput error
instead of producing ambiguous wire framing.
6. AttrSet no longer returns empty Ok(Bytes::new()). Returns
EncodingError::InvalidInput("not yet implemented").
8. OPEN no longer silently switches to RFC 9072 extended format when
params >255 bytes with extended_length=false. Now returns
EncodingError::ValueTooLarge.
Boundary fixes:
9. FlowSpec NLRI length now correctly bounded at 0x0FFF (12-bit) instead
of u16::MAX.
10. Tlv::length() and SubTlv::length() marked #[deprecated] to eliminate
dual sources of truth.
Cleanup:
- Fixed garbled doc comment ("Infinitely convenient")
- Added EncodingError::InvalidInput variant for non-overflow failures
- Updated test_bgp_open_automatically_uses_extended_parameter_encoding
to verify error behavior instead of silent format switch
fmt ✅ | clippy ✅ | 686 tests, 0 failures ✅
- #16: TableDumpMessage::try_encode now uses Attributes::try_encode helper instead of hand-rolled loop - #17: Added EncodingError::check_u8/check_u16 helpers to eliminate the ~24 copies of boilerplate try_from().map_err(ValueTooLarge) pattern - #19: Replaced dead per-param u16 check in OPEN extended encoding with debug_assert (unreachable due to aggregate check) - #20: Fixed garbled doc comment - #22: Removed pointless .min(u16::MAX) on constant strings in geo_peer tests - #23: Deduplicated type-byte write in tunnel_encap sub-TLV encoding fmt ✅ | clippy ✅ | 686 tests, 0 failures ✅
Redesign per ties's fallible-encoding-redesign proposal. Replaces the try_encode()/encode() dual API (which structurally invited the bugs found in review) with a single fallible sink convention: 1. encode_to(&mut BytesMut) -> Result<(), EncodingError> as the primary API, with encode() -> Result<Bytes, EncodingError> as the convenience wrapper. Composition is child.encode_to(buf)? — the extend(Result) silent-drop footgun (review issue #1) becomes impossible. 2. Length prefixes centralized in src/encoder/sink.rs: with_u8_len, with_u16_len, and check_max write placeholder lengths, encode the payload into the same buffer, and back-patch — rolling the buffer back to its pre-call state on any error. Replaces 24 hand-rolled try_from(..).map_err(ValueTooLarge) copies (issue #12) and makes non-power-of-two bounds explicit (FlowSpec's 12-bit 0x0FFF via check_max, issue #9). 3. EncodingError gains Unencodable for values the wire format cannot express at all: ATTR_SET (unimplemented, issue #6), OPEN param_type 255 in non-extended framing (issue #5), MP_REACH/MP_UNREACH NLRI failures (issue #3). The RFC 9072 auto-upgrade for oversized OPEN params stays as documented behavior (issue #8). 4. Mutation-time validation: PeerIndexTable::add_peer returns Result<u16, EncodingError> and errors on the 65537th peer (issue #2); MrtRibEncoder::process_elem propagates it. export_bytes and all top-level encoders (MrtMessage, Bgp4MpMessage, MrtRecord) return Result, closing the inherited-panic escape hatch (issue #7). 5. Dual API deleted: all panicking encode() wrappers and try_encode adapters removed; Tlv::length()/SubTlv::length() removed (issue #10). #![deny(unused_must_use)] makes dropping an encode Result a compile error crate-wide. New regression tests: peer table overflow at 65537 with uncorrupted state, RIB entries oversized-entry and count-overflow errors, sink helper boundary tests (0/max/max+1, nested prefixes, rollback on child error). fmt + clippy -D warnings + 695 unit tests + integration + doc tests: all green. No version bump per maintainer request.
|
closed, prefer #315 solution |
Summary
The encoding layer had three classes of bugs that let arbitrary input crash the process or silently corrupt wire output. This PR adds a proper
EncodingErrortype andtry_encode()methods to fix all three.Closes #313.
Problems fixed
Hard panics:
.expect()inencode_bgp_open_param_value(introduced in Fix BGP open encoder and remove redundant param_length #312) crashed on oversized capability values — a round-trip parse→encode of any OPEN with a >255-byte raw capability would panic.Silent truncation: Unchecked
as u8/as u16casts throughout encoding code silently discarded high bits, producing wrong length/count fields on the wire. Examples: AS_PATH segment with >255 ASes, attribute value >65535 bytes, BGP message total >65519 bytes.Error swallowing: MP_REACH/MP_UNREACH NLRI encoding failures were silently replaced with empty bytes.
Changes
New API (additive, backwards-compatible):
EncodingErrorenum inerror.rs— exported asbgpkit_parser::error::EncodingErrorandbgpkit_parser::EncodingErrortry_encode()methods onBgpOpenMessage,BgpUpdateMessage,BgpMessage,Attribute,Attributes— returnResult<Bytes, EncodingError>encode_as_path()now returnsResult<Bytes, EncodingError>encode()methods retained as panic-on-error wrappersInternal fixes:
.expect()/debug_assert!in encoding replaced with checkedu8::try_from/u16::try_from+?propagationas u8/as u16truncation casts in secondary attribute encoders (tunnel_encap, linkstate, bfd, prefix_sid, bier, sfp, flowspec) replaced with.min()clamps as a defensive boundaryRegression tests (5 new):
test_fallible_encoding_as_path_overflow— >255 ASes in a segmenttest_fallible_encoding_open_raw_capability_oversize— >255-byte raw capabilitytest_fallible_encoding_open_extended_param_oversize— extended params >65535 bytestest_fallible_encoding_update_attributes_oversize— total attributes >65535 bytestest_bgp_open_encoding_rejects_oversized_add_path_capability— updated from#[should_panic]to verify bothtry_encode()returnsErrandencode()panicsTest Plan
cargo fmt --check✅cargo clippy --all-targets --all-features -- -D warnings✅cargo test --all-features— 673 unit tests + 47 doc tests + 1 integration test, all passing ✅