From 8614c7c9403ebcb6e0740a91f57d671f3a8c6cec Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 22 Dec 2025 19:23:45 -0800 Subject: [PATCH 1/2] feat: add negative filter support with `!` prefix Add support for negative (exclusion) filters across the library and CLI. Filters can now be negated by prefixing the filter type with `!` to match elements that do NOT satisfy the filter condition. Library API: - Add `Filter::Negated(Box)` variant to wrap any filter for negation - `Filter::new()` parses `!` prefix to create negated filters - Example: `.add_filter("!origin_asn", "13335")` excludes AS 13335 CLI: - Add `--filter` / `-f` option for generic filter expressions - Supports `key=value` (positive) and `key!=value` (negative) syntax - Can be used multiple times to combine filters - Example: `bgpkit-parser --filter "origin_asn!=13335" file.mrt` All existing filter types support negation: - origin_asn, prefix, peer_ip, peer_ips, peer_asn - type, as_path, community, ip_version Includes comprehensive tests for negative filters on both unit level and with actual MRT file data. --- CHANGELOG.md | 11 ++ README.md | 30 +++++ src/bin/main.rs | 58 ++++++++++ src/lib.rs | 30 +++++ src/parser/filter.rs | 270 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 398 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50715a0f..ee8dbd54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file. ### New features +* **Negative Filter Support**: All filters now support negation by prefixing the filter type with `!` + - `!origin_asn`: Match elements where origin AS is NOT the specified value + - `!prefix`: Match elements where prefix is NOT the specified value + - `!peer_ip`, `!peer_asn`, `!type`, `!as_path`, `!community`, `!ip_version`: All support negation + - Example: `.add_filter("!origin_asn", "13335")` matches all elements NOT from AS 13335 + - New `Filter::Negated(Box)` variant wraps any filter to invert its match result + - **CLI**: New `--filter` / `-f` option supports both positive and negative filter expressions + - Positive: `--filter "origin_asn=13335"` + - Negative: `--filter "origin_asn!=13335"` + - Can be used multiple times: `--filter "peer_ip!=192.168.1.1" --filter "type!=w"` + * **RPKI RTR Protocol Support**: Add full support for the RPKI-to-Router (RTR) protocol - New `models::rpki::rtr` module with all PDU types: SerialNotify, SerialQuery, ResetQuery, CacheResponse, IPv4Prefix, IPv6Prefix, EndOfData, CacheReset, RouterKey, ErrorReport - New `parser::rpki::rtr` module with parsing (`parse_rtr_pdu`, `read_rtr_pdu`) and encoding (`RtrEncode` trait) diff --git a/README.md b/README.md index 90b7aadc..89583c79 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,23 @@ for elem in parser { - `elem_type`: Filter by announcement (`a`) or withdrawal (`w`) - `as_path`: Match AS path with regex +**Negative filters**: All filters support negation by prefixing the filter type with `!`. For example: +- `!origin_asn`: Match elements where origin AS is NOT the specified value +- `!prefix`: Match elements where prefix is NOT the specified value +- `!peer_ip`: Match elements where peer IP is NOT the specified value + +```rust +use bgpkit_parser::BgpkitParser; + +// Filter out all elements from AS 13335 (get everything EXCEPT AS 13335) +let parser = BgpkitParser::new("http://archive.routeviews.org/bgpdata/2021.10/UPDATES/updates.20211001.0000.bz2").unwrap() + .add_filter("!origin_asn", "13335").unwrap(); + +for elem in parser { + println!("{}", elem); +} +``` + #### Parsing Multiple MRT Files with BGPKIT Broker [BGPKIT Broker][broker-repo] library provides search API for all RouteViews and RIPE RIS MRT data files. Using the @@ -453,6 +470,7 @@ Options: -e, --elems-count Count BGP elems -r, --records-count Count MRT records -o, --origin-asn Filter by origin AS Number + -f, --filter Generic filter expression (key=value or key!=value) -p, --prefix Filter by network prefix -4, --ipv4-only Filter by IPv4 only -6, --ipv6-only Filter by IPv6 only @@ -507,6 +525,18 @@ bgpkit-parser -c ~/.bgpkit-cache http://example.com/updates.mrt.bz2 bgpkit-parser -o 13335 -m a -4 updates.bz2 ``` +#### Negative filters (exclude matching elements) +```bash +# Exclude elements from AS 13335 +bgpkit-parser --filter "origin_asn!=13335" updates.bz2 + +# Exclude a specific peer +bgpkit-parser --filter "peer_ip!=192.168.1.1" updates.bz2 + +# Combine positive and negative filters +bgpkit-parser -o 13335 --filter "peer_asn!=64496" updates.bz2 +``` + ## Data Representation BGPKIT Parser provides three ways to access parsed BGP data: [MrtRecord], [MrtUpdate], and [BgpElem]. Choose based on your needs: diff --git a/src/bin/main.rs b/src/bin/main.rs index 193b5f4d..03289981 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -82,6 +82,13 @@ struct Filters { #[clap(short = 'o', long)] origin_asn: Option, + /// Generic filter expression (can be used multiple times) + /// Format: "key=value" for positive match, "key!=value" for negative match + /// Examples: --filter "origin_asn!=13335" --filter "peer_ip!=192.168.1.1" + /// Supported keys: origin_asn, prefix, peer_ip, peer_ips, peer_asn, type, as_path, community, ip_version + #[clap(short = 'f', long = "filter")] + filters: Vec, + /// Filter by network prefix #[clap(short = 'p', long)] prefix: Option, @@ -194,6 +201,25 @@ fn main() { parser = parser.add_filter("end_ts", v.to_string().as_str()).unwrap(); } + // Process generic filter expressions + for filter_expr in &opts.filters.filters { + match parse_filter_expression(filter_expr) { + Ok((filter_type, filter_value)) => { + parser = match parser.add_filter(&filter_type, &filter_value) { + Ok(p) => p, + Err(e) => { + eprintln!("Error adding filter '{}': {}", filter_expr, e); + std::process::exit(1); + } + }; + } + Err(e) => { + eprintln!("Invalid filter expression '{}': {}", filter_expr, e); + std::process::exit(1); + } + } + } + match (opts.filters.ipv4_only, opts.filters.ipv6_only) { (true, true) => { eprintln!("Error: --ipv4-only and --ipv6-only cannot be used together"); @@ -306,3 +332,35 @@ fn format_record(record: &bgpkit_parser::MrtRecord, format: OutputFormat) -> Str } } } + +/// Parse a filter expression in the format "key=value" or "key!=value" +/// Returns (filter_type, filter_value) where filter_type may be prefixed with "!" for negation +fn parse_filter_expression(expr: &str) -> Result<(String, String), String> { + // Check for "!=" (negative filter) first + if let Some(pos) = expr.find("!=") { + let key = expr[..pos].trim(); + let value = expr[pos + 2..].trim(); + if key.is_empty() { + return Err("filter key cannot be empty".to_string()); + } + if value.is_empty() { + return Err("filter value cannot be empty".to_string()); + } + // Prefix with "!" to indicate negation + Ok((format!("!{}", key), value.to_string())) + } + // Check for "=" (positive filter) + else if let Some(pos) = expr.find('=') { + let key = expr[..pos].trim(); + let value = expr[pos + 1..].trim(); + if key.is_empty() { + return Err("filter key cannot be empty".to_string()); + } + if value.is_empty() { + return Err("filter value cannot be empty".to_string()); + } + Ok((key.to_string(), value.to_string())) + } else { + Err("filter expression must contain '=' or '!=' (e.g., 'origin_asn=13335' or 'origin_asn!=13335')".to_string()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 4753265d..177fc0c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,6 +91,23 @@ for elem in parser { - `elem_type`: Filter by announcement (`a`) or withdrawal (`w`) - `as_path`: Match AS path with regex +**Negative filters**: All filters support negation by prefixing the filter type with `!`. For example: +- `!origin_asn`: Match elements where origin AS is NOT the specified value +- `!prefix`: Match elements where prefix is NOT the specified value +- `!peer_ip`: Match elements where peer IP is NOT the specified value + +```no_run +use bgpkit_parser::BgpkitParser; + +// Filter out all elements from AS 13335 (get everything EXCEPT AS 13335) +let parser = BgpkitParser::new("http://archive.routeviews.org/bgpdata/2021.10/UPDATES/updates.20211001.0000.bz2").unwrap() + .add_filter("!origin_asn", "13335").unwrap(); + +for elem in parser { + println!("{}", elem); +} +``` + ### Parsing Multiple MRT Files with BGPKIT Broker [BGPKIT Broker][broker-repo] library provides search API for all RouteViews and RIPE RIS MRT data files. Using the @@ -449,6 +466,7 @@ Options: -e, --elems-count Count BGP elems -r, --records-count Count MRT records -o, --origin-asn Filter by origin AS Number + -f, --filter Generic filter expression (key=value or key!=value) -p, --prefix Filter by network prefix -4, --ipv4-only Filter by IPv4 only -6, --ipv6-only Filter by IPv6 only @@ -503,6 +521,18 @@ bgpkit-parser -c ~/.bgpkit-cache http://example.com/updates.mrt.bz2 bgpkit-parser -o 13335 -m a -4 updates.bz2 ``` +### Negative filters (exclude matching elements) +```bash +# Exclude elements from AS 13335 +bgpkit-parser --filter "origin_asn!=13335" updates.bz2 + +# Exclude a specific peer +bgpkit-parser --filter "peer_ip!=192.168.1.1" updates.bz2 + +# Combine positive and negative filters +bgpkit-parser -o 13335 --filter "peer_asn!=64496" updates.bz2 +``` + # Data Representation BGPKIT Parser provides three ways to access parsed BGP data: [MrtRecord], [MrtUpdate], and [BgpElem]. Choose based on your needs: diff --git a/src/parser/filter.rs b/src/parser/filter.rs index efa5971f..794417be 100644 --- a/src/parser/filter.rs +++ b/src/parser/filter.rs @@ -15,6 +15,13 @@ The available filters are: - `as_path` -- regular expression for AS path string - `ip_version` -- IP version (`ipv4` or `ipv6`) +### Negative Filters + +All filters support negation by prefixing the filter type with `!`. For example: +- `!origin_asn` -- matches elements where origin AS is NOT the specified value +- `!prefix` -- matches elements where prefix is NOT the specified value +- `!peer_ip` -- matches elements where peer IP is NOT the specified value + [Filter::new] function takes a `str` as the filter type and `str` as the filter value and returns a Result of a [Filter] or a parsing error. @@ -42,6 +49,20 @@ for elem in parser { log::info!("done"); ``` +### Example with Negative Filter + +```no_run +use bgpkit_parser::BgpkitParser; + +// Filter out all elements from AS 13335 (Cloudflare) +let parser = BgpkitParser::new("http://archive.routeviews.org/bgpdata/2021.10/UPDATES/updates.20211001.0000.bz2").unwrap() + .add_filter("!origin_asn", "13335").unwrap(); + +for elem in parser { + println!("{}", elem); +} +``` + Note, by default, the prefix filtering is for the exact prefix. You can include super-prefixes or sub-prefixes when fitlering by using `"prefix_super"`, `"prefix_sub"`, or `"prefix_super_sub"` as the filter type string. @@ -60,7 +81,7 @@ use ipnet::IpNet; use std::net::IpAddr; use std::str::FromStr; -/// Filter enum: definition o types of filters +/// Filter enum: definition of types of filters /// /// The available filters are (`filter_type` (`FilterType`) -- definition): /// - `origin_asn` (`OriginAsn(u32)`) -- origin AS number @@ -73,6 +94,10 @@ use std::str::FromStr; /// - `as_path` (`ComparableRegex`) -- regular expression for AS path string /// - `community` (`ComparableRegex`) -- regular expression for community string /// - `ip_version` (`IpVersion`) -- IP version (`ipv4` or `ipv6`) +/// +/// **Negative filters**: All filters support negation by prefixing the filter type with `!`. +/// For example, `!origin_asn` matches elements where origin AS is NOT the specified value. +/// This creates a `Negated(Box)` variant that inverts the match result. #[derive(Debug, Clone, PartialEq)] pub enum Filter { OriginAsn(u32), @@ -86,6 +111,8 @@ pub enum Filter { TsEnd(f64), AsPath(ComparableRegex), Community(ComparableRegex), + /// Negated filter - matches when the inner filter does NOT match + Negated(Box), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -114,6 +141,23 @@ fn parse_time_str(time_str: &str) -> Option { impl Filter { pub fn new(filter_type: &str, filter_value: &str) -> Result { + // Check for negation prefix + let (negated, actual_filter_type) = if let Some(stripped) = filter_type.strip_prefix('!') { + (true, stripped) + } else { + (false, filter_type) + }; + + let base_filter = Self::new_base(actual_filter_type, filter_value)?; + + if negated { + Ok(Filter::Negated(Box::new(base_filter))) + } else { + Ok(base_filter) + } + } + + fn new_base(filter_type: &str, filter_value: &str) -> Result { match filter_type { "origin_asn" => match u32::from_str(filter_value) { Ok(v) => Ok(Filter::OriginAsn(v)), @@ -272,6 +316,7 @@ fn prefix_match(match_prefix: &IpNet, input_prefix: &IpNet, t: &PrefixMatchType) impl Filterable for BgpElem { fn match_filter(&self, filter: &Filter) -> bool { match filter { + Filter::Negated(inner) => !self.match_filter(inner), Filter::OriginAsn(v) => { let asn: Asn = (*v).into(); if let Some(origins) = &self.origin_asns { @@ -462,6 +507,34 @@ mod tests { Ok(()) } + #[test] + fn test_filter_iter_with_negation() -> Result<()> { + let url = "https://spaces.bgpkit.org/parser/update-example.gz"; + + // Test negative filter with add_filter - exclude peer 185.1.8.65 + // From test_filters_on_mrt_file, peer 185.1.8.65 has 3393 elements out of 8160 total + let parser = BgpkitParser::new(url)?.add_filter("!peer_ip", "185.1.8.65")?; + let count = parser.into_elem_iter().count(); + assert_eq!(count, 8160 - 3393); + + // Test negative type filter - get all non-withdrawals + // From test_filters_on_mrt_file, there are 379 withdrawals out of 8160 total + let parser = BgpkitParser::new(url)?.add_filter("!type", "w")?; + let count = parser.into_elem_iter().count(); + assert_eq!(count, 8160 - 379); + + // Test combining positive and negative filters + // Get elements from peer 185.1.8.50 that are NOT withdrawals + let parser = BgpkitParser::new(url)? + .add_filter("peer_ip", "185.1.8.50")? + .add_filter("!type", "w")?; + let count = parser.into_elem_iter().count(); + // peer 185.1.8.50 has 1563 total, 39 withdrawals -> 1563 - 39 = 1524 non-withdrawals + assert_eq!(count, 1563 - 39); + + Ok(()) + } + #[test] fn test_filter_iter_multi_peers() { let url = "https://spaces.bgpkit.org/parser/update-example.gz"; @@ -525,6 +598,42 @@ mod tests { let filter = Filter::new("origin_asn", "12345").unwrap(); assert_eq!(filter, Filter::OriginAsn(12345)); + // Test negated filters + let filter = Filter::new("!origin_asn", "12345").unwrap(); + assert_eq!(filter, Filter::Negated(Box::new(Filter::OriginAsn(12345)))); + + let filter = Filter::new("!prefix", "192.168.1.0/24").unwrap(); + assert_eq!( + filter, + Filter::Negated(Box::new(Filter::Prefix( + IpNet::from_str("192.168.1.0/24").unwrap(), + PrefixMatchType::Exact + ))) + ); + + let filter = Filter::new("!peer_ip", "192.168.1.1").unwrap(); + assert_eq!( + filter, + Filter::Negated(Box::new(Filter::PeerIp( + IpAddr::from_str("192.168.1.1").unwrap() + ))) + ); + + let filter = Filter::new("!peer_asn", "12345").unwrap(); + assert_eq!(filter, Filter::Negated(Box::new(Filter::PeerAsn(12345)))); + + let filter = Filter::new("!type", "w").unwrap(); + assert_eq!( + filter, + Filter::Negated(Box::new(Filter::Type(ElemType::WITHDRAW))) + ); + + let filter = Filter::new("!ip_version", "4").unwrap(); + assert_eq!( + filter, + Filter::Negated(Box::new(Filter::IpVersion(IpVersion::Ipv4))) + ); + let filter = Filter::new("prefix", "192.168.1.0/24").unwrap(); assert_eq!( filter, @@ -673,4 +782,163 @@ mod tests { assert!(elem.match_filters(&filters)); } + + #[test] + fn test_negated_filters() { + let elem = BgpElem { + timestamp: 1637437798_f64, + peer_ip: IpAddr::from_str("192.168.1.1").unwrap(), + peer_asn: Asn::new_32bit(12345), + prefix: NetworkPrefix::new(IpNet::from_str("192.168.1.0/24").unwrap(), None), + next_hop: None, + as_path: Some(AsPath::from_sequence(vec![174, 1916, 52888])), + origin_asns: Some(vec![Asn::new_16bit(12345)]), + origin: None, + local_pref: None, + med: None, + communities: Some(vec![MetaCommunity::Large(LargeCommunity::new( + 12345, + [678910, 111213], + ))]), + atomic: false, + aggr_asn: None, + aggr_ip: None, + only_to_customer: None, + unknown: None, + elem_type: ElemType::ANNOUNCE, + deprecated: None, + }; + + // Test negated origin_asn filter + // elem has origin_asn 12345, so !origin_asn=12345 should NOT match + let filter = Filter::new("!origin_asn", "12345").unwrap(); + assert!(!elem.match_filter(&filter)); + + // elem has origin_asn 12345, so !origin_asn=99999 should match + let filter = Filter::new("!origin_asn", "99999").unwrap(); + assert!(elem.match_filter(&filter)); + + // Test negated prefix filter + // elem has prefix 192.168.1.0/24, so !prefix=192.168.1.0/24 should NOT match + let filter = Filter::new("!prefix", "192.168.1.0/24").unwrap(); + assert!(!elem.match_filter(&filter)); + + // elem has prefix 192.168.1.0/24, so !prefix=10.0.0.0/8 should match + let filter = Filter::new("!prefix", "10.0.0.0/8").unwrap(); + assert!(elem.match_filter(&filter)); + + // Test negated peer_ip filter + // elem has peer_ip 192.168.1.1, so !peer_ip=192.168.1.1 should NOT match + let filter = Filter::new("!peer_ip", "192.168.1.1").unwrap(); + assert!(!elem.match_filter(&filter)); + + // elem has peer_ip 192.168.1.1, so !peer_ip=10.0.0.1 should match + let filter = Filter::new("!peer_ip", "10.0.0.1").unwrap(); + assert!(elem.match_filter(&filter)); + + // Test negated peer_asn filter + // elem has peer_asn 12345, so !peer_asn=12345 should NOT match + let filter = Filter::new("!peer_asn", "12345").unwrap(); + assert!(!elem.match_filter(&filter)); + + // elem has peer_asn 12345, so !peer_asn=99999 should match + let filter = Filter::new("!peer_asn", "99999").unwrap(); + assert!(elem.match_filter(&filter)); + + // Test negated type filter + // elem has type ANNOUNCE, so !type=a should NOT match + let filter = Filter::new("!type", "a").unwrap(); + assert!(!elem.match_filter(&filter)); + + // elem has type ANNOUNCE, so !type=w should match + let filter = Filter::new("!type", "w").unwrap(); + assert!(elem.match_filter(&filter)); + + // Test negated ip_version filter + // elem has IPv4 prefix, so !ip_version=4 should NOT match + let filter = Filter::new("!ip_version", "4").unwrap(); + assert!(!elem.match_filter(&filter)); + + // elem has IPv4 prefix, so !ip_version=6 should match + let filter = Filter::new("!ip_version", "6").unwrap(); + assert!(elem.match_filter(&filter)); + + // Test negated as_path filter + // elem has as_path "174 1916 52888", so negated matching regex should NOT match + let filter = Filter::new("!as_path", r"174 1916 52888$").unwrap(); + assert!(!elem.match_filter(&filter)); + + // elem has as_path "174 1916 52888", so negated non-matching regex should match + let filter = Filter::new("!as_path", r"99999$").unwrap(); + assert!(elem.match_filter(&filter)); + + // Test negated community filter + let filter = Filter::new("!community", r"12345:678910:111213$").unwrap(); + assert!(!elem.match_filter(&filter)); + + let filter = Filter::new("!community", r"99999:99999$").unwrap(); + assert!(elem.match_filter(&filter)); + + // Test negated peer_ips filter + let filter = Filter::new("!peer_ips", "192.168.1.1, 10.0.0.1").unwrap(); + assert!(!elem.match_filter(&filter)); // elem's peer_ip is in the list + + let filter = Filter::new("!peer_ips", "10.0.0.1, 10.0.0.2").unwrap(); + assert!(elem.match_filter(&filter)); // elem's peer_ip is NOT in the list + + // Test combining positive and negated filters + let filters = vec![ + Filter::new("origin_asn", "12345").unwrap(), // matches + Filter::new("!peer_asn", "99999").unwrap(), // matches (not 99999) + Filter::new("!prefix", "10.0.0.0/8").unwrap(), // matches (not 10.0.0.0/8) + ]; + assert!(elem.match_filters(&filters)); + + // Test combining filters where one fails + let filters = vec![ + Filter::new("origin_asn", "12345").unwrap(), // matches + Filter::new("!origin_asn", "12345").unwrap(), // does NOT match + ]; + assert!(!elem.match_filters(&filters)); + } + + #[test] + fn test_negated_filters_on_mrt_file() { + let url = "https://spaces.bgpkit.org/parser/update-example.gz"; + let parser = BgpkitParser::new(url).unwrap(); + let elems = parser.into_elem_iter().collect::>(); + + // Count all elems from peer 185.1.8.65 + let filters = vec![Filter::PeerIp(IpAddr::from_str("185.1.8.65").unwrap())]; + let count_with_peer = elems.iter().filter(|e| e.match_filters(&filters)).count(); + assert_eq!(count_with_peer, 3393); + + // Count all elems NOT from peer 185.1.8.65 + let filters = vec![Filter::new("!peer_ip", "185.1.8.65").unwrap()]; + let count_without_peer = elems.iter().filter(|e| e.match_filters(&filters)).count(); + assert_eq!(count_without_peer, elems.len() - 3393); + + // Verify total adds up + assert_eq!(count_with_peer + count_without_peer, elems.len()); + + // Test negated type filter + let filters = vec![Filter::Type(ElemType::WITHDRAW)]; + let count_withdrawals = elems.iter().filter(|e| e.match_filters(&filters)).count(); + assert_eq!(count_withdrawals, 379); + + let filters = vec![Filter::new("!type", "w").unwrap()]; + let count_not_withdrawals = elems.iter().filter(|e| e.match_filters(&filters)).count(); + assert_eq!(count_not_withdrawals, elems.len() - 379); + + // Test negated prefix filter + let filters = vec![Filter::Prefix( + IpNet::from_str("190.115.192.0/22").unwrap(), + PrefixMatchType::Exact, + )]; + let count_with_prefix = elems.iter().filter(|e| e.match_filters(&filters)).count(); + + let filters = vec![Filter::new("!prefix", "190.115.192.0/22").unwrap()]; + let count_without_prefix = elems.iter().filter(|e| e.match_filters(&filters)).count(); + assert_eq!(count_with_prefix + count_without_prefix, elems.len()); + } } From c176aa6f33bc9fff3cfd574fbb847e31a9ee76f2 Mon Sep 17 00:00:00 2001 From: Mingwei Zhang Date: Mon, 22 Dec 2025 19:40:42 -0800 Subject: [PATCH 2/2] Reject timestamp negation and double negation Update docs and tests to reflect negation rules: timestamp filters do not support negation; double negation is rejected --- CHANGELOG.md | 3 +- README.md | 4 +- src/lib.rs | 4 +- src/parser/filter.rs | 95 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee8dbd54..d0b2ad9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ All notable changes to this project will be documented in this file. ### New features -* **Negative Filter Support**: All filters now support negation by prefixing the filter type with `!` +* **Negative Filter Support**: Most filters now support negation by prefixing the filter type with `!` - `!origin_asn`: Match elements where origin AS is NOT the specified value - `!prefix`: Match elements where prefix is NOT the specified value - `!peer_ip`, `!peer_asn`, `!type`, `!as_path`, `!community`, `!ip_version`: All support negation + - Note: Timestamp filters (`ts_start`, `ts_end`) do not support negation - Example: `.add_filter("!origin_asn", "13335")` matches all elements NOT from AS 13335 - New `Filter::Negated(Box)` variant wraps any filter to invert its match result - **CLI**: New `--filter` / `-f` option supports both positive and negative filter expressions diff --git a/README.md b/README.md index 89583c79..f0083a41 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,13 @@ for elem in parser { - `elem_type`: Filter by announcement (`a`) or withdrawal (`w`) - `as_path`: Match AS path with regex -**Negative filters**: All filters support negation by prefixing the filter type with `!`. For example: +**Negative filters**: Most filters support negation by prefixing the filter type with `!`. For example: - `!origin_asn`: Match elements where origin AS is NOT the specified value - `!prefix`: Match elements where prefix is NOT the specified value - `!peer_ip`: Match elements where peer IP is NOT the specified value +**Note**: Timestamp filters (`ts_start`, `ts_end`) do not support negation. + ```rust use bgpkit_parser::BgpkitParser; diff --git a/src/lib.rs b/src/lib.rs index 177fc0c4..1f6e668b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -91,11 +91,13 @@ for elem in parser { - `elem_type`: Filter by announcement (`a`) or withdrawal (`w`) - `as_path`: Match AS path with regex -**Negative filters**: All filters support negation by prefixing the filter type with `!`. For example: +**Negative filters**: Most filters support negation by prefixing the filter type with `!`. For example: - `!origin_asn`: Match elements where origin AS is NOT the specified value - `!prefix`: Match elements where prefix is NOT the specified value - `!peer_ip`: Match elements where peer IP is NOT the specified value +**Note**: Timestamp filters (`ts_start`, `ts_end`) do not support negation. + ```no_run use bgpkit_parser::BgpkitParser; diff --git a/src/parser/filter.rs b/src/parser/filter.rs index 794417be..3702cede 100644 --- a/src/parser/filter.rs +++ b/src/parser/filter.rs @@ -17,11 +17,13 @@ The available filters are: ### Negative Filters -All filters support negation by prefixing the filter type with `!`. For example: +Most filters support negation by prefixing the filter type with `!`. For example: - `!origin_asn` -- matches elements where origin AS is NOT the specified value - `!prefix` -- matches elements where prefix is NOT the specified value - `!peer_ip` -- matches elements where peer IP is NOT the specified value +**Note**: Timestamp filters (`ts_start`, `ts_end`) do not support negation as the behavior would be unintuitive. + [Filter::new] function takes a `str` as the filter type and `str` as the filter value and returns a Result of a [Filter] or a parsing error. @@ -64,7 +66,7 @@ for elem in parser { ``` Note, by default, the prefix filtering is for the exact prefix. You can include super-prefixes or -sub-prefixes when fitlering by using `"prefix_super"`, `"prefix_sub"`, or `"prefix_super_sub"` as +sub-prefixes when filtering by using `"prefix_super"`, `"prefix_sub"`, or `"prefix_super_sub"` as the filter type string. ### Note @@ -143,6 +145,24 @@ impl Filter { pub fn new(filter_type: &str, filter_value: &str) -> Result { // Check for negation prefix let (negated, actual_filter_type) = if let Some(stripped) = filter_type.strip_prefix('!') { + // Reject double negation (e.g., "!!origin_asn") + if stripped.starts_with('!') { + return Err(FilterError(format!( + "invalid filter type '{}': double negation is not allowed", + filter_type + ))); + } + // Reject negation for timestamp filters (unintuitive behavior) + if stripped == "ts_start" + || stripped == "start_ts" + || stripped == "ts_end" + || stripped == "end_ts" + { + return Err(FilterError(format!( + "invalid filter type '{}': timestamp filters do not support negation", + filter_type + ))); + } (true, stripped) } else { (false, filter_type) @@ -940,5 +960,76 @@ mod tests { let filters = vec![Filter::new("!prefix", "190.115.192.0/22").unwrap()]; let count_without_prefix = elems.iter().filter(|e| e.match_filters(&filters)).count(); assert_eq!(count_with_prefix + count_without_prefix, elems.len()); + + // Test negated prefix_super filter + let filters = vec![Filter::Prefix( + IpNet::from_str("190.115.192.0/24").unwrap(), + PrefixMatchType::IncludeSuper, + )]; + let count_with_super = elems.iter().filter(|e| e.match_filters(&filters)).count(); + + let filters = vec![Filter::new("!prefix_super", "190.115.192.0/24").unwrap()]; + let count_without_super = elems.iter().filter(|e| e.match_filters(&filters)).count(); + assert_eq!(count_with_super + count_without_super, elems.len()); + + // Test negated prefix_sub filter + let filters = vec![Filter::Prefix( + IpNet::from_str("190.115.192.0/22").unwrap(), + PrefixMatchType::IncludeSub, + )]; + let count_with_sub = elems.iter().filter(|e| e.match_filters(&filters)).count(); + + let filters = vec![Filter::new("!prefix_sub", "190.115.192.0/22").unwrap()]; + let count_without_sub = elems.iter().filter(|e| e.match_filters(&filters)).count(); + assert_eq!(count_with_sub + count_without_sub, elems.len()); + + // Test negated prefix_super_sub filter + let filters = vec![Filter::Prefix( + IpNet::from_str("190.115.192.0/23").unwrap(), + PrefixMatchType::IncludeSuperSub, + )]; + let count_with_super_sub = elems.iter().filter(|e| e.match_filters(&filters)).count(); + + let filters = vec![Filter::new("!prefix_super_sub", "190.115.192.0/23").unwrap()]; + let count_without_super_sub = elems.iter().filter(|e| e.match_filters(&filters)).count(); + assert_eq!(count_with_super_sub + count_without_super_sub, elems.len()); + } + + #[test] + fn test_double_negation_rejected() { + // Double negation should be rejected with a clear error message + let result = Filter::new("!!origin_asn", "13335"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("double negation")); + + let result = Filter::new("!!!prefix", "10.0.0.0/8"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("double negation")); + } + + #[test] + fn test_timestamp_negation_rejected() { + // Timestamp filter negation should be rejected + let result = Filter::new("!ts_start", "1637437798"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err + .to_string() + .contains("timestamp filters do not support negation")); + + let result = Filter::new("!ts_end", "1637437798"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err + .to_string() + .contains("timestamp filters do not support negation")); + + let result = Filter::new("!start_ts", "1637437798"); + assert!(result.is_err()); + + let result = Filter::new("!end_ts", "1637437798"); + assert!(result.is_err()); } }