use std::net::IpAddr;
pub fn normalize_mapped_ipv4(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(IpAddr::V6(v6), IpAddr::V4),
v4 @ IpAddr::V4(_) => v4,
}
}
#[derive(Clone, Debug)]
pub struct CidrRange {
addr: IpAddr,
prefix_len: u8,
}
impl CidrRange {
pub fn parse(s: &str) -> Result<Self, String> {
let (addr_str, len_str) = s
.split_once('/')
.ok_or_else(|| format!("invalid CIDR: {s} (missing /)"))?;
let addr: IpAddr = addr_str.parse().map_err(|e| format!("invalid IP in CIDR {s}: {e}"))?;
let prefix_len: u8 = len_str
.parse()
.map_err(|e| format!("invalid prefix length in {s}: {e}"))?;
let max = match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
if prefix_len > max {
return Err(format!("prefix length {prefix_len} exceeds maximum {max} for {s}"));
}
Ok(Self { addr, prefix_len })
}
pub fn contains(&self, ip: &IpAddr) -> bool {
match (&self.addr, ip) {
(IpAddr::V4(net), IpAddr::V4(candidate)) => v4_contains(*net, *candidate, self.prefix_len),
(IpAddr::V6(net), IpAddr::V6(candidate)) => v6_contains(*net, *candidate, self.prefix_len),
(IpAddr::V4(net), IpAddr::V6(candidate)) => v4_contains_mapped_v6(*net, *candidate, self.prefix_len),
(IpAddr::V6(net), IpAddr::V4(candidate)) => v6_contains_v4(*net, *candidate, self.prefix_len),
}
}
}
fn v4_contains(net: std::net::Ipv4Addr, candidate: std::net::Ipv4Addr, prefix_len: u8) -> bool {
let mask = v4_mask(prefix_len);
u32::from(net) & mask == u32::from(candidate) & mask
}
fn v6_contains(net: std::net::Ipv6Addr, candidate: std::net::Ipv6Addr, prefix_len: u8) -> bool {
let mask = v6_mask(prefix_len);
u128::from(net) & mask == u128::from(candidate) & mask
}
fn v4_contains_mapped_v6(net: std::net::Ipv4Addr, candidate: std::net::Ipv6Addr, prefix_len: u8) -> bool {
candidate
.to_ipv4_mapped()
.is_some_and(|mapped| v4_contains(net, mapped, prefix_len))
}
fn v6_contains_v4(net: std::net::Ipv6Addr, candidate: std::net::Ipv4Addr, prefix_len: u8) -> bool {
net.to_ipv4_mapped()
.filter(|_| prefix_len >= 96)
.is_some_and(|mapped| v4_contains(mapped, candidate, prefix_len - 96))
}
fn v4_mask(prefix_len: u8) -> u32 {
if prefix_len == 0 {
0
} else {
u32::MAX << (32 - prefix_len)
}
}
fn v6_mask(prefix_len: u8) -> u128 {
if prefix_len == 0 {
0
} else {
u128::MAX << (128 - prefix_len)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_v4() {
let r = CidrRange::parse("10.0.0.0/8").unwrap();
assert_eq!(r.prefix_len, 8, "IPv4 /8 prefix should parse as 8");
}
#[test]
fn parse_v6() {
let r = CidrRange::parse("fd00::/8").unwrap();
assert_eq!(r.prefix_len, 8, "IPv6 /8 prefix should parse as 8");
}
#[test]
fn parse_invalid_missing_slash() {
assert!(CidrRange::parse("10.0.0.0").is_err(), "CIDR without slash should fail");
}
#[test]
fn parse_invalid_prefix_too_large() {
assert!(CidrRange::parse("10.0.0.0/33").is_err(), "/33 exceeds IPv4 max of 32");
}
#[test]
fn contains_v4_match() {
let r = CidrRange::parse("10.0.0.0/8").unwrap();
assert!(
r.contains(&"10.1.2.3".parse().unwrap()),
"10.1.2.3 is within 10.0.0.0/8"
);
}
#[test]
fn contains_v4_no_match() {
let r = CidrRange::parse("10.0.0.0/8").unwrap();
assert!(
!r.contains(&"192.168.1.1".parse().unwrap()),
"192.168.1.1 is outside 10.0.0.0/8"
);
}
#[test]
fn contains_v4_exact() {
let r = CidrRange::parse("192.168.1.100/32").unwrap();
assert!(
r.contains(&"192.168.1.100".parse().unwrap()),
"/32 should match exact IP"
);
}
#[test]
fn v4_zero_prefix_matches_all() {
let r = CidrRange::parse("0.0.0.0/0").unwrap();
assert!(r.contains(&"8.8.8.8".parse().unwrap()), "/0 should match any IPv4");
}
#[test]
fn v4_v6_mismatch() {
let r = CidrRange::parse("10.0.0.0/8").unwrap();
assert!(
!r.contains(&"fd00::1".parse().unwrap()),
"IPv4 range should not match non-mapped IPv6"
);
}
#[test]
fn ipv4_mapped_ipv6_matches_v4_range() {
let r = CidrRange::parse("10.0.0.0/8").unwrap();
assert!(
r.contains(&"::ffff:10.1.2.3".parse().unwrap()),
"::ffff:10.1.2.3 should match 10.0.0.0/8"
);
}
#[test]
fn ipv4_mapped_ipv6_outside_v4_range() {
let r = CidrRange::parse("10.0.0.0/8").unwrap();
assert!(
!r.contains(&"::ffff:192.168.1.1".parse().unwrap()),
"::ffff:192.168.1.1 should not match 10.0.0.0/8"
);
}
#[test]
fn ipv4_mapped_ipv6_exact_match() {
let r = CidrRange::parse("192.168.1.100/32").unwrap();
assert!(
r.contains(&"::ffff:192.168.1.100".parse().unwrap()),
"::ffff:192.168.1.100 should match 192.168.1.100/32"
);
}
#[test]
fn ipv4_mapped_ipv6_zero_prefix() {
let r = CidrRange::parse("0.0.0.0/0").unwrap();
assert!(
r.contains(&"::ffff:8.8.8.8".parse().unwrap()),
"::ffff:8.8.8.8 should match 0.0.0.0/0"
);
}
#[test]
fn plain_v4_matches_mapped_v6_range() {
let r = CidrRange::parse("::ffff:10.0.0.0/104").unwrap();
assert!(
r.contains(&"10.1.2.3".parse().unwrap()),
"10.1.2.3 should match ::ffff:10.0.0.0/104 (equivalent to 10.0.0.0/8)"
);
}
#[test]
fn plain_v4_outside_mapped_v6_range() {
let r = CidrRange::parse("::ffff:10.0.0.0/104").unwrap();
assert!(
!r.contains(&"192.168.1.1".parse().unwrap()),
"192.168.1.1 should not match ::ffff:10.0.0.0/104"
);
}
#[test]
fn v6_range_below_96_does_not_match_v4() {
let r = CidrRange::parse("::ffff:0.0.0.0/64").unwrap();
assert!(
!r.contains(&"10.0.0.1".parse().unwrap()),
"IPv6 /64 range should not match plain IPv4 (prefix < 96)"
);
}
#[test]
fn contains_v6_match() {
let r = CidrRange::parse("fd00::/16").unwrap();
assert!(r.contains(&"fd00::1".parse().unwrap()), "fd00::1 is within fd00::/16");
}
#[test]
fn contains_v6_no_match() {
let r = CidrRange::parse("fd00::/16").unwrap();
assert!(!r.contains(&"fe80::1".parse().unwrap()), "fe80::1 is outside fd00::/16");
}
}