use std::sync::Arc;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use crate::transport::egress::{TransportConfig, validated_pinned_client};
use roxmltree::Document;
pub mod bdxl;
#[cfg(feature = "dns")]
pub use bdxl::HickoryBdxlResolver;
pub use bdxl::{
BdxlResolver, NaptrRecord, SmlDiscovery, StaticBdxlResolver, bdxl_dns_name, legacy_cname_host,
select_smp_base_url,
};
pub const PEPPOL_AS4_TRANSPORT_PROFILE: &str = "peppol-transport-as4-v2_0";
pub const PEPPOL_PARTICIPANT_SCHEME: &str = "iso6523-actorid-upis";
pub const PEPPOL_PRODUCTION_SML_ZONE: &str = "participant.sml.prod.tech.peppol.org";
pub const PEPPOL_TEST_SML_ZONE: &str = "participant.sml.test.tech.peppol.org";
#[derive(Debug, Clone)]
pub struct SmpConfig {
pub sml_zone: String,
pub discovery: SmlDiscovery,
pub participant_scheme: String,
pub transport_profile: String,
pub signature_policy: SmpSignaturePolicy,
}
impl SmpConfig {
pub fn peppol_test() -> Self {
Self {
sml_zone: PEPPOL_TEST_SML_ZONE.to_string(),
discovery: SmlDiscovery::Naptr,
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
signature_policy: SmpSignaturePolicy::Deny,
}
}
pub fn peppol_production() -> Self {
Self {
sml_zone: PEPPOL_PRODUCTION_SML_ZONE.to_string(),
discovery: SmlDiscovery::Naptr,
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
signature_policy: SmpSignaturePolicy::Deny,
}
}
pub fn with_static_smp(smp_base_url: impl Into<String>) -> Self {
Self {
sml_zone: String::new(),
discovery: SmlDiscovery::Static {
smp_base_url: smp_base_url.into(),
},
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
signature_policy: SmpSignaturePolicy::Deny,
}
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub enum SmpSignaturePolicy {
#[default]
Deny,
Verify(Box<crate::crypto::wssec::OwnedRevocationPolicy>),
RequireSignaturePresent,
AllowUnsigned,
}
impl SmpSignaturePolicy {
pub fn verify_with_trust_anchors(trust_anchor_pems: Vec<String>) -> Self {
Self::Verify(Box::new(
crate::crypto::wssec::OwnedRevocationPolicy::production(trust_anchor_pems),
))
}
}
#[derive(Debug, Clone)]
pub struct SmpEndpoint {
pub url: String,
pub verified_signer_fingerprint_sha256: Option<String>,
pub signed_document: std::sync::Arc<[u8]>,
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>,
}
impl SmpLookupRequest {
pub fn new(
participant_id: impl Into<String>,
document_type_id: impl Into<String>,
process_id: impl Into<String>,
) -> Self {
Self {
participant_id: participant_id.into(),
document_type_id: document_type_id.into(),
process_id: process_id.into(),
transport_profile: None,
}
}
#[must_use]
pub fn with_transport_profile(mut self, transport_profile: impl Into<String>) -> Self {
self.transport_profile = Some(transport_profile.into());
self
}
}
#[derive(Clone, Debug)]
pub struct SmpClient {
config: SmpConfig,
transport: TransportConfig,
resolver: Option<Arc<dyn BdxlResolver>>,
}
impl SmpClient {
pub fn new(sml_zone: impl Into<String>) -> Self {
Self::with_config(SmpConfig {
sml_zone: sml_zone.into(),
discovery: SmlDiscovery::Naptr,
participant_scheme: PEPPOL_PARTICIPANT_SCHEME.to_string(),
transport_profile: PEPPOL_AS4_TRANSPORT_PROFILE.to_string(),
signature_policy: SmpSignaturePolicy::Deny,
})
}
pub fn with_config(config: SmpConfig) -> Self {
Self {
config,
transport: TransportConfig {
request_timeout: std::time::Duration::from_secs(10),
..TransportConfig::default()
},
resolver: None,
}
}
#[must_use]
pub fn with_resolver(mut self, resolver: Arc<dyn BdxlResolver>) -> Self {
self.resolver = Some(resolver);
self
}
pub fn config(&self) -> &SmpConfig {
&self.config
}
pub async fn lookup_endpoint(&self, req: SmpLookupRequest) -> Result<SmpEndpoint> {
let smp_base = self.resolve_smp_base_url(&req.participant_id).await?;
let url = self.build_lookup_url_at(&smp_base, &req);
let http = validated_pinned_client(&url, &self.transport, "smp_lookup").await?;
let response = 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,
&self.config.signature_policy,
)
}
pub fn discovery_dns_name(&self, participant_id: &str) -> Option<String> {
match &self.config.discovery {
SmlDiscovery::Naptr => Some(bdxl_dns_name(
&self.config.participant_scheme,
participant_id,
&self.config.sml_zone,
)),
SmlDiscovery::LegacyCname => Some(legacy_cname_host(
&self.config.participant_scheme,
participant_id,
&self.config.sml_zone,
)),
SmlDiscovery::Static { .. } => None,
}
}
async fn resolve_smp_base_url(&self, participant_id: &str) -> Result<String> {
match &self.config.discovery {
SmlDiscovery::Static { smp_base_url } => {
Ok(smp_base_url.trim_end_matches('/').to_string())
}
SmlDiscovery::LegacyCname => Ok(format!(
"https://{}",
legacy_cname_host(
&self.config.participant_scheme,
participant_id,
&self.config.sml_zone,
)
)),
SmlDiscovery::Naptr => {
let Some(resolver) = self.resolver.as_ref() else {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"SmlDiscovery::Naptr needs a DNS resolver: call \
SmpClient::with_resolver(..) — with HickoryBdxlResolver under the \
`dns` feature, or your own BdxlResolver. Use \
SmpConfig::with_static_smp(..) if the SMP URL is known in advance",
ErrorContext::new("smp_discovery"),
));
};
let name = bdxl_dns_name(
&self.config.participant_scheme,
participant_id,
&self.config.sml_zone,
);
let records = resolver.lookup_naptr(&name).await?;
if records.is_empty() {
return Err(AsxError::new(
ErrorCode::NotFound,
format!(
"participant '{participant_id}' is not registered in SML zone \
'{}': no NAPTR records at '{name}'",
self.config.sml_zone
),
ErrorContext::new("smp_discovery"),
));
}
select_smp_base_url(&records)
}
}
}
pub fn build_lookup_url_at(&self, smp_base_url: &str, req: &SmpLookupRequest) -> String {
let canonical = format!("{}::{}", self.config.participant_scheme, req.participant_id);
format!(
"{}/{}/services/{}",
smp_base_url.trim_end_matches('/'),
percent_encode(&canonical),
percent_encode(&req.document_type_id),
)
}
}
const SMP_NS: &str = "http://busdox.org/serviceMetadata/publishing/1.0/";
const SMP2_NS_SERVICE_METADATA: &str = "http://docs.oasis-open.org/bdxr/ns/SMP/2/ServiceMetadata";
const SMP2_NS_BASIC: &str = "http://docs.oasis-open.org/bdxr/ns/SMP/2/BasicComponents";
const SMP2_NS_AGGREGATE: &str = "http://docs.oasis-open.org/bdxr/ns/SMP/2/AggregateComponents";
fn parse_service_metadata(
xml: &[u8],
process_id: &str,
transport_profile: &str,
signature_policy: &SmpSignaturePolicy,
) -> 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"),
)
})?;
let signer = enforce_smp_signature_policy(text, &doc, signature_policy)?;
let version = SmpVersion::detect(&doc)?;
let mut endpoint =
extract_endpoint(&doc, version, 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"),
)
})?;
endpoint.signed_document = std::sync::Arc::from(xml);
endpoint.verified_signer_fingerprint_sha256 = signer;
Ok(endpoint)
}
fn enforce_smp_signature_policy(
text: &str,
doc: &Document<'_>,
policy: &SmpSignaturePolicy,
) -> Result<Option<String>> {
match policy {
SmpSignaturePolicy::Deny => Err(AsxError::new(
ErrorCode::PolicyViolation,
"SMP lookup results are not authorized for use: SmpConfig::signature_policy is \
SmpSignaturePolicy::Deny (the default). Set \
SmpSignaturePolicy::verify_with_trust_anchors(smp_ca_pems) to verify the \
response against the network's SMP CA, or one of the weaker variants for a \
closed or test network",
ErrorContext::new("smp_signature_policy"),
)),
SmpSignaturePolicy::Verify(revocation) => {
let verified = crate::crypto::wssec::verify_enveloped_document_signature(
text,
None,
&crate::crypto::wssec::RevocationPolicy::from(revocation.as_ref()),
)
.map_err(|err| {
AsxError::new(
ErrorCode::SecurityVerificationFailed,
format!(
"SMP ServiceMetadata signature verification failed: {}. The endpoint \
URL and recipient certificate in this response cannot be trusted",
err.message
),
ErrorContext::new("smp_verify_signature"),
)
})?;
Ok(Some(verified.signer_fingerprint_sha256))
}
SmpSignaturePolicy::RequireSignaturePresent => {
if !has_enveloped_signature(doc) {
return Err(AsxError::new(
ErrorCode::SecurityVerificationFailed,
"SMP ServiceMetadata response carries no ds:Signature; PEPPOL and CEF \
eDelivery require the SMP to sign its metadata. Set \
SmpConfig::signature_policy = AllowUnsigned only for a closed or test \
network",
ErrorContext::new("smp_parse_signature"),
));
}
Ok(None)
}
SmpSignaturePolicy::AllowUnsigned => Ok(None),
}
}
const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#";
fn has_enveloped_signature(doc: &Document<'_>) -> bool {
doc.descendants().any(|n| {
n.is_element()
&& n.tag_name().namespace() == Some(XMLDSIG_NS)
&& n.tag_name().name() == "Signature"
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SmpVersion {
V1,
V2,
}
impl SmpVersion {
fn detect(doc: &Document<'_>) -> Result<Self> {
let root = doc.root_element();
match root.tag_name().namespace() {
Some(SMP_NS) => Ok(Self::V1),
Some(SMP2_NS_SERVICE_METADATA) => Ok(Self::V2),
other => Err(AsxError::new(
ErrorCode::ParseFailed,
format!(
"SMP response document element is {{{}}}{}; expected an OASIS SMP 1.0 \
({SMP_NS}) or SMP 2.0 ({SMP2_NS_SERVICE_METADATA}) ServiceMetadata",
other.unwrap_or("no namespace"),
root.tag_name().name(),
),
ErrorContext::new("smp_parse"),
)),
}
}
}
fn extract_endpoint(
doc: &Document<'_>,
version: SmpVersion,
process_id: &str,
transport_profile: &str,
) -> Result<Option<SmpEndpoint>> {
let mut found: Option<SmpEndpoint> = None;
for endpoint in doc.descendants().filter(|n| is_endpoint(*n, version)) {
let Some(profile) = endpoint_transport_profile(endpoint, version) else {
continue;
};
if !profile.trim().eq_ignore_ascii_case(transport_profile) {
continue;
}
let Some(declared_process) = ancestor_process_id(endpoint, version) else {
continue;
};
if !declared_process.trim().eq_ignore_ascii_case(process_id) {
continue;
}
let Some(url) = endpoint_address(endpoint, version) else {
continue;
};
if found.is_some() {
return Err(AsxError::new(
ErrorCode::ParseFailed,
format!(
"SMP response offers more than one endpoint for process '{process_id}' \
with transport profile '{transport_profile}'; refusing to resolve the \
ambiguity by document order"
),
ErrorContext::new("smp_parse"),
));
}
found = Some(SmpEndpoint {
signed_document: std::sync::Arc::from(&[][..]),
verified_signer_fingerprint_sha256: None,
url: url.trim().to_string(),
certificate_der_b64: endpoint_certificate(endpoint, version)
.map(|s| s.split_whitespace().collect()),
transport_profile: profile.trim().to_string(),
service_description: child_text(endpoint, version, "ServiceDescription", "Description")
.map(|s| s.trim().to_string()),
service_activation_date: child_text(
endpoint,
version,
"ServiceActivationDate",
"ActivationDate",
)
.map(|s| s.trim().to_string()),
service_expiration_date: child_text(
endpoint,
version,
"ServiceExpirationDate",
"ExpirationDate",
)
.map(|s| s.trim().to_string()),
});
}
Ok(found)
}
fn is_endpoint(node: roxmltree::Node<'_, '_>, version: SmpVersion) -> bool {
match version {
SmpVersion::V1 => is_named(node, SMP_NS, "Endpoint"),
SmpVersion::V2 => is_named(node, SMP2_NS_AGGREGATE, "Endpoint"),
}
}
fn endpoint_transport_profile<'a>(
endpoint: roxmltree::Node<'a, '_>,
version: SmpVersion,
) -> Option<&'a str> {
match version {
SmpVersion::V1 => endpoint.attribute("transportProfile"),
SmpVersion::V2 => child_in(endpoint, SMP2_NS_BASIC, "TransportProfileID")?.text(),
}
}
fn endpoint_address<'a>(endpoint: roxmltree::Node<'a, '_>, version: SmpVersion) -> Option<&'a str> {
match version {
SmpVersion::V1 => child_in(endpoint, SMP_NS, "EndpointReference")
.and_then(|reference| child_in(reference, WSA_NS, "Address"))
.or_else(|| child_in(endpoint, SMP_NS, "EndpointURI"))?
.text(),
SmpVersion::V2 => child_in(endpoint, SMP2_NS_BASIC, "AddressURI")?.text(),
}
}
fn endpoint_certificate<'a>(
endpoint: roxmltree::Node<'a, '_>,
version: SmpVersion,
) -> Option<&'a str> {
match version {
SmpVersion::V1 => child_in(endpoint, SMP_NS, "Certificate")?.text(),
SmpVersion::V2 => child_in(endpoint, SMP2_NS_AGGREGATE, "Certificate")
.and_then(|cert| child_in(cert, SMP2_NS_BASIC, "ContentBinaryObject"))?
.text(),
}
}
fn child_text<'a>(
endpoint: roxmltree::Node<'a, '_>,
version: SmpVersion,
v1_name: &str,
v2_name: &str,
) -> Option<&'a str> {
match version {
SmpVersion::V1 => child_in(endpoint, SMP_NS, v1_name)?.text(),
SmpVersion::V2 => child_in(endpoint, SMP2_NS_BASIC, v2_name)?.text(),
}
}
fn ancestor_process_id<'a>(
endpoint: roxmltree::Node<'a, '_>,
version: SmpVersion,
) -> Option<&'a str> {
match version {
SmpVersion::V1 => {
let process = endpoint.parent()?.parent()?;
child_in(process, SMP_NS, "ProcessIdentifier")?.text()
}
SmpVersion::V2 => {
let process_metadata = endpoint.parent()?;
let process = child_in(process_metadata, SMP2_NS_AGGREGATE, "Process")?;
child_in(process, SMP2_NS_BASIC, "ID")?.text()
}
}
}
const WSA_NS: &str = "http://www.w3.org/2005/08/addressing";
fn is_named(node: roxmltree::Node<'_, '_>, namespace: &str, local: &str) -> bool {
node.is_element()
&& node.tag_name().name() == local
&& node.tag_name().namespace() == Some(namespace)
}
fn child_in<'a, 'i>(
node: roxmltree::Node<'a, 'i>,
namespace: &str,
local: &str,
) -> Option<roxmltree::Node<'a, 'i>> {
node.children().find(|c| is_named(*c, namespace, local))
}
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 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 lookup_url_appends_the_smp_rest_binding_to_the_discovered_base() {
let client = SmpClient::with_config(SmpConfig::with_static_smp("https://smp.example.org/"));
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,
};
assert_eq!(
client.build_lookup_url_at("https://smp.example.org/", &req),
"https://smp.example.org\
/iso6523-actorid-upis%3A%3A0088%3A5798009883995\
/services/urn%3Atest%3Adoc"
);
}
#[test]
fn peppol_presets_name_the_openpeppol_sml_zones() {
assert_eq!(
SmpConfig::peppol_production().sml_zone,
"participant.sml.prod.tech.peppol.org"
);
assert_eq!(
SmpConfig::peppol_test().sml_zone,
"participant.sml.test.tech.peppol.org"
);
assert!(matches!(
SmpConfig::peppol_production().discovery,
SmlDiscovery::Naptr
));
}
#[test]
fn discovery_dns_name_matches_the_bdxl_construction() {
let client = SmpClient::with_config(SmpConfig::peppol_production());
assert_eq!(
client
.discovery_dns_name("0088:5790002590993")
.expect("NAPTR discovery names a host"),
"2M2UFGZNGSS25JOOMOV2S4VGG7PW64KIVYNONDSVZSRT4EAZVCLQ\
.iso6523-actorid-upis.participant.sml.prod.tech.peppol.org"
);
assert_eq!(
SmpClient::with_config(SmpConfig::with_static_smp("https://smp.example.org"))
.discovery_dns_name("0088:5790002590993"),
None,
"a statically configured SMP is not looked up"
);
}
#[tokio::test]
async fn naptr_discovery_without_a_resolver_reports_the_missing_seam() {
let client = SmpClient::with_config(SmpConfig::peppol_production());
let err = client
.resolve_smp_base_url("0088:5790002590993")
.await
.expect_err("NAPTR discovery needs a resolver");
assert_eq!(err.code, crate::core::ErrorCode::InvalidInput);
assert!(err.message.contains("with_resolver"), "{}", err.message);
}
#[tokio::test]
async fn naptr_discovery_resolves_the_smp_base_url() {
let name = bdxl_dns_name(
"iso6523-actorid-upis",
"0088:5790002590993",
"participant.sml.prod.tech.peppol.org",
);
let client = SmpClient::with_config(SmpConfig::peppol_production()).with_resolver(
Arc::new(StaticBdxlResolver::new().with_smp(name, "https://smp.example.org")),
);
assert_eq!(
client
.resolve_smp_base_url("0088:5790002590993")
.await
.expect("the participant is registered"),
"https://smp.example.org"
);
}
#[tokio::test]
async fn unregistered_participant_reports_not_found_with_the_queried_name() {
let client = SmpClient::with_config(SmpConfig::peppol_production())
.with_resolver(Arc::new(StaticBdxlResolver::new()));
let err = client
.resolve_smp_base_url("0088:0000000000000")
.await
.expect_err("nothing is registered");
assert_eq!(err.code, crate::core::ErrorCode::NotFound);
assert!(
err.message.contains("participant.sml.prod.tech.peppol.org"),
"{}",
err.message
);
}
#[tokio::test]
async fn legacy_cname_discovery_builds_the_historical_host() {
let client = SmpClient::with_config(SmpConfig {
discovery: SmlDiscovery::LegacyCname,
sml_zone: "edelivery.tech.ec.europa.eu".to_string(),
..SmpConfig::peppol_production()
});
assert_eq!(
client
.resolve_smp_base_url("0088:123abc")
.await
.expect("the construction is pure"),
"https://B-f5e78500450d37de5aabe6648ac3bb70\
.iso6523-actorid-upis.edelivery.tech.ec.europa.eu"
);
}
#[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",
&SmpSignaturePolicy::AllowUnsigned,
)
.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_smp2_roundtrip() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<smp:ServiceMetadata
xmlns:smp="http://docs.oasis-open.org/bdxr/ns/SMP/2/ServiceMetadata"
xmlns:sma="http://docs.oasis-open.org/bdxr/ns/SMP/2/AggregateComponents"
xmlns:smb="http://docs.oasis-open.org/bdxr/ns/SMP/2/BasicComponents">
<smb:ID>urn:test:doc</smb:ID>
<smb:ParticipantID>0088:1234567890123</smb:ParticipantID>
<sma:ProcessMetadata>
<sma:Process>
<smb:ID>urn:test:process</smb:ID>
</sma:Process>
<sma:Endpoint>
<smb:TransportProfileID>peppol-transport-as4-v2_0</smb:TransportProfileID>
<smb:Description>Test AP</smb:Description>
<smb:AddressURI>https://ap.example.com/as4/receive</smb:AddressURI>
<smb:ActivationDate>2024-01-01</smb:ActivationDate>
<smb:ExpirationDate>2025-12-31</smb:ExpirationDate>
<sma:Certificate>
<smb:ContentBinaryObject>MIIB</smb:ContentBinaryObject>
</sma:Certificate>
</sma:Endpoint>
</sma:ProcessMetadata>
</smp:ServiceMetadata>"#;
let ep = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::AllowUnsigned,
)
.expect("an SMP 2.0 response must parse");
assert_eq!(ep.url, "https://ap.example.com/as4/receive");
assert_eq!(ep.certificate_der_b64.as_deref(), Some("MIIB"));
assert_eq!(ep.service_description.as_deref(), Some("Test AP"));
assert_eq!(ep.service_activation_date.as_deref(), Some("2024-01-01"));
assert_eq!(ep.service_expiration_date.as_deref(), Some("2025-12-31"));
}
#[test]
fn parse_service_metadata_smp1_endpoint_reference_address() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ServiceMetadata xmlns="http://busdox.org/serviceMetadata/publishing/1.0/"
xmlns:wsa="http://www.w3.org/2005/08/addressing">
<ServiceInformation>
<ProcessList>
<Process>
<ProcessIdentifier>urn:test:process</ProcessIdentifier>
<ServiceEndpointList>
<Endpoint transportProfile="peppol-transport-as4-v2_0">
<EndpointReference><wsa:Address>https://ap.example.com/as4</wsa:Address></EndpointReference>
</Endpoint>
</ServiceEndpointList>
</Process>
</ProcessList>
</ServiceInformation>
</ServiceMetadata>"#;
let ep = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::AllowUnsigned,
)
.expect("the WS-Addressing shape must parse");
assert_eq!(ep.url, "https://ap.example.com/as4");
}
#[test]
fn duplicate_endpoints_for_one_process_are_rejected() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ServiceMetadata xmlns="http://busdox.org/serviceMetadata/publishing/1.0/">
<ServiceInformation>
<ProcessList>
<Process>
<ProcessIdentifier>urn:test:process</ProcessIdentifier>
<ServiceEndpointList>
<Endpoint transportProfile="peppol-transport-as4-v2_0">
<EndpointURI>https://first.example/as4</EndpointURI>
</Endpoint>
<Endpoint transportProfile="peppol-transport-as4-v2_0">
<EndpointURI>https://second.example/as4</EndpointURI>
</Endpoint>
</ServiceEndpointList>
</Process>
</ProcessList>
</ServiceInformation>
</ServiceMetadata>"#;
let err = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::AllowUnsigned,
)
.expect_err("an ambiguous response must be refused");
assert_eq!(err.code, crate::core::ErrorCode::ParseFailed);
assert!(err.message.contains("more than one"), "{}", err.message);
}
#[test]
fn unknown_document_namespace_is_refused() {
let xml = r#"<ServiceMetadata xmlns="urn:not-an-smp"><Endpoint/></ServiceMetadata>"#;
let err = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::AllowUnsigned,
)
.expect_err("an unknown namespace must be refused");
assert_eq!(err.code, crate::core::ErrorCode::ParseFailed);
}
#[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",
&SmpSignaturePolicy::AllowUnsigned,
)
.unwrap_err();
assert_eq!(err.code, crate::core::ErrorCode::NotFound);
}
#[test]
fn unsigned_service_metadata_is_rejected_by_the_presence_gate() {
let xml = SIGNED_FIXTURE_TEMPLATE.replace("{signature}", "");
let err = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::RequireSignaturePresent,
)
.expect_err("unsigned response must be rejected by the presence gate");
assert_eq!(err.code, crate::core::ErrorCode::SecurityVerificationFailed);
assert!(err.message.contains("ds:Signature"), "{}", err.message);
}
#[test]
fn lookup_results_are_denied_until_a_policy_is_chosen() {
let signature = r#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignatureValue>AAAA</ds:SignatureValue></ds:Signature>"#;
let xml = SIGNED_FIXTURE_TEMPLATE.replace("{signature}", signature);
let err = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::default(),
)
.expect_err("the default policy must refuse to hand back a routing decision");
assert_eq!(err.code, crate::core::ErrorCode::PolicyViolation);
assert!(
err.message.contains("SmpSignaturePolicy"),
"the error must name the knob to set: {}",
err.message
);
assert!(matches!(
SmpConfig::peppol_production().signature_policy,
SmpSignaturePolicy::Deny
));
}
#[test]
fn signed_service_metadata_is_accepted_and_bytes_are_retained() {
let signature = r#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignatureValue>AAAA</ds:SignatureValue></ds:Signature>"#;
let xml = SIGNED_FIXTURE_TEMPLATE.replace("{signature}", signature);
let ep = parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::RequireSignaturePresent,
)
.expect("a signed response passes the presence gate");
assert_eq!(ep.signed_document.as_ref(), xml.as_bytes());
}
#[test]
fn signature_presence_gate_can_be_disabled_for_closed_networks() {
let xml = SIGNED_FIXTURE_TEMPLATE.replace("{signature}", "");
parse_service_metadata(
xml.as_bytes(),
"urn:test:process",
"peppol-transport-as4-v2_0",
&SmpSignaturePolicy::AllowUnsigned,
)
.expect("closed networks may opt out");
}
const SIGNED_FIXTURE_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<ServiceMetadata xmlns="http://busdox.org/serviceMetadata/publishing/1.0/">
<ServiceInformation>
<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>
</Endpoint>
</ServiceEndpointList>
</Process>
</ProcessList>
</ServiceInformation>
{signature}
</ServiceMetadata>"#;
}