use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use crate::transport::egress::validate_egress_url;
use roxmltree::Document;
pub const PEPPOL_AS4_TRANSPORT_PROFILE: &str = "peppol-transport-as4-v2_0";
pub const PEPPOL_PARTICIPANT_SCHEME: &str = "iso6523-actorid-upis";
#[derive(Debug, Clone)]
pub struct SmpConfig {
pub sml_zone: String,
pub participant_scheme: String,
pub transport_profile: String,
}
impl SmpConfig {
pub fn peppol_test() -> Self {
Self {
sml_zone: "acc.edelivery.tech.ec.europa.eu".to_string(),
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
}
}
pub fn peppol_production() -> Self {
Self {
sml_zone: "edelivery.tech.ec.europa.eu".to_string(),
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct SmpEndpoint {
pub url: String,
pub certificate_der_b64: Option<String>,
pub transport_profile: String,
pub service_description: Option<String>,
pub service_activation_date: Option<String>,
pub service_expiration_date: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SmpLookupRequest {
pub participant_id: String,
pub document_type_id: String,
pub process_id: String,
pub transport_profile: Option<String>,
}
#[derive(Clone)]
pub struct SmpClient {
config: SmpConfig,
http: reqwest::Client,
}
impl SmpClient {
pub fn new(sml_zone: impl Into<String>) -> Self {
Self::with_config(SmpConfig {
sml_zone: sml_zone.into(),
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
})
}
pub fn with_config(config: SmpConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("failed to build SMP reqwest client");
Self { config, http }
}
pub fn config(&self) -> &SmpConfig {
&self.config
}
pub async fn lookup_endpoint(&self, req: SmpLookupRequest) -> Result<SmpEndpoint> {
let url = self.build_lookup_url(&req);
validate_egress_url(&url, "smp_lookup").await?;
let response = self.http.get(&url).send().await.map_err(|e| {
AsxError::new(
ErrorCode::TransportFailure,
format!("SMP HTTP request failed for '{url}': {e}"),
ErrorContext::new("smp_lookup"),
)
})?;
let status = response.status();
if !status.is_success() {
return Err(AsxError::new(
ErrorCode::NotFound,
format!(
"SMP returned HTTP {status} for participant '{}' / doc-type '{}'",
req.participant_id, req.document_type_id
),
ErrorContext::new("smp_lookup"),
));
}
let body = response.bytes().await.map_err(|e| {
AsxError::new(
ErrorCode::TransportFailure,
format!("SMP response body read failed: {e}"),
ErrorContext::new("smp_lookup_body"),
)
})?;
let transport_profile = req
.transport_profile
.as_deref()
.unwrap_or(&self.config.transport_profile);
parse_service_metadata(&body, &req.process_id, transport_profile)
}
pub fn build_lookup_url(&self, req: &SmpLookupRequest) -> String {
let smp_base = self.build_smp_base_url(&req.participant_id);
let canonical = format!("{}::{}", self.config.participant_scheme, req.participant_id);
format!(
"{}{}/services/{}",
smp_base,
percent_encode(&canonical),
percent_encode(&req.document_type_id),
)
}
fn build_smp_base_url(&self, participant_id: &str) -> String {
let canonical = format!(
"{}::{}",
self.config.participant_scheme,
participant_id.to_lowercase()
);
let hash = md5_hex(canonical.as_bytes());
format!("https://B-{}.{}/", hash, self.config.sml_zone)
}
}
const SMP_NS: &str = "http://busdox.org/serviceMetadata/publishing/1.0/";
const SMP_NS_V2: &str = "http://docs.oasis-open.org/bdxr/ns/SMP/2/ServiceMetadata";
fn parse_service_metadata(
xml: &[u8],
process_id: &str,
transport_profile: &str,
) -> Result<SmpEndpoint> {
let text = std::str::from_utf8(xml).map_err(|_| {
AsxError::new(
ErrorCode::ParseFailed,
"SMP ServiceMetadata response is not valid UTF-8",
ErrorContext::new("smp_parse"),
)
})?;
let doc = Document::parse(text).map_err(|e| {
AsxError::new(
ErrorCode::ParseFailed,
format!("SMP ServiceMetadata XML parse failed: {e}"),
ErrorContext::new("smp_parse"),
)
})?;
extract_endpoint(&doc, process_id, transport_profile).ok_or_else(|| {
AsxError::new(
ErrorCode::NotFound,
format!(
"no matching AS4 endpoint found in SMP for process '{process_id}' \
with transport profile '{transport_profile}'"
),
ErrorContext::new("smp_parse"),
)
})
}
fn extract_endpoint(
doc: &Document<'_>,
process_id: &str,
transport_profile: &str,
) -> Option<SmpEndpoint> {
for node in doc.descendants() {
if !matches_smp_element(&node, "Endpoint") {
continue;
}
let profile = node.attribute("transportProfile")?;
if !profile.eq_ignore_ascii_case(transport_profile) {
continue;
}
let process_node = find_ancestor_process_id(doc, &node)?;
if !process_node.eq_ignore_ascii_case(process_id) {
continue;
}
let url =
find_child_text(&node, "EndpointURI").or_else(|| find_child_text(&node, "Address"))?;
let certificate_der_b64 = find_child_text(&node, "Certificate");
let service_description = find_child_text(&node, "ServiceDescription");
let service_activation_date = find_child_text(&node, "ServiceActivationDate");
let service_expiration_date = find_child_text(&node, "ServiceExpirationDate");
return Some(SmpEndpoint {
url: url.trim().to_string(),
certificate_der_b64: certificate_der_b64.map(|s| s.trim().to_string()),
transport_profile: profile.to_string(),
service_description: service_description.map(|s| s.trim().to_string()),
service_activation_date: service_activation_date.map(|s| s.trim().to_string()),
service_expiration_date: service_expiration_date.map(|s| s.trim().to_string()),
});
}
None
}
fn matches_smp_element(node: &roxmltree::Node<'_, '_>, local: &str) -> bool {
if !node.is_element() {
return false;
}
if node.tag_name().name() != local {
return false;
}
let ns = node.tag_name().namespace().unwrap_or("");
ns.is_empty() || ns == SMP_NS || ns == SMP_NS_V2
}
fn find_ancestor_process_id<'a>(
_doc: &'a Document<'a>,
endpoint: &roxmltree::Node<'a, '_>,
) -> Option<&'a str> {
let service_endpoint_list = endpoint.parent()?;
let process = service_endpoint_list.parent()?;
for child in process.children() {
if matches_smp_element(&child, "ProcessIdentifier") {
return child.text();
}
}
None
}
fn find_child_text<'a>(node: &roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> {
for child in node.children() {
if matches_smp_element(&child, name) {
return child.text();
}
}
None
}
fn md5_hex(input: &[u8]) -> String {
use openssl::hash::{MessageDigest, hash};
let digest = hash(MessageDigest::md5(), input).expect("MD5 unavailable");
let mut hex = String::with_capacity(32);
for b in &*digest {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
fn percent_encode(s: &str) -> String {
let mut encoded = String::with_capacity(s.len() * 3);
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
encoded.push(b as char);
} else {
use std::fmt::Write;
let _ = write!(encoded, "%{b:02X}");
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn md5_hex_known_value() {
let result = md5_hex("iso6523-actorid-upis::0088:5798009883995".as_bytes());
assert_eq!(result.len(), 32, "MD5 hex should be 32 chars");
assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn percent_encode_peppol_doc_type() {
let raw = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##test";
let encoded = percent_encode(raw);
assert!(!encoded.contains(':'), "colons must be encoded");
assert!(!encoded.contains('#'), "hash must be encoded");
assert!(encoded.contains("urn%3Aoasis"), "colon should be %3A");
}
#[test]
fn build_lookup_url_structure() {
let client = SmpClient::new("acc.edelivery.tech.ec.europa.eu");
let req = SmpLookupRequest {
participant_id: "0088:5798009883995".to_string(),
document_type_id: "urn:test:doc".to_string(),
process_id: "urn:test:process".to_string(),
transport_profile: None,
};
let url = client.build_lookup_url(&req);
assert!(
url.starts_with("https://B-"),
"must start with SMP DNS scheme"
);
assert!(
url.contains(".acc.edelivery.tech.ec.europa.eu/"),
"must embed SML zone"
);
assert!(url.contains("/services/"), "must have /services/ path");
}
#[test]
fn parse_service_metadata_smp1_roundtrip() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ServiceMetadata xmlns="http://busdox.org/serviceMetadata/publishing/1.0/">
<ServiceInformation>
<ParticipantIdentifier scheme="iso6523-actorid-upis">0088:1234567890123</ParticipantIdentifier>
<DocumentIdentifier scheme="busdox-docid-qns">urn:test:doc</DocumentIdentifier>
<ProcessList>
<Process>
<ProcessIdentifier scheme="cenbii-procid-ubl">urn:test:process</ProcessIdentifier>
<ServiceEndpointList>
<Endpoint transportProfile="peppol-transport-as4-v2_0">
<EndpointURI>https://ap.example.com/as4/receive</EndpointURI>
<Certificate>MIIB…</Certificate>
<ServiceDescription>Test AP</ServiceDescription>
<ServiceActivationDate>2024-01-01</ServiceActivationDate>
<ServiceExpirationDate>2025-12-31</ServiceExpirationDate>
</Endpoint>
</ServiceEndpointList>
</Process>
</ProcessList>
</ServiceInformation>
</ServiceMetadata>"#;
let ep = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
)
.expect("should parse");
assert_eq!(ep.url, "https://ap.example.com/as4/receive");
assert_eq!(ep.transport_profile, "peppol-transport-as4-v2_0");
assert_eq!(ep.service_description.as_deref(), Some("Test AP"));
assert_eq!(ep.service_activation_date.as_deref(), Some("2024-01-01"));
}
#[test]
fn parse_service_metadata_no_match_returns_not_found() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ServiceMetadata xmlns="http://busdox.org/serviceMetadata/publishing/1.0/">
<ServiceInformation>
<ProcessList>
<Process>
<ProcessIdentifier scheme="x">urn:other:process</ProcessIdentifier>
<ServiceEndpointList>
<Endpoint transportProfile="peppol-transport-as4-v2_0">
<EndpointURI>https://ap.example.com/as4/receive</EndpointURI>
</Endpoint>
</ServiceEndpointList>
</Process>
</ProcessList>
</ServiceInformation>
</ServiceMetadata>"#;
let err = parse_service_metadata(
xml.as_bytes(),
"urn:test:process", "peppol-transport-as4-v2_0",
)
.unwrap_err();
assert_eq!(err.code, crate::core::ErrorCode::NotFound);
}
}