use super::super::stream::{MultipartAs4Payload, decrypt_xmlenc_payload_if_present};
use super::super::types::As4PushPolicy;
use crate::core::{AsxError, ErrorCode, ErrorContext, Result, SessionContext};
use crate::lifecycle::{DomainReady, TrustEvidence, UntrustedBytes};
use crate::sbdh::{SbdhHeader, StandardBusinessDocument};
use crate::wire::{DEFAULT_MAX_BODY_BYTES, enforce_payload_limit};
use memchr::memmem;
use std::sync::Arc;
const MAX_AS4_PAYLOAD_BYTES: usize = DEFAULT_MAX_BODY_BYTES;
pub(super) struct WsSecVerifiedGate;
pub(super) enum ResolvedVerifiedPayload<'a> {
Borrowed(&'a [u8]),
Owned(Vec<u8>),
}
impl<'a> ResolvedVerifiedPayload<'a> {
fn as_slice(&self) -> &[u8] {
match self {
Self::Borrowed(bytes) => bytes,
Self::Owned(bytes) => bytes,
}
}
fn into_payload_input(self) -> crate::core::PayloadInput<'a> {
match self {
Self::Borrowed(bytes) => crate::core::PayloadInput::Borrowed(bytes),
Self::Owned(bytes) => crate::core::PayloadInput::Owned(bytes),
}
}
}
pub(super) struct WsSecVerifiedPayload<'a> {
payload_input: crate::core::PayloadInput<'a>,
_gate: WsSecVerifiedGate,
}
impl<'a> WsSecVerifiedPayload<'a> {
pub(super) fn new(payload: ResolvedVerifiedPayload<'a>, gate: WsSecVerifiedGate) -> Self {
Self {
payload_input: payload.into_payload_input(),
_gate: gate,
}
}
}
pub(super) fn promote_payload_to_domain_ready(
session: &SessionContext,
verified_payload: WsSecVerifiedPayload<'_>,
) -> Result<DomainReady<Arc<[u8]>>> {
let payload_input = verified_payload.payload_input;
let payload_len = payload_input.as_slice().len();
enforce_payload_limit("as4_receive_parse", payload_len, MAX_AS4_PAYLOAD_BYTES)?;
if payload_len == 0 {
return Err(AsxError::new(
ErrorCode::ParseFailed,
"as4 payload is empty",
ErrorContext::new("as4_receive_parse")
.with_session_and_partner(session.session_id(), session.partner_id()),
));
}
let trust = TrustEvidence::verified_and_decryptable();
let trusted = UntrustedBytes::new(payload_input.into_arc())
.into_parsed_unchecked()
.verify(trust.signature)?
.decrypt(trust.decryption)?
.into_domain_ready();
Ok(trusted)
}
pub(super) fn maybe_unwrap_sbdh_payload<'a>(
session: &SessionContext,
message_id: &str,
payload: ResolvedVerifiedPayload<'a>,
) -> Result<(ResolvedVerifiedPayload<'a>, Option<SbdhHeader>)> {
let payload_bytes = payload.as_slice();
if memmem::find(payload_bytes, b"<StandardBusinessDocument").is_none() {
return Ok((payload, None));
}
let wrapped = StandardBusinessDocument::unwrap(payload_bytes).map_err(|err| {
AsxError::new(
ErrorCode::ParseFailed,
format!(
"failed to parse SBDH-wrapped AS4 business payload: {}",
err.message
),
ErrorContext::for_session_with_message("as4_receive_sbdh_unwrap", session, message_id),
)
})?;
Ok((
ResolvedVerifiedPayload::Owned(wrapped.payload),
Some(wrapped.header),
))
}
pub(super) struct ResolvedNamedPayload<'a> {
pub content_id: String,
pub payload: ResolvedVerifiedPayload<'a>,
}
pub(super) fn resolve_verified_payloads<'a>(
session: &SessionContext,
policy: &As4PushPolicy,
message_id: &str,
multipart: Option<MultipartAs4Payload<'a>>,
soap_bytes: &'a [u8],
) -> Result<Vec<ResolvedNamedPayload<'a>>> {
let multipart = multipart.ok_or_else(|| {
AsxError::new(
ErrorCode::PolicyViolation,
"AS4 inbound payload must be multipart/related with a detached payload attachment",
ErrorContext::for_session_with_message("as4_receive_push", session, message_id),
)
})?;
if multipart.payloads.is_empty() {
if memmem::find(soap_bytes, b"<asx:Base64>").is_some() {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
"embedded AS4 payload receive is unsupported; inbound payload must be multipart/related",
ErrorContext::for_session_with_message("as4_receive_push", session, message_id),
));
}
return Err(AsxError::new(
ErrorCode::InteropViolation,
"AS4 message has no MIME payload attachment: inline SOAP body payloads are not \
supported. PEPPOL/CEF require MIME multipart/related attachment packaging. \
If the sending partner uses inline body mode, reconfigure their AS4 gateway \
to use SwA MIME attachment packaging.",
ErrorContext::for_session_with_message("as4_receive_push", session, message_id),
));
}
let mut resolved = Vec::with_capacity(multipart.payloads.len());
for attachment in &multipart.payloads {
let payload = if let Some(decrypted) = decrypt_xmlenc_payload_if_present(
attachment.bytes,
policy.inbound_decryption_key_pem.as_deref(),
"as4_receive_push",
)? {
ResolvedVerifiedPayload::Owned(decrypted)
} else {
if policy.require_encrypted_inbound {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
format!(
"inbound AS4 payload cid:{} is not XML-encrypted but \
require_encrypted_inbound = true",
attachment.content_id
),
ErrorContext::for_session_with_message("as4_receive_push", session, message_id),
));
}
ResolvedVerifiedPayload::Borrowed(attachment.bytes)
};
resolved.push(ResolvedNamedPayload {
content_id: attachment.content_id.to_string(),
payload,
});
}
Ok(resolved)
}
pub(super) fn compression_types_by_content_id(
soap_bytes: &[u8],
) -> std::collections::HashMap<String, String> {
const EBMS3_NS: &str = "http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/";
let mut out = std::collections::HashMap::new();
let Ok(xml) = std::str::from_utf8(soap_bytes) else {
return out;
};
let Ok(doc) = roxmltree::Document::parse(xml) else {
return out;
};
for part_info in doc.descendants().filter(|n| {
n.is_element()
&& n.tag_name().name() == "PartInfo"
&& n.tag_name().namespace() == Some(EBMS3_NS)
}) {
let Some(href) = part_info.attribute("href") else {
continue;
};
let content_id = normalize_content_id(href);
let compression = part_info
.descendants()
.filter(|n| {
n.is_element()
&& n.tag_name().name() == "Property"
&& n.tag_name().namespace() == Some(EBMS3_NS)
})
.find(|n| n.attribute("name") == Some("CompressionType"))
.and_then(|n| n.attribute("value").map(str::trim))
.filter(|v| !v.is_empty());
if let Some(compression) = compression {
out.insert(content_id, compression.to_string());
}
}
out
}
fn normalize_content_id(value: &str) -> String {
let value = value.trim();
let value = value.strip_prefix('<').unwrap_or(value);
let value = value.strip_suffix('>').unwrap_or(value);
let value = value
.get(..4)
.filter(|p| p.eq_ignore_ascii_case("cid:"))
.map_or(value, |_| &value[4..]);
value.to_string()
}
pub(super) fn decompress_payload_if_declared<'a>(
session: &SessionContext,
content_id: &str,
compression_types: &std::collections::HashMap<String, String>,
payload: ResolvedVerifiedPayload<'a>,
) -> Result<ResolvedVerifiedPayload<'a>> {
let Some(compression_type) = compression_types.get(&normalize_content_id(content_id)) else {
return Ok(payload);
};
if !compression_type.eq_ignore_ascii_case(crate::crypto::compression::AS4_COMPRESSION_TYPE) {
return Err(AsxError::new(
ErrorCode::InteropViolation,
format!(
"AS4 payload declares unsupported CompressionType \"{compression_type}\"; \
the AS4 profile defines only \"{}\"",
crate::crypto::compression::AS4_COMPRESSION_TYPE
),
ErrorContext::for_session("as4_receive_decompress", session),
));
}
#[cfg(feature = "compression")]
{
let plain =
crate::crypto::compression::decompress_gzip(payload.as_slice()).map_err(|err| {
AsxError::new(
err.code,
format!(
"failed to decompress AS4 payload attachment: {}",
err.message
),
ErrorContext::for_session("as4_receive_decompress", session),
)
})?;
Ok(ResolvedVerifiedPayload::Owned(plain))
}
#[cfg(not(feature = "compression"))]
{
Err(AsxError::new(
ErrorCode::PolicyViolation,
"partner sent a gzip-compressed AS4 payload but the 'compression' feature \
is disabled",
ErrorContext::for_session("as4_receive_decompress", session),
))
}
}