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,
}
}
pub fn is_private_ip(ip: &IpAddr) -> bool {
let normalized = normalize_mapped_ipv4(*ip);
match normalized {
IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.octets()[0] == 0 || is_cgnat(v4)
},
IpAddr::V6(v6) => {
let seg0 = v6.segments()[0];
v6.is_loopback() || (seg0 & 0xFFC0) == 0xFE80 || (seg0 & 0xFE00) == 0xFC00
},
}
}
fn is_cgnat(v4: std::net::Ipv4Addr) -> bool {
u32::from(v4) & 0xFFC0_0000 == 0x6440_0000 }
#[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)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
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 v6_range_at_96_matches_all_v4() {
let r = CidrRange::parse("::ffff:0.0.0.0/96").unwrap();
assert!(
r.contains(&"10.0.0.1".parse().unwrap()),
"/96 mapped range covers all IPv4 addresses"
);
assert!(
r.contains(&"192.168.1.1".parse().unwrap()),
"/96 mapped range covers all IPv4 addresses"
);
}
#[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");
}
#[test]
fn is_private_ip_loopback_v4() {
assert!(is_private_ip(&"127.0.0.1".parse().unwrap()), "127.0.0.1 is loopback");
assert!(
is_private_ip(&"127.255.255.255".parse().unwrap()),
"127.255.255.255 is loopback"
);
}
#[test]
fn is_private_ip_rfc1918_10() {
assert!(is_private_ip(&"10.0.0.1".parse().unwrap()), "10.0.0.1 is RFC 1918");
assert!(
is_private_ip(&"10.255.255.255".parse().unwrap()),
"10.255.255.255 is RFC 1918"
);
}
#[test]
fn is_private_ip_rfc1918_172() {
assert!(is_private_ip(&"172.16.0.1".parse().unwrap()), "172.16.0.1 is RFC 1918");
assert!(
is_private_ip(&"172.31.255.255".parse().unwrap()),
"172.31.255.255 is RFC 1918"
);
assert!(
!is_private_ip(&"172.32.0.1".parse().unwrap()),
"172.32.0.1 is outside 172.16/12"
);
}
#[test]
fn is_private_ip_rfc1918_192() {
assert!(
is_private_ip(&"192.168.0.1".parse().unwrap()),
"192.168.0.1 is RFC 1918"
);
assert!(
is_private_ip(&"192.168.255.255".parse().unwrap()),
"192.168.255.255 is RFC 1918"
);
}
#[test]
fn is_private_ip_link_local_v4() {
assert!(
is_private_ip(&"169.254.1.1".parse().unwrap()),
"169.254.1.1 is link-local"
);
assert!(
is_private_ip(&"169.254.169.254".parse().unwrap()),
"169.254.169.254 is link-local"
);
}
#[test]
fn is_private_ip_current_network() {
assert!(is_private_ip(&"0.0.0.0".parse().unwrap()), "0.0.0.0 is current network");
assert!(
is_private_ip(&"0.255.255.255".parse().unwrap()),
"0.255.255.255 is current network"
);
}
#[test]
fn is_private_ip_cgnat() {
assert!(is_private_ip(&"100.64.0.1".parse().unwrap()), "100.64.0.1 is CGNAT");
assert!(
is_private_ip(&"100.127.255.255".parse().unwrap()),
"100.127.255.255 is CGNAT"
);
assert!(
!is_private_ip(&"100.128.0.1".parse().unwrap()),
"100.128.0.1 is outside CGNAT"
);
}
#[test]
fn is_private_ip_loopback_v6() {
assert!(is_private_ip(&"::1".parse().unwrap()), "::1 is IPv6 loopback");
}
#[test]
fn is_private_ip_link_local_v6() {
assert!(is_private_ip(&"fe80::1".parse().unwrap()), "fe80::1 is IPv6 link-local");
}
#[test]
fn is_private_ip_unique_local_v6() {
assert!(
is_private_ip(&"fc00::1".parse().unwrap()),
"fc00::1 is IPv6 unique local"
);
assert!(
is_private_ip(&"fd00::1".parse().unwrap()),
"fd00::1 is IPv6 unique local"
);
}
#[test]
fn is_private_ip_mapped_v4() {
assert!(
is_private_ip(&"::ffff:10.0.0.1".parse().unwrap()),
"mapped 10.0.0.1 is private"
);
assert!(
is_private_ip(&"::ffff:127.0.0.1".parse().unwrap()),
"mapped 127.0.0.1 is private"
);
assert!(
!is_private_ip(&"::ffff:8.8.8.8".parse().unwrap()),
"mapped 8.8.8.8 is public"
);
}
#[test]
fn is_private_ip_public_addresses() {
assert!(!is_private_ip(&"8.8.8.8".parse().unwrap()), "8.8.8.8 is public");
assert!(!is_private_ip(&"1.1.1.1".parse().unwrap()), "1.1.1.1 is public");
assert!(!is_private_ip(&"2001:db8::1".parse().unwrap()), "2001:db8::1 is public");
assert!(!is_private_ip(&"203.0.113.1".parse().unwrap()), "203.0.113.1 is public");
}
}