#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Expect {
Verbatim,
Canonical(&'static str),
Rejected(&'static str),
Unknown,
}
#[derive(Debug, Clone)]
pub struct Probe {
pub id: &'static str,
pub subname: &'static str,
pub record_type: &'static str,
pub wire: &'static str,
pub expect: Expect,
pub note: &'static str,
}
const TXT_300: &str = concat!(
'"',
"012345678901234567890123456789012345678901234567890123456789",
"012345678901234567890123456789012345678901234567890123456789",
"012345678901234567890123456789012345678901234567890123456789",
"012345678901234567890123456789012345678901234567890123456789",
"012345678901234567890123456789012345678901234567890123456789",
'"',
);
const TXT_300_SPLIT: &str = concat!(
'"',
"012345678901234567890123456789012345678901234567890123456789",
"012345678901234567890123456789012345678901234567890123456789",
"012345678901234567890123456789012345678901234567890123456789",
"012345678901234567890123456789012345678901234567890123456789",
"012345678901234",
"\" \"",
"567890123456789012345678901234567890123456789",
'"',
);
pub const PROBES: &[Probe] = &[
Probe {
id: "a-control",
subname: "a",
record_type: "A",
wire: "192.0.2.1",
expect: Expect::Verbatim,
note: "The control. A difference here means the harness is wrong, not deSEC.",
},
Probe {
id: "aaaa-expanded",
subname: "aaaa",
record_type: "AAAA",
wire: "2001:0DB8:0000:0000:0000:0000:0000:0001",
expect: Expect::Canonical("2001:db8::1"),
note: "Zero-compression and hex case, the axis deSEC's own docs give as the example. \
Compressed and lowercased, as documented.",
},
Probe {
id: "aaaa-v4mapped-dotted",
subname: "aaaa-v4m",
record_type: "AAAA",
wire: "::ffff:192.0.2.1",
expect: Expect::Canonical("::ffff:c000:201"),
note: "The surprise of the set. deSEC renders an embedded IPv4 address as hex, where \
Rust's Ipv6Addr, Go's netip.Addr and dnspython all render it dotted — so a \
consumer normalizing with any of the three still disagrees with storage.",
},
Probe {
id: "aaaa-v4mapped-hex",
subname: "aaaa-v4h",
record_type: "AAAA",
wire: "::ffff:c000:0201",
expect: Expect::Canonical("::ffff:c000:201"),
note: "The same address as aaaa-v4mapped-dotted, written as hex. Both come back \
identical, which is what makes the hex form the server's choice rather than \
an inference from one sample.",
},
Probe {
id: "aaaa-v4compat-dotted",
subname: "aaaa-v4c",
record_type: "AAAA",
wire: "::192.0.2.1",
expect: Expect::Canonical("::c000:201"),
note: "IPv4-compatible, deprecated by RFC 4291. Accepted, and rendered as hex — which \
is also what Rust's Ipv6Addr does, so this form is the one embedded-IPv4 case \
where a stock renderer already agrees with storage.",
},
Probe {
id: "cname-case",
subname: "cname",
record_type: "CNAME",
wire: "Alias.Example.ORG.",
expect: Expect::Verbatim,
note: "Names are stored with the case they were given. Nothing case-folds them, so a \
consumer that lowercases on both sides of its own comparison is doing the \
right thing for its own reasons, not matching the server.",
},
Probe {
id: "mx-case",
subname: "mx",
record_type: "MX",
wire: "10 Mail.Example.ORG.",
expect: Expect::Verbatim,
note: "Name case survives and the preference field is not respaced.",
},
Probe {
id: "srv-name",
subname: "_sip._tcp.srv",
record_type: "SRV",
wire: "10 20 5060 Sip.Example.ORG.",
expect: Expect::Verbatim,
note: "The name is field 3, so a consumer that dots field 0 corrupts this. Untouched.",
},
Probe {
id: "ns-sub",
subname: "deleg",
record_type: "NS",
wire: "Ns1.Example.ORG.",
expect: Expect::Verbatim,
note: "A delegation at a subname, which a caller owns. Only apex NS is deSEC's.",
},
Probe {
id: "ds-sub",
subname: "deleg",
record_type: "DS",
wire: "12345 13 2 3A5B7C9D1E2F4A6B8C0D2E4F6A8B0C1D3E5F7A9B1C3D5E7F9A0B2C4D6E8F0A1B",
expect: Expect::Canonical(
"12345 13 2 3a5b7c9d1e2f4a6b8c0d2e4f6a8b0c1d3e5f7a9b1c3d5e7f9a0b2c4d6e8f0a1b",
),
note: "Digests lowercase. A byte-for-byte comparison differs; a case-insensitive one \
does not, which is why this costs external-dns consumers nothing.",
},
Probe {
id: "ptr-case",
subname: "ptr",
record_type: "PTR",
wire: "Host.Example.ORG.",
expect: Expect::Verbatim,
note: "Name at field 0. Untouched.",
},
Probe {
id: "txt-long",
subname: "txt-long",
record_type: "TXT",
wire: TXT_300,
expect: Expect::Canonical(TXT_300_SPLIT),
note: "Split at exactly 255, into two character-strings inside one presentation value \
— one record, not two. A consumer that rejoins chunks when reading sees the \
value it sent; one that compares presentation forms does not.",
},
Probe {
id: "txt-escapes",
subname: "txt-esc",
record_type: "TXT",
wire: r#""has \"quotes\" and \\ backslash and unicode: \195\188""#,
expect: Expect::Verbatim,
note: "Escapes survive exactly, including \\DDD octets. Nothing re-escapes or \
re-encodes, so a consumer's own escaping is the only thing that has to agree \
with itself.",
},
Probe {
id: "txt-prechunked",
subname: "txt-multi",
record_type: "TXT",
wire: r#""already" "chunked""#,
expect: Expect::Verbatim,
note: "An author's own chunking survives untouched. Combined with txt-long: deSEC \
splits only when it has to, and never re-chunks what already fits.",
},
Probe {
id: "spf-quoted",
subname: "spf",
record_type: "SPF",
wire: r#""v=spf1 include:_spf.example.org -all""#,
expect: Expect::Verbatim,
note: "Accepted despite the type being obsolete, and stored as given.",
},
Probe {
id: "caa-tag-case",
subname: "caa",
record_type: "CAA",
wire: r#"0 ISSUE "letsencrypt.org""#,
expect: Expect::Verbatim,
note: "The tag keeps its case; it is not folded to `issue`. Nothing to reproduce.",
},
Probe {
id: "naptr-flag-case",
subname: "naptr",
record_type: "NAPTR",
wire: r#"100 10 "S" "SIP+D2U" "" _sip._udp.Example.ORG."#,
expect: Expect::Verbatim,
note: "Flags, service and the name at field 5 all survive. Note the index only holds \
while the earlier fields contain no whitespace, which this value respects.",
},
Probe {
id: "tlsa-hex-case",
subname: "_443._tcp.tlsa",
record_type: "TLSA",
wire: "3 1 1 3A5B7C9D1E2F4A6B8C0D2E4F6A8B0C1D3E5F7A9B1C3D5E7F9A0B2C4D6E8F0A1B",
expect: Expect::Canonical(
"3 1 1 3a5b7c9d1e2f4a6b8c0d2e4f6a8b0c1d3e5f7a9b1c3d5e7f9a0b2c4d6e8f0a1b",
),
note: "Lowercased, as ds-sub.",
},
Probe {
id: "sshfp-hex-case",
subname: "sshfp",
record_type: "SSHFP",
wire: "2 1 3A5B7C9D1E2F4A6B8C0D2E4F6A8B0C1D3E5F7A9B",
expect: Expect::Canonical("2 1 3a5b7c9d1e2f4a6b8c0d2e4f6a8b0c1d3e5f7a9b"),
note: "Lowercased, as ds-sub.",
},
Probe {
id: "uri-target",
subname: "_http._tcp.uri",
record_type: "URI",
wire: r#"10 1 "https://Example.ORG/Path""#,
expect: Expect::Verbatim,
note: "Case survives, as it must: a URI path is case-sensitive. Quotes are echoed.",
},
Probe {
id: "loc-format",
subname: "loc",
record_type: "LOC",
wire: "42 21 54 N 71 06 18 W 24m 30m 10m 10m",
expect: Expect::Canonical("42 21 54.000 N 71 6 18.000 W 24.00m 30.00m 10.00m 10.00m"),
note: "Reformatted on three axes at once: seconds gain .000, minutes lose a leading \
zero, distances gain .00. All four defaults are spelled out rather than \
omitted.",
},
Probe {
id: "loc-quantized",
subname: "loc2",
record_type: "LOC",
wire: "42 21 54 N 71 06 18 W 24m 33m 10m 10m",
expect: Expect::Canonical("42 21 54.000 N 71 6 18.000 W 24.00m 30.00m 10.00m 10.00m"),
note: "The strongest result in the table. 33m is not representable — size is a \
mantissa-and-exponent byte — so it lands on 30.00m, and this row and \
loc-format come back identical despite differing on input. The rewrite is \
lossy, so no amount of string normalization can predict it. A consumer has to \
remember what it wrote, or write again forever.",
},
Probe {
id: "svcb-param-order",
subname: "svcb",
record_type: "SVCB",
wire: "1 svc.Example.ORG. port=443 alpn=h2,h3 ipv6hint=2001:0DB8::1",
expect: Expect::Canonical("1 svc.Example.ORG. alpn=h2,h3 port=443 ipv6hint=2001:db8::1"),
note: "Parameters are sorted into key order (alpn=1, port=3, ipv6hint=6) and the hint \
is compressed like an AAAA. The target name keeps its case. Reproducible in \
principle, but only by implementing the parameter registry.",
},
Probe {
id: "https-alias",
subname: "https",
record_type: "HTTPS",
wire: "0 Svc.Example.ORG.",
expect: Expect::Verbatim,
note: "AliasMode: priority 0, no parameters, nothing to reorder. The control that \
isolates svcb-param-order's difference to the parameters.",
},
Probe {
id: "https-params",
subname: "https2",
record_type: "HTTPS",
wire: r#"1 . alpn="h3,h2" no-default-alpn ipv4hint=192.0.2.1,192.0.2.2"#,
expect: Expect::Canonical("1 . alpn=h3,h2 no-default-alpn ipv4hint=192.0.2.1,192.0.2.2"),
note: "Quoting is dropped from alpn, the valueless parameter is echoed, and the IPv4 \
hints are left alone. Already in key order, so this isolates the quoting.",
},
];
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
use std::collections::HashSet;
fn probe(id: &str) -> &'static Probe {
PROBES.iter().find(|p| p.id == id).expect("probe exists")
}
#[test]
fn probe_ids_are_unique() {
let mut seen = HashSet::new();
for probe in PROBES {
assert!(seen.insert(probe.id), "duplicate probe id {}", probe.id);
}
}
#[test]
fn only_the_delegation_probes_share_a_subname() {
let mut seen = HashSet::new();
for probe in PROBES {
if !seen.insert(probe.subname) {
assert_eq!(
probe.subname, "deleg",
"{} shares a subname, which makes bulk failures unattributable",
probe.id
);
}
}
}
#[test]
fn every_record_type_parses() {
for probe in PROBES {
let parsed: crate::RecordType = probe
.record_type
.parse()
.expect("probe record type is a mnemonic");
assert_eq!(parsed.as_str(), probe.record_type, "{}", probe.id);
}
}
#[test]
fn no_probe_sits_at_the_apex() {
for probe in PROBES {
assert!(!probe.subname.is_empty(), "{} is at the apex", probe.id);
}
}
#[test]
fn the_long_txt_probe_is_past_the_character_string_limit() {
let payload = TXT_300.trim_matches('"');
assert_eq!(payload.len(), 300);
assert!(payload.len() > 255);
}
#[test]
fn the_recorded_split_preserves_the_payload() {
let sent = TXT_300.trim_matches('"');
let chunks: Vec<&str> = TXT_300_SPLIT.trim_matches('"').split("\" \"").collect();
assert_eq!(chunks.len(), 2, "{TXT_300_SPLIT}");
assert_eq!(
chunks[0].len(),
255,
"deSEC cuts at the character-string limit"
);
assert_eq!(
chunks.concat(),
sent,
"the split must not alter the payload"
);
}
#[test]
fn the_digest_probes_carry_a_digest_of_the_right_length() {
for (id, field, expected) in [
("ds-sub", 3, 64),
("tlsa-hex-case", 3, 64),
("sshfp-hex-case", 2, 40),
] {
let digest = probe(id)
.wire
.split_whitespace()
.nth(field)
.expect("digest field is present");
assert_eq!(digest.len(), expected, "{id}");
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
}
}
#[test]
fn the_ipv4_mapped_pair_is_one_address_written_two_ways() {
let dotted: std::net::Ipv6Addr = probe("aaaa-v4mapped-dotted")
.wire
.parse()
.expect("dotted form parses");
let hex: std::net::Ipv6Addr = probe("aaaa-v4mapped-hex")
.wire
.parse()
.expect("hex form parses");
assert_eq!(dotted, hex);
assert_eq!(
probe("aaaa-v4mapped-dotted").expect,
probe("aaaa-v4mapped-hex").expect,
"one address stored two ways would make the pairing meaningless"
);
}
#[test]
fn rust_matches_desec_on_every_ipv6_form_but_the_mapped_one() {
let rendered = |id: &str| {
probe(id)
.wire
.parse::<std::net::Ipv6Addr>()
.expect("probe parses as an address")
.to_string()
};
let stored = |id: &str| match &probe(id).expect {
Expect::Canonical(stored) => *stored,
other => panic!("{id} is expected to be rewritten, not {other:?}"),
};
assert_eq!(rendered("aaaa-expanded"), stored("aaaa-expanded"));
assert_eq!(
rendered("aaaa-v4compat-dotted"),
stored("aaaa-v4compat-dotted")
);
assert_eq!(rendered("aaaa-v4mapped-dotted"), "::ffff:192.0.2.1");
assert_eq!(stored("aaaa-v4mapped-dotted"), "::ffff:c000:201");
}
#[test]
fn loc_is_lossy() {
assert_ne!(probe("loc-format").wire, probe("loc-quantized").wire);
assert_eq!(probe("loc-format").expect, probe("loc-quantized").expect);
}
}