use std::net::{Ipv4Addr, Ipv6Addr};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Kind {
Ipv4,
Ipv6,
Cidr,
Mac,
}
impl Kind {
pub(crate) const ALL: [&'static str; 4] = ["ipv4", "ipv6", "cidr", "mac"];
pub(crate) fn parse(name: &str) -> Option<Self> {
match name {
"ipv4" => Some(Self::Ipv4),
"ipv6" => Some(Self::Ipv6),
"cidr" => Some(Self::Cidr),
"mac" => Some(Self::Mac),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Class {
Loopback,
Private,
LinkLocal,
Cgnat,
Multicast,
Broadcast,
Reserved,
Documentation,
UniqueLocal,
Global,
}
impl Class {
pub(crate) const ALL: [&'static str; 10] = [
"loopback",
"private",
"link-local",
"cgnat",
"multicast",
"broadcast",
"reserved",
"documentation",
"unique-local",
"global",
];
pub(crate) fn parse(name: &str) -> Option<Self> {
match name {
"loopback" => Some(Self::Loopback),
"private" => Some(Self::Private),
"link-local" => Some(Self::LinkLocal),
"cgnat" => Some(Self::Cgnat),
"multicast" => Some(Self::Multicast),
"broadcast" => Some(Self::Broadcast),
"reserved" => Some(Self::Reserved),
"documentation" => Some(Self::Documentation),
"unique-local" => Some(Self::UniqueLocal),
"global" => Some(Self::Global),
_ => None,
}
}
pub(crate) const fn name(self) -> &'static str {
match self {
Self::Loopback => "loopback",
Self::Private => "private",
Self::LinkLocal => "link-local",
Self::Cgnat => "cgnat",
Self::Multicast => "multicast",
Self::Broadcast => "broadcast",
Self::Reserved => "reserved",
Self::Documentation => "documentation",
Self::UniqueLocal => "unique-local",
Self::Global => "global",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Reason {
AmbiguousVersion,
MalformedAddress,
OctalHazard,
IntegerForm,
PrefixOutOfRange,
MacAmbiguous,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Refusal {
pub(crate) reason: Reason,
pub(crate) detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Block {
pub(crate) prefix: u8,
pub(crate) network: String,
pub(crate) broadcast: Option<String>,
pub(crate) last: String,
pub(crate) hosts: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Reading {
Address {
kind: Kind,
normalized: String,
class: Class,
block: Option<Block>,
},
Refused {
kind: Option<Kind>,
refusal: Refusal,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Context {
pub(crate) version: bool,
pub(crate) address: bool,
}
impl Context {
pub(crate) fn from_key(key: Option<&str>) -> Self {
let Some(key) = key else {
return Self::default();
};
let last = key.rsplit(['.', '/']).next().unwrap_or(key);
let flat: String = last
.chars()
.filter(char::is_ascii_alphanumeric)
.map(|character| character.to_ascii_lowercase())
.collect();
Self {
version: flat.contains("version") || flat == "ver" || flat == "rev",
address: ["ip", "addr", "address", "host", "gateway", "cidr", "subnet"]
.iter()
.any(|suffix| flat.ends_with(suffix)),
}
}
}
pub(crate) fn read(token: &str, context: Context) -> Option<Reading> {
if let Some((base, prefix)) = token.split_once('/') {
return read_cidr(base, prefix);
}
if let Some(octets) = mac_octets(token) {
return Some(read_mac(octets));
}
if token.contains(':') {
return looks_like_ipv6(token).then(|| read_ipv6(token));
}
if token.contains('.') {
return read_dotted(token, context);
}
read_bare(token, context)
}
pub(crate) fn looks_like_ipv6(token: &str) -> bool {
if token.contains("::") {
return true;
}
let colons = token.bytes().filter(|byte| *byte == b':').count();
if colons == 7 {
return true;
}
let dotted_tail = token
.rsplit(':')
.next()
.is_some_and(|tail| tail.split('.').count() == 4);
colons == 6 && dotted_tail
}
fn read_dotted(token: &str, context: Context) -> Option<Reading> {
let groups: Vec<&str> = token.split('.').collect();
if !groups
.iter()
.all(|group| !group.is_empty() && group.bytes().all(|byte| byte.is_ascii_digit()))
{
return None;
}
if groups.len() == 4 {
if let Some(hazard) = octal_hazard(token, &groups) {
return Some(hazard);
}
if context.version {
return None;
}
return Some(read_ipv4(token));
}
if groups.len() == 3 {
if context.version {
return None;
}
return Some(Reading::Refused {
kind: None,
refusal: Refusal {
reason: Reason::AmbiguousVersion,
detail: format!(
"{token} has three groups: a version string, or an IPv4 address missing an \
octet. Nothing here says which."
),
},
});
}
None
}
fn octal_hazard(token: &str, groups: &[&str]) -> Option<Reading> {
if !groups
.iter()
.any(|group| group.len() > 1 && group.starts_with('0'))
{
return None;
}
Some(Reading::Refused {
kind: Some(Kind::Ipv4),
refusal: Refusal {
reason: Reason::OctalHazard,
detail: format!(
"{token} has a leading zero. Some resolvers read a leading-zero octet as octal \
and some as decimal, so this text has two addresses and no way to choose."
),
},
})
}
fn read_ipv4(token: &str) -> Reading {
let Ok(address) = token.parse::<Ipv4Addr>() else {
return Reading::Refused {
kind: Some(Kind::Ipv4),
refusal: Refusal {
reason: Reason::MalformedAddress,
detail: format!("{token} has four groups and is not an IPv4 address."),
},
};
};
Reading::Address {
kind: Kind::Ipv4,
normalized: address.to_string(),
class: class_of_v4(address),
block: None,
}
}
fn read_ipv6(token: &str) -> Reading {
let bare = token.split('%').next().unwrap_or(token);
let Ok(address) = bare.parse::<Ipv6Addr>() else {
return Reading::Refused {
kind: Some(Kind::Ipv6),
refusal: Refusal {
reason: Reason::MalformedAddress,
detail: format!("{token} has the shape of an IPv6 address and is not one."),
},
};
};
Reading::Address {
kind: Kind::Ipv6,
normalized: address.to_string(),
class: class_of_v6(address),
block: None,
}
}
fn read_cidr(base: &str, prefix: &str) -> Option<Reading> {
let shaped = if base.contains(':') {
looks_like_ipv6(base)
} else {
base.split('.').count() == 4
};
if !shaped {
return None;
}
if prefix.is_empty() || !prefix.bytes().all(|byte| byte.is_ascii_digit()) {
return Some(refuse_cidr(
Reason::MalformedAddress,
format!("{base}/{prefix} does not carry a prefix length."),
));
}
let width = prefix.parse::<u16>().unwrap_or(u16::MAX);
if base.contains(':') {
return Some(cidr_v6(base, width));
}
Some(cidr_v4(base, width))
}
fn cidr_v4(base: &str, prefix: u16) -> Reading {
let groups: Vec<&str> = base.split('.').collect();
if let Some(hazard) = octal_hazard(base, &groups) {
return hazard;
}
let Ok(address) = base.parse::<Ipv4Addr>() else {
return refuse_cidr(
Reason::MalformedAddress,
format!("{base}/{prefix} does not carry an IPv4 address."),
);
};
let width = u8::try_from(prefix).ok().filter(|width| *width <= 32);
let Some(prefix) = width else {
return refuse_cidr(
Reason::PrefixOutOfRange,
format!("an IPv4 prefix is 0 to 32; {base}/{prefix} names {prefix}."),
);
};
let bits = u32::from(address);
let mask = u32::MAX.checked_shl(32 - u32::from(prefix)).unwrap_or(0);
let network = Ipv4Addr::from(bits & mask);
let last = Ipv4Addr::from((bits & mask) | !mask);
Reading::Address {
kind: Kind::Cidr,
normalized: format!("{address}/{prefix}"),
class: class_of_v4(network),
block: Some(Block {
prefix,
network: network.to_string(),
broadcast: Some(last.to_string()),
last: last.to_string(),
hosts: (1u128 << (32 - u32::from(prefix))).to_string(),
}),
}
}
fn cidr_v6(base: &str, prefix: u16) -> Reading {
let without_zone = base.split('%').next().unwrap_or(base);
let Ok(address) = without_zone.parse::<Ipv6Addr>() else {
return refuse_cidr(
Reason::MalformedAddress,
format!("{base}/{prefix} does not carry an IPv6 address."),
);
};
let width = u8::try_from(prefix).ok().filter(|width| *width <= 128);
let Some(prefix) = width else {
return refuse_cidr(
Reason::PrefixOutOfRange,
format!("an IPv6 prefix is 0 to 128; {base}/{prefix} names {prefix}."),
);
};
let bits = u128::from(address);
let mask = u128::MAX.checked_shl(128 - u32::from(prefix)).unwrap_or(0);
let network = Ipv6Addr::from(bits & mask);
let last = Ipv6Addr::from((bits & mask) | !mask);
Reading::Address {
kind: Kind::Cidr,
normalized: format!("{address}/{prefix}"),
class: class_of_v6(network),
block: Some(Block {
prefix,
network: network.to_string(),
broadcast: None,
last: last.to_string(),
hosts: v6_hosts(prefix),
}),
}
}
fn v6_hosts(prefix: u8) -> String {
if prefix == 0 {
return "340282366920938463463374607431768211456".to_string();
}
(1u128 << (128 - u32::from(prefix))).to_string()
}
fn refuse_cidr(reason: Reason, detail: String) -> Reading {
Reading::Refused {
kind: Some(Kind::Cidr),
refusal: Refusal { reason, detail },
}
}
pub(crate) fn mac_octets(token: &str) -> Option<[u8; 6]> {
[':', '-']
.into_iter()
.find_map(|separator| octets_under(token, separator))
}
fn octets_under(token: &str, separator: char) -> Option<[u8; 6]> {
let groups: Vec<&str> = token.split(separator).collect();
if groups.len() != 6 {
return None;
}
let octets: Vec<u8> = groups.iter().copied().filter_map(group_octet).collect();
<[u8; 6]>::try_from(octets.as_slice()).ok()
}
fn group_octet(group: &str) -> Option<u8> {
if group.len() != 2 || !group.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
u8::from_str_radix(group, 16).ok()
}
fn read_mac(octets: [u8; 6]) -> Reading {
let class = match octets {
[0xff, 0xff, 0xff, 0xff, 0xff, 0xff] => Class::Broadcast,
[first, ..] if first & 1 == 1 => Class::Multicast,
_ => Class::Global,
};
Reading::Address {
kind: Kind::Mac,
normalized: octets
.iter()
.map(|octet| format!("{octet:02x}"))
.collect::<Vec<String>>()
.join(":"),
class,
block: None,
}
}
fn read_bare(token: &str, context: Context) -> Option<Reading> {
if token.bytes().all(|byte| byte.is_ascii_digit()) {
if !context.address {
return None;
}
let value = token.parse::<u32>().ok()?;
return Some(Reading::Refused {
kind: Some(Kind::Ipv4),
refusal: Refusal {
reason: Reason::IntegerForm,
detail: format!(
"{token} in an address field is {} read as a 32-bit integer. Some resolvers \
accept that form; this tool will not decide that they did.",
Ipv4Addr::from(value)
),
},
});
}
let mac_like = token.len() == 12
&& token.bytes().all(|byte| byte.is_ascii_hexdigit())
&& token.bytes().any(|byte| byte.is_ascii_alphabetic());
if !mac_like {
return None;
}
Some(Reading::Refused {
kind: None,
refusal: Refusal {
reason: Reason::MacAmbiguous,
detail: format!(
"{token} is twelve hex digits with no separators: a MAC address written bare, or \
the front of a hash. Nothing here says which."
),
},
})
}
fn class_of_v4(address: Ipv4Addr) -> Class {
match address.octets() {
[127, ..] => Class::Loopback,
[10, ..] | [192, 168, ..] => Class::Private,
[172, second, ..] if (16..=31).contains(&second) => Class::Private,
[169, 254, ..] => Class::LinkLocal,
[100, second, ..] if (64..=127).contains(&second) => Class::Cgnat,
[255, 255, 255, 255] => Class::Broadcast,
[192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _] => Class::Documentation,
[224..=239, ..] => Class::Multicast,
[0 | 240..=255, ..] | [192, 0, 0, _] | [198, 18 | 19, ..] => Class::Reserved,
_ => Class::Global,
}
}
fn class_of_v6(address: Ipv6Addr) -> Class {
if let Some(mapped) = address.to_ipv4_mapped() {
return class_of_v4(mapped);
}
if address == Ipv6Addr::LOCALHOST {
return Class::Loopback;
}
if address == Ipv6Addr::UNSPECIFIED {
return Class::Reserved;
}
let segments = address.segments();
if segments[0] & 0xff00 == 0xff00 {
return Class::Multicast;
}
if segments[0] & 0xffc0 == 0xfe80 {
return Class::LinkLocal;
}
if segments[0] & 0xfe00 == 0xfc00 {
return Class::UniqueLocal;
}
if segments[0] == 0x2001 && segments[1] == 0x0db8 {
return Class::Documentation;
}
if segments[0] == 0x2001 && segments[1] < 0x0200 {
return Class::Reserved;
}
if segments[0] == 0x0100 && segments[1..4] == [0, 0, 0] {
return Class::Reserved;
}
Class::Global
}
#[cfg(test)]
mod tests {
use super::*;
fn address(token: &str) -> (Kind, String, Class) {
match read(token, Context::default()).expect("a reading") {
Reading::Address {
kind,
normalized,
class,
..
} => (kind, normalized, class),
Reading::Refused { refusal, .. } => {
panic!("{token} was refused: {:?}", refusal.reason)
}
}
}
fn refusal(token: &str, context: Context) -> Reason {
match read(token, context).expect("a reading") {
Reading::Refused { refusal, .. } => refusal.reason,
Reading::Address { normalized, .. } => {
panic!("{token} was read as {normalized}")
}
}
}
fn block(token: &str) -> Block {
match read(token, Context::default()).expect("a reading") {
Reading::Address { block, .. } => block.expect("a block"),
Reading::Refused { refusal, .. } => {
panic!("{token} was refused: {:?}", refusal.reason)
}
}
}
#[test]
fn an_ipv4_address_is_read_and_classified() {
assert_eq!(
address("192.168.1.1"),
(Kind::Ipv4, "192.168.1.1".to_string(), Class::Private)
);
}
#[test]
fn four_spellings_of_one_ipv6_address_normalize_together() {
for token in [
"2001:0db8:0000:0000:0000:0000:0000:0001",
"2001:db8:0:0:0:0:0:1",
"2001:0db8::0001",
"2001:DB8::1",
] {
assert_eq!(address(token).1, "2001:db8::1", "{token}");
}
}
#[test]
fn every_ipv4_class_has_an_address_that_reaches_it() {
for (token, expected) in [
("127.0.0.1", Class::Loopback),
("10.1.2.3", Class::Private),
("172.16.0.1", Class::Private),
("172.32.0.1", Class::Global),
("192.168.0.1", Class::Private),
("169.254.169.254", Class::LinkLocal),
("100.64.0.1", Class::Cgnat),
("100.128.0.1", Class::Global),
("224.0.0.1", Class::Multicast),
("255.255.255.255", Class::Broadcast),
("192.0.2.5", Class::Documentation),
("198.51.100.5", Class::Documentation),
("203.0.113.5", Class::Documentation),
("0.0.0.0", Class::Reserved),
("192.0.0.8", Class::Reserved),
("198.18.0.1", Class::Reserved),
("240.0.0.1", Class::Reserved),
("8.8.8.8", Class::Global),
] {
assert_eq!(address(token).2, expected, "{token}");
}
}
#[test]
fn every_ipv6_class_has_an_address_that_reaches_it() {
for (token, expected) in [
("::1", Class::Loopback),
("::", Class::Reserved),
("fe80::1", Class::LinkLocal),
("fd00::1", Class::UniqueLocal),
("ff02::1", Class::Multicast),
("2001:db8::1", Class::Documentation),
("2001:0:1::1", Class::Reserved),
("100::1", Class::Reserved),
("2606:4700:4700::1111", Class::Global),
] {
assert_eq!(address(token).2, expected, "{token}");
}
}
#[test]
fn an_ipv4_mapped_address_takes_the_ipv4_class() {
assert_eq!(address("::ffff:127.0.0.1").2, Class::Loopback);
assert_eq!(address("::ffff:169.254.169.254").2, Class::LinkLocal);
}
#[test]
fn a_mac_address_is_lowercased_and_colon_separated() {
assert_eq!(
address("AA-BB-CC-DD-EE-FF"),
(Kind::Mac, "aa:bb:cc:dd:ee:ff".to_string(), Class::Global)
);
assert_eq!(address("ff:ff:ff:ff:ff:ff").2, Class::Broadcast);
assert_eq!(address("01:00:5e:00:00:01").2, Class::Multicast);
}
#[test]
fn a_mac_with_mixed_separators_is_not_a_mac() {
assert!(read("aa:bb-cc:dd-ee:ff", Context::default()).is_none());
}
#[test]
fn a_seventh_group_is_not_a_mac_with_a_suffix() {
for token in [
"aa:bb:cc:dd:ee:ff:zz",
"aa:bb:cc:dd:ee:ff:00",
"aa:bb:cc:dd:ee",
"aa:bb:cc:dd:ee:fff",
"aa:bb:cc:dd:ee:zz",
] {
assert!(mac_octets(token).is_none(), "{token}");
}
assert_eq!(
mac_octets("aa:bb:cc:dd:ee:ff"),
Some([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])
);
}
#[test]
fn a_leading_zero_is_an_octal_hazard_and_is_never_resolved() {
for token in ["010.1.1.1", "0177.0.0.1", "192.168.001.1"] {
assert_eq!(refusal(token, Context::default()), Reason::OctalHazard);
}
let Some(Reading::Refused { refusal, .. }) = read("010.1.1.1", Context::default()) else {
panic!("a refusal");
};
assert!(!refusal.detail.contains("8.1.1.1"), "{}", refusal.detail);
assert!(
!refusal.detail.replace("010.1.1.1", "").contains("10.1.1.1"),
"{}",
refusal.detail
);
}
#[test]
fn an_ordinary_zero_octet_is_not_a_hazard() {
assert_eq!(address("10.0.0.1").1, "10.0.0.1");
}
#[test]
fn a_dotted_triple_is_ambiguous_unless_the_key_says_version() {
assert_eq!(
refusal("10.0.1", Context::default()),
Reason::AmbiguousVersion
);
assert!(
read(
"10.0.1",
Context {
version: true,
..Context::default()
}
)
.is_none()
);
}
#[test]
fn a_version_key_suppresses_a_four_group_reading_too() {
assert!(
read(
"1.2.3.4",
Context {
version: true,
..Context::default()
}
)
.is_none()
);
assert_eq!(address("1.2.3.4").0, Kind::Ipv4);
}
#[test]
fn a_four_group_run_that_is_not_an_address_is_malformed() {
for token in ["256.1.1.1", "1.2.3.9999"] {
assert_eq!(refusal(token, Context::default()), Reason::MalformedAddress);
}
}
#[test]
fn an_ipv6_shape_that_does_not_parse_is_malformed() {
for token in ["12345::1", "1::2::3", "2001:db8:::1"] {
assert_eq!(refusal(token, Context::default()), Reason::MalformedAddress);
}
}
#[test]
fn an_integer_in_an_address_field_is_flagged_and_decoded_together() {
let context = Context {
address: true,
..Context::default()
};
let Some(Reading::Refused { refusal, .. }) = read("2130706433", context) else {
panic!("a refusal");
};
assert_eq!(refusal.reason, Reason::IntegerForm);
assert!(refusal.detail.contains("127.0.0.1"), "{}", refusal.detail);
}
#[test]
fn a_bare_integer_with_no_address_context_is_not_a_finding() {
assert!(read("2130706433", Context::default()).is_none());
}
#[test]
fn twelve_bare_hex_digits_are_ambiguous() {
assert_eq!(
refusal("deadbeefcafe", Context::default()),
Reason::MacAmbiguous
);
assert!(read("123456789012", Context::default()).is_none());
assert!(read("deadbeefcafeff", Context::default()).is_none());
}
#[test]
fn a_prefix_outside_its_family_is_refused() {
assert_eq!(
refusal("10.0.0.0/33", Context::default()),
Reason::PrefixOutOfRange
);
assert_eq!(
refusal("2001:db8::/129", Context::default()),
Reason::PrefixOutOfRange
);
}
#[test]
fn a_prefix_too_wide_to_narrow_is_still_out_of_range() {
for token in ["10.0.0.0/999", "2001:db8::/999"] {
assert_eq!(
refusal(token, Context::default()),
Reason::PrefixOutOfRange,
"{token}"
);
}
}
#[test]
fn a_block_over_a_base_that_is_not_an_address_is_malformed() {
for token in [
"256.1.1.1/24",
"1.2.3.9999/8",
"2001:db8:::1/64",
"12345::/16",
] {
assert_eq!(
refusal(token, Context::default()),
Reason::MalformedAddress,
"{token}"
);
}
}
#[test]
fn a_prefix_that_is_not_a_number_is_malformed() {
for token in ["10.0.0.0/abc", "2001:db8::/x", "10.0.0.0/"] {
assert_eq!(
refusal(token, Context::default()),
Reason::MalformedAddress,
"{token}"
);
}
}
#[test]
fn a_prefix_of_digits_is_out_of_range_however_long_it_is() {
for token in [
"10.0.0.0/33",
"10.0.0.0/999",
"10.0.0.0/65536",
"10.0.0.0/99999999999999999999",
"2001:db8::/129",
"2001:db8::/65536",
"2001:db8::/99999999999999999999",
] {
assert_eq!(
refusal(token, Context::default()),
Reason::PrefixOutOfRange,
"{token}"
);
}
}
#[test]
fn a_slash_after_a_non_address_is_not_a_block() {
for token in ["1/2", "10.0/8", "1.2.3/24", "ab/16"] {
assert!(read(token, Context::default()).is_none(), "{token}");
}
}
#[test]
fn a_dotted_run_that_is_not_decimal_is_silent() {
for token in ["a.b", "fe.ff", "10..1", "1.2.c.4", "."] {
assert!(read(token, Context::default()).is_none(), "{token}");
}
}
#[test]
fn an_ipv4_block_reports_its_bounds() {
let block = block("10.0.0.0/8");
assert_eq!(block.prefix, 8);
assert_eq!(block.network, "10.0.0.0");
assert_eq!(block.broadcast.as_deref(), Some("10.255.255.255"));
assert_eq!(block.hosts, "16777216");
}
#[test]
fn a_block_written_with_host_bits_keeps_both_forms() {
let Some(Reading::Address {
normalized, block, ..
}) = read("10.1.2.3/24", Context::default())
else {
panic!("an address");
};
assert_eq!(normalized, "10.1.2.3/24");
assert_eq!(block.expect("a block").network, "10.1.2.0");
}
#[test]
fn the_widest_and_narrowest_blocks_are_arithmetically_right() {
let all = block("0.0.0.0/0");
assert_eq!(all.hosts, "4294967296");
assert_eq!(all.broadcast.as_deref(), Some("255.255.255.255"));
let one = block("192.0.2.1/32");
assert_eq!(one.hosts, "1");
assert_eq!(one.network, "192.0.2.1");
}
#[test]
fn the_whole_ipv6_space_counts_correctly() {
let all = block("::/0");
assert_eq!(all.hosts, "340282366920938463463374607431768211456");
assert_eq!(all.broadcast, None, "IPv6 has no broadcast address");
assert_eq!(all.last, "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff");
assert_eq!(
block("2001:db8::/32").hosts,
"79228162514264337593543950336"
);
}
#[test]
fn a_block_is_classified_by_its_network_address() {
let Some(Reading::Address { class, .. }) = read("127.0.0.0/8", Context::default()) else {
panic!("an address");
};
assert_eq!(class, Class::Loopback);
}
#[test]
fn an_octal_hazard_in_a_block_is_still_refused() {
assert_eq!(
refusal("010.0.0.0/8", Context::default()),
Reason::OctalHazard
);
}
#[test]
fn every_named_kind_and_class_parses_back() {
for name in Kind::ALL {
let kind = Kind::parse(name).expect(name);
let rendered = serde_json::to_value(kind).expect("serializes");
assert_eq!(rendered, serde_json::json!(name));
}
for name in Class::ALL {
let class = Class::parse(name).expect(name);
let rendered = serde_json::to_value(class).expect("serializes");
assert_eq!(rendered, serde_json::json!(name));
}
assert!(Kind::parse("ipv5").is_none());
assert!(Class::parse("public").is_none());
}
#[test]
fn the_printed_name_of_a_class_is_the_name_serde_writes() {
for name in Class::ALL {
let class = Class::parse(name).expect(name);
assert_eq!(class.name(), name);
assert_eq!(serde_json::to_value(class).expect("serializes"), name);
}
}
#[test]
fn an_address_key_is_recognised_by_its_last_segment() {
assert!(Context::from_key(Some("server.remote_ip")).address);
assert!(Context::from_key(Some("bind-address")).address);
assert!(Context::from_key(Some("DB_HOST")).address);
assert!(!Context::from_key(Some("description")).address);
assert!(!Context::from_key(None).address);
assert!(Context::from_key(Some("app.version")).version);
assert!(!Context::from_key(Some("app.name")).version);
}
}