use std::collections::HashMap;
#[cfg(feature = "dns")]
use std::sync::Arc;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use crate::storage::BoxFuture;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SmlDiscovery {
Naptr,
LegacyCname,
Static {
smp_base_url: String,
},
}
const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
fn base32_nopad(input: &[u8]) -> String {
let mut out = String::with_capacity(input.len().div_ceil(5) * 8);
for chunk in input.chunks(5) {
let mut buffer: u64 = 0;
for (i, &byte) in chunk.iter().enumerate() {
buffer |= u64::from(byte) << (32 - 8 * i);
}
let chars = (chunk.len() * 8).div_ceil(5);
for i in 0..chars {
let index = ((buffer >> (35 - 5 * i)) & 0x1f) as usize;
out.push(BASE32_ALPHABET[index] as char);
}
}
out
}
fn hex_lower(input: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(input.len() * 2);
for &b in input {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
pub fn bdxl_dns_name(scheme: &str, participant_id: &str, sml_zone: &str) -> String {
use sha2::Digest;
let digest = sha2::Sha256::digest(participant_id.to_lowercase().as_bytes());
format!(
"{}.{}.{}",
base32_nopad(&digest),
scheme,
sml_zone.trim_end_matches('.')
)
}
pub fn legacy_cname_host(scheme: &str, participant_id: &str, sml_zone: &str) -> String {
let digest = openssl::hash::hash(
openssl::hash::MessageDigest::md5(),
participant_id.to_lowercase().as_bytes(),
)
.expect("MD5 is available in every OpenSSL build this crate supports");
format!(
"B-{}.{}.{}",
hex_lower(&digest),
scheme,
sml_zone.trim_end_matches('.')
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NaptrRecord {
pub order: u16,
pub preference: u16,
pub flags: String,
pub service: String,
pub regexp: String,
pub replacement: String,
}
const BDXL_SERVICE_SMP: &str = "meta:smp";
pub fn select_smp_base_url(records: &[NaptrRecord]) -> Result<String> {
let mut candidates: Vec<&NaptrRecord> = records
.iter()
.filter(|r| {
r.flags.eq_ignore_ascii_case("U") && r.service.eq_ignore_ascii_case(BDXL_SERVICE_SMP)
})
.collect();
candidates.sort_by_key(|r| (r.order, r.preference));
let mut rejections: Vec<String> = Vec::new();
for record in candidates {
match unaptr_uri(&record.regexp) {
Ok(uri) => return Ok(uri),
Err(reason) => rejections.push(format!("{:?}: {reason}", record.regexp)),
}
}
let detail = if rejections.is_empty() {
format!(
"no NAPTR record with flags=\"U\" and service=\"Meta:SMP\" among {} record(s)",
records.len()
)
} else {
format!(
"every Meta:SMP record was unusable — {}",
rejections.join("; ")
)
};
Err(AsxError::new(
ErrorCode::NotFound,
format!("BDXL discovery found no SMP for this participant: {detail}"),
ErrorContext::new("bdxl_select_smp"),
))
}
fn unaptr_uri(regexp: &str) -> std::result::Result<String, String> {
let mut chars = regexp.chars();
let delimiter = chars.next().ok_or("empty regexp field")?;
if delimiter.is_alphanumeric() || delimiter == '\\' {
return Err(format!("invalid delimiter {delimiter:?}"));
}
let mut fields: Vec<String> = vec![String::new()];
let mut escaped = false;
let mut saw_backreference = false;
for ch in chars {
if escaped {
if ch.is_ascii_digit() {
saw_backreference = true;
}
fields.last_mut().expect("one field always exists").push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
fields.push(String::new());
} else {
fields.last_mut().expect("one field always exists").push(ch);
}
}
if escaped {
return Err("regexp ends with a dangling escape".to_string());
}
if saw_backreference {
return Err("backreferences are not permitted in a U-NAPTR replacement".to_string());
}
if fields.len() != 3 || !fields[2].is_empty() {
return Err(format!(
"expected the U-NAPTR form !<ere>!<uri>!, got {} field(s)",
fields.len()
));
}
let uri = fields.swap_remove(1);
if !uri.starts_with("https://") {
return Err(format!("{uri:?} is not an https URI"));
}
Ok(uri.trim_end_matches('/').to_string())
}
pub trait BdxlResolver: Send + Sync + std::fmt::Debug {
fn lookup_naptr<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<Vec<NaptrRecord>>>;
}
#[derive(Debug, Clone, Default)]
pub struct StaticBdxlResolver {
records: HashMap<String, Vec<NaptrRecord>>,
}
impl StaticBdxlResolver {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_records(mut self, name: impl Into<String>, records: Vec<NaptrRecord>) -> Self {
self.records.insert(name.into(), records);
self
}
#[must_use]
pub fn with_smp(mut self, name: impl Into<String>, smp_base_url: &str) -> Self {
self.records.insert(
name.into(),
vec![NaptrRecord {
order: 100,
preference: 10,
flags: "U".to_string(),
service: "Meta:SMP".to_string(),
regexp: format!("!.*!{smp_base_url}!"),
replacement: ".".to_string(),
}],
);
self
}
}
impl BdxlResolver for StaticBdxlResolver {
fn lookup_naptr<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<Vec<NaptrRecord>>> {
let records = self.records.get(name).cloned().unwrap_or_default();
Box::pin(async move { Ok(records) })
}
}
#[cfg(feature = "dns")]
#[derive(Debug, Clone)]
pub struct HickoryBdxlResolver {
resolver: Arc<hickory_resolver::TokioResolver>,
}
#[cfg(feature = "dns")]
impl HickoryBdxlResolver {
pub fn from_system_config() -> Result<Self> {
let init_error = |e: hickory_resolver::net::NetError| {
AsxError::new(
ErrorCode::InvalidInput,
format!("could not build a DNS resolver from the system configuration: {e}"),
ErrorContext::new("bdxl_resolver_init"),
)
};
let resolver = hickory_resolver::Resolver::builder_tokio()
.map_err(init_error)?
.build()
.map_err(init_error)?;
Ok(Self {
resolver: Arc::new(resolver),
})
}
pub fn from_resolver(resolver: hickory_resolver::TokioResolver) -> Self {
Self {
resolver: Arc::new(resolver),
}
}
}
#[cfg(feature = "dns")]
impl BdxlResolver for HickoryBdxlResolver {
fn lookup_naptr<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<Vec<NaptrRecord>>> {
Box::pin(async move {
use hickory_resolver::proto::rr::{RData, RecordType};
let fqdn = if name.ends_with('.') {
name.to_string()
} else {
format!("{name}.")
};
let lookup = match self.resolver.lookup(fqdn.as_str(), RecordType::NAPTR).await {
Ok(lookup) => lookup,
Err(e) if e.is_no_records_found() => return Ok(Vec::new()),
Err(e) => {
return Err(AsxError::new(
ErrorCode::TransportFailure,
format!("NAPTR lookup for '{fqdn}' failed: {e}"),
ErrorContext::new("bdxl_lookup_naptr"),
));
}
};
Ok(lookup
.answers()
.iter()
.filter_map(|record| match &record.data {
RData::NAPTR(naptr) => Some(NaptrRecord {
order: naptr.order,
preference: naptr.preference,
flags: String::from_utf8_lossy(&naptr.flags).into_owned(),
service: String::from_utf8_lossy(&naptr.services).into_owned(),
regexp: String::from_utf8_lossy(&naptr.regexp).into_owned(),
replacement: naptr.replacement.to_string(),
}),
_ => None,
})
.collect())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base32_matches_rfc4648_test_vectors() {
for (input, expected) in [
("", ""),
("f", "MY"),
("fo", "MZXQ"),
("foo", "MZXW6"),
("foob", "MZXW6YQ"),
("fooba", "MZXW6YTB"),
("foobar", "MZXW6YTBOI"),
] {
assert_eq!(base32_nopad(input.as_bytes()), expected, "input {input:?}");
}
}
#[test]
fn bdxl_name_hashes_the_identifier_value_only() {
assert_eq!(
bdxl_dns_name(
"iso6523-actorid-upis",
"0088:5790002590993",
"participant.sml.prod.tech.peppol.org"
),
"2M2UFGZNGSS25JOOMOV2S4VGG7PW64KIVYNONDSVZSRT4EAZVCLQ\
.iso6523-actorid-upis.participant.sml.prod.tech.peppol.org"
);
}
#[test]
fn bdxl_name_lowercases_the_identifier_before_hashing() {
assert_eq!(
bdxl_dns_name("iso6523-actorid-upis", "0088:ABC", "example.org"),
bdxl_dns_name("iso6523-actorid-upis", "0088:abc", "example.org"),
);
}
#[test]
fn bdxl_name_tolerates_a_fully_qualified_zone() {
assert_eq!(
bdxl_dns_name("iso6523-actorid-upis", "0088:abc", "example.org."),
bdxl_dns_name("iso6523-actorid-upis", "0088:abc", "example.org"),
);
}
#[test]
fn legacy_cname_matches_the_peppol_worked_example() {
assert_eq!(
legacy_cname_host(
"iso6523-actorid-upis",
"0088:123abc",
"edelivery.tech.ec.europa.eu"
),
"B-f5e78500450d37de5aabe6648ac3bb70.iso6523-actorid-upis.edelivery.tech.ec.europa.eu"
);
}
fn naptr(order: u16, preference: u16, regexp: &str) -> NaptrRecord {
NaptrRecord {
order,
preference,
flags: "U".to_string(),
service: "Meta:SMP".to_string(),
regexp: regexp.to_string(),
replacement: ".".to_string(),
}
}
#[test]
fn selects_the_uri_from_a_live_shaped_record() {
let records = vec![naptr(100, 10, "!.*!https://smp.logiq.no!")];
assert_eq!(
select_smp_base_url(&records).expect("record is usable"),
"https://smp.logiq.no"
);
}
#[test]
fn selects_by_order_then_preference() {
let records = vec![
naptr(200, 1, "!.*!https://third.example!"),
naptr(100, 20, "!.*!https://second.example!"),
naptr(100, 10, "!.*!https://first.example!"),
];
assert_eq!(
select_smp_base_url(&records).expect("record is usable"),
"https://first.example"
);
}
#[test]
fn ignores_records_for_other_services_and_non_terminal_rules() {
let mut other_service = naptr(10, 10, "!.*!https://not-an-smp.example!");
other_service.service = "Meta:SMP-OTHER".to_string();
let mut non_terminal = naptr(20, 10, "!.*!https://non-terminal.example!");
non_terminal.flags = "S".to_string();
let records = vec![
other_service,
non_terminal,
naptr(30, 10, "!.*!https://smp.example!"),
];
assert_eq!(
select_smp_base_url(&records).expect("record is usable"),
"https://smp.example"
);
}
#[test]
fn service_and_flags_are_matched_case_insensitively() {
let mut record = naptr(10, 10, "!.*!https://smp.example!");
record.service = "meta:smp".to_string();
record.flags = "u".to_string();
assert_eq!(
select_smp_base_url(&[record]).expect("RFC 4848 §4.5 is case-insensitive"),
"https://smp.example"
);
}
#[test]
fn rejects_a_plaintext_uri() {
let err = select_smp_base_url(&[naptr(10, 10, "!.*!http://smp.example!")])
.expect_err("http must be refused");
assert_eq!(err.code, ErrorCode::NotFound);
assert!(err.message.contains("not an https URI"), "{}", err.message);
}
#[test]
fn falls_through_to_the_next_record_when_the_first_is_unusable() {
let records = vec![
naptr(10, 10, "!.*!http://insecure.example!"),
naptr(20, 10, "!.*!https://smp.example!"),
];
assert_eq!(
select_smp_base_url(&records).expect("second record is usable"),
"https://smp.example"
);
}
#[test]
fn rejects_a_malformed_substitution_expression() {
for regexp in ["", "!.*!https://smp.example", "!.*!", "https://smp.example"] {
assert!(
select_smp_base_url(&[naptr(10, 10, regexp)]).is_err(),
"regexp {regexp:?} must be refused"
);
}
}
#[test]
fn rejects_a_backreference_in_the_replacement() {
let err = select_smp_base_url(&[naptr(10, 10, r"!(.*)!https://\1.example!")])
.expect_err("backreferences must be refused");
assert!(err.message.contains("backreference"), "{}", err.message);
}
#[test]
fn honours_an_escaped_delimiter() {
let records = vec![naptr(10, 10, r"!.*!https://smp.example/a\!b!")];
assert_eq!(
select_smp_base_url(&records).expect("escaped delimiter is legal"),
"https://smp.example/a!b"
);
}
#[test]
fn reports_not_found_for_an_empty_record_set() {
let err = select_smp_base_url(&[]).expect_err("no records means no SMP");
assert_eq!(err.code, ErrorCode::NotFound);
}
#[test]
fn trailing_slash_is_normalized_away() {
let records = vec![naptr(10, 10, "!.*!https://smp.example/!")];
assert_eq!(
select_smp_base_url(&records).expect("record is usable"),
"https://smp.example"
);
}
#[tokio::test]
async fn static_resolver_answers_registered_names_only() {
let resolver = StaticBdxlResolver::new().with_smp("a.example", "https://smp.example");
assert_eq!(
resolver
.lookup_naptr("a.example")
.await
.expect("lookup succeeds")
.len(),
1
);
assert!(
resolver
.lookup_naptr("b.example")
.await
.expect("lookup succeeds")
.is_empty()
);
}
}