Skip to main content

cidr_aggregator/
lib.rs

1//! Parse CIDR strings, aggregate, reverse, and difference IP ranges, then
2//! normalize back to CIDR notation.
3//!
4//! Supports both IPv4 and IPv6. The core abstraction is the [`IpRange`] trait,
5//! implemented by [`Ipv4Range`] and [`Ipv6Range`]. Operations like
6//! `.aggregate()`, `.reverse()`, `.normalize()`, and `.export()` are provided
7//! on `Vec` of either range type via the [`Aggregator`] trait.
8//!
9//! # Quick start
10//!
11//! Aggregate overlapping and adjacent CIDR blocks into a minimal set:
12//!
13//! ```
14//! use cidr_aggregator::{parse_cidrs, Aggregator};
15//!
16//! let (mut v4_ranges, _, _) = parse_cidrs("10.0.0.0/24\n10.0.1.0/24\n10.0.0.128/25");
17//! // 10.0.0.0/24 and 10.0.1.0/24 are adjacent → merge to 10.0.0.0/23
18//! // 10.0.0.128/25 is already covered by the /23 → absorbed
19//! v4_ranges.aggregate();
20//! // Aggregate produces a minimal set but not necessarily canonical CIDR
21//! // blocks — normalize() is required before export().
22//! v4_ranges.normalize();
23//! assert_eq!(v4_ranges.export(), "10.0.0.0/23");
24//! ```
25//!
26//! Chain operations in a pipeline — filter reserved addresses, then reverse:
27//!
28//! ```
29//! use cidr_aggregator::{parse_cidrs, Aggregator, IpRange, Ipv6Range};
30//!
31//! let (_, v6_ranges, _) = parse_cidrs("2001:db8::/32\n64:ff9b::/96");
32//!
33//! println!(
34//!     "{}",
35//!     v6_ranges
36//!         .aggregated()
37//!         .differenced(Ipv6Range::reserved()) // strip RFC 6890 reserved blocks
38//!         // Normalize is required to produce valid CIDR blocks after aggregation
39//!         // and difference, which may leave non-canonical ranges.
40//!         .normalized()
41//!         .reversed()
42//!         .export()
43//! );
44//! ```
45//!
46//! A WASM build of this crate powers the web app at
47//! <https://cidr-aggregator.pages.dev>.
48
49use std::fmt::{self, Debug, Display};
50use std::hash::Hash;
51use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
52use std::str::FromStr;
53
54use crate::utils::{
55    ip_addr_to_bit_length, ip_addr_trailing_zeros, MathLog2, IPV4_RESERVED, IPV6_RESERVED,
56};
57use num_traits::{Bounded, NumAssignOps, NumCast, PrimInt, WrappingAdd, Zero};
58
59/// An inclusive IPv4 range `[first, last]` stored as `u32`.
60///
61/// Ranges can be created from CIDR notation via [`EitherIpRange::from_str`]
62/// or from a `(first_address, last_address)` pair via `from_cidr_pair_decimal`.
63#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub struct Ipv4Range(u32, u32);
65
66/// An inclusive IPv6 range `[first, last]` stored as `u128`.
67///
68/// Ranges can be created from CIDR notation via [`EitherIpRange::from_str`]
69/// or from a `(first_address, last_address)` pair via `from_cidr_pair_decimal`.
70#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
71pub struct Ipv6Range(u128, u128);
72
73/// Either an IPv4 or IPv6 range, used for parsing CIDR strings.
74///
75/// Use [`into_v4`](EitherIpRange::into_v4) / [`into_v6`](EitherIpRange::into_v6)
76/// or match to extract the inner range.
77#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub enum EitherIpRange {
79    V4(Ipv4Range),
80    V6(Ipv6Range),
81}
82
83impl EitherIpRange {
84    /// Extract the `Ipv4Range`, or `None` if this is an IPv6 range.
85    pub fn into_v4(self) -> Option<Ipv4Range> {
86        match self {
87            EitherIpRange::V4(r) => Some(r),
88            _ => None,
89        }
90    }
91
92    /// Extract the `Ipv6Range`, or `None` if this is an IPv4 range.
93    pub fn into_v6(self) -> Option<Ipv6Range> {
94        match self {
95            EitherIpRange::V6(r) => Some(r),
96            _ => None,
97        }
98    }
99
100    /// Returns `true` if this is an IPv4 range.
101    pub fn is_v4(self) -> bool {
102        matches!(self, EitherIpRange::V4(_))
103    }
104
105    /// Returns `true` if this is an IPv6 range.
106    pub fn is_v6(self) -> bool {
107        matches!(self, EitherIpRange::V6(_))
108    }
109}
110
111impl From<Ipv4Range> for EitherIpRange {
112    fn from(r: Ipv4Range) -> Self {
113        EitherIpRange::V4(r)
114    }
115}
116
117impl From<Ipv6Range> for EitherIpRange {
118    fn from(r: Ipv6Range) -> Self {
119        EitherIpRange::V6(r)
120    }
121}
122
123impl FromStr for EitherIpRange {
124    type Err = ();
125
126    fn from_str(s: &str) -> Result<EitherIpRange, Self::Err> {
127        if let Some((ip, cidr)) = s.split_once("/").or(Some((s, ""))) {
128            let ip = ip.parse::<IpAddr>().map_err(|_| ())?;
129            let cidr = if cidr.is_empty() {
130                ip_addr_to_bit_length(ip) as u8
131            } else {
132                cidr.parse::<u8>().map_err(|_| ())?
133            };
134            if cidr as u32 > ip_addr_to_bit_length(ip)
135                || ip_addr_trailing_zeros(ip) < ip_addr_to_bit_length(ip) - cidr as u32
136            {
137                return Err(()); // a host instead of a range
138            }
139            Ok(match ip {
140                IpAddr::V4(ip) => EitherIpRange::V4(Ipv4Range::from_cidr_pair((ip, cidr))),
141                IpAddr::V6(ip) => EitherIpRange::V6(Ipv6Range::from_cidr_pair((ip, cidr))),
142            })
143        } else {
144            Err(())
145        }
146    }
147}
148
149/// Core abstraction for an IP range.
150///
151/// Implemented by [`Ipv4Range`] and [`Ipv6Range`] via the `impl_ip_range!` macro.
152/// This trait enables generic algorithms in the [`aggregator`] module that work
153/// identically for both address families.
154///
155/// Note: [`Display`] (and therefore [`export`](Aggregator::export)) **panics**
156/// if the range has not been normalized — its length must be a power of two.
157pub trait IpRange: Copy + Eq + Ord + Display + Debug + Hash + 'static {
158    type Address;
159    type AddressDecimal: PrimInt + NumAssignOps + WrappingAdd + Bounded + Display + Debug;
160
161    fn first_address(&self) -> Self::Address;
162    fn first_address_as_decimal(&self) -> Self::AddressDecimal;
163    fn last_address(&self) -> Self::Address;
164    fn last_address_as_decimal(&self) -> Self::AddressDecimal;
165    fn length(&self) -> Self::AddressDecimal;
166    fn from_cidr_pair(first_address_and_cidr: (Self::Address, u8)) -> Self;
167    fn into_cidr_pair(self) -> (Self::Address, u8);
168    fn from_cidr_pair_decimal(
169        first_and_last_address_decimal: (Self::AddressDecimal, Self::AddressDecimal),
170    ) -> Self;
171    fn into_cidr_pair_decimal(self) -> (Self::AddressDecimal, Self::AddressDecimal);
172
173    /// The full IP space — from `0` to `max_value`.
174    fn full() -> Self {
175        Self::from_cidr_pair_decimal((
176            Self::AddressDecimal::zero(),
177            Self::AddressDecimal::max_value(),
178        ))
179    }
180
181    /// Reserved / special-purpose address blocks (RFC 5735, RFC 6890).
182    fn reserved() -> &'static [Self];
183}
184
185macro_rules! impl_ip_range {
186    ($ip_range: ident, $address_type: ident, $decimal_type: ident, $reserved: ident) => {
187        #[allow(clippy::legacy_numeric_constants)]
188        impl IpRange for $ip_range {
189            type Address = $address_type;
190            type AddressDecimal = $decimal_type;
191
192            fn first_address(&self) -> Self::Address {
193                self.0.into()
194            }
195
196            fn first_address_as_decimal(&self) -> Self::AddressDecimal {
197                self.0
198            }
199
200            fn last_address(&self) -> Self::Address {
201                self.1.into()
202            }
203
204            fn last_address_as_decimal(&self) -> Self::AddressDecimal {
205                self.1
206            }
207
208            fn length(&self) -> Self::AddressDecimal {
209                (self.1 - self.0).wrapping_add(1 as $decimal_type)
210            }
211
212            fn from_cidr_pair(first_address_and_cidr: (Self::Address, u8)) -> Self {
213                let first: $decimal_type = first_address_and_cidr.0.into();
214                let last = if first_address_and_cidr.1 == 0 {
215                    Self::AddressDecimal::max_value()
216                } else {
217                    first
218                        + (<Self::AddressDecimal as NumCast>::from(2).unwrap().pow(
219                            std::mem::size_of::<$address_type>() as u32 * 8
220                                - first_address_and_cidr.1 as u32,
221                        ) - 1)
222                };
223                Self(first, last)
224            }
225
226            fn into_cidr_pair(self) -> (Self::Address, u8) {
227                self.into()
228            }
229
230            fn from_cidr_pair_decimal(
231                first_and_last_address_decimal: (Self::AddressDecimal, Self::AddressDecimal),
232            ) -> Self {
233                assert!(first_and_last_address_decimal.0 <= first_and_last_address_decimal.1);
234                Self(
235                    first_and_last_address_decimal.0,
236                    first_and_last_address_decimal.1,
237                )
238            }
239
240            fn into_cidr_pair_decimal(self) -> (Self::AddressDecimal, Self::AddressDecimal) {
241                self.into()
242            }
243
244            fn reserved() -> &'static [Self] {
245                &$reserved[..]
246            }
247        }
248
249        impl From<($address_type, u8)> for $ip_range {
250            fn from(first_address_and_cidr: ($address_type, u8)) -> $ip_range {
251                Self::from_cidr_pair(first_address_and_cidr)
252            }
253        }
254
255        impl From<$ip_range> for ($address_type, u8) {
256            fn from(range: $ip_range) -> ($address_type, u8) {
257                (
258                    $address_type::from(range.0),
259                    (range.1 - range.0 + 1)
260                        .checked_log2()
261                        .expect("Range not normalize yet") as u8,
262                )
263            }
264        }
265
266        impl From<($decimal_type, $decimal_type)> for $ip_range {
267            fn from(first_and_last_address_decimal: ($decimal_type, $decimal_type)) -> $ip_range {
268                Self::from_cidr_pair_decimal(first_and_last_address_decimal)
269            }
270        }
271
272        impl From<$ip_range> for ($decimal_type, $decimal_type) {
273            fn from(range: $ip_range) -> ($decimal_type, $decimal_type) {
274                (range.0.into(), range.1)
275            }
276        }
277
278        /// Formats as CIDR notation (e.g. `192.168.1.0/24`).
279        ///
280        /// **Panics** if the range has not been normalized (its length must be a
281        /// power of two, since that's what defines a valid CIDR prefix length).
282        impl fmt::Display for $ip_range {
283            fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
284                let cidr = if self.first_address_as_decimal() == $decimal_type::zero()
285                    && self.length() == 0
286                {
287                    0
288                } else {
289                    std::mem::size_of::<$decimal_type>() as u32 * 8
290                        - self
291                            .length()
292                            .checked_log2()
293                            .expect("Range not normalize yet")
294                };
295                write!(f, "{}/{}", self.first_address(), cidr)
296            }
297        }
298    };
299}
300
301impl_ip_range!(Ipv4Range, Ipv4Addr, u32, IPV4_RESERVED);
302impl_ip_range!(Ipv6Range, Ipv6Addr, u128, IPV6_RESERVED);
303
304pub mod aggregator;
305pub mod parser;
306mod utils;
307
308pub use aggregator::Aggregator;
309pub use parser::parse_cidrs;
310
311#[cfg(all(target_arch = "wasm32", feature = "wasm"))]
312mod wasm;
313
314#[cfg(test)]
315mod tests;