use super::{As4SendPolicy, As4SendPolicyBuilder};
use crate::core::{AsxError, ErrorCode, ErrorContext, InteropMode, Result};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum PayloadPackagingMode {
#[default]
MimeAttachment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub enum MepType {
#[default]
OneWayPush,
OneWayPull,
TwoWayPushPush,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PModeSecurity {
pub sign: bool,
pub encrypt: bool,
pub encrypt_soap_headers: bool,
pub compress: bool,
pub outbound_key_info_profile: crate::crypto::wssec::WsSecOutboundKeyInfoProfile,
}
impl Default for PModeSecurity {
fn default() -> Self {
Self {
sign: true,
encrypt: false,
encrypt_soap_headers: false,
compress: false,
outbound_key_info_profile: crate::crypto::wssec::WsSecOutboundKeyInfoProfile::default(),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PMode {
pub id: String,
pub partner_id: String,
pub service: String,
pub service_type: String,
pub action: String,
pub mep: MepType,
pub security: PModeSecurity,
pub payload_packaging: PayloadPackagingMode,
pub endpoint_url: Option<String>,
}
impl PMode {
fn validate_strict_policy_materialization(&self, stage: &'static str) -> Result<()> {
fn require_non_empty(value: &str, field: &'static str, stage: &'static str) -> Result<()> {
if value.trim().is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
format!("P-Mode {field} must not be empty"),
ErrorContext::new(stage),
));
}
Ok(())
}
require_non_empty(&self.id, "id", stage)?;
require_non_empty(&self.partner_id, "partner_id", stage)?;
require_non_empty(&self.service, "service", stage)?;
require_non_empty(&self.action, "action", stage)?;
if let Some(url) = &self.endpoint_url {
if url.trim().is_empty() {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"P-Mode endpoint_url must not be empty when Some; use None to omit",
ErrorContext::new(stage),
));
}
#[cfg(not(feature = "testing"))]
if !url.starts_with("https://") {
return Err(AsxError::new(
ErrorCode::PolicyViolation,
format!(
"P-Mode endpoint_url must use the HTTPS scheme (eDelivery AS4 §4.1); \
got: {url}"
),
ErrorContext::new(stage),
));
}
}
#[cfg(not(feature = "testing"))]
if !self.security.sign {
return Err(AsxError::new(
ErrorCode::InvalidInput,
"P-Mode materialization forbids sign=false in non-testing builds",
ErrorContext::new(stage),
));
}
Ok(())
}
pub fn to_send_policy_builder(&self) -> As4SendPolicyBuilder {
As4SendPolicyBuilder::new()
.interop(InteropMode::Strict)
.sign(self.security.sign)
.encrypt(self.security.encrypt)
.encrypt_soap_headers(self.security.encrypt_soap_headers)
.compress(self.security.compress)
.payload_packaging_mode(self.payload_packaging)
.outbound_key_info_profile(self.security.outbound_key_info_profile)
.action(&self.action)
.service(&self.service, &self.service_type)
}
pub fn to_send_policy(&self) -> Result<As4SendPolicy> {
self.validate_strict_policy_materialization("as4_pmode_to_send_policy")?;
Ok(As4SendPolicy {
interop: InteropMode::Strict,
sign: self.security.sign,
encrypt: self.security.encrypt,
encrypt_soap_headers: self.security.encrypt_soap_headers,
compress: self.security.compress,
action: self.action.clone(),
service: self.service.clone(),
service_type: self.service_type.clone(),
payload_packaging_mode: self.payload_packaging,
outbound_key_info_profile: self.security.outbound_key_info_profile,
..As4SendPolicy::default()
})
}
pub fn into_send_policy(self) -> Result<As4SendPolicy> {
self.validate_strict_policy_materialization("as4_pmode_into_send_policy")?;
Ok(As4SendPolicy {
interop: InteropMode::Strict,
sign: self.security.sign,
encrypt: self.security.encrypt,
encrypt_soap_headers: self.security.encrypt_soap_headers,
compress: self.security.compress,
action: self.action,
service: self.service,
service_type: self.service_type,
payload_packaging_mode: self.payload_packaging,
outbound_key_info_profile: self.security.outbound_key_info_profile,
..As4SendPolicy::default()
})
}
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct PModeRegistry {
modes: Vec<PMode>,
}
impl PModeRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, mode: PMode) {
self.modes.push(mode);
}
pub fn resolve(&self, partner_id: &str, service: &str, action: &str) -> Option<&PMode> {
self.modes
.iter()
.find(|m| m.partner_id == partner_id && m.service == service && m.action == action)
}
pub fn resolve_by_action(&self, partner_id: &str, action: &str) -> Option<&PMode> {
self.modes
.iter()
.find(|m| m.partner_id == partner_id && m.action == action)
}
pub fn resolve_by_id(&self, id: &str) -> Option<&PMode> {
self.modes.iter().find(|m| m.id == id)
}
pub fn resolve_by_mep(&self, partner_id: &str, mep: MepType) -> Option<&PMode> {
self.modes
.iter()
.find(|m| m.partner_id == partner_id && m.mep == mep)
}
pub fn all(&self) -> &[PMode] {
&self.modes
}
pub fn len(&self) -> usize {
self.modes.len()
}
pub fn is_empty(&self) -> bool {
self.modes.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::ErrorCode;
fn pmode(id: &str, partner: &str, svc: &str, action: &str, mep: MepType) -> PMode {
PMode {
id: id.into(),
partner_id: partner.into(),
service: svc.into(),
service_type: "".into(),
action: action.into(),
mep,
security: PModeSecurity::default(),
payload_packaging: PayloadPackagingMode::default(),
endpoint_url: None,
}
}
#[test]
fn resolve_matches_all_three_dimensions() {
let mut reg = PModeRegistry::new();
reg.register(pmode("pm-1", "p1", "svc:a", "act:x", MepType::OneWayPush));
reg.register(pmode("pm-2", "p1", "svc:b", "act:y", MepType::OneWayPull));
assert_eq!(
reg.resolve("p1", "svc:a", "act:x").map(|p| p.id.as_str()),
Some("pm-1")
);
assert_eq!(
reg.resolve("p1", "svc:b", "act:y").map(|p| p.id.as_str()),
Some("pm-2")
);
assert!(
reg.resolve("p1", "svc:a", "act:y").is_none(),
"wrong action"
);
assert!(
reg.resolve("p2", "svc:a", "act:x").is_none(),
"wrong partner"
);
}
#[test]
fn resolve_by_id_returns_correct_pmode() {
let mut reg = PModeRegistry::new();
reg.register(pmode("pm-abc", "p1", "svc", "act", MepType::OneWayPush));
assert_eq!(
reg.resolve_by_id("pm-abc").map(|p| p.id.as_str()),
Some("pm-abc")
);
assert!(reg.resolve_by_id("pm-xyz").is_none());
}
#[test]
fn resolve_by_mep_returns_first_match() {
let mut reg = PModeRegistry::new();
reg.register(pmode("pm-1", "p1", "svc", "act1", MepType::OneWayPull));
reg.register(pmode("pm-2", "p1", "svc", "act2", MepType::TwoWayPushPush));
assert_eq!(
reg.resolve_by_mep("p1", MepType::TwoWayPushPush)
.map(|p| p.id.as_str()),
Some("pm-2")
);
assert!(reg.resolve_by_mep("p1", MepType::OneWayPush).is_none());
}
#[test]
fn payload_packaging_modes_default_and_explicit() {
assert_eq!(
PayloadPackagingMode::default(),
PayloadPackagingMode::MimeAttachment
);
let mime = PayloadPackagingMode::MimeAttachment;
assert_eq!(mime, PayloadPackagingMode::MimeAttachment);
}
#[test]
fn pmode_preserves_payload_packaging_mode() {
let mut pmode_mime = pmode("pm-m", "p1", "svc", "act", MepType::OneWayPush);
pmode_mime.payload_packaging = PayloadPackagingMode::MimeAttachment;
assert_eq!(
pmode_mime.payload_packaging,
PayloadPackagingMode::MimeAttachment
);
}
#[test]
fn pmode_to_send_policy_builder_sets_fields() {
let pm = pmode(
"pm-1",
"p1",
"urn:svc:invoice",
"urn:act:submit",
MepType::OneWayPush,
);
let policy = pm
.to_send_policy()
.expect("strict-compatible P-Mode must materialize");
assert_eq!(policy.action, "urn:act:submit");
assert_eq!(policy.service, "urn:svc:invoice");
assert!(policy.sign);
assert_eq!(policy.interop, InteropMode::Strict);
assert!(policy.fail_closed_audit_events);
}
#[test]
fn pmode_into_send_policy_sets_fields() {
let pm = pmode(
"pm-1",
"p1",
"urn:svc:invoice",
"urn:act:submit",
MepType::OneWayPush,
);
let policy = pm
.into_send_policy()
.expect("strict-compatible P-Mode must materialize");
assert_eq!(policy.action, "urn:act:submit");
assert_eq!(policy.service, "urn:svc:invoice");
assert_eq!(
policy.payload_packaging_mode,
PayloadPackagingMode::MimeAttachment
);
assert_eq!(policy.interop, InteropMode::Strict);
assert!(policy.fail_closed_audit_events);
}
#[test]
fn pmode_to_send_policy_accepts_strict_only_wssec_profile() {
let pm = pmode(
"pm-1",
"p1",
"urn:svc:invoice",
"urn:act:submit",
MepType::OneWayPush,
);
let policy = pm
.to_send_policy()
.expect("strict P-Mode materialization with strict-only profile must succeed");
assert_eq!(policy.interop, InteropMode::Strict);
}
#[test]
fn pmode_into_send_policy_accepts_strict_only_wssec_profile() {
let pm = pmode(
"pm-1",
"p1",
"urn:svc:invoice",
"urn:act:submit",
MepType::OneWayPush,
);
let policy = pm
.into_send_policy()
.expect("strict P-Mode consume materialization with strict-only profile must succeed");
assert_eq!(policy.interop, InteropMode::Strict);
}
#[test]
fn registry_is_empty_when_new() {
let reg = PModeRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn to_send_policy_rejects_empty_required_fields() {
let mut pm = pmode(
"pm-1",
"p1",
"urn:svc:invoice",
"urn:act:submit",
MepType::OneWayPush,
);
pm.action = " ".into();
let err = pm
.to_send_policy()
.expect_err("empty action must fail strict materialization");
assert_eq!(err.code, ErrorCode::InvalidInput);
assert!(err.message.contains("action"));
}
#[cfg(not(feature = "testing"))]
#[test]
fn to_send_policy_rejects_unsigned_strict_materialization() {
let mut pm = pmode(
"pm-1",
"p1",
"urn:svc:invoice",
"urn:act:submit",
MepType::OneWayPush,
);
pm.security.sign = false;
let err = pm
.to_send_policy()
.expect_err("strict materialization with sign=false must fail in non-testing builds");
assert_eq!(err.code, ErrorCode::InvalidInput);
assert!(err.message.contains("sign=false"));
}
#[test]
fn outbound_key_info_profile_propagated_through_to_send_policy() {
use crate::crypto::wssec::WsSecOutboundKeyInfoProfile;
let mut pm = pmode("pm-kip", "p1", "urn:svc", "urn:act", MepType::OneWayPush);
pm.security.outbound_key_info_profile = WsSecOutboundKeyInfoProfile::X509PKIPathv1;
let policy = pm
.to_send_policy()
.expect("P-Mode with X509PKIPathv1 must materialise");
assert_eq!(
policy.outbound_key_info_profile,
WsSecOutboundKeyInfoProfile::X509PKIPathv1,
"X509PKIPathv1 must be forwarded from PModeSecurity to As4SendPolicy"
);
}
#[test]
fn outbound_key_info_profile_propagated_through_into_send_policy() {
use crate::crypto::wssec::WsSecOutboundKeyInfoProfile;
let mut pm = pmode("pm-kip2", "p1", "urn:svc", "urn:act", MepType::OneWayPush);
pm.security.outbound_key_info_profile = WsSecOutboundKeyInfoProfile::X509DataOnly;
let policy = pm
.into_send_policy()
.expect("P-Mode with X509DataOnly must materialise");
assert_eq!(
policy.outbound_key_info_profile,
WsSecOutboundKeyInfoProfile::X509DataOnly,
);
}
#[test]
fn outbound_key_info_profile_propagated_through_to_send_policy_builder() {
use crate::crypto::wssec::WsSecOutboundKeyInfoProfile;
let mut pm = pmode("pm-kip3", "p1", "urn:svc", "urn:act", MepType::OneWayPush);
pm.security.outbound_key_info_profile = WsSecOutboundKeyInfoProfile::X509PKIPathv1;
#[cfg(all(feature = "testing", feature = "interop-relaxed"))]
{
use crate::core::InteropMode;
let policy = pm
.to_send_policy_builder()
.interop(InteropMode::Relaxed)
.sign(false)
.fail_closed_audit_events(false)
.action("urn:act")
.service("urn:svc", "")
.build()
.expect("relaxed build must succeed");
assert_eq!(
policy.0.outbound_key_info_profile,
WsSecOutboundKeyInfoProfile::X509PKIPathv1,
"builder must carry X509PKIPathv1 from PModeSecurity"
);
}
assert_eq!(
pm.security.outbound_key_info_profile,
WsSecOutboundKeyInfoProfile::X509PKIPathv1,
);
}
#[test]
fn pmode_security_default_uses_binary_security_token() {
use crate::crypto::wssec::WsSecOutboundKeyInfoProfile;
let sec = PModeSecurity::default();
assert_eq!(
sec.outbound_key_info_profile,
WsSecOutboundKeyInfoProfile::BinarySecurityTokenX509v3,
"WSS 1.1.1 requires an STR in KeyInfo; the BST token shape is what \
WSS4J-based counterparties accept"
);
}
}