Skip to content

Commit f0e006d

Browse files
fix: deep code review passes 25-28 - 18 issues fixed (#276)
* fix: deep code review passes 25-28 - 18 issues fixed Pass 25 (6 fixes): - IPFIX V9 options template validation aligned with V9 parser (reject individual zero-length fields) - would_accept() false negative causing data truncation fixed - set_pending_flows_config(None) now records dropped metrics - IPFIX replay flowset count bound added (consistent with V9) - YAF DnsSRVTarget (219) type corrected to String - YAF SslPublicKeyLength (250) type corrected to UnsignedDataNumber Pass 26 (3 fixes): - IPFIX "withdraw all" templates implemented per RFC 7011 §8.1 - V9 replay flowset limit uses break instead of continue - IPFIX withdrawal+redefinition in same flowset no longer drains pending flows Pass 27 (3 fixes): - Fuzz round-trip target uses fresh parser for re-parse (no accumulated state) - README: 3 missing examples added - Redundant test renamed to test_v5_serialization_single_packet Pass 28 (3 fixes): - IPFIX withdraw-all now drains only same-type pending flows (not cross-type) - test_pcap_iterator_api now only counts successful parses - README v0.7.0 breaking change note updated for v1.0.0 Additional improvements: - Dead macro parameter removed from impl_try_from! - TemplateHooks::clear() method added - test_parser_builder_with_field_count_limits made non-vacuous - test_parser_builder_comprehensive strengthened with functional verification * fix: fmt * fix: tests
1 parent e698cce commit f0e006d

11 files changed

Lines changed: 278 additions & 86 deletions

File tree

README.md

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ The parser also automatically validates:
317317

318318
### Template TTL (Time-to-Live)
319319

320-
> **⚠️ Breaking Change in v0.7.0:** Packet-based and combined TTL modes have been removed. Only time-based TTL is now supported. See [RELEASES.md](RELEASES.md) for migration guide.
320+
> **Note:** Only time-based TTL is supported. See [RELEASES.md](RELEASES.md) for details.
321321
322322
Optionally configure templates to expire after a time duration. This is useful for:
323323
- Handling exporters that reuse template IDs with different schemas
@@ -706,18 +706,18 @@ pub struct NetflowCommon {
706706
pub flowsets: Vec<NetflowCommonFlowSet>,
707707
}
708708

709-
#[derive(Debug, Default)]
710-
struct NetflowCommonFlowSet {
711-
src_addr: Option<IpAddr>,
712-
dst_addr: Option<IpAddr>,
713-
src_port: Option<u16>,
714-
dst_port: Option<u16>,
715-
protocol_number: Option<u8>,
716-
protocol_type: Option<ProtocolTypes>,
717-
first_seen: Option<u32>,
718-
last_seen: Option<u32>,
719-
src_mac: Option<String>,
720-
dst_mac: Option<String>,
709+
#[derive(Debug, Default, Clone)]
710+
pub struct NetflowCommonFlowSet {
711+
pub src_addr: Option<IpAddr>,
712+
pub dst_addr: Option<IpAddr>,
713+
pub src_port: Option<u16>,
714+
pub dst_port: Option<u16>,
715+
pub protocol_number: Option<u8>,
716+
pub protocol_type: Option<ProtocolTypes>,
717+
pub first_seen: Option<u64>,
718+
pub last_seen: Option<u64>,
719+
pub src_mac: Option<String>,
720+
pub dst_mac: Option<String>,
721721
}
722722
```
723723

@@ -1194,7 +1194,13 @@ To run:
11941194

11951195
```cargo run --example custom_enterprise_fields```
11961196

1197-
The pcap example also shows how to cache flows that have not yet discovered a template. The custom_enterprise_fields example demonstrates how to register vendor-specific IPFIX fields.
1197+
```cargo run --example template_hooks```
1198+
1199+
```cargo run --example template_management_demo```
1200+
1201+
```cargo run --example multi_source_comparison```
1202+
1203+
The pcap example also shows how to cache flows that have not yet discovered a template. The custom_enterprise_fields example demonstrates how to register vendor-specific IPFIX fields. The template_hooks example shows how to monitor template lifecycle events.
11981204

11991205
## Support My Work
12001206

fuzz/fuzz_targets/fuzz_round_trip.rs

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ use netflow_parser::{NetflowPacket, NetflowParser};
66
// Round-trip fuzz target: parse → serialize → re-parse → compare.
77
// Catches serialization bugs where to_be_bytes() produces output that
88
// doesn't re-parse to the same structure.
9+
//
10+
// Uses a persistent PARSER to accumulate template state across iterations
11+
// (exercising caching/collision paths), but re-parses with a FRESH parser
12+
// each time to ensure the serialized output is self-contained.
913
thread_local! {
1014
static PARSER: std::cell::RefCell<NetflowParser> =
1115
std::cell::RefCell::new(NetflowParser::default());
12-
static REPARSE: std::cell::RefCell<NetflowParser> =
13-
std::cell::RefCell::new(NetflowParser::default());
1416
}
1517

1618
fuzz_target!(|data: &[u8]| {
@@ -33,34 +35,40 @@ fuzz_target!(|data: &[u8]| {
3335
_ => continue,
3436
};
3537

36-
// Re-parse the serialized output and verify it produces a valid packet
37-
REPARSE.with(|rp| {
38-
let mut reparser = rp.borrow_mut();
39-
let re_result = reparser.parse_bytes(&serialized);
40-
41-
// Serialized output of a successful parse must re-parse without error
42-
assert!(
43-
re_result.error.is_none(),
44-
"Round-trip failed: serialized output of {:?} did not re-parse cleanly: {:?}",
45-
std::mem::discriminant(packet),
46-
re_result.error,
47-
);
38+
// Re-parse with a fresh parser to ensure serialized output is
39+
// self-contained (not relying on accumulated template state).
40+
let mut reparser = NetflowParser::default();
41+
let re_result = reparser.parse_bytes(&serialized);
4842

49-
// Must produce at least one packet
50-
assert!(
51-
!re_result.packets.is_empty(),
52-
"Round-trip failed: serialized output produced no packets",
53-
);
43+
// Serialized output of a successful parse must re-parse without error
44+
assert!(
45+
re_result.error.is_none(),
46+
"Round-trip failed: serialized output of {:?} did not re-parse cleanly: {:?}",
47+
std::mem::discriminant(packet),
48+
re_result.error,
49+
);
5450

55-
// Verify the re-parsed packet is the same variant
56-
if let Some(re_packet) = re_result.packets.first() {
57-
assert_eq!(
58-
std::mem::discriminant(packet),
59-
std::mem::discriminant(re_packet),
60-
"Round-trip changed packet type",
51+
// Must produce at least one packet (V5/V7 always, V9/IPFIX
52+
// may produce template-only packets with no data flowsets).
53+
// For V9/IPFIX template-only packets, an empty result is acceptable.
54+
match packet {
55+
NetflowPacket::V5(_) | NetflowPacket::V7(_) => {
56+
assert!(
57+
!re_result.packets.is_empty(),
58+
"Round-trip failed: serialized V5/V7 output produced no packets",
6159
);
6260
}
63-
});
61+
_ => {}
62+
}
63+
64+
// Verify the re-parsed packet is the same variant (when present)
65+
if let Some(re_packet) = re_result.packets.first() {
66+
assert_eq!(
67+
std::mem::discriminant(packet),
68+
std::mem::discriminant(re_packet),
69+
"Round-trip changed packet type",
70+
);
71+
}
6472
}
6573
});
6674
});

src/template_events.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ impl TemplateHooks {
190190
self.hooks.push(Arc::new(hook));
191191
}
192192

193+
/// Removes all registered hooks.
194+
pub fn clear(&mut self) {
195+
self.hooks.clear();
196+
}
197+
193198
/// Triggers all registered hooks with the given event.
194199
///
195200
/// All hooks are called regardless of whether earlier hooks return errors or panic.

src/variable_versions/field_value.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use serde::ser::Serializer;
1616
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
1717

1818
macro_rules! impl_try_from {
19-
($($t:ty => $v:ident),*; $($s:ty => $sv:ident),*) => {
19+
($($t:ty => $v:ident),*) => {
2020
$(
2121
impl TryFrom<&DataNumber> for $t {
2222
type Error = DataNumberError;
@@ -159,7 +159,7 @@ impl_try_from!(
159159
u64 => U64,
160160
i64 => I64,
161161
u128 => U128,
162-
i128 => I128;
162+
i128 => I128
163163
);
164164

165165
// Manual TryFrom for u32/i32 to also handle U24/I24 variants

src/variable_versions/ipfix/lookup.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ pub enum YafIPFixField {
240240
DnsSRVPriority = 216 => FieldDataType::UnsignedDataNumber,
241241
DnsSRVWeight = 217 => FieldDataType::UnsignedDataNumber,
242242
DnsSRVPort = 218 => FieldDataType::UnsignedDataNumber,
243-
DnsSRVTarget = 219 => FieldDataType::UnsignedDataNumber,
243+
DnsSRVTarget = 219 => FieldDataType::String,
244244
TcpUrgTotalCount = 223 => FieldDataType::UnsignedDataNumber,
245245
DnsID = 226 => FieldDataType::UnsignedDataNumber,
246246
SslCertSerialNumber = 244 => FieldDataType::String,
@@ -249,7 +249,7 @@ pub enum YafIPFixField {
249249
SslCertValidityNotBefore = 247 => FieldDataType::String,
250250
SslCertValidityNotAfter = 248 => FieldDataType::String,
251251
SslPublicKeyAlgorithm = 249 => FieldDataType::String,
252-
SslPublicKeyLength = 250 => FieldDataType::String,
252+
SslPublicKeyLength = 250 => FieldDataType::UnsignedDataNumber,
253253
RtpPayloadType = 287 => FieldDataType::UnsignedDataNumber,
254254
ReverseRtpPayloadType = 288 => FieldDataType::UnsignedDataNumber,
255255
MptcpInitialDataSequenceNumber = 289 => FieldDataType::UnsignedDataNumber,

src/variable_versions/ipfix/parser.rs

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ impl ParserConfig for IPFixParser {
140140
}
141141
}
142142
None => {
143+
// Record all cached entries as dropped before discarding.
144+
if let Some(ref cache) = self.pending_flows {
145+
let count = cache.count();
146+
if count > 0 {
147+
self.metrics.record_pending_dropped_n(count as u64);
148+
}
149+
}
143150
self.pending_flows = None;
144151
}
145152
}
@@ -284,6 +291,14 @@ impl IPFixParser {
284291
let entries = cache.drain(template_id, &mut self.metrics);
285292
let total_entries = entries.len();
286293
for (processed, entry) in entries.iter().enumerate() {
294+
// Bound flowset count, consistent with V9 replay.
295+
if ipfix.flowsets.len() >= u16::MAX as usize {
296+
let remaining = (total_entries - processed) as u64;
297+
for _ in 0..remaining {
298+
self.metrics.record_pending_replay_failed();
299+
}
300+
break;
301+
}
287302
let flowset_length =
288303
u16::try_from(entry.raw_data.len().saturating_add(4)).unwrap_or(u16::MAX);
289304
let Some(new_header_length) = ipfix.header.length.checked_add(flowset_length)
@@ -550,29 +565,79 @@ impl IPFixParser {
550565
);
551566
}
552567

553-
/// Remove an IPFIX template by ID (RFC 7011 Section 3.4.3 template withdrawal).
568+
/// Remove an IPFIX template by ID (RFC 7011 Section 8.1 template withdrawal).
554569
/// Also purges any pending flows cached under this template ID to prevent
555570
/// stale data from being replayed against a replacement template.
571+
///
572+
/// Per RFC 7011 §8.1, template_id == DATA_TEMPLATE_IPFIX_ID (2) with
573+
/// field_count == 0 signals "withdraw ALL data templates". This method
574+
/// handles both individual and bulk withdrawal.
556575
fn withdraw_ipfix_template(&mut self, template_id: u16) {
557-
self.templates.pop(&template_id);
558-
if let Some(ref mut cache) = self.pending_flows {
559-
let drained = cache.drain(template_id, &mut self.metrics);
560-
let n = drained.len() as u64;
561-
if n > 0 {
562-
self.metrics.record_pending_dropped_n(n);
576+
if template_id == DATA_TEMPLATE_IPFIX_ID {
577+
// "Withdraw all data templates" — clear entire data template
578+
// cache and drain pending flows only for those template IDs.
579+
// Pending flows for options template IDs are left untouched
580+
// since those templates remain valid.
581+
let ids: Vec<u16> = self.templates.iter().map(|(&id, _)| id).collect();
582+
for id in &ids {
583+
self.templates.pop(id);
584+
}
585+
if let Some(ref mut cache) = self.pending_flows {
586+
for &id in &ids {
587+
let drained = cache.drain(id, &mut self.metrics);
588+
let n = drained.len() as u64;
589+
if n > 0 {
590+
self.metrics.record_pending_dropped_n(n);
591+
}
592+
}
593+
}
594+
} else {
595+
self.templates.pop(&template_id);
596+
if let Some(ref mut cache) = self.pending_flows {
597+
let drained = cache.drain(template_id, &mut self.metrics);
598+
let n = drained.len() as u64;
599+
if n > 0 {
600+
self.metrics.record_pending_dropped_n(n);
601+
}
563602
}
564603
}
565604
}
566605

567606
/// Remove an IPFIX options template by ID (template withdrawal).
568607
/// Also purges any pending flows cached under this template ID.
608+
///
609+
/// Per RFC 7011 §8.1, template_id == OPTIONS_TEMPLATE_IPFIX_ID (3) with
610+
/// field_count == 0 signals "withdraw ALL options templates".
569611
fn withdraw_ipfix_options_template(&mut self, template_id: u16) {
570-
self.ipfix_options_templates.pop(&template_id);
571-
if let Some(ref mut cache) = self.pending_flows {
572-
let drained = cache.drain(template_id, &mut self.metrics);
573-
let n = drained.len() as u64;
574-
if n > 0 {
575-
self.metrics.record_pending_dropped_n(n);
612+
if template_id == OPTIONS_TEMPLATE_IPFIX_ID {
613+
// "Withdraw all options templates" — clear entire options template
614+
// cache and drain pending flows only for those template IDs.
615+
// Pending flows for data template IDs are left untouched.
616+
let ids: Vec<u16> = self
617+
.ipfix_options_templates
618+
.iter()
619+
.map(|(&id, _)| id)
620+
.collect();
621+
for id in &ids {
622+
self.ipfix_options_templates.pop(id);
623+
}
624+
if let Some(ref mut cache) = self.pending_flows {
625+
for &id in &ids {
626+
let drained = cache.drain(id, &mut self.metrics);
627+
let n = drained.len() as u64;
628+
if n > 0 {
629+
self.metrics.record_pending_dropped_n(n);
630+
}
631+
}
632+
}
633+
} else {
634+
self.ipfix_options_templates.pop(&template_id);
635+
if let Some(ref mut cache) = self.pending_flows {
636+
let drained = cache.drain(template_id, &mut self.metrics);
637+
let n = drained.len() as u64;
638+
if n > 0 {
639+
self.metrics.record_pending_dropped_n(n);
640+
}
576641
}
577642
}
578643
}
@@ -596,13 +661,26 @@ impl FlowSetBody {
596661
{
597662
let (i, templates) = many0(complete(parse_fn))(i)?;
598663

599-
// Handle template withdrawals (RFC 7011 Section 3.4.3):
664+
// Handle template withdrawals (RFC 7011 Section 8.1):
600665
// Templates with field_count=0 signal withdrawal from the cache.
666+
// Skip withdrawal for IDs that also have a new definition in the
667+
// same flowset — the new definition will simply replace the old one
668+
// without needlessly draining pending flows.
601669
let mut had_withdrawals = false;
602670
if let Some(withdraw_fn) = withdraw_template {
603671
for t in &templates {
604672
if t.field_count() == 0 {
605-
withdraw_fn(parser, t.template_id());
673+
let id = t.template_id();
674+
// "Withdraw all" IDs (2 for data, 3 for options) always
675+
// take effect regardless of other templates in the batch.
676+
let has_redefinition = id != DATA_TEMPLATE_IPFIX_ID
677+
&& id != OPTIONS_TEMPLATE_IPFIX_ID
678+
&& templates
679+
.iter()
680+
.any(|other| other.template_id() == id && other.field_count() > 0);
681+
if !has_redefinition {
682+
withdraw_fn(parser, id);
683+
}
606684
had_withdrawals = true;
607685
}
608686
}
@@ -696,10 +774,11 @@ impl FlowSetBody {
696774
&& usize::from(t.get_total_size()) <= p.max_template_total_size
697775
&& !t.has_duplicate_scope_fields()
698776
&& !t.has_duplicate_option_fields()
699-
// Reject templates where all fields have zero length to prevent
700-
// zero-byte-per-record parsing issues
701-
&& (t.scope_fields.iter().any(|f| f.field_length > 0)
702-
|| t.option_fields.iter().any(|f| f.field_length > 0))
777+
// V9 does not support variable-length fields; reject any
778+
// zero-length or variable-length sentinel (65535) fields,
779+
// consistent with the V9 parser's own validation.
780+
&& t.scope_fields.iter().all(|f| f.field_length > 0 && f.field_length != 65535)
781+
&& t.option_fields.iter().all(|f| f.field_length > 0 && f.field_length != 65535)
703782
},
704783
|parser, templates| parser.add_v9_options_templates(templates),
705784
None, // V9 doesn't support template withdrawal

src/variable_versions/pending_flows.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ impl PendingFlowCache {
119119
if data_len > self.config.max_entry_size_bytes {
120120
return false;
121121
}
122-
// Reject if adding this entry would exceed the total byte budget
123-
if self.total_bytes.saturating_add(data_len) > self.config.max_total_bytes {
122+
// Check total byte budget, but allow if eviction could make room.
123+
// Only reject if the single entry alone exceeds the total budget
124+
// (cache() evicts LRU templates to free space when over budget).
125+
if data_len > self.config.max_total_bytes {
124126
return false;
125127
}
126128
match self.cache.peek(&template_id) {

0 commit comments

Comments
 (0)