Skip to main content

firewall_objects/ip/
range.rs

1//! Inclusive IPv4/IPv6 range utilities.
2
3use std::fmt;
4use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
5use std::str::FromStr;
6
7/// Inclusive IP range (start <= end) for either IPv4 or IPv6.
8///
9/// Keep this in your crate so you control ordering rules and serialization.
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub enum IpRange {
13    V4 { start: Ipv4Addr, end: Ipv4Addr },
14    V6 { start: Ipv6Addr, end: Ipv6Addr },
15}
16
17/// IP address family for an `IpRange`.
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub enum IpFamily {
21    V4,
22    V6,
23}
24
25impl IpRange {
26    /// Create a validated range.
27    ///
28    /// Ensures the start/end address families match and that `start <= end`.
29    ///
30    /// # Examples
31    ///
32    /// IPv4:
33    /// ```rust
34    /// use std::net::{IpAddr, Ipv4Addr};
35    /// use firewall_objects::ip::range::IpRange;
36    ///
37    /// let r = IpRange::new(
38    ///     IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)),
39    ///     IpAddr::V4(Ipv4Addr::new(192, 0, 2, 20)),
40    /// ).unwrap();
41    /// assert!(r.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 15))));
42    /// ```
43    ///
44    /// IPv6:
45    /// ```rust
46    /// use std::net::{IpAddr, Ipv6Addr};
47    /// use firewall_objects::ip::range::IpRange;
48    ///
49    /// let r = IpRange::new(
50    ///     IpAddr::V6(Ipv6Addr::LOCALHOST),
51    ///     IpAddr::V6(Ipv6Addr::LOCALHOST),
52    /// ).unwrap();
53    /// assert!(r.contains(IpAddr::V6(Ipv6Addr::LOCALHOST)));
54    /// ```
55    ///
56    /// Mismatched families fail:
57    /// ```rust
58    /// use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
59    /// use firewall_objects::ip::range::IpRange;
60    ///
61    /// let err = IpRange::new(
62    ///     IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)),
63    ///     IpAddr::V6(Ipv6Addr::LOCALHOST),
64    /// ).unwrap_err();
65    /// assert!(err.contains("families must match"));
66    /// ```
67    pub fn new(start: IpAddr, end: IpAddr) -> Result<Self, String> {
68        match (start, end) {
69            (IpAddr::V4(s), IpAddr::V4(e)) => {
70                if u32::from(s) > u32::from(e) {
71                    return Err("IPv4 range start > end".into());
72                }
73                Ok(IpRange::V4 { start: s, end: e })
74            }
75            (IpAddr::V6(s), IpAddr::V6(e)) => {
76                if u128::from(s) > u128::from(e) {
77                    return Err("IPv6 range start > end".into());
78                }
79                Ok(IpRange::V6 { start: s, end: e })
80            }
81            _ => Err("IP range start/end address families must match".into()),
82        }
83    }
84
85    /// Create a validated IPv4 range.
86    pub fn new_v4(start: Ipv4Addr, end: Ipv4Addr) -> Result<Self, String> {
87        IpRange::new(IpAddr::V4(start), IpAddr::V4(end))
88    }
89
90    /// Create a validated IPv6 range.
91    pub fn new_v6(start: Ipv6Addr, end: Ipv6Addr) -> Result<Self, String> {
92        IpRange::new(IpAddr::V6(start), IpAddr::V6(end))
93    }
94
95    /// Return the IP family of this range.
96    pub fn family(&self) -> IpFamily {
97        match self {
98            IpRange::V4 { .. } => IpFamily::V4,
99            IpRange::V6 { .. } => IpFamily::V6,
100        }
101    }
102
103    /// Return the (start, end) tuple for an IPv4 range.
104    pub fn as_v4(&self) -> Option<(Ipv4Addr, Ipv4Addr)> {
105        match self {
106            IpRange::V4 { start, end } => Some((*start, *end)),
107            _ => None,
108        }
109    }
110
111    /// Return the (start, end) tuple for an IPv6 range.
112    pub fn as_v6(&self) -> Option<(Ipv6Addr, Ipv6Addr)> {
113        match self {
114            IpRange::V6 { start, end } => Some((*start, *end)),
115            _ => None,
116        }
117    }
118
119    /// Return the start of the range as IPv4, if this is an IPv4 range.
120    pub fn start_v4(&self) -> Option<Ipv4Addr> {
121        self.as_v4().map(|(s, _)| s)
122    }
123
124    /// Return the end of the range as IPv4, if this is an IPv4 range.
125    pub fn end_v4(&self) -> Option<Ipv4Addr> {
126        self.as_v4().map(|(_, e)| e)
127    }
128
129    /// Return the start of the range as IPv6, if this is an IPv6 range.
130    pub fn start_v6(&self) -> Option<Ipv6Addr> {
131        self.as_v6().map(|(s, _)| s)
132    }
133
134    /// Return the end of the range as IPv6, if this is an IPv6 range.
135    pub fn end_v6(&self) -> Option<Ipv6Addr> {
136        self.as_v6().map(|(_, e)| e)
137    }
138
139    /// Check whether the given IPv4 address is within the range (inclusive).
140    pub fn contains_v4(&self, ip: Ipv4Addr) -> bool {
141        matches!(self, IpRange::V4 { .. }) && self.contains(IpAddr::V4(ip))
142    }
143
144    /// Check whether the given IPv6 address is within the range (inclusive).
145    pub fn contains_v6(&self, ip: Ipv6Addr) -> bool {
146        matches!(self, IpRange::V6 { .. }) && self.contains(IpAddr::V6(ip))
147    }
148
149    /// Parse an IP range from a string.
150    ///
151    /// Supported format is `start-end` where both sides are valid IP addresses
152    /// of the same family (IPv4 or IPv6). Whitespace around addresses is allowed.
153    ///
154    /// # Examples
155    ///
156    /// IPv4:
157    /// ```rust
158    /// use std::net::{IpAddr, Ipv4Addr};
159    /// use firewall_objects::ip::range::IpRange;
160    ///
161    /// let r = IpRange::parse("192.0.2.10-192.0.2.20").unwrap();
162    /// assert!(r.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 15))));
163    /// ```
164    ///
165    /// IPv6:
166    /// ```rust
167    /// use std::net::{IpAddr, Ipv6Addr};
168    /// use firewall_objects::ip::range::IpRange;
169    ///
170    /// let r = IpRange::parse("::1-::1").unwrap();
171    /// assert!(r.contains(IpAddr::V6(Ipv6Addr::LOCALHOST)));
172    /// ```
173    ///
174    /// Bad formats fail:
175    /// ```rust
176    /// use firewall_objects::ip::range::IpRange;
177    ///
178    /// assert!(IpRange::parse("192.0.2.10").is_err());
179    /// assert!(IpRange::parse("192.0.2.10-").is_err());
180    /// assert!(IpRange::parse("-192.0.2.10").is_err());
181    /// ```
182    pub fn parse(s: &str) -> Result<Self, String> {
183        let input = s.trim();
184
185        if input.is_empty() {
186            return Err("IP range string cannot be empty".into());
187        }
188
189        let (a, b) = input
190            .split_once('-')
191            .ok_or_else(|| "invalid IP range format; expected 'start-end'".to_string())?;
192
193        let a = a.trim();
194        let b = b.trim();
195
196        if a.is_empty() || b.is_empty() {
197            return Err("invalid IP range format; missing start or end".into());
198        }
199
200        let start: IpAddr = a
201            .parse()
202            .map_err(|_| "invalid IP range start".to_string())?;
203        let end: IpAddr = b.parse().map_err(|_| "invalid IP range end".to_string())?;
204
205        IpRange::new(start, end)
206    }
207
208    /// Return the start of the range.
209    pub fn start(&self) -> IpAddr {
210        match self {
211            IpRange::V4 { start, .. } => IpAddr::V4(*start),
212            IpRange::V6 { start, .. } => IpAddr::V6(*start),
213        }
214    }
215
216    /// Return the end of the range.
217    pub fn end(&self) -> IpAddr {
218        match self {
219            IpRange::V4 { end, .. } => IpAddr::V4(*end),
220            IpRange::V6 { end, .. } => IpAddr::V6(*end),
221        }
222    }
223
224    /// Returns true if this is an IPv4 range.
225    pub fn is_v4(&self) -> bool {
226        matches!(self, IpRange::V4 { .. })
227    }
228
229    /// Returns true if this is an IPv6 range.
230    pub fn is_v6(&self) -> bool {
231        matches!(self, IpRange::V6 { .. })
232    }
233
234    /// Check whether the given IP is within the range (inclusive).
235    pub fn contains(&self, ip: IpAddr) -> bool {
236        match (self, ip) {
237            (IpRange::V4 { start, end }, IpAddr::V4(v)) => {
238                let s = u32::from(*start);
239                let e = u32::from(*end);
240                let x = u32::from(v);
241                s <= x && x <= e
242            }
243            (IpRange::V6 { start, end }, IpAddr::V6(v)) => {
244                let s = u128::from(*start);
245                let e = u128::from(*end);
246                let x = u128::from(v);
247                s <= x && x <= e
248            }
249            _ => false,
250        }
251    }
252}
253
254impl fmt::Debug for IpRange {
255    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
256        match self {
257            IpRange::V4 { start, end } => f
258                .debug_struct("IpRange")
259                .field("start", start)
260                .field("end", end)
261                .finish(),
262            IpRange::V6 { start, end } => f
263                .debug_struct("IpRange")
264                .field("start", start)
265                .field("end", end)
266                .finish(),
267        }
268    }
269}
270
271impl FromStr for IpRange {
272    type Err = String;
273
274    fn from_str(s: &str) -> Result<Self, Self::Err> {
275        IpRange::parse(s)
276    }
277}
278
279impl fmt::Display for IpRange {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        match self {
282            IpRange::V4 { start, end } => write!(f, "{}-{}", start, end),
283            IpRange::V6 { start, end } => write!(f, "{}-{}", start, end),
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::IpRange;
291    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
292    use std::str::FromStr;
293
294    #[test]
295    fn parse_rejects_mismatched_families() {
296        let err = IpRange::parse("192.0.2.1-2001:db8::1").unwrap_err();
297        assert!(err.contains("families"));
298    }
299
300    #[test]
301    fn contains_checks_are_inclusive() {
302        let start = Ipv4Addr::new(192, 0, 2, 10);
303        let end = Ipv4Addr::new(192, 0, 2, 12);
304        let range = IpRange::new(IpAddr::V4(start), IpAddr::V4(end)).unwrap();
305        assert!(range.contains(IpAddr::V4(start)));
306        assert!(range.contains(IpAddr::V4(end)));
307        assert!(!range.contains(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 13))));
308    }
309
310    #[test]
311    fn from_str_round_trips_ipv6() {
312        let input = "2001:db8::1-2001:db8::5";
313        let parsed = IpRange::from_str(input).unwrap();
314        assert_eq!(parsed.to_string(), input);
315        assert!(parsed.contains(IpAddr::V6(Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 2))));
316    }
317}