Skip to content

Commit 43afc21

Browse files
fix: deep code review passes 19-20 - API hardening, test coverage, an… (#271)
* fix: deep code review passes 19-20 - API hardening, test coverage, and correctness fixes Pass 19: - clear_v9_templates/clear_ipfix_templates now also clear pending flows - V9/IPFIX template validation rejects field_length=0 - Deduplicate validate_config (delegate to Config::validate) - Strengthen 5 malformed-input test assertions - Remove 7 vacuous initial-state tests - Add round-trip tests: DurationMicrosNTP, DurationNanosNTP, U128, I128, U24, enterprise fields, multi-template flowset - Add concurrent V9 template test and template collision data verification Pass 20: - Add #[non_exhaustive] to 9 public types (NetflowError, ConfigError, FieldValue, Config, PendingFlowsConfig, CacheStats, ParserCacheStats, CacheMetricsSnapshot, NoTemplateInfo) - Remove dead MissingTemplate error variant - Add root re-exports: FieldValue, DataNumber, FieldDataType, V9Field, V9FieldPair, V9FlowRecord - InvalidErrorSampleSize/InvalidMaxSources now carry the rejected value - CacheMetrics mutation methods scoped to pub(crate), dead reset() removed - resize() now validates full PendingFlowsConfig - README: fix error type list (removed MissingTemplate, added FilteredVersion), fix URP->UDP typo * fix: fmt
1 parent 7b63b52 commit 43afc21

17 files changed

Lines changed: 428 additions & 231 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ for result in parser.iter_packets(&buffer) {
489489
}
490490
```
491491

492-
**Error types**: `Incomplete`, `UnsupportedVersion`, `Partial`, `MissingTemplate`, `ParseError`. All implement `Display` and `std::error::Error`.
492+
**Error types**: `Incomplete`, `UnsupportedVersion`, `FilteredVersion`, `ParseError`, `Partial`. All implement `Display` and `std::error::Error`.
493493

494494
#### Error Sample Size Configuration
495495

@@ -1173,7 +1173,7 @@ This library includes several performance optimizations:
11731173

11741174
Examples have been included mainly for those who want to use this parser to read from a Socket and parse netflow. In those cases with V9/IPFix it is best to create a new parser for each router. There are both single threaded and multi-threaded examples in the examples directory.
11751175

1176-
Examples that listen on a specific port use 9995 by default, however netflow can be configured to use a variety of URP ports:
1176+
Examples that listen on a specific port use 9995 by default, however netflow can be configured to use a variety of UDP ports:
11771177
* **2055**: The most widely recognized default for NetFlow.
11781178
* **9995 / 9996**: Popular alternatives, especially with Cisco devices.
11791179
* **9025, 9026**: Other recognized port options.

src/lib.rs

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ pub use variable_versions::{
4949
pub use variable_versions::ipfix::lookup::IpfixField;
5050
pub use variable_versions::ipfix::{Ipfix, IpfixFieldPair, IpfixFlowRecord, IpfixParser};
5151

52+
// Re-export commonly used field/data types
53+
pub use variable_versions::field_value::{DataNumber, FieldDataType, FieldValue};
54+
pub use variable_versions::v9::lookup::V9Field;
55+
pub use variable_versions::v9::{V9FieldPair, V9FlowRecord};
56+
5257
/// Enum of supported Netflow Versions
5358
#[derive(Debug, PartialEq, Clone, Serialize)]
5459
pub enum NetflowPacket {
@@ -233,6 +238,7 @@ pub struct NetflowParser {
233238
}
234239

235240
/// Statistics about template cache utilization.
241+
#[non_exhaustive]
236242
#[derive(Debug, Clone)]
237243
pub struct CacheStats {
238244
/// Current number of cached templates (summed across all internal caches).
@@ -260,6 +266,7 @@ pub struct CacheStats {
260266
///
261267
/// This struct provides named fields instead of positional tuples,
262268
/// making it clear which stats belong to V9 vs IPFIX.
269+
#[non_exhaustive]
263270
#[derive(Debug, Clone)]
264271
pub struct ParserCacheStats {
265272
/// V9 template cache statistics
@@ -891,6 +898,7 @@ pub(crate) enum ParsedNetflow<'a> {
891898
///
892899
/// Provides rich context about parsing failures including offset, error kind,
893900
/// and relevant data for debugging.
901+
#[non_exhaustive]
894902
#[derive(Debug, PartialEq, Clone, Serialize)]
895903
pub enum NetflowError {
896904
/// Incomplete data - more bytes needed to parse a complete packet.
@@ -925,22 +933,6 @@ pub enum NetflowError {
925933
version: u16,
926934
},
927935

928-
/// Template definition is required but not found in cache.
929-
///
930-
/// For V9 and IPFIX, data packets reference template IDs that must be
931-
/// learned from template packets. This error occurs when data arrives
932-
/// before (or without) its corresponding template.
933-
MissingTemplate {
934-
/// The template ID that was not found
935-
template_id: u16,
936-
/// The protocol (V9 or IPFIX)
937-
protocol: TemplateProtocol,
938-
/// List of currently cached template IDs for this protocol
939-
available_templates: Vec<u16>,
940-
/// Raw packet data that couldn't be parsed
941-
raw_data: Vec<u8>,
942-
},
943-
944936
/// Parsing error with detailed context.
945937
///
946938
/// Generic parsing failure with information about what failed and where.
@@ -991,18 +983,6 @@ impl std::fmt::Display for NetflowError {
991983
version
992984
)
993985
}
994-
NetflowError::MissingTemplate {
995-
template_id,
996-
protocol,
997-
available_templates,
998-
..
999-
} => {
1000-
write!(
1001-
f,
1002-
"Missing template {} for {:?} (available: {:?})",
1003-
template_id, protocol, available_templates
1004-
)
1005-
}
1006986
NetflowError::ParseError {
1007987
offset,
1008988
context,
@@ -1380,6 +1360,7 @@ impl NetflowParser {
13801360
pub fn clear_v9_templates(&mut self) {
13811361
self.v9_parser.templates.clear();
13821362
self.v9_parser.options_templates.clear();
1363+
self.v9_parser.clear_pending_flows();
13831364
}
13841365

13851366
/// Clears all cached IPFIX templates.
@@ -1399,6 +1380,7 @@ impl NetflowParser {
13991380
self.ipfix_parser.v9_templates.clear();
14001381
self.ipfix_parser.ipfix_options_templates.clear();
14011382
self.ipfix_parser.v9_options_templates.clear();
1383+
self.ipfix_parser.clear_pending_flows();
14021384
}
14031385

14041386
/// Clears all pending V9 flows.

src/scoped_parser.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ impl<K: Hash + Eq> RouterScopedParser<K> {
145145
/// Returns `ConfigError::InvalidMaxSources` if `max` is 0.
146146
pub fn with_max_sources(mut self, max: usize) -> Result<Self, ConfigError> {
147147
if max == 0 {
148-
return Err(ConfigError::InvalidMaxSources);
148+
return Err(ConfigError::InvalidMaxSources(0));
149149
}
150150
self.max_sources = max;
151151
self.parsers
@@ -598,7 +598,7 @@ impl AutoScopedParser {
598598
/// Returns `ConfigError::InvalidMaxSources` if `max` is 0.
599599
pub fn with_max_sources(mut self, max: usize) -> Result<Self, ConfigError> {
600600
if max == 0 {
601-
return Err(ConfigError::InvalidMaxSources);
601+
return Err(ConfigError::InvalidMaxSources(0));
602602
}
603603
self.max_sources = max;
604604
// Size each per-protocol LRU cache to max. The global cross-cache

src/tests.rs

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,10 @@ mod base_tests {
276276

277277
let packet = hex::decode(hex).unwrap();
278278
let result = parser.parse_bytes(&packet);
279-
// The parser should either produce packets or an error, but never both be empty
280-
// (at minimum it recognizes the IPFIX version and attempts parsing).
281-
let handled = !result.packets.is_empty() || result.error.is_some();
279+
// Partially malformed IPFIX — parser must not silently skip; it should
280+
// produce at least one packet or report an error.
282281
assert!(
283-
handled,
282+
!result.packets.is_empty() || result.error.is_some(),
284283
"parser should produce packets or an error, not silently skip"
285284
);
286285
for p in &result.packets {
@@ -323,9 +322,12 @@ mod base_tests {
323322

324323
let packet = hex::decode(hex).unwrap();
325324
let result = parser.parse_bytes(&packet);
326-
// Parser should produce packets or an error, not silently skip the entire input.
327-
let handled = !result.packets.is_empty() || result.error.is_some();
328-
assert!(handled, "parser should produce packets or an error");
325+
// Partially malformed V9 — parser must not silently skip; it should
326+
// produce at least one packet or report an error.
327+
assert!(
328+
!result.packets.is_empty() || result.error.is_some(),
329+
"parser should produce packets or an error, not silently skip"
330+
);
329331
for p in &result.packets {
330332
assert!(p.is_v9(), "all parsed packets should be V9");
331333
}
@@ -849,8 +851,8 @@ mod malformed_packet_tests {
849851
packet.extend_from_slice(&[0u8; 8]);
850852
let result = parser.parse_bytes(&packet);
851853
assert!(
852-
result.packets.is_empty() || result.error.is_some(),
853-
"truncated V9 header should not produce valid packets without an error"
854+
result.error.is_some(),
855+
"truncated V9 header must produce an error"
854856
);
855857
}
856858

@@ -871,10 +873,10 @@ mod malformed_packet_tests {
871873
// Flowset with id=0 (template), length=0 (malformed)
872874
packet.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
873875
let result = parser.parse_bytes(&packet);
874-
// Must not infinite loop or panic; zero-length flowset should produce no valid packets or an error
876+
// Must not infinite loop or panic; zero-length flowset must produce an error
875877
assert!(
876-
result.packets.is_empty() || result.error.is_some(),
877-
"V9 with zero-length flowset should produce no valid packets or an error"
878+
result.error.is_some(),
879+
"V9 with zero-length flowset must produce an error"
878880
);
879881
}
880882

@@ -895,8 +897,8 @@ mod malformed_packet_tests {
895897
// Only 4 bytes of flowset data actually present
896898
let result = parser.parse_bytes(&packet);
897899
assert!(
898-
result.packets.is_empty() || result.error.is_some(),
899-
"V9 with flowset length exceeding packet should produce no valid packets or an error"
900+
result.error.is_some(),
901+
"V9 with flowset length exceeding packet must produce an error"
900902
);
901903
}
902904

@@ -916,8 +918,8 @@ mod malformed_packet_tests {
916918
let result = parser.parse_bytes(&packet);
917919
// Parser should handle the length mismatch without panicking
918920
assert!(
919-
result.packets.is_empty() || result.error.is_some(),
920-
"IPFIX with wrong length field should produce no valid packets or an error"
921+
result.error.is_some(),
922+
"IPFIX with wrong length field must produce an error"
921923
);
922924
}
923925

src/variable_versions/config.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pub const DEFAULT_MAX_RECORDS_PER_FLOWSET: usize = 1024;
2626
/// Controls template cache size, field limits, TTL, enterprise field definitions,
2727
/// and pending flow caching. Use [`Config::new`] for defaults or construct directly
2828
/// for full control.
29+
#[non_exhaustive]
2930
#[derive(Debug, Clone)]
3031
pub struct Config {
3132
/// Maximum number of templates to keep in the LRU cache.
@@ -54,6 +55,7 @@ pub struct Config {
5455
pub pending_flows_config: Option<PendingFlowsConfig>,
5556
}
5657

58+
#[non_exhaustive]
5759
#[derive(Debug, Clone, PartialEq, Eq)]
5860
pub enum ConfigError {
5961
/// Template cache size must be greater than 0
@@ -82,9 +84,9 @@ pub enum ConfigError {
8284
max_entry_size_bytes: usize,
8385
},
8486
/// max_sources must be greater than 0
85-
InvalidMaxSources,
87+
InvalidMaxSources(usize),
8688
/// max_error_sample_size must be greater than 0
87-
InvalidErrorSampleSize,
89+
InvalidErrorSampleSize(usize),
8890
}
8991

9092
impl std::error::Error for ConfigError {}
@@ -167,11 +169,15 @@ impl std::fmt::Display for ConfigError {
167169
max_total_bytes, max_entry_size_bytes
168170
)
169171
}
170-
ConfigError::InvalidMaxSources => {
171-
write!(f, "Invalid max_sources: must be greater than 0.")
172+
ConfigError::InvalidMaxSources(size) => {
173+
write!(f, "Invalid max_sources: {}. Must be greater than 0.", size)
172174
}
173-
ConfigError::InvalidErrorSampleSize => {
174-
write!(f, "Invalid max_error_sample_size: must be greater than 0.")
175+
ConfigError::InvalidErrorSampleSize(size) => {
176+
write!(
177+
f,
178+
"Invalid max_error_sample_size: {}. Must be greater than 0.",
179+
size
180+
)
175181
}
176182
}
177183
}
@@ -233,7 +239,7 @@ impl Config {
233239
return Err(ConfigError::InvalidRecordsPerFlowset(0));
234240
}
235241
if self.max_error_sample_size == 0 {
236-
return Err(ConfigError::InvalidErrorSampleSize);
242+
return Err(ConfigError::InvalidErrorSampleSize(0));
237243
}
238244
if let Some(ref ttl) = self.ttl_config
239245
&& ttl.duration.is_zero()
@@ -286,7 +292,7 @@ pub trait ParserConfig: ParserFields {
286292
return Err(ConfigError::InvalidRecordsPerFlowset(0));
287293
}
288294
if config.max_error_sample_size == 0 {
289-
return Err(ConfigError::InvalidErrorSampleSize);
295+
return Err(ConfigError::InvalidErrorSampleSize(0));
290296
}
291297
if let Some(ref ttl) = config.ttl_config
292298
&& ttl.duration.is_zero()

src/variable_versions/field_value.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ impl Serialize for StringValue {
467467
}
468468

469469
/// Holds the post parsed field with its relevant datatype
470+
#[non_exhaustive]
470471
#[derive(Debug, PartialEq, PartialOrd, Clone)]
471472
pub enum FieldValue {
472473
ApplicationId(ApplicationId),

src/variable_versions/ipfix/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,8 +353,9 @@ pub(crate) trait CommonTemplate {
353353
{
354354
return false;
355355
}
356-
// Check fields are not empty and have at least one non-zero length field
357-
if self.get_fields().is_empty() || !self.get_fields().iter().any(|f| f.field_length > 0)
356+
// Check fields are not empty and no field has zero length
357+
// (field_length=65535 is the RFC 7011 variable-length sentinel, which is valid)
358+
if self.get_fields().is_empty() || self.get_fields().iter().any(|f| f.field_length == 0)
358359
{
359360
return false;
360361
}

src/variable_versions/ipfix/parser.rs

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -58,30 +58,7 @@ impl Default for IPFixParser {
5858
impl IPFixParser {
5959
/// Validates a configuration without allocating parser internals.
6060
pub fn validate_config(config: &Config) -> Result<(), ConfigError> {
61-
NonZeroUsize::new(config.max_template_cache_size).ok_or(
62-
ConfigError::InvalidCacheSize(config.max_template_cache_size),
63-
)?;
64-
if config.max_field_count == 0 {
65-
return Err(ConfigError::InvalidFieldCount(0));
66-
}
67-
if config.max_template_total_size == 0 {
68-
return Err(ConfigError::InvalidTemplateTotalSize(0));
69-
}
70-
if config.max_records_per_flowset == 0 {
71-
return Err(ConfigError::InvalidRecordsPerFlowset(0));
72-
}
73-
if config.max_error_sample_size == 0 {
74-
return Err(ConfigError::InvalidErrorSampleSize);
75-
}
76-
if let Some(ref ttl) = config.ttl_config
77-
&& ttl.duration.is_zero()
78-
{
79-
return Err(ConfigError::InvalidTtlDuration);
80-
}
81-
if let Some(ref pf) = config.pending_flows_config {
82-
PendingFlowCache::validate_config(pf)?;
83-
}
84-
Ok(())
61+
config.validate()
8562
}
8663

8764
/// Create a new IPFixParser with a custom template cache size and optional TTL configuration.

0 commit comments

Comments
 (0)