use base64::{Engine as _, engine::general_purpose::STANDARD};
use crate::core::{AsxError, ErrorCode, ErrorContext, Result};
use crate::xml::{Slot, XmlDoc, XmlFragment, XmlLimits, XmlOut, ns};
#[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,
}
}
}
#[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,
conversation_id: Option<String>,
ref_to_message_id: Option<String>,
original_sender: String,
message_properties: bool,
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_addressing: Option<WsAddressingHeaders>,
}
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";
#[derive(Debug)]
pub(crate) struct UnsignedEnvelope {
doc: XmlDoc,
slot: Slot,
text: String,
}
impl UnsignedEnvelope {
pub(crate) fn text(&self) -> &str {
&self.text
}
pub(crate) fn seal(mut self, header: XmlFragment) -> Result<String> {
self.doc.fill(self.slot, header)?;
self.doc.into_string()
}
}
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(),
conversation_id: None,
ref_to_message_id: None,
original_sender: String::new(),
message_properties: true,
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_addressing: None,
}
.with_default_four_corner_properties()
}
pub(crate) fn without_message_properties(mut self) -> Self {
self.message_properties = false;
self
}
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_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
}
pub fn with_ws_addressing(mut self, headers: WsAddressingHeaders) -> Self {
self.ws_addressing = Some(headers);
self
}
pub fn build(self) -> Result<Vec<u8>> {
let (doc, _) = self.write(false)?;
Ok(doc.into_string()?.into_bytes())
}
pub(crate) fn build_with_security_slot(self) -> Result<UnsignedEnvelope> {
let (doc, slot) = self.write(true)?;
let slot = slot.expect("a slot was reserved");
let text = doc.text_with_empty_slots();
Ok(UnsignedEnvelope { doc, slot, text })
}
fn write(self, reserve_security: bool) -> Result<(XmlDoc, Option<Slot>)> {
let Some(message_timestamp) = self.message_timestamp.as_deref() else {
return Err(AsxError::new(
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",
ErrorContext::new("as4_soap_builder"),
));
};
let mut out = XmlOut::new();
out.declaration();
out.start("soap:Envelope")?
.ns("soap", ns::SOAP12)?
.ns("ebms", ns::EBMS3)?
.ns("wsse", ns::WSSE)?
.ns("wsu", ns::WSU)?;
if self.ws_addressing.is_some() {
out.ns("wsa", ns::WSA)?;
}
out.start("soap:Header")?;
if let Some(wsa) = &self.ws_addressing {
out.leaf("wsa:MessageID", &wsa.message_id, "wsa:MessageID")?;
out.start("wsa:Action")?
.attr("soap:mustUnderstand", "true", "soap:mustUnderstand")?
.text(&wsa.action, "wsa:Action")?
.end()?;
out.leaf("wsa:To", &wsa.to, "wsa:To")?;
if let Some(reply_to) = &wsa.reply_to {
out.start("wsa:ReplyTo")?;
out.leaf("wsa:Address", reply_to, "wsa:ReplyTo/wsa:Address")?;
out.end()?;
}
}
out.start("ebms:Messaging")?
.attr("soap:mustUnderstand", "true", "soap:mustUnderstand")?
.attr("wsu:Id", MESSAGING_WSU_ID, "wsu:Id")?;
out.start("ebms:UserMessage")?;
out.start("ebms:MessageInfo")?;
out.leaf("ebms:Timestamp", message_timestamp, "eb:Timestamp")?;
out.leaf("ebms:MessageId", &self.message_id, "eb:MessageId")?;
if let Some(ref_id) = &self.ref_to_message_id {
out.leaf("ebms:RefToMessageId", ref_id, "eb:RefToMessageId")?;
}
out.end()?;
out.start("ebms:PartyInfo")?;
for (element, id, id_type, role) in [
(
"ebms:From",
&self.from_party_id,
&self.from_party_id_type,
&self.from_role,
),
(
"ebms:To",
&self.to_party_id,
&self.to_party_id_type,
&self.to_role,
),
] {
out.start(element)?;
out.start("ebms:PartyId")?;
if let Some(t) = id_type {
out.attr("type", t, "eb:PartyId/@type")?;
}
out.text(id, "eb:PartyId")?.end()?;
out.leaf("ebms:Role", role, "eb:Role")?;
out.end()?;
}
out.end()?;
out.start("ebms:CollaborationInfo")?;
if let Some(agreement) = &self.agreement_ref {
out.start("ebms:AgreementRef")?;
if let Some(t) = &self.agreement_ref_type {
out.attr("type", t, "eb:AgreementRef/@type")?;
}
out.text(agreement, "eb:AgreementRef")?.end()?;
}
out.start("ebms:Service")?;
if !self.service_type.is_empty() {
out.attr("type", &self.service_type, "eb:Service/@type")?;
}
out.text(&self.service, "eb:Service")?.end()?;
out.leaf("ebms:Action", &self.action, "eb:Action")?;
out.leaf(
"ebms:ConversationId",
self.conversation_id.as_deref().unwrap_or("1"),
"eb:ConversationId",
)?;
out.end()?;
if self.message_properties {
out.start("ebms:MessageProperties")?;
for (name, value) in [
("originalSender", &self.original_sender),
("finalRecipient", &self.final_recipient),
("trackingIdentifier", &self.tracking_identifier),
] {
write_property(&mut out, name, value)?;
}
out.end()?;
}
if !self.payload.is_empty() || self.detached_payload_reference {
out.start("ebms:PayloadInfo")?;
write_part_info(
&mut out,
&self.payload_content_id,
&self.payload_mime_type,
self.payload_compression_type.as_deref(),
)?;
for (content_id, mime_type, compression_type) in &self.extra_part_infos {
write_part_info(&mut out, content_id, mime_type, compression_type.as_deref())?;
}
out.end()?;
}
out.end()?; out.end()?;
let slot = reserve_security.then(|| out.slot());
out.end()?;
out.start("soap:Body")?
.attr("wsu:Id", SOAP_BODY_WSU_ID, "wsu:Id")?;
if !self.payload.is_empty() && !self.detached_payload_reference {
out.start("asx:Payload")?.ns("asx", "urn:asx:payload")?;
out.leaf("asx:MimeType", &self.payload_mime_type, "MimeType")?;
out.leaf("asx:Base64", &STANDARD.encode(&self.payload), "payload")?;
out.end()?;
}
out.end()?;
out.end()?;
Ok((out.finish()?, slot))
}
}
fn write_property(out: &mut XmlOut, name: &'static str, value: &str) -> Result<()> {
out.start("ebms:Property")?
.attr("name", name, "eb:Property/@name")?
.text(value, name)?
.end()?;
Ok(())
}
fn write_part_info(
out: &mut XmlOut,
content_id: &str,
mime_type: &str,
compression_type: Option<&str>,
) -> Result<()> {
out.start("ebms:PartInfo")?
.attr("href", &format!("cid:{content_id}"), "eb:PartInfo/@href")?;
out.start("ebms:PartProperties")?;
write_property(out, "MimeType", mime_type)?;
if let Some(compression_type) = compression_type {
write_property(out, "CompressionType", compression_type)?;
}
out.end()?;
out.end()?;
Ok(())
}
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
}
pub(crate) fn build_pkipath_chain_der(root_first: &[&[u8]]) -> Vec<u8> {
let content: Vec<u8> = root_first.concat();
build_pkipath_der(&content)
}
pub(crate) fn security_header(
prepared: crate::crypto::wssec::sign::PreparedSignature,
raw_signature: Vec<u8>,
chain_der: &[Vec<u8>],
key_info: crate::crypto::wssec::WsSecOutboundKeyInfoProfile,
now: std::time::SystemTime,
encryption: Option<XmlFragment>,
) -> Result<XmlFragment> {
let leaf_der = chain_der.first().ok_or_else(|| {
AsxError::new(
ErrorCode::PolicyViolation,
"the signer has no certificate",
ErrorContext::new("wssec_sign"),
)
})?;
let signature_xml =
crate::crypto::wssec::sign::finish_signature(prepared, raw_signature, leaf_der, key_info)?
.into_string();
let mut builder = WsSecurityHeaderBuilder::new()
.at(now)
.with_signature_xml(signature_xml);
builder.encryption = encryption;
let builder = if key_info == crate::crypto::wssec::WsSecOutboundKeyInfoProfile::X509PKIPathv1 {
let root_first: Vec<&[u8]> = chain_der.iter().rev().map(Vec::as_slice).collect();
builder.with_signing_cert_pkipath_der(build_pkipath_chain_der(&root_first))
} else {
let pem = openssl::x509::X509::from_der(leaf_der)
.and_then(|cert| cert.to_pem())
.map_err(|_err| {
AsxError::new(
ErrorCode::ParseFailed,
"failed to serialize the signing certificate to PEM",
ErrorContext::new("wssec_sign"),
)
})?;
builder.with_signing_cert(pem)
};
builder.build_fragment()
}
#[derive(Debug, Clone)]
pub struct WsSecurityHeaderBuilder {
signing_cert_pem: Option<Vec<u8>>,
signing_cert_pkipath_der: Option<Vec<u8>>,
signature_xml: Option<String>,
now: std::time::SystemTime,
encryption: Option<XmlFragment>,
}
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,
signature_xml: None,
now: crate::clock::system().now(),
encryption: None,
}
}
pub(crate) fn at(mut self, now: std::time::SystemTime) -> Self {
self.now = now;
self
}
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_xml(mut self, signature_xml: impl Into<String>) -> Self {
self.signature_xml = Some(signature_xml.into());
self
}
pub fn build(self) -> Result<Vec<u8>> {
Ok(self.build_fragment()?.into_string().into_bytes())
}
pub(crate) fn build_fragment(self) -> Result<XmlFragment> {
let now = self.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 out = XmlOut::new();
out.start("wsse:Security")?
.ns("wsse", ns::WSSE)?
.ns("wsu", ns::WSU)?
.ns("soap", ns::SOAP12)?
.attr("soap:mustUnderstand", "true", "soap:mustUnderstand")?;
out.start("wsu:Timestamp")?
.attr("wsu:Id", "Timestamp", "wsu:Id")?;
out.leaf("wsu:Created", &created, "wsu:Created")?;
out.leaf("wsu:Expires", &expires, "wsu:Expires")?;
out.end()?;
if let Some(encryption) = &self.encryption {
out.fragment(encryption)?;
}
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| {
AsxError::new(
ErrorCode::ParseFailed,
format!("signing certificate PEM could not be converted to DER: {err}"),
ErrorContext::new("wssec_header_builder"),
)
})?;
write_binary_security_token(
&mut out,
"X509Token",
ns::WSSE_X509V3_VALUE_TYPE,
&cert_der,
)?;
}
if let Some(pkipath_der) = self.signing_cert_pkipath_der {
write_binary_security_token(
&mut out,
"X509PKIPathToken",
ns::WSSE_X509_PKIPATH_VALUE_TYPE,
&pkipath_der,
)?;
}
if let Some(signature_xml) = &self.signature_xml {
out.document_element(
signature_xml,
XmlLimits::ENVELOPE,
"WsSecurityHeaderBuilder signature",
)?;
}
out.end()?;
out.finish()?.into_fragment()
}
}
fn write_binary_security_token(
out: &mut XmlOut,
id: &str,
value_type: &'static str,
der: &[u8],
) -> Result<()> {
out.start("wsse:BinarySecurityToken")?
.attr("wsu:Id", id, "wsu:Id")?
.attr("EncodingType", ns::WSSE_BASE64_ENCODING, "EncodingType")?
.attr("ValueType", value_type, "ValueType")?
.text(&STANDARD.encode(der), "BinarySecurityToken")?
.end()?;
Ok(())
}
#[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\">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\">participant-a</"));
assert!(envelope_str.contains("name=\"finalRecipient\">participant-b</"));
assert!(envelope_str.contains("name=\"trackingIdentifier\">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_xml(r#"<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/>"#);
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 sealing_the_security_header_leaves_the_signed_subtrees_untouched() {
let unsigned = SoapEnvelopeBuilder::new("msg-1", "from", "to")
.with_action("urn:example:action")
.with_message_timestamp("2026-09-06T12:00:00Z")
.build_with_security_slot()
.expect("build");
let before = unsigned.text().to_string();
assert!(!before.contains("wsse:Security"));
let header = WsSecurityHeaderBuilder::new()
.build_fragment()
.expect("header");
let sealed = unsigned.seal(header).expect("seal");
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");
xml[start..].to_string()
};
assert_eq!(messaging(&before), messaging(&sealed));
assert_eq!(body(&before), body(&sealed));
assert!(sealed.contains("<wsse:Security"));
roxmltree::Document::parse(&sealed).expect("sealed envelope is well-formed");
}
#[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"
);
}
}