Skip to content

Commit 27a561a

Browse files
committed
bgp/policy: fix match-prefix-set to accept covered sub-prefixes
The MatchPrefixSet condition currently requires exact network-address equality between the candidate prefix and the prefix-set entry's ip-prefix. This rejects sub-prefixes that are tree-wise covered by the entry's ip-prefix even when their length falls within the mask-length range. The ietf-routing-policy YANG module shipped with Holo (published as RFC 9067, "A YANG Data Model for Routing Policy") describes the `prefix` grouping as expressing a mask-length range for a covering ip-prefix. The YANG text's worked example is ambiguous on whether "range" means literal same-network-address-with-varying-length or tree-wise containment, but peer implementations converge on the containment reading: FRR (lib/plist.c), BIRD (filter/tree.c), Cisco IOS `ip prefix-list ... le N`, and Juniper `prefix-list-filter` all treat the entry as a covering pattern. That is also what operators reach for by default, so containment is the principle of least surprise. Switch to IpNetwork::contains(prefix.ip()), which returns true when the candidate's network address lies within the range's covering prefix. The match logic is extracted into a private prefix_set_contains helper so it can be unit-tested directly. Semantics change disclosure: this is strictly more permissive. Existing `mask-length-lower == mask-length-upper` entries (the IETF "exact-match" pattern, which the existing topo1-1 conformance tests use) are unaffected because the length constraint still rules out any prefix whose length differs from the anchor. Ranged entries start matching covered sub-prefixes as intended. Non-canonical ip-prefix entries (stored verbatim by the northbound) are now interpreted as their covering network rather than matching only a literal host IP; this is consistent with how FRR/BIRD/Cisco/Juniper treat the same input. Nine unit tests cover exact-length entries, ranged entries, length-bound rejection, supernet and disjoint rejection, multi-entry OR semantics, IPv6 parity, the `0.0.0.0/0 [0, 32]` match-any pattern, empty prefix-sets, and non-canonical anchor behavior.
1 parent 158a78c commit 27a561a

1 file changed

Lines changed: 176 additions & 6 deletions

File tree

holo-bgp/src/policy.rs

Lines changed: 176 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use holo_utils::policy::{
1515
BgpNexthop, BgpPolicyAction, BgpPolicyCondition, BgpSetCommMethod,
1616
BgpSetCommOptions, BgpSetMed, DefaultPolicyType, MatchSets,
1717
MetricModification, Policy, PolicyAction, PolicyCondition, PolicyResult,
18-
PolicyType,
18+
PolicyType, PrefixSet,
1919
};
2020
use holo_utils::southbound::RouteOpaqueAttrs;
2121
use ipnetwork::IpNetwork;
@@ -112,6 +112,28 @@ pub(crate) fn redistribute_apply(
112112

113113
// ===== helper functions =====
114114

115+
// Returns true if `prefix` matches any range in `set`. A range matches
116+
// when the candidate `prefix` is tree-wise covered by the range's
117+
// `ip-prefix` AND the candidate's length falls within
118+
// `[mask-length-lower, mask-length-upper]`.
119+
//
120+
// Containment (`IpNetwork::contains(prefix.ip())`) is the test used by
121+
// FRR (`lib/plist.c`), BIRD (`filter/tree.c`), Cisco IOS
122+
// `ip prefix-list`, and Juniper `prefix-list-filter`: a more-specific
123+
// prefix matches a less-specific covering prefix when its network
124+
// address lies within that covering prefix. RFC 9067 ("A YANG Data
125+
// Model for Routing Policy") §3.3 and the `ietf-routing-policy` module
126+
// (grouping `prefix`) use `ip-prefix` plus a mask-length range to
127+
// express the covering pattern; the range reduces to an exact-length
128+
// match when `mask-length-lower == mask-length-upper`.
129+
fn prefix_set_contains(prefix: &IpNetwork, set: &PrefixSet) -> bool {
130+
set.prefixes.iter().any(|range| {
131+
range.prefix.contains(prefix.ip())
132+
&& prefix.prefix() >= range.masklen_lower
133+
&& prefix.prefix() <= range.masklen_upper
134+
})
135+
}
136+
115137
// Processes routing policies for a specific route and returns the policy
116138
// result.
117139
fn process_policies(
@@ -182,11 +204,7 @@ fn process_stmt_condition(
182204
PolicyCondition::MatchPrefixSet(value) => {
183205
let af = prefix.address_family();
184206
match match_sets.prefixes.get(&(value.clone(), af)) {
185-
Some(set) => set.prefixes.iter().any(|range| {
186-
prefix.ip() == range.prefix.ip()
187-
&& prefix.prefix() >= range.masklen_lower
188-
&& prefix.prefix() <= range.masklen_upper
189-
}),
207+
Some(set) => prefix_set_contains(prefix, set),
190208
None => false,
191209
}
192210
}
@@ -488,3 +506,155 @@ fn action_set_comm<T>(
488506
*comm_list = None;
489507
}
490508
}
509+
510+
// ===== tests =====
511+
512+
#[cfg(test)]
513+
mod tests {
514+
use std::collections::BTreeSet;
515+
use std::str::FromStr;
516+
517+
use holo_utils::ip::AddressFamily;
518+
use holo_utils::policy::IpPrefixRange;
519+
520+
use super::*;
521+
522+
fn net(s: &str) -> IpNetwork {
523+
IpNetwork::from_str(s).unwrap()
524+
}
525+
526+
fn range(cidr: &str, lower: u8, upper: u8) -> IpPrefixRange {
527+
IpPrefixRange {
528+
prefix: net(cidr),
529+
masklen_lower: lower,
530+
masklen_upper: upper,
531+
}
532+
}
533+
534+
fn set_from(ranges: impl IntoIterator<Item = IpPrefixRange>) -> PrefixSet {
535+
let mut prefixes = BTreeSet::new();
536+
prefixes.extend(ranges);
537+
PrefixSet {
538+
name: "TEST".to_owned(),
539+
mode: AddressFamily::Ipv4,
540+
prefixes,
541+
}
542+
}
543+
544+
#[test]
545+
fn exact_length_matches_only_that_length() {
546+
let set = set_from([range("192.0.2.0/24", 24, 24)]);
547+
assert!(prefix_set_contains(&net("192.0.2.0/24"), &set));
548+
// Same covering prefix, different length — rejected by length bound.
549+
assert!(!prefix_set_contains(&net("192.0.2.0/25"), &set));
550+
// Sub-network with same length, different network-address — rejected.
551+
assert!(!prefix_set_contains(&net("192.0.2.0/26"), &set));
552+
// Completely unrelated network.
553+
assert!(!prefix_set_contains(&net("198.51.100.0/24"), &set));
554+
}
555+
556+
#[test]
557+
fn range_accepts_covered_subprefixes() {
558+
// `203.0.113.0/24 [24, 32]` must accept any prefix tree-wise
559+
// covered by 203.0.113.0/24 whose length is in [24, 32].
560+
let set = set_from([range("203.0.113.0/24", 24, 32)]);
561+
assert!(prefix_set_contains(&net("203.0.113.0/24"), &set));
562+
assert!(prefix_set_contains(&net("203.0.113.0/25"), &set));
563+
assert!(prefix_set_contains(&net("203.0.113.128/25"), &set));
564+
assert!(prefix_set_contains(&net("203.0.113.0/26"), &set));
565+
assert!(prefix_set_contains(&net("203.0.113.64/27"), &set));
566+
assert!(prefix_set_contains(&net("203.0.113.1/32"), &set));
567+
}
568+
569+
#[test]
570+
fn range_rejects_length_outside_bounds() {
571+
let set = set_from([range("203.0.113.0/24", 26, 28)]);
572+
// Length below `masklen_lower`.
573+
assert!(!prefix_set_contains(&net("203.0.113.0/24"), &set));
574+
assert!(!prefix_set_contains(&net("203.0.113.0/25"), &set));
575+
// Length inside bounds.
576+
assert!(prefix_set_contains(&net("203.0.113.0/26"), &set));
577+
assert!(prefix_set_contains(&net("203.0.113.0/27"), &set));
578+
assert!(prefix_set_contains(&net("203.0.113.0/28"), &set));
579+
// Length above `masklen_upper`.
580+
assert!(!prefix_set_contains(&net("203.0.113.0/29"), &set));
581+
assert!(!prefix_set_contains(&net("203.0.113.0/32"), &set));
582+
}
583+
584+
#[test]
585+
fn range_rejects_supernets_and_disjoint_prefixes() {
586+
let set = set_from([range("203.0.113.0/24", 8, 32)]);
587+
// Supernet of the covering prefix — not contained.
588+
assert!(!prefix_set_contains(&net("203.0.0.0/16"), &set));
589+
assert!(!prefix_set_contains(&net("203.0.112.0/23"), &set));
590+
// Sibling /24 at the same tree depth.
591+
assert!(!prefix_set_contains(&net("203.0.112.0/24"), &set));
592+
// Completely disjoint address space.
593+
assert!(!prefix_set_contains(&net("198.51.100.0/24"), &set));
594+
}
595+
596+
#[test]
597+
fn multiple_entries_match_any() {
598+
let set = set_from([
599+
range("192.0.2.0/24", 24, 24),
600+
range("203.0.113.0/24", 24, 32),
601+
]);
602+
assert!(prefix_set_contains(&net("192.0.2.0/24"), &set));
603+
assert!(prefix_set_contains(&net("203.0.113.64/27"), &set));
604+
assert!(!prefix_set_contains(&net("192.0.2.64/27"), &set));
605+
assert!(!prefix_set_contains(&net("198.51.100.0/24"), &set));
606+
}
607+
608+
#[test]
609+
fn ipv6_ranges_match_like_ipv4() {
610+
let set = PrefixSet {
611+
name: "TEST6".to_owned(),
612+
mode: AddressFamily::Ipv6,
613+
prefixes: [range("2001:db8::/32", 32, 128)].into_iter().collect(),
614+
};
615+
assert!(prefix_set_contains(&net("2001:db8::/32"), &set));
616+
assert!(prefix_set_contains(&net("2001:db8::/48"), &set));
617+
assert!(prefix_set_contains(&net("2001:db8:1::/48"), &set));
618+
assert!(prefix_set_contains(&net("2001:db8::1/128"), &set));
619+
assert!(!prefix_set_contains(&net("2001:db9::/48"), &set));
620+
assert!(!prefix_set_contains(&net("2001:db8::/31"), &set));
621+
}
622+
623+
#[test]
624+
fn default_zero_prefix_matches_any_in_range() {
625+
// `0.0.0.0/0 [0, 32]` is the "match any IPv4 prefix" pattern
626+
// commonly used to redistribute everything of the configured
627+
// address family.
628+
let set = set_from([range("0.0.0.0/0", 0, 32)]);
629+
assert!(prefix_set_contains(&net("0.0.0.0/0"), &set));
630+
assert!(prefix_set_contains(&net("10.0.0.0/8"), &set));
631+
assert!(prefix_set_contains(&net("192.0.2.0/24"), &set));
632+
assert!(prefix_set_contains(&net("198.51.100.1/32"), &set));
633+
}
634+
635+
#[test]
636+
fn empty_prefix_set_never_matches() {
637+
let set = PrefixSet {
638+
name: "EMPTY".to_owned(),
639+
mode: AddressFamily::Ipv4,
640+
prefixes: BTreeSet::new(),
641+
};
642+
assert!(!prefix_set_contains(&net("192.0.2.0/24"), &set));
643+
assert!(!prefix_set_contains(&net("0.0.0.0/0"), &set));
644+
}
645+
646+
#[test]
647+
fn non_canonical_anchor_is_interpreted_as_its_covering_network() {
648+
// Holo's northbound does not canonicalize `ip-prefix` on
649+
// ingest, so a configured range like `192.0.2.1/24` is stored
650+
// verbatim with host bits set. Under containment semantics the
651+
// stored anchor is interpreted as the covering network
652+
// (`192.0.2.0/24`), because `IpNetwork::contains` masks the
653+
// candidate before comparing. This test pins that behavior.
654+
let set = set_from([range("192.0.2.1/24", 24, 32)]);
655+
assert!(prefix_set_contains(&net("192.0.2.0/24"), &set));
656+
assert!(prefix_set_contains(&net("192.0.2.64/27"), &set));
657+
assert!(prefix_set_contains(&net("192.0.2.255/32"), &set));
658+
assert!(!prefix_set_contains(&net("198.51.100.0/24"), &set));
659+
}
660+
}

0 commit comments

Comments
 (0)