use crate::core::Result;
pub fn splice_ws_security_header(envelope: &str, header_xml: &str) -> Result<String> {
let occurrences = envelope.matches(WSSE_HEADER_PLACEHOLDER).count();
if occurrences != 1 {
return Err(crate::core::AsxError::new(
crate::core::ErrorCode::InvalidInput,
format!(
"SOAP envelope must contain exactly one wsse:Security placeholder to splice \
into, found {occurrences}; build it with \
SoapEnvelopeBuilder::with_ws_security_placeholder()"
),
crate::core::ErrorContext::new("as4_soap_builder"),
));
}
Ok(envelope.replacen(WSSE_HEADER_PLACEHOLDER, header_xml, 1))
}
pub const WSSE_HEADER_PLACEHOLDER: &str = "<!--asx:wsse-security-header-->";
use base64::{Engine as _, engine::general_purpose::STANDARD};
const SOAP12_NAMESPACE: &str = "http://www.w3.org/2003/05/soap-envelope";
const EBMS_NAMESPACE: &str = "http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/";
const WSSE_NAMESPACE: &str =
"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
const WSSEC_UTILITY_NAMESPACE: &str =
"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
const WSA_NAMESPACE: &str = "http://www.w3.org/2005/08/addressing";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WsAddressingHeaders {
pub message_id: String,
pub action: String,
pub to: String,
pub reply_to: Option<String>,
}
impl WsAddressingHeaders {
pub fn new(
message_id: impl Into<String>,
action: impl Into<String>,
to: impl Into<String>,
) -> Self {
Self {
message_id: message_id.into(),
action: action.into(),
to: to.into(),
reply_to: None,
}
}
pub fn with_reply_to(mut self, reply_to: impl Into<String>) -> Self {
self.reply_to = Some(reply_to.into());
self
}
}
#[derive(Debug, Clone)]
pub struct SoapEnvelopeBuilder {
message_id: String,
message_timestamp: Option<String>,
from_party_id: String,
to_party_id: String,
from_party_id_type: Option<String>,
to_party_id_type: Option<String>,
from_role: String,
to_role: String,
agreement_ref: Option<String>,
agreement_ref_type: Option<String>,
action: String,
service: String,
service_type: String,
mpc: Option<String>,
conversation_id: Option<String>,
ref_to_message_id: Option<String>,
original_sender: String,
final_recipient: String,
tracking_identifier: String,
payload: Vec<u8>,
detached_payload_reference: bool,
payload_mime_type: String,
payload_compression_type: Option<String>,
payload_content_id: String,
extra_part_infos: Vec<(String, String, Option<String>)>,
ws_security_header: Option<String>,
ws_security_placeholder: bool,
ws_addressing: Option<WsAddressingHeaders>,
}
pub(crate) const MESSAGE_ID_WSU_ID: &str = "as4-message-id";
pub(crate) const SOAP_BODY_WSU_ID: &str = "as4-body";
pub(crate) const MESSAGING_WSU_ID: &str = "as4-messaging";
pub const EBMS_DEFAULT_ROLE: &str =
"http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/defaultRole";
pub const EBCORE_PARTY_ID_TYPE_UNREGISTERED: &str =
"urn:oasis:names:tc:ebcore:partyid-type:unregistered";
impl SoapEnvelopeBuilder {
pub fn new(
message_id: impl Into<String>,
from_party_id: impl Into<String>,
to_party_id: impl Into<String>,
) -> Self {
Self {
message_id: message_id.into(),
message_timestamp: None,
from_party_id: from_party_id.into(),
to_party_id: to_party_id.into(),
from_party_id_type: Some(EBCORE_PARTY_ID_TYPE_UNREGISTERED.into()),
to_party_id_type: Some(EBCORE_PARTY_ID_TYPE_UNREGISTERED.into()),
from_role: EBMS_DEFAULT_ROLE.into(),
to_role: EBMS_DEFAULT_ROLE.into(),
agreement_ref: None,
agreement_ref_type: None,
action: "http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/action".into(),
service: "http://example.org/example".into(),
service_type: "example".into(),
mpc: None,
conversation_id: None,
ref_to_message_id: None,
original_sender: String::new(),
final_recipient: String::new(),
tracking_identifier: String::new(),
payload: Vec::new(),
detached_payload_reference: false,
payload_mime_type: "application/octet-stream".into(),
payload_compression_type: None,
payload_content_id: "payload@example.org".into(),
extra_part_infos: Vec::new(),
ws_security_header: None,
ws_security_placeholder: false,
ws_addressing: None,
}
.with_default_four_corner_properties()
}
fn with_default_four_corner_properties(mut self) -> Self {
self.original_sender = self.from_party_id.clone();
self.final_recipient = self.to_party_id.clone();
self.tracking_identifier = self.message_id.clone();
self
}
pub fn with_action(mut self, action: impl Into<String>) -> Self {
self.action = action.into();
self
}
pub fn with_service(
mut self,
service: impl Into<String>,
service_type: impl Into<String>,
) -> Self {
self.service = service.into();
self.service_type = service_type.into();
self
}
pub fn with_ref_to_message_id(mut self, id: impl Into<String>) -> Self {
self.ref_to_message_id = Some(id.into());
self
}
pub fn with_four_corner_properties(
mut self,
original_sender: impl Into<String>,
final_recipient: impl Into<String>,
tracking_identifier: impl Into<String>,
) -> Self {
self.original_sender = original_sender.into();
self.final_recipient = final_recipient.into();
self.tracking_identifier = tracking_identifier.into();
self
}
pub fn with_detached_payload_reference(mut self) -> Self {
self.detached_payload_reference = true;
self
}
pub fn with_extra_part_infos(mut self, parts: Vec<(String, String, Option<String>)>) -> Self {
self.extra_part_infos = parts;
self
}
pub fn with_mpc(mut self, mpc: impl Into<String>) -> Self {
self.mpc = Some(mpc.into());
self
}
pub fn with_party_id_types(
mut self,
from_type: Option<String>,
to_type: Option<String>,
) -> Self {
self.from_party_id_type = from_type;
self.to_party_id_type = to_type;
self
}
pub fn with_roles(mut self, from_role: impl Into<String>, to_role: impl Into<String>) -> Self {
self.from_role = from_role.into();
self.to_role = to_role.into();
self
}
pub fn with_agreement_ref(
mut self,
agreement: impl Into<String>,
agreement_type: Option<String>,
) -> Self {
self.agreement_ref = Some(agreement.into());
self.agreement_ref_type = agreement_type;
self
}
#[must_use]
pub fn with_message_timestamp(mut self, timestamp: impl Into<String>) -> Self {
self.message_timestamp = Some(timestamp.into());
self
}
pub fn with_conversation_id(mut self, conversation_id: impl Into<String>) -> Self {
self.conversation_id = Some(conversation_id.into());
self
}
pub fn with_payload(mut self, payload: Vec<u8>) -> Self {
self.payload = payload;
self
}
pub fn with_payload_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.payload_mime_type = mime_type.into();
self
}
pub fn with_payload_compression_type(mut self, compression_type: impl Into<String>) -> Self {
self.payload_compression_type = Some(compression_type.into());
self
}
pub fn with_payload_content_id(mut self, payload_content_id: impl Into<String>) -> Self {
self.payload_content_id = payload_content_id.into();
self
}
#[must_use]
pub fn with_ws_security_placeholder(mut self) -> Self {
self.ws_security_placeholder = true;
self
}
pub fn with_ws_security_header(mut self, header_xml: impl Into<String>) -> Self {
self.ws_security_header = Some(header_xml.into());
self
}
pub fn with_ws_addressing(mut self, headers: WsAddressingHeaders) -> Self {
self.ws_addressing = Some(headers);
self
}
pub fn build(self) -> Result<Vec<u8>> {
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
if self.ws_addressing.is_some() {
xml.push_str(&format!(
"<soap:Envelope xmlns:soap=\"{}\" xmlns:ebms=\"{}\" xmlns:wsse=\"{}\" xmlns:wsu=\"{}\" xmlns:wsa=\"{}\">\n",
SOAP12_NAMESPACE, EBMS_NAMESPACE, WSSE_NAMESPACE, WSSEC_UTILITY_NAMESPACE, WSA_NAMESPACE
));
} else {
xml.push_str(&format!(
"<soap:Envelope xmlns:soap=\"{}\" xmlns:ebms=\"{}\" xmlns:wsse=\"{}\" xmlns:wsu=\"{}\">\n",
SOAP12_NAMESPACE, EBMS_NAMESPACE, WSSE_NAMESPACE, WSSEC_UTILITY_NAMESPACE
));
}
xml.push_str(" <soap:Header>\n");
if let Some(wsa) = &self.ws_addressing {
xml.push_str(&format!(
" <wsa:MessageID>{}</wsa:MessageID>\n",
escape_xml(&wsa.message_id)
));
xml.push_str(&format!(
" <wsa:Action soap:mustUnderstand=\"{}\">{}</wsa:Action>\n",
"true",
escape_xml(&wsa.action)
));
xml.push_str(&format!(" <wsa:To>{}</wsa:To>\n", escape_xml(&wsa.to)));
if let Some(reply_to) = &wsa.reply_to {
xml.push_str(&format!(
" <wsa:ReplyTo><wsa:Address>{}</wsa:Address></wsa:ReplyTo>\n",
escape_xml(reply_to)
));
}
}
xml.push_str(&format!(
" <ebms:Messaging soap:mustUnderstand=\"true\" wsu:Id=\"{MESSAGING_WSU_ID}\">\n"
));
if let Some(mpc) = &self.mpc {
xml.push_str(&format!(
" <ebms:UserMessage mpc=\"{}\">\n",
escape_xml(mpc)
));
} else {
xml.push_str(" <ebms:UserMessage>\n");
}
xml.push_str(" <ebms:MessageInfo>\n");
let Some(message_timestamp) = self.message_timestamp.as_deref() else {
return Err(crate::core::AsxError::new(
crate::core::ErrorCode::InvalidInput,
"SoapEnvelopeBuilder requires an eb:Timestamp pinned with \
with_message_timestamp(..); reading the clock inside build() lets two \
builds of the same message disagree and ship an envelope its own \
signature does not cover",
crate::core::ErrorContext::new("as4_soap_builder"),
));
};
xml.push_str(&format!(
" <ebms:Timestamp>{message_timestamp}</ebms:Timestamp>\n"
));
xml.push_str(&format!(
" <ebms:MessageId wsu:Id=\"{}\">{}</ebms:MessageId>\n",
MESSAGE_ID_WSU_ID,
escape_xml(&self.message_id)
));
if let Some(ref_id) = &self.ref_to_message_id {
xml.push_str(&format!(
" <ebms:RefToMessageId>{}</ebms:RefToMessageId>\n",
escape_xml(ref_id)
));
}
xml.push_str(" </ebms:MessageInfo>\n");
let party_id_xml = |id: &str, id_type: &Option<String>| -> String {
match id_type {
Some(t) => format!(
" <ebms:PartyId type=\"{}\">{}</ebms:PartyId>\n",
escape_xml(t),
escape_xml(id)
),
None => format!(
" <ebms:PartyId>{}</ebms:PartyId>\n",
escape_xml(id)
),
}
};
xml.push_str(" <ebms:PartyInfo>\n");
xml.push_str(" <ebms:From>\n");
xml.push_str(&party_id_xml(&self.from_party_id, &self.from_party_id_type));
xml.push_str(&format!(
" <ebms:Role>{}</ebms:Role>\n",
escape_xml(&self.from_role)
));
xml.push_str(" </ebms:From>\n");
xml.push_str(" <ebms:To>\n");
xml.push_str(&party_id_xml(&self.to_party_id, &self.to_party_id_type));
xml.push_str(&format!(
" <ebms:Role>{}</ebms:Role>\n",
escape_xml(&self.to_role)
));
xml.push_str(" </ebms:To>\n");
xml.push_str(" </ebms:PartyInfo>\n");
xml.push_str(" <ebms:CollaborationInfo>\n");
if let Some(agreement) = &self.agreement_ref {
match &self.agreement_ref_type {
Some(t) => xml.push_str(&format!(
" <ebms:AgreementRef type=\"{}\">{}</ebms:AgreementRef>\n",
escape_xml(t),
escape_xml(agreement)
)),
None => xml.push_str(&format!(
" <ebms:AgreementRef>{}</ebms:AgreementRef>\n",
escape_xml(agreement)
)),
}
}
if self.service_type.is_empty() {
xml.push_str(&format!(
" <ebms:Service>{}</ebms:Service>\n",
escape_xml(&self.service)
));
} else {
xml.push_str(&format!(
" <ebms:Service type=\"{}\">{}</ebms:Service>\n",
escape_xml(&self.service_type),
escape_xml(&self.service)
));
}
xml.push_str(&format!(
" <ebms:Action>{}</ebms:Action>\n",
escape_xml(&self.action)
));
let conversation_id = self.conversation_id.as_deref().unwrap_or("1");
xml.push_str(&format!(
" <ebms:ConversationId>{}</ebms:ConversationId>\n",
escape_xml(conversation_id)
));
xml.push_str(" </ebms:CollaborationInfo>\n");
xml.push_str(" <ebms:MessageProperties>\n");
xml.push_str(&format!(
" <ebms:Property name=\"originalSender\" value=\"{}\"/>\n",
escape_xml(&self.original_sender)
));
xml.push_str(&format!(
" <ebms:Property name=\"finalRecipient\" value=\"{}\"/>\n",
escape_xml(&self.final_recipient)
));
xml.push_str(&format!(
" <ebms:Property name=\"trackingIdentifier\" value=\"{}\"/>\n",
escape_xml(&self.tracking_identifier)
));
xml.push_str(" </ebms:MessageProperties>\n");
if !self.payload.is_empty() || self.detached_payload_reference {
xml.push_str(" <ebms:PayloadInfo>\n");
xml.push_str(&format!(
" <ebms:PartInfo href=\"cid:{}\">\n",
escape_xml(&self.payload_content_id)
));
xml.push_str(" <ebms:Properties>\n");
xml.push_str(&format!(
" <ebms:Property name=\"MimeType\" value=\"{}\"/>\n",
escape_xml(&self.payload_mime_type)
));
if let Some(compression_type) = &self.payload_compression_type {
xml.push_str(&format!(
" <ebms:Property name=\"CompressionType\" value=\"{}\"/>\n",
escape_xml(compression_type)
));
}
xml.push_str(" </ebms:Properties>\n");
xml.push_str(" </ebms:PartInfo>\n");
for (content_id, mime_type, compression_type) in &self.extra_part_infos {
xml.push_str(&format!(
" <ebms:PartInfo href=\"cid:{}\">\n",
escape_xml(content_id)
));
xml.push_str(" <ebms:Properties>\n");
xml.push_str(&format!(
" <ebms:Property name=\"MimeType\" value=\"{}\"/>\n",
escape_xml(mime_type)
));
if let Some(compression_type) = compression_type {
xml.push_str(&format!(
" <ebms:Property name=\"CompressionType\" value=\"{}\"/>\n",
escape_xml(compression_type)
));
}
xml.push_str(" </ebms:Properties>\n");
xml.push_str(" </ebms:PartInfo>\n");
}
xml.push_str(" </ebms:PayloadInfo>\n");
}
xml.push_str(" </ebms:UserMessage>\n");
xml.push_str(" </ebms:Messaging>\n");
if let Some(wsse) = &self.ws_security_header {
xml.push_str(wsse);
} else if self.ws_security_placeholder {
xml.push_str(WSSE_HEADER_PLACEHOLDER);
xml.push('\n');
}
xml.push_str(" </soap:Header>\n");
xml.push_str(&format!(" <soap:Body wsu:Id=\"{}\">\n", SOAP_BODY_WSU_ID));
if !self.payload.is_empty() && !self.detached_payload_reference {
xml.push_str(" <asx:Payload xmlns:asx=\"urn:asx:payload\">\n");
xml.push_str(&format!(
" <asx:MimeType>{}</asx:MimeType>\n",
escape_xml(&self.payload_mime_type)
));
xml.push_str(&format!(
" <asx:Base64>{}</asx:Base64>\n",
STANDARD.encode(&self.payload)
));
xml.push_str(" </asx:Payload>\n");
}
xml.push_str(" </soap:Body>\n");
xml.push_str("</soap:Envelope>\n");
Ok(xml.into_bytes())
}
}
fn escape_xml(s: &str) -> String {
s.chars()
.map(|c| match c {
'<' => "<".to_string(),
'>' => ">".to_string(),
'&' => "&".to_string(),
'"' => """.to_string(),
'\'' => "'".to_string(),
c => c.to_string(),
})
.collect()
}
pub fn build_pkipath_der(cert_der: &[u8]) -> Vec<u8> {
let len = cert_der.len();
let mut result = vec![0x30u8]; if len < 128 {
result.push(len as u8);
} else if len < 0x100 {
result.extend_from_slice(&[0x81, len as u8]);
} else if len < 0x1_0000 {
result.extend_from_slice(&[0x82, (len >> 8) as u8, len as u8]);
} else {
result.extend_from_slice(&[0x83, (len >> 16) as u8, (len >> 8) as u8, len as u8]);
}
result.extend_from_slice(cert_der);
result
}
#[derive(Debug, Clone)]
pub struct WsSecurityHeaderBuilder {
signing_cert_pem: Option<Vec<u8>>,
signing_cert_pkipath_der: Option<Vec<u8>>,
include_signature_placeholder: bool,
signature_xml: Option<String>,
}
impl Default for WsSecurityHeaderBuilder {
fn default() -> Self {
Self::new()
}
}
impl WsSecurityHeaderBuilder {
pub fn new() -> Self {
Self {
signing_cert_pem: None,
signing_cert_pkipath_der: None,
include_signature_placeholder: false,
signature_xml: None,
}
}
pub fn with_signing_cert(mut self, cert_pem: Vec<u8>) -> Self {
self.signing_cert_pem = Some(cert_pem);
self
}
pub fn with_signing_cert_pkipath_der(mut self, pkipath_der: Vec<u8>) -> Self {
self.signing_cert_pkipath_der = Some(pkipath_der);
self
}
pub fn with_signature_placeholder(mut self, enabled: bool) -> Self {
self.include_signature_placeholder = enabled;
self
}
pub fn with_signature_xml(mut self, signature_xml: impl Into<String>) -> Self {
self.signature_xml = Some(signature_xml.into());
self
}
pub fn build(self) -> Result<Vec<u8>> {
let now = std::time::SystemTime::now();
let created = crate::time_utils::format_rfc3339_secs(now);
let expires =
crate::time_utils::format_rfc3339_secs(now + std::time::Duration::from_secs(300));
let mut xml = String::new();
xml.push_str(" <wsse:Security soap:mustUnderstand=\"true\" xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">\n");
xml.push_str(&format!(
" <wsu:Timestamp wsu:Id=\"Timestamp\">\n <wsu:Created>{created}</wsu:Created>\n <wsu:Expires>{expires}</wsu:Expires>\n </wsu:Timestamp>\n"
));
if let Some(cert_pem) = self.signing_cert_pem {
let cert_der = openssl::x509::X509::from_pem(&cert_pem)
.and_then(|cert| cert.to_der())
.map_err(|err| {
crate::core::AsxError::new(
crate::core::ErrorCode::ParseFailed,
format!("signing certificate PEM could not be converted to DER: {err}"),
crate::core::ErrorContext::new("wssec_header_builder"),
)
})?;
xml.push_str(" <wsse:BinarySecurityToken wsu:Id=\"X509Token\" EncodingType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary\" ValueType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3\">\n");
xml.push_str(" ");
xml.push_str(&STANDARD.encode(cert_der));
xml.push('\n');
xml.push_str(" </wsse:BinarySecurityToken>\n");
}
if let Some(pkipath_der) = self.signing_cert_pkipath_der {
xml.push_str(" <wsse:BinarySecurityToken wsu:Id=\"X509PKIPathToken\" \
EncodingType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary\" \
ValueType=\"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509PKIPathv1\">\n");
xml.push_str(" ");
xml.push_str(&STANDARD.encode(pkipath_der));
xml.push('\n');
xml.push_str(" </wsse:BinarySecurityToken>\n");
}
if let Some(signature_xml) = self.signature_xml {
xml.push_str(&signature_xml);
if !signature_xml.ends_with('\n') {
xml.push('\n');
}
} else if self.include_signature_placeholder {
xml.push_str(" <ds:Signature xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\">\n");
xml.push_str(" <!-- XMLDSig signature will be inserted here -->\n");
xml.push_str(" </ds:Signature>\n");
}
xml.push_str(" </wsse:Security>\n");
Ok(xml.into_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn soap_envelope_builder_generates_valid_xml() {
let builder =
SoapEnvelopeBuilder::new("msg-123", "sender@example.org", "receiver@example.com")
.with_message_timestamp("2026-09-06T12:00:00Z")
.with_action("urn:example:action")
.with_conversation_id("conv-456");
let envelope = builder.build().expect("build");
let envelope_str = String::from_utf8(envelope).expect("utf8");
assert!(envelope_str.contains("<?xml version"));
assert!(envelope_str.contains("soap:Envelope"));
assert!(envelope_str.contains("<ebms:Messaging"));
assert!(envelope_str.contains("ebms:UserMessage"));
assert!(envelope_str.contains("msg-123"));
assert!(envelope_str.contains("sender@example.org"));
assert!(envelope_str.contains("receiver@example.com"));
assert!(envelope_str.contains("conv-456"));
assert!(envelope_str.contains("name=\"trackingIdentifier\" value=\"msg-123\""));
assert_ne!(
envelope_str.find("sender@example.org"),
envelope_str
.rfind("sender@example.org")
.filter(|_| envelope_str.contains("receiver@example.com")),
);
}
#[test]
fn soap_envelope_emits_schema_mandatory_party_and_collaboration_elements() {
let default_envelope = String::from_utf8(
SoapEnvelopeBuilder::new("m-1", "pa", "pb")
.with_message_timestamp("2026-09-06T12:00:00Z")
.build()
.expect("build"),
)
.expect("utf8");
assert!(default_envelope.contains(
"<ebms:PartyId type=\"urn:oasis:names:tc:ebcore:partyid-type:unregistered\">pa</ebms:PartyId>"
));
assert_eq!(
default_envelope
.matches(&format!("<ebms:Role>{EBMS_DEFAULT_ROLE}</ebms:Role>"))
.count(),
2,
"both From and To must carry the mandatory eb:Role: {default_envelope}"
);
assert!(
default_envelope.contains("<ebms:ConversationId>1</ebms:ConversationId>"),
"ConversationId is schema-mandatory and must default to \"1\": {default_envelope}"
);
let envelope = String::from_utf8(
SoapEnvelopeBuilder::new("m-2", "org:example:company:A", "org:example:company:B")
.with_message_timestamp("2026-09-06T12:00:00Z")
.with_party_id_types(None, None)
.with_roles("Sender", "Receiver")
.with_agreement_ref("http://agreements.example.org/a0", None)
.build()
.expect("build"),
)
.expect("utf8");
assert!(envelope.contains("<ebms:PartyId>org:example:company:A</ebms:PartyId>"));
assert!(envelope.contains("<ebms:Role>Sender</ebms:Role>"));
assert!(envelope.contains("<ebms:Role>Receiver</ebms:Role>"));
let agreement_pos = envelope
.find("<ebms:AgreementRef>http://agreements.example.org/a0</ebms:AgreementRef>")
.expect("AgreementRef present");
let service_pos = envelope.find("<ebms:Service").expect("Service present");
assert!(
agreement_pos < service_pos,
"schema order: AgreementRef must precede Service"
);
}
#[test]
fn soap_envelope_builder_allows_overriding_four_corner_properties() {
let builder = SoapEnvelopeBuilder::new("msg-abc", "ap-sender", "ap-receiver")
.with_message_timestamp("2026-09-06T12:00:00Z")
.with_four_corner_properties("participant-a", "participant-b", "track-789");
let envelope = builder.build().expect("build");
let envelope_str = String::from_utf8(envelope).expect("utf8");
assert!(envelope_str.contains("name=\"originalSender\" value=\"participant-a\""));
assert!(envelope_str.contains("name=\"finalRecipient\" value=\"participant-b\""));
assert!(envelope_str.contains("name=\"trackingIdentifier\" value=\"track-789\""));
}
#[test]
fn soap_envelope_escapes_xml_characters() {
let builder =
SoapEnvelopeBuilder::new("msg-<test>", "sender@example.org", "receiver@example.com")
.with_message_timestamp("2026-09-06T12:00:00Z");
let envelope = builder.build().expect("build");
let envelope_str = String::from_utf8(envelope).expect("utf8");
assert!(envelope_str.contains("msg-<test>"));
assert!(!envelope_str.contains("msg-<test>"));
}
#[test]
fn wssecurity_header_builds_valid_structure() {
let builder = WsSecurityHeaderBuilder::new();
let header = builder.build().expect("build");
let header_str = String::from_utf8(header).expect("utf8");
assert!(header_str.contains("wsse:Security"));
assert!(header_str.contains("</wsse:Security>"));
assert!(header_str.contains("wsu:Timestamp"));
assert!(header_str.contains("wsu:Created"));
assert!(header_str.contains("wsu:Expires"));
}
#[test]
fn wssecurity_header_includes_certificate_structure_when_provided() {
let rsa = openssl::rsa::Rsa::generate(2048).expect("rsa");
let pkey = openssl::pkey::PKey::from_rsa(rsa).expect("pkey");
let mut name = openssl::x509::X509NameBuilder::new().expect("name");
name.append_entry_by_nid(openssl::nid::Nid::COMMONNAME, "bst-test")
.expect("cn");
let name = name.build();
let mut cert = openssl::x509::X509::builder().expect("builder");
cert.set_subject_name(&name).expect("subject");
cert.set_issuer_name(&name).expect("issuer");
cert.set_pubkey(&pkey).expect("pubkey");
let not_before = openssl::asn1::Asn1Time::days_from_now(0).expect("nb");
let not_after = openssl::asn1::Asn1Time::days_from_now(1).expect("na");
cert.set_not_before(¬_before).expect("nb");
cert.set_not_after(¬_after).expect("na");
cert.sign(&pkey, openssl::hash::MessageDigest::sha256())
.expect("sign");
let cert = cert.build();
let cert_pem = cert.to_pem().expect("pem");
let builder = WsSecurityHeaderBuilder::new()
.with_signing_cert(cert_pem)
.with_signature_placeholder(true);
let header = builder.build().expect("build");
let header_str = String::from_utf8(header).expect("utf8");
assert!(header_str.contains("wsse:BinarySecurityToken"));
assert!(header_str.contains("ds:Signature"));
let der_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
cert.to_der().expect("der"),
);
assert!(
header_str.contains(&der_b64),
"BinarySecurityToken must carry base64(DER)"
);
}
}
#[cfg(test)]
mod timestamp_pinning_tests {
use super::*;
#[test]
fn build_refuses_without_a_pinned_timestamp() {
let err = SoapEnvelopeBuilder::new("msg-1", "from", "to")
.with_action("urn:example:action")
.build()
.expect_err("an unpinned timestamp must be an error, not a clock read");
assert_eq!(err.code, crate::core::ErrorCode::InvalidInput);
assert!(
err.message.contains("with_message_timestamp"),
"the error names the fix: {}",
err.message
);
}
#[test]
fn two_builds_with_a_pinned_timestamp_agree() {
let make = || {
SoapEnvelopeBuilder::new("msg-1", "from", "to")
.with_action("urn:example:action")
.with_message_timestamp("2026-09-06T12:00:00Z")
.build()
.expect("build")
};
assert_eq!(
make(),
make(),
"a pinned timestamp must make the build deterministic"
);
}
#[test]
fn splicing_the_security_header_leaves_the_signed_subtrees_untouched() {
let envelope = SoapEnvelopeBuilder::new("msg-1", "from", "to")
.with_action("urn:example:action")
.with_message_timestamp("2026-09-06T12:00:00Z")
.with_ws_security_placeholder()
.build()
.expect("build");
let envelope = String::from_utf8(envelope).expect("utf8");
let spliced = splice_ws_security_header(&envelope, "<wsse:Security/>").expect("splice");
let messaging = |xml: &str| {
let start = xml.find("<ebms:Messaging").expect("messaging");
let end = xml.find("</ebms:Messaging>").expect("messaging end");
xml[start..end].to_string()
};
let body = |xml: &str| {
let start = xml.find("<soap:Body").expect("body");
let end = xml.find("</soap:Body>").expect("body end");
xml[start..end].to_string()
};
assert_eq!(messaging(&envelope), messaging(&spliced));
assert_eq!(body(&envelope), body(&spliced));
assert!(spliced.contains("<wsse:Security/>"));
assert!(!spliced.contains(WSSE_HEADER_PLACEHOLDER));
}
#[test]
fn splicing_requires_exactly_one_placeholder() {
let without = SoapEnvelopeBuilder::new("msg-1", "from", "to")
.with_action("urn:example:action")
.with_message_timestamp("2026-09-06T12:00:00Z")
.build()
.expect("build");
let without = String::from_utf8(without).expect("utf8");
let err = splice_ws_security_header(&without, "<wsse:Security/>")
.expect_err("no placeholder must be an error, not a no-op");
assert_eq!(err.code, crate::core::ErrorCode::InvalidInput);
let doubled = format!("{WSSE_HEADER_PLACEHOLDER}{WSSE_HEADER_PLACEHOLDER}");
splice_ws_security_header(&doubled, "<wsse:Security/>")
.expect_err("two placeholders are ambiguous");
}
#[test]
fn a_timestamp_one_second_apart_changes_the_signed_bytes() {
let make = |ts: &str| {
SoapEnvelopeBuilder::new("msg-1", "from", "to")
.with_action("urn:example:action")
.with_message_timestamp(ts)
.build()
.expect("build")
};
assert_ne!(
make("2026-09-06T12:00:00Z"),
make("2026-09-06T12:00:01Z"),
"eb:Timestamp is inside the signed eb:Messaging block"
);
}
}