1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, OnceLock};
5use tokio::sync::{OwnedSemaphorePermit, Semaphore};
6
7pub type Result<T> = std::result::Result<T, AsxError>;
8
9pub fn escape_xml(s: &str) -> String {
25 let mut out = String::new();
26 let mut last = 0usize;
27 let mut modified = false;
28 let mut stripped_count: usize = 0;
29 for (i, b) in s.bytes().enumerate() {
30 let escaped = match b {
31 0x00..=0x08 | 0x0B..=0x0C | 0x0E..=0x1F | 0x7F => {
33 if !modified {
34 out.reserve(s.len());
35 modified = true;
36 }
37 out.push_str(&s[last..i]);
38 last = i + 1;
39 stripped_count += 1;
40 continue;
41 }
42 b'&' => "&",
43 b'<' => "<",
44 b'>' => ">",
45 b'"' => """,
46 b'\'' => "'",
47 _ => continue,
48 };
49 if !modified {
50 out.reserve(s.len() + 16);
51 modified = true;
52 }
53 out.push_str(&s[last..i]);
54 out.push_str(escaped);
55 last = i + 1;
56 }
57 if !modified {
58 return s.to_owned();
59 }
60 out.push_str(&s[last..]);
61 if stripped_count > 0 {
62 tracing::warn!(
65 stripped_bytes = stripped_count,
66 "escape_xml stripped {} forbidden XML 1.0 control character(s) from input; \
67 output differs from input — check the source field for binary/control data",
68 stripped_count,
69 );
70 }
71 out
72}
73
74fn default_blocking_crypto_concurrency() -> usize {
75 std::thread::available_parallelism()
77 .map(|n| n.get().saturating_mul(2))
78 .unwrap_or(8)
79 .clamp(4, 128)
80}
81
82const BLOCKING_CRYPTO_CONCURRENCY_ENV: &str = "ASX_BLOCKING_CRYPTO_CONCURRENCY";
83
84fn configured_blocking_crypto_concurrency() -> usize {
85 std::env::var(BLOCKING_CRYPTO_CONCURRENCY_ENV)
86 .ok()
87 .and_then(|raw| raw.trim().parse::<usize>().ok())
88 .filter(|value| *value > 0)
89 .map(|value| value.clamp(1, 4096))
90 .unwrap_or_else(default_blocking_crypto_concurrency)
91}
92
93fn blocking_crypto_semaphore() -> Arc<Semaphore> {
94 static SEM: OnceLock<Arc<Semaphore>> = OnceLock::new();
95 Arc::clone(
96 SEM.get_or_init(|| Arc::new(Semaphore::new(configured_blocking_crypto_concurrency()))),
97 )
98}
99
100pub const DEFAULT_MAX_BODY_BYTES: usize = 256 * 1024 * 1024;
111
112#[derive(Clone, Debug)]
131pub struct CryptoAdmissionControl {
132 semaphore: Arc<Semaphore>,
133 label: &'static str,
135}
136
137impl CryptoAdmissionControl {
138 pub fn new(concurrency: usize) -> Self {
143 let cap = concurrency.clamp(1, 4096);
144 Self {
145 semaphore: Arc::new(Semaphore::new(cap)),
146 label: "instance-scoped crypto semaphore",
147 }
148 }
149
150 pub fn process_global() -> Self {
155 Self {
156 semaphore: blocking_crypto_semaphore(),
157 label: "process-global crypto semaphore",
158 }
159 }
160
161 pub async fn acquire(
163 &self,
164 stage: &'static str,
165 session: &SessionContext,
166 ) -> Result<OwnedSemaphorePermit> {
167 Arc::clone(&self.semaphore)
168 .acquire_owned()
169 .await
170 .map_err(|_| {
171 AsxError::new(
172 ErrorCode::TransportFailure,
173 format!("{} is closed", self.label),
174 ErrorContext::for_session(stage, session),
175 )
176 })
177 }
178}
179
180#[cfg(feature = "as4")]
183pub(crate) fn bytes_to_utf8_str<'a>(
184 bytes: &'a [u8],
185 stage: &'static str,
186 session: &SessionContext,
187) -> Result<&'a str> {
188 std::str::from_utf8(bytes).map_err(|_| {
189 AsxError::new(
190 ErrorCode::ParseFailed,
191 format!("{stage}: payload is not valid UTF-8"),
192 ErrorContext::for_session(stage, session),
193 )
194 })
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct ErrorContext {
199 pub stage: &'static str,
200 pub message_id: Option<String>,
201 pub partner_id: Option<String>,
202 pub session_id: Option<String>,
203}
204
205impl ErrorContext {
206 #[must_use]
207 pub fn new(stage: &'static str) -> Self {
208 Self {
209 stage,
210 message_id: None,
211 partner_id: None,
212 session_id: None,
213 }
214 }
215
216 #[must_use]
219 pub fn for_session(stage: &'static str, session: &SessionContext) -> Self {
220 Self::new(stage).with_session_and_partner(session.session_id(), session.partner_id())
221 }
222
223 #[must_use]
226 pub fn for_session_with_message(
227 stage: &'static str,
228 session: &SessionContext,
229 message_id: impl Into<String>,
230 ) -> Self {
231 Self::new(stage)
232 .with_session_and_partner(session.session_id(), session.partner_id())
233 .with_message_id(message_id)
234 }
235
236 #[must_use]
237 pub fn with_session_and_partner(
238 mut self,
239 session_id: impl Into<String>,
240 partner_id: impl Into<String>,
241 ) -> Self {
242 self.session_id = Some(session_id.into());
243 self.partner_id = Some(partner_id.into());
244 self
245 }
246
247 #[must_use]
248 pub fn with_message_id(mut self, message_id: impl Into<String>) -> Self {
249 self.message_id = Some(message_id.into());
250 self
251 }
252
253 #[must_use]
254 pub fn with_partner_id(mut self, partner_id: impl Into<String>) -> Self {
255 self.partner_id = Some(partner_id.into());
256 self
257 }
258
259 #[must_use]
260 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
261 self.session_id = Some(session_id.into());
262 self
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267#[non_exhaustive]
268pub enum ErrorCode {
269 InvalidInput,
270 ParseFailed,
271 SecurityVerificationFailed,
272 DecryptionFailed,
273 PolicyViolation,
274 TransportFailure,
275 InteropViolation,
276 ReliabilityFailure,
277 NotFound,
279 CapacityExhausted,
282 PayloadTooLarge,
286 StorageBackendFailure,
296 CertificateRevoked,
304 CertificateExpired,
312 Timeout,
320}
321
322impl ErrorCode {
323 pub fn as_str(self) -> &'static str {
324 match self {
325 Self::InvalidInput => "invalid_input",
326 Self::ParseFailed => "parse_failed",
327 Self::SecurityVerificationFailed => "security_verification_failed",
328 Self::DecryptionFailed => "decryption_failed",
329 Self::PolicyViolation => "policy_violation",
330 Self::TransportFailure => "transport_failure",
331 Self::InteropViolation => "interop_violation",
332 Self::ReliabilityFailure => "reliability_failure",
333 Self::NotFound => "not_found",
334 Self::CapacityExhausted => "capacity_exhausted",
335 Self::PayloadTooLarge => "payload_too_large",
336 Self::StorageBackendFailure => "storage_backend_failure",
337 Self::CertificateRevoked => "certificate_revoked",
338 Self::CertificateExpired => "certificate_expired",
339 Self::Timeout => "timeout",
340 }
341 }
342
343 pub fn to_http_status(self) -> u16 {
368 match self {
369 Self::InvalidInput => 400,
370 Self::ParseFailed => 400,
371 Self::SecurityVerificationFailed => 401,
372 Self::DecryptionFailed => 400,
373 Self::PolicyViolation => 422,
374 Self::TransportFailure => 502,
375 Self::InteropViolation => 400,
376 Self::ReliabilityFailure => 503,
377 Self::NotFound => 404,
378 Self::CapacityExhausted => 429,
379 Self::PayloadTooLarge => 413,
380 Self::StorageBackendFailure => 503,
381 Self::CertificateRevoked => 403,
382 Self::CertificateExpired => 403,
383 Self::Timeout => 504,
384 }
385 }
386
387 pub fn remediation_hint(self) -> Option<&'static str> {
393 match self {
394 Self::DecryptionFailed => Some(
395 "Verify that the recipient certificate PEM and its private key PEM match. \
396 Ensure the sender is encrypting to the correct public certificate. \
397 Re-key the key pair if the certificate has been re-issued.",
398 ),
399 Self::SecurityVerificationFailed => Some(
400 "Confirm the trust anchor PEM includes the full CA chain of the signer. \
401 Check certificate validity period. \
402 Ensure CRL distribution points or OCSP responders are reachable.",
403 ),
404 Self::TransportFailure => Some(
405 "Check network connectivity and DNS resolution for the remote endpoint. \
406 Verify TLS certificate chain and mutual-TLS configuration. \
407 Ensure the spool directory exists and is writable.",
408 ),
409 Self::ReliabilityFailure => Some(
410 "Ensure an EventBus broadcast subscriber is active before message sends. \
411 Check dedup and reconciliation backend availability and capacity.",
412 ),
413 Self::PolicyViolation => Some(
414 "Review the PMode and profile configuration against the partner specification. \
415 Verify the interop mode matches the partner's published requirements.",
416 ),
417 Self::CapacityExhausted => Some(
418 "Shed load or retry after a backoff delay. \
419 Consider increasing channel capacity or conversation gate limits.",
420 ),
421 Self::StorageBackendFailure => Some(
422 "Check dedup/reconciliation/audit backend connectivity and disk space. \
423 Inspect backend logs for I/O errors. \
424 Consider a circuit-breaker or fallback backend for resilience.",
425 ),
426 Self::CertificateRevoked => Some(
427 "The partner's signing certificate has been revoked by its issuing CA. \
428 Contact the trading partner to obtain a replacement certificate. \
429 Update the trust anchor and retry.",
430 ),
431 Self::CertificateExpired => Some(
432 "The partner's signing certificate has passed its notAfter validity date. \
433 Request a renewed certificate from the trading partner. \
434 Do not extend trust to expired certificates.",
435 ),
436 Self::Timeout => Some(
437 "The remote endpoint did not respond within the configured timeout. \
438 Verify network connectivity and DNS resolution. \
439 Apply exponential back-off before retrying.",
440 ),
441 _ => None,
442 }
443 }
444}
445
446#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct AsxError {
460 pub code: ErrorCode,
462 pub message: String,
464 pub context: Box<ErrorContext>,
466}
467
468impl AsxError {
469 pub fn new(code: ErrorCode, message: impl Into<String>, context: ErrorContext) -> Self {
471 Self {
472 code,
473 message: message.into(),
474 context: Box::new(context),
475 }
476 }
477
478 pub fn remediation_hint(&self) -> Option<&'static str> {
482 self.code.remediation_hint()
483 }
484
485 #[must_use]
494 pub fn with_partner_id(mut self, partner_id: impl Into<String>) -> Self {
495 self.context = Box::new(self.context.with_partner_id(partner_id));
496 self
497 }
498
499 #[must_use]
503 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
504 self.context = Box::new(self.context.with_session_id(session_id));
505 self
506 }
507
508 #[must_use]
512 pub fn with_session_and_partner(
513 mut self,
514 session_id: impl Into<String>,
515 partner_id: impl Into<String>,
516 ) -> Self {
517 self.context = Box::new(
518 self.context
519 .with_session_and_partner(session_id, partner_id),
520 );
521 self
522 }
523
524 #[must_use]
528 pub fn with_message_id(mut self, message_id: impl Into<String>) -> Self {
529 self.context = Box::new(self.context.with_message_id(message_id));
530 self
531 }
532
533 #[inline]
553 pub fn is_duplicate(&self) -> bool {
554 self.code == ErrorCode::ReliabilityFailure && self.message.contains("replay")
555 }
556
557 #[inline]
559 pub fn is_storage_failure(&self) -> bool {
560 self.code == ErrorCode::StorageBackendFailure
561 }
562}
563
564impl fmt::Display for AsxError {
565 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
566 write!(
567 f,
568 "{} [{}|stage={}]",
569 self.message,
570 self.code.as_str(),
571 self.context.stage
572 )?;
573 if let Some(pid) = &self.context.partner_id {
574 write!(f, "[partner={pid}]")?;
575 }
576 if let Some(mid) = &self.context.message_id {
577 write!(f, "[msg={mid}]")?;
578 }
579 if let Some(sid) = &self.context.session_id {
580 write!(f, "[session={sid}]")?;
581 }
582 Ok(())
583 }
584}
585
586impl std::error::Error for AsxError {}
587
588#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
611#[non_exhaustive]
612pub enum InteropMode {
613 #[default]
619 Strict,
620 #[cfg(feature = "interop-relaxed")]
633 Relaxed,
634}
635
636#[derive(Debug, Clone, PartialEq, Eq)]
677pub struct SessionContext {
678 session_id: String,
679 partner_id: String,
680 profile_name: String,
681 metadata: SessionMetadata,
682 cert_handle: Arc<CertHandle>,
697 correlation_scope: CorrelationScope,
698 #[cfg(any(feature = "as2", feature = "as4"))]
704 trust_anchors_cache: TrustAnchorCache,
705 #[cfg(any(feature = "as2", feature = "as4"))]
708 x509_store_cache: X509StoreCache,
709}
710
711#[cfg(feature = "as4")]
725pub(crate) fn decode_xml_base64(value: &str, label: &str, stage: &'static str) -> Result<Vec<u8>> {
726 use base64::Engine as _;
727 let normalized: String = value.chars().filter(|c| !c.is_ascii_whitespace()).collect();
728 base64::engine::general_purpose::STANDARD
729 .decode(normalized)
730 .map_err(|err| {
731 AsxError::new(
732 ErrorCode::ParseFailed,
733 format!("failed to decode base64 {label}: {err}"),
734 ErrorContext::new(stage),
735 )
736 })
737}
738
739#[must_use]
746pub(crate) fn redact_present(present: bool) -> &'static str {
747 if present { "<redacted>" } else { "<none>" }
748}
749
750#[must_use]
763#[inline]
764pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
765 use subtle::ConstantTimeEq;
766 if a.len() != b.len() {
767 return false;
768 }
769 a.ct_eq(b).into()
770}
771
772#[derive(Debug, Clone, PartialEq, Eq, Default)]
784#[non_exhaustive]
785pub struct SessionMetadata {
786 pub effective_policy_snapshot_json: Option<String>,
788 pub(crate) strict_runtime_bootstrap_validated: bool,
789}
790
791#[derive(Debug, Clone)]
793pub struct SessionContextBuilder {
794 session_id: String,
795 partner_id: String,
796 profile_name: String,
797 cert_handle: Option<CertHandle>,
798 effective_policy_snapshot_json: Option<String>,
799 correlation_scope: Option<CorrelationScope>,
800}
801
802impl SessionContextBuilder {
803 pub fn new(session_id: impl Into<String>, partner_id: impl Into<String>) -> Self {
808 Self {
809 session_id: session_id.into(),
810 partner_id: partner_id.into(),
811 profile_name: "strict".to_string(),
812 cert_handle: None,
813 effective_policy_snapshot_json: None,
814 correlation_scope: None,
815 }
816 }
817
818 pub fn profile_name(mut self, profile_name: impl Into<String>) -> Self {
820 self.profile_name = profile_name.into();
821 self
822 }
823
824 pub fn cert_handle(mut self, cert_handle: CertHandle) -> Self {
826 self.cert_handle = Some(cert_handle);
827 self
828 }
829
830 fn default_cert_handle_key_id(&self) -> String {
833 format!("cert:{}", self.partner_id)
834 }
835
836 fn cert_handle_or_init(&mut self) -> &mut CertHandle {
838 let key_id = self.default_cert_handle_key_id();
839 self.cert_handle
840 .get_or_insert_with(|| CertHandle::new(key_id))
841 }
842
843 pub fn with_trust_anchor_pem(mut self, pem: impl Into<String>) -> Self {
855 self.cert_handle_or_init()
856 .trust_anchor_pems
857 .push(pem.into());
858 self
859 }
860
861 pub fn with_intermediate_ca_pem(mut self, pem: impl Into<String>) -> Self {
868 self.cert_handle_or_init()
869 .intermediate_ca_pems
870 .push(pem.into());
871 self
872 }
873
874 pub fn with_intermediate_ca_pems(
879 mut self,
880 pems: impl IntoIterator<Item = impl Into<String>>,
881 ) -> Self {
882 self.cert_handle_or_init()
883 .intermediate_ca_pems
884 .extend(pems.into_iter().map(|p| p.into()));
885 self
886 }
887
888 pub fn with_trust_anchor_pems(
893 mut self,
894 pems: impl IntoIterator<Item = impl Into<String>>,
895 ) -> Self {
896 self.cert_handle_or_init()
897 .trust_anchor_pems
898 .extend(pems.into_iter().map(|p| p.into()));
899 self
900 }
901
902 pub fn with_ocsp_mode(mut self, mode: OcspMode) -> Self {
906 self.cert_handle_or_init().ocsp_mode = mode;
907 self
908 }
909
910 pub fn with_signing_cert_pem(mut self, pem: impl Into<String>) -> Self {
938 self.cert_handle_or_init().signing_cert_pem = Some(pem.into());
939 self
940 }
941
942 pub fn with_signing_key_pem(mut self, pem: impl Into<String>) -> Self {
948 self.cert_handle_or_init().signing_key_pem = Some(zeroize::Zeroizing::new(pem.into()));
949 self
950 }
951
952 pub fn with_recipient_cert_pem(mut self, pem: impl Into<String>) -> Self {
959 self.cert_handle_or_init().recipient_cert_pem = Some(pem.into());
960 self
961 }
962
963 pub fn with_signing_material(
970 mut self,
971 cert_pem: impl Into<String>,
972 key_pem: impl Into<String>,
973 ) -> Self {
974 let ch = self.cert_handle_or_init();
975 ch.signing_cert_pem = Some(cert_pem.into());
976 ch.signing_key_pem = Some(zeroize::Zeroizing::new(key_pem.into()));
977 self
978 }
979
980 pub fn with_fingerprint_sha256(mut self, fingerprint: impl Into<String>) -> Self {
990 self.cert_handle_or_init().fingerprint_sha256 = fingerprint.into();
991 self
992 }
993
994 pub fn effective_policy_snapshot_json(mut self, snapshot_json: impl Into<String>) -> Self {
996 self.effective_policy_snapshot_json = Some(snapshot_json.into());
997 self
998 }
999
1000 pub fn correlation_scope(
1002 mut self,
1003 root_id: impl Into<String>,
1004 parent_message_id: Option<String>,
1005 ) -> Self {
1006 self.correlation_scope = Some(CorrelationScope {
1007 root_id: root_id.into(),
1008 parent_message_id,
1009 traceparent: None,
1010 });
1011 self
1012 }
1013
1014 pub fn build(self) -> Result<SessionContext> {
1016 let mut session = SessionContext::new(self.session_id, self.partner_id, self.profile_name)?;
1017
1018 if let Some(cert_handle) = self.cert_handle {
1019 match (&cert_handle.signing_key_pem, &cert_handle.signing_cert_pem) {
1022 (Some(_), None) | (None, Some(_)) => {
1023 return Err(AsxError::new(
1024 ErrorCode::InvalidInput,
1025 "signing_key_pem and signing_cert_pem must both be set or both absent",
1026 ErrorContext::new("session_context_builder"),
1027 ));
1028 }
1029 _ => {}
1030 }
1031 #[cfg(any(feature = "as2", feature = "as4"))]
1035 validate_cert_handle_outbound_pem(&cert_handle)?;
1036 session = session.with_cert_handle(cert_handle)?;
1037 }
1038
1039 if let Some(correlation_scope) = self.correlation_scope {
1040 if correlation_scope.root_id.trim().is_empty() {
1041 return Err(AsxError::new(
1042 ErrorCode::InvalidInput,
1043 "correlation root_id must not be empty",
1044 ErrorContext::for_session("session_context_builder", &session),
1045 ));
1046 }
1047 session.correlation_scope = correlation_scope;
1048 }
1049
1050 if let Some(snapshot_json) = self.effective_policy_snapshot_json {
1051 session = session.with_effective_policy_snapshot_json(snapshot_json)?;
1052 }
1053
1054 Ok(session)
1055 }
1056}
1057
1058#[cfg(any(feature = "as2", feature = "as4"))]
1065fn validate_cert_handle_outbound_pem(cert_handle: &CertHandle) -> Result<()> {
1066 if let (Some(key_pem), Some(cert_pem)) =
1067 (&cert_handle.signing_key_pem, &cert_handle.signing_cert_pem)
1068 {
1069 let cert = openssl::x509::X509::from_pem(cert_pem.as_bytes()).map_err(|_| {
1070 AsxError::new(
1071 ErrorCode::InvalidInput,
1072 "signing_cert_pem is not a valid PEM X.509 certificate",
1073 ErrorContext::new("session_context_builder_validate"),
1074 )
1075 })?;
1076
1077 let key = openssl::pkey::PKey::private_key_from_pem(key_pem.as_bytes()).map_err(|_| {
1078 AsxError::new(
1079 ErrorCode::InvalidInput,
1080 "signing_key_pem is not a valid PEM private key",
1081 ErrorContext::new("session_context_builder_validate"),
1082 )
1083 })?;
1084
1085 let cert_pub = cert.public_key().map_err(|_| {
1086 AsxError::new(
1087 ErrorCode::InvalidInput,
1088 "signing_cert_pem does not contain a usable public key",
1089 ErrorContext::new("session_context_builder_validate"),
1090 )
1091 })?;
1092
1093 if !key.public_eq(&cert_pub) {
1094 return Err(AsxError::new(
1095 ErrorCode::InvalidInput,
1096 "signing_key_pem does not match signing_cert_pem",
1097 ErrorContext::new("session_context_builder_validate"),
1098 ));
1099 }
1100 }
1101
1102 if let Some(pem) = &cert_handle.recipient_cert_pem {
1103 openssl::x509::X509::from_pem(pem.as_bytes()).map_err(|_| {
1104 AsxError::new(
1105 ErrorCode::InvalidInput,
1106 "recipient_cert_pem is not a valid PEM X.509 certificate",
1107 ErrorContext::new("session_context_builder_validate"),
1108 )
1109 })?;
1110 }
1111
1112 Ok(())
1113}
1114
1115#[derive(Debug, Default, Clone)]
1124#[cfg(any(feature = "as2", feature = "as4"))]
1125pub(crate) struct TrustAnchorCache(Arc<OnceLock<Vec<openssl::x509::X509>>>);
1126
1127#[cfg(any(feature = "as2", feature = "as4"))]
1128impl PartialEq for TrustAnchorCache {
1129 fn eq(&self, _: &Self) -> bool {
1130 true }
1132}
1133#[cfg(any(feature = "as2", feature = "as4"))]
1134impl Eq for TrustAnchorCache {}
1135
1136#[derive(Default, Clone)]
1143#[cfg(any(feature = "as2", feature = "as4"))]
1144pub(crate) struct X509StoreCache(Arc<OnceLock<Arc<openssl::x509::store::X509Store>>>);
1145
1146#[cfg(any(feature = "as2", feature = "as4"))]
1147impl std::fmt::Debug for X509StoreCache {
1148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1149 f.debug_tuple("X509StoreCache")
1150 .field(&self.0.get().is_some())
1151 .finish()
1152 }
1153}
1154
1155#[cfg(any(feature = "as2", feature = "as4"))]
1156impl PartialEq for X509StoreCache {
1157 fn eq(&self, _: &Self) -> bool {
1158 true
1159 }
1160}
1161#[cfg(any(feature = "as2", feature = "as4"))]
1162impl Eq for X509StoreCache {}
1163
1164#[derive(Clone, PartialEq, Eq)]
1165pub struct CertHandle {
1166 pub key_id: String,
1167 pub fingerprint_sha256: String,
1168 pub trust_anchor_pems: Vec<String>,
1169 pub intermediate_ca_pems: Vec<String>,
1181 pub revocation_crl_pems: Vec<String>,
1182 pub ocsp_mode: OcspMode,
1183 pub ocsp_failure_mode: OcspFailureMode,
1184 pub stapled_ocsp_responses_der: Vec<Vec<u8>>,
1185 pub responder_ocsp_responses_der: Vec<Vec<u8>>,
1186 pub signing_cert_pem: Option<String>,
1196 pub signing_key_pem: Option<zeroize::Zeroizing<String>>,
1204 pub recipient_cert_pem: Option<String>,
1208}
1209
1210impl std::fmt::Debug for CertHandle {
1216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1217 f.debug_struct("CertHandle")
1218 .field("key_id", &self.key_id)
1219 .field("fingerprint_sha256", &self.fingerprint_sha256)
1220 .field("trust_anchor_pems", &self.trust_anchor_pems)
1221 .field("intermediate_ca_pems", &self.intermediate_ca_pems)
1222 .field("revocation_crl_pems", &self.revocation_crl_pems)
1223 .field("ocsp_mode", &self.ocsp_mode)
1224 .field("ocsp_failure_mode", &self.ocsp_failure_mode)
1225 .field(
1226 "stapled_ocsp_responses_der",
1227 &self.stapled_ocsp_responses_der,
1228 )
1229 .field(
1230 "responder_ocsp_responses_der",
1231 &self.responder_ocsp_responses_der,
1232 )
1233 .field("signing_cert_pem", &self.signing_cert_pem)
1234 .field(
1235 "signing_key_pem",
1236 &self.signing_key_pem.as_ref().map(|_| "<redacted>"),
1237 )
1238 .field("recipient_cert_pem", &self.recipient_cert_pem)
1239 .finish()
1240 }
1241}
1242
1243impl CertHandle {
1244 pub fn new(key_id: impl Into<String>) -> Self {
1259 Self {
1260 key_id: key_id.into(),
1261 fingerprint_sha256: String::new(),
1262 trust_anchor_pems: Vec::new(),
1263 intermediate_ca_pems: Vec::new(),
1264 revocation_crl_pems: Vec::new(),
1265 ocsp_mode: OcspMode::default(),
1266 ocsp_failure_mode: OcspFailureMode::HardFail,
1267 stapled_ocsp_responses_der: Vec::new(),
1268 responder_ocsp_responses_der: Vec::new(),
1269 signing_cert_pem: None,
1270 signing_key_pem: None,
1271 recipient_cert_pem: None,
1272 }
1273 }
1274
1275 pub fn set_signing_key_pem(&mut self, key_pem: impl Into<String>) {
1281 self.signing_key_pem = Some(zeroize::Zeroizing::new(key_pem.into()));
1282 }
1283}
1284
1285#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1286#[non_exhaustive]
1287pub enum OcspMode {
1288 Disabled,
1289 StapledOnly,
1290 #[default]
1291 ResponderOnly,
1292 StapledThenResponder,
1293}
1294
1295#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1296#[non_exhaustive]
1297pub enum OcspFailureMode {
1298 #[default]
1299 HardFail,
1300 SoftFail,
1301}
1302
1303#[derive(Debug, Clone, PartialEq, Eq)]
1304pub struct CorrelationScope {
1305 pub root_id: String,
1306 pub parent_message_id: Option<String>,
1307 pub traceparent: Option<Arc<str>>,
1316}
1317
1318#[non_exhaustive]
1323#[derive(Debug)]
1324pub enum PayloadInput<'a> {
1325 Owned(Vec<u8>),
1326 Shared(Arc<[u8]>),
1327 Borrowed(&'a [u8]),
1328}
1329
1330impl<'a> PayloadInput<'a> {
1331 pub fn as_slice(&self) -> &[u8] {
1332 match self {
1333 Self::Owned(payload) => payload,
1334 Self::Shared(payload) => payload,
1335 Self::Borrowed(payload) => payload,
1336 }
1337 }
1338
1339 pub fn into_arc(self) -> Arc<[u8]> {
1340 match self {
1341 Self::Owned(payload) => Arc::from(payload),
1342 Self::Shared(payload) => payload,
1343 Self::Borrowed(payload) => Arc::from(payload),
1344 }
1345 }
1346}
1347
1348#[derive(Debug, Clone, PartialEq, Eq)]
1353#[non_exhaustive]
1354pub enum SpoolEncryption {
1355 Plaintext,
1356 Aes256Gcm { key: Arc<[u8; 32]> },
1357}
1358
1359pub(crate) const SPOOLED_AES256_GCM_MAGIC: [u8; 8] = *b"ASXSPG01";
1360pub(crate) const SPOOLED_AES256_GCM_NONCE_LEN: usize = 12;
1361pub(crate) const SPOOLED_AES256_GCM_TAG_LEN: usize = 16;
1362
1363#[derive(Debug, Clone, PartialEq, Eq)]
1364pub struct SpoolLifecyclePolicy {
1365 pub delete_on_materialize: bool,
1366 pub secure_delete_on_materialize: bool,
1367}
1368
1369impl Default for SpoolLifecyclePolicy {
1370 fn default() -> Self {
1371 Self {
1372 delete_on_materialize: true,
1373 secure_delete_on_materialize: false,
1374 }
1375 }
1376}
1377
1378#[derive(Debug, Clone, PartialEq, Eq)]
1379#[non_exhaustive]
1380pub enum ReceivedBodyHandle {
1381 InMemory(Arc<[u8]>),
1382 Spooled {
1383 path: PathBuf,
1384 encryption: SpoolEncryption,
1385 lifecycle: SpoolLifecyclePolicy,
1386 },
1387}
1388
1389impl ReceivedBodyHandle {
1390 #[must_use]
1391 pub fn from_payload_input(input: PayloadInput<'_>) -> Self {
1392 Self::InMemory(input.into_arc())
1393 }
1394
1395 pub fn payload_len(&self, stage: &'static str, session: &SessionContext) -> Result<usize> {
1396 match self {
1397 Self::InMemory(bytes) => Ok(bytes.len()),
1398 Self::Spooled { path, .. } => {
1399 let metadata = std::fs::metadata(path).map_err(|err| {
1400 AsxError::new(
1401 ErrorCode::TransportFailure,
1402 format!("failed to stat spooled body {}: {err}", path.display()),
1403 ErrorContext::for_session(stage, session),
1404 )
1405 })?;
1406 usize::try_from(metadata.len()).map_err(|_| {
1407 AsxError::new(
1408 ErrorCode::PolicyViolation,
1409 format!(
1410 "spooled body {} exceeds platform addressable size",
1411 path.display()
1412 ),
1413 ErrorContext::for_session(stage, session),
1414 )
1415 })
1416 }
1417 }
1418 }
1419
1420 pub fn materialize_contiguous(
1421 &self,
1422 stage: &'static str,
1423 session: &SessionContext,
1424 ) -> Result<Arc<[u8]>> {
1425 match self {
1426 Self::InMemory(bytes) => Ok(Arc::clone(bytes)),
1427 Self::Spooled {
1428 path, encryption, ..
1429 } => Ok(Arc::from(read_spooled_bytes(
1430 path, encryption, stage, session,
1431 )?)),
1432 }
1433 }
1434
1435 pub fn into_arc(self, stage: &'static str, session: &SessionContext) -> Result<Arc<[u8]>> {
1436 match self {
1437 Self::InMemory(bytes) => Ok(bytes),
1438 Self::Spooled {
1439 path,
1440 encryption,
1441 lifecycle,
1442 } => {
1443 let bytes = read_spooled_bytes(&path, &encryption, stage, session)?;
1444 if lifecycle.delete_on_materialize {
1445 delete_spooled_file(
1446 &path,
1447 lifecycle.secure_delete_on_materialize,
1448 stage,
1449 session,
1450 )?;
1451 }
1452 Ok(Arc::from(bytes))
1453 }
1454 }
1455 }
1456
1457 pub fn dispose(self, stage: &'static str, session: &SessionContext) -> Result<()> {
1458 match self {
1459 Self::InMemory(_) => Ok(()),
1460 Self::Spooled {
1461 path, lifecycle, ..
1462 } => {
1463 if lifecycle.delete_on_materialize {
1464 delete_spooled_file(
1465 &path,
1466 lifecycle.secure_delete_on_materialize,
1467 stage,
1468 session,
1469 )?;
1470 }
1471 Ok(())
1472 }
1473 }
1474 }
1475}
1476
1477fn read_spooled_bytes(
1478 path: &Path,
1479 encryption: &SpoolEncryption,
1480 stage: &'static str,
1481 session: &SessionContext,
1482) -> Result<Vec<u8>> {
1483 let bytes = std::fs::read(path).map_err(|err| {
1484 AsxError::new(
1485 ErrorCode::TransportFailure,
1486 format!("failed to read spooled body {}: {err}", path.display()),
1487 ErrorContext::for_session(stage, session),
1488 )
1489 })?;
1490
1491 match encryption {
1492 SpoolEncryption::Plaintext => Ok(bytes),
1493 SpoolEncryption::Aes256Gcm { key } => {
1494 let min_len = SPOOLED_AES256_GCM_MAGIC.len()
1495 + SPOOLED_AES256_GCM_NONCE_LEN
1496 + SPOOLED_AES256_GCM_TAG_LEN;
1497 if bytes.len() < min_len {
1498 return Err(AsxError::new(
1499 ErrorCode::DecryptionFailed,
1500 format!(
1501 "spooled encrypted body {} is too short for AES-GCM envelope",
1502 path.display()
1503 ),
1504 ErrorContext::for_session(stage, session),
1505 ));
1506 }
1507
1508 let magic = &bytes[..SPOOLED_AES256_GCM_MAGIC.len()];
1509 if magic != SPOOLED_AES256_GCM_MAGIC {
1510 return Err(AsxError::new(
1511 ErrorCode::DecryptionFailed,
1512 format!(
1513 "spooled encrypted body {} has invalid envelope magic",
1514 path.display()
1515 ),
1516 ErrorContext::for_session(stage, session),
1517 ));
1518 }
1519
1520 let nonce_start = SPOOLED_AES256_GCM_MAGIC.len();
1521 let nonce_end = nonce_start + SPOOLED_AES256_GCM_NONCE_LEN;
1522 let tag_start = bytes.len() - SPOOLED_AES256_GCM_TAG_LEN;
1523 let nonce = &bytes[nonce_start..nonce_end];
1524 let ciphertext = &bytes[nonce_end..tag_start];
1525 let tag = &bytes[tag_start..];
1526
1527 decrypt_spooled_aes256_gcm(path, key.as_ref(), nonce, ciphertext, tag, stage, session)
1528 }
1529 }
1530}
1531
1532#[cfg(any(feature = "as2", feature = "as4", feature = "async-ocsp"))]
1533fn decrypt_spooled_aes256_gcm(
1534 path: &Path,
1535 key: &[u8],
1536 nonce: &[u8],
1537 ciphertext: &[u8],
1538 tag: &[u8],
1539 stage: &'static str,
1540 session: &SessionContext,
1541) -> Result<Vec<u8>> {
1542 openssl::symm::decrypt_aead(
1543 openssl::symm::Cipher::aes_256_gcm(),
1544 key,
1545 Some(nonce),
1546 &[],
1547 ciphertext,
1548 tag,
1549 )
1550 .map_err(|err| {
1551 AsxError::new(
1552 ErrorCode::DecryptionFailed,
1553 format!(
1554 "failed to decrypt spooled encrypted body {}: {err}",
1555 path.display()
1556 ),
1557 ErrorContext::for_session(stage, session),
1558 )
1559 })
1560}
1561
1562#[cfg(not(any(feature = "as2", feature = "as4", feature = "async-ocsp")))]
1563fn decrypt_spooled_aes256_gcm(
1564 path: &Path,
1565 _key: &[u8],
1566 _nonce: &[u8],
1567 _ciphertext: &[u8],
1568 _tag: &[u8],
1569 stage: &'static str,
1570 session: &SessionContext,
1571) -> Result<Vec<u8>> {
1572 Err(AsxError::new(
1573 ErrorCode::PolicyViolation,
1574 format!(
1575 "spool AES-256-GCM decryption unavailable for {} without crypto protocol features",
1576 path.display()
1577 ),
1578 ErrorContext::for_session(stage, session),
1579 ))
1580}
1581
1582fn delete_spooled_file(
1583 path: &Path,
1584 secure_delete: bool,
1585 stage: &'static str,
1586 session: &SessionContext,
1587) -> Result<()> {
1588 if secure_delete {
1589 use std::io::{Seek, SeekFrom, Write};
1590
1591 let mut file = std::fs::OpenOptions::new()
1592 .read(true)
1593 .write(true)
1594 .open(path)
1595 .map_err(|err| {
1596 AsxError::new(
1597 ErrorCode::TransportFailure,
1598 format!(
1599 "failed to open spooled file {} for secure delete: {err}",
1600 path.display()
1601 ),
1602 ErrorContext::for_session(stage, session),
1603 )
1604 })?;
1605 let file_len = file
1606 .metadata()
1607 .map_err(|err| {
1608 AsxError::new(
1609 ErrorCode::TransportFailure,
1610 format!(
1611 "failed to stat spooled file {} for secure delete: {err}",
1612 path.display()
1613 ),
1614 ErrorContext::for_session(stage, session),
1615 )
1616 })?
1617 .len();
1618
1619 file.seek(SeekFrom::Start(0)).map_err(|err| {
1620 AsxError::new(
1621 ErrorCode::TransportFailure,
1622 format!(
1623 "failed to seek spooled file {} for secure delete: {err}",
1624 path.display()
1625 ),
1626 ErrorContext::for_session(stage, session),
1627 )
1628 })?;
1629
1630 let zeroes = vec![0u8; 8192];
1631 let mut remaining = file_len;
1632 while remaining > 0 {
1633 let write_len =
1634 usize::try_from(remaining.min(zeroes.len() as u64)).unwrap_or(zeroes.len());
1635 file.write_all(&zeroes[..write_len]).map_err(|err| {
1636 AsxError::new(
1637 ErrorCode::TransportFailure,
1638 format!(
1639 "failed to overwrite spooled file {} for secure delete: {err}",
1640 path.display()
1641 ),
1642 ErrorContext::for_session(stage, session),
1643 )
1644 })?;
1645 remaining -= write_len as u64;
1646 }
1647 file.flush().map_err(|err| {
1648 AsxError::new(
1649 ErrorCode::TransportFailure,
1650 format!(
1651 "failed to flush overwritten spooled file {}: {err}",
1652 path.display()
1653 ),
1654 ErrorContext::for_session(stage, session),
1655 )
1656 })?;
1657 file.sync_all().map_err(|err| {
1658 AsxError::new(
1659 ErrorCode::TransportFailure,
1660 format!(
1661 "failed to sync overwritten spooled file {}: {err}",
1662 path.display()
1663 ),
1664 ErrorContext::for_session(stage, session),
1665 )
1666 })?;
1667 }
1668
1669 std::fs::remove_file(path).map_err(|err| {
1670 AsxError::new(
1671 ErrorCode::TransportFailure,
1672 format!("failed to remove spooled file {}: {err}", path.display()),
1673 ErrorContext::for_session(stage, session),
1674 )
1675 })
1676}
1677
1678impl SessionContext {
1679 pub fn builder(
1681 session_id: impl Into<String>,
1682 partner_id: impl Into<String>,
1683 ) -> SessionContextBuilder {
1684 SessionContextBuilder::new(session_id, partner_id)
1685 }
1686
1687 pub fn new(
1688 session_id: impl Into<String>,
1689 partner_id: impl Into<String>,
1690 profile_name: impl Into<String>,
1691 ) -> Result<Self> {
1692 let session_id = session_id.into();
1693 let partner_id = partner_id.into();
1694 let profile_name = profile_name.into();
1695
1696 if session_id.trim().is_empty() {
1697 return Err(AsxError::new(
1698 ErrorCode::InvalidInput,
1699 "session_id must not be empty",
1700 ErrorContext::new("session_context_init"),
1701 ));
1702 }
1703 if partner_id.trim().is_empty() {
1704 return Err(AsxError::new(
1705 ErrorCode::InvalidInput,
1706 "partner_id must not be empty",
1707 ErrorContext::new("session_context_init").with_session_id(&session_id),
1708 ));
1709 }
1710 if profile_name.trim().is_empty() {
1711 return Err(AsxError::new(
1712 ErrorCode::InvalidInput,
1713 "profile_name must not be empty",
1714 ErrorContext::new("session_context_init")
1715 .with_session_and_partner(&session_id, &partner_id),
1716 ));
1717 }
1718
1719 Ok(Self {
1720 metadata: SessionMetadata::default(),
1721 cert_handle: Arc::new(CertHandle::new(format!("cert:{partner_id}"))),
1722 correlation_scope: CorrelationScope {
1723 root_id: format!("corr:{session_id}"),
1724 parent_message_id: None,
1725 traceparent: None,
1726 },
1727 session_id,
1728 partner_id,
1729 profile_name,
1730 #[cfg(any(feature = "as2", feature = "as4"))]
1731 trust_anchors_cache: TrustAnchorCache::default(),
1732 #[cfg(any(feature = "as2", feature = "as4"))]
1733 x509_store_cache: X509StoreCache::default(),
1734 })
1735 }
1736
1737 pub fn with_cert_handle(mut self, cert_handle: CertHandle) -> Result<Self> {
1748 Self::validate_cert_handle_fields(&cert_handle, "session_context_cert_update", &self)?;
1749 self.cert_handle = Arc::new(cert_handle);
1750 #[cfg(any(feature = "as2", feature = "as4"))]
1751 {
1752 self.trust_anchors_cache = TrustAnchorCache::default();
1753 self.x509_store_cache = X509StoreCache::default();
1754 }
1755 Ok(self)
1756 }
1757
1758 pub fn rotate_cert_handle(&mut self, cert_handle: CertHandle) -> Result<()> {
1776 Self::validate_cert_handle_fields(&cert_handle, "session_context_cert_rotate", self)?;
1777 self.cert_handle = Arc::new(cert_handle);
1778 #[cfg(any(feature = "as2", feature = "as4"))]
1779 {
1780 self.trust_anchors_cache = TrustAnchorCache::default();
1781 self.x509_store_cache = X509StoreCache::default();
1782 }
1783 Ok(())
1784 }
1785
1786 fn validate_cert_handle_fields(
1787 cert_handle: &CertHandle,
1788 stage: &'static str,
1789 session: &SessionContext,
1790 ) -> Result<()> {
1791 if cert_handle.key_id.trim().is_empty() {
1792 return Err(AsxError::new(
1793 ErrorCode::InvalidInput,
1794 "cert handle key_id must not be empty",
1795 ErrorContext::for_session(stage, session),
1796 ));
1797 }
1798 if cert_handle
1799 .trust_anchor_pems
1800 .iter()
1801 .any(|pem| pem.trim().is_empty())
1802 || cert_handle
1803 .revocation_crl_pems
1804 .iter()
1805 .any(|pem| pem.trim().is_empty())
1806 || cert_handle
1807 .stapled_ocsp_responses_der
1808 .iter()
1809 .any(Vec::is_empty)
1810 || cert_handle
1811 .responder_ocsp_responses_der
1812 .iter()
1813 .any(Vec::is_empty)
1814 {
1815 return Err(AsxError::new(
1816 ErrorCode::InvalidInput,
1817 "cert handle PKIX/OCSP material must not contain empty entries",
1818 ErrorContext::for_session(stage, session),
1819 ));
1820 }
1821 Ok(())
1822 }
1823
1824 pub fn with_effective_policy_snapshot_json(
1825 mut self,
1826 snapshot_json: impl Into<String>,
1827 ) -> Result<Self> {
1828 let snapshot_json = snapshot_json.into();
1829 if snapshot_json.trim().is_empty() {
1830 return Err(AsxError::new(
1831 ErrorCode::InvalidInput,
1832 "effective policy snapshot JSON must not be empty",
1833 ErrorContext::for_session("session_context_metadata_update", &self),
1834 ));
1835 }
1836 self.metadata.effective_policy_snapshot_json = Some(snapshot_json);
1837 Ok(self)
1838 }
1839
1840 pub fn effective_policy_snapshot_json(&self) -> Option<&str> {
1841 self.metadata.effective_policy_snapshot_json.as_deref()
1842 }
1843
1844 pub fn strict_runtime_bootstrap_validated(&self) -> bool {
1847 self.metadata.strict_runtime_bootstrap_validated
1848 }
1849
1850 #[cfg(feature = "testing")]
1861 #[must_use]
1862 pub fn test_only_mark_strict_runtime_bootstrap_validated(self) -> Self {
1863 self.with_strict_runtime_bootstrap_validated(true)
1864 }
1865
1866 pub(crate) fn with_strict_runtime_bootstrap_validated(mut self, validated: bool) -> Self {
1872 self.metadata.strict_runtime_bootstrap_validated = validated;
1873 self
1874 }
1875
1876 pub fn session_id(&self) -> &str {
1877 &self.session_id
1878 }
1879
1880 pub fn partner_id(&self) -> &str {
1881 &self.partner_id
1882 }
1883
1884 pub fn profile_name(&self) -> &str {
1885 &self.profile_name
1886 }
1887
1888 pub fn cert_handle(&self) -> &CertHandle {
1889 self.cert_handle.as_ref()
1890 }
1891
1892 #[cfg(any(feature = "as2", feature = "as4"))]
1899 pub(crate) fn trust_anchors_x509(&self) -> Result<Vec<openssl::x509::X509>> {
1900 if let Some(anchors) = self.trust_anchors_cache.0.get() {
1901 return Ok(anchors.clone());
1902 }
1903 let mut anchors = Vec::new();
1904 for pem in &self.cert_handle.trust_anchor_pems {
1905 let certs = openssl::x509::X509::stack_from_pem(pem.as_bytes()).map_err(|e| {
1906 AsxError::new(
1907 ErrorCode::InvalidInput,
1908 format!("invalid trust-anchor PEM in CertHandle: {e}"),
1909 ErrorContext::new("session_parse_trust_anchors"),
1910 )
1911 })?;
1912 anchors.extend(certs);
1913 }
1914 let _ = self.trust_anchors_cache.0.set(anchors.clone());
1915 Ok(anchors)
1916 }
1917
1918 #[cfg(any(feature = "as2", feature = "as4"))]
1924 pub(crate) fn trust_anchor_x509_store(&self) -> Result<Arc<openssl::x509::store::X509Store>> {
1925 if let Some(store) = self.x509_store_cache.0.get() {
1926 return Ok(Arc::clone(store));
1927 }
1928 let anchors = self.trust_anchors_x509()?;
1929 let mut builder = openssl::x509::store::X509StoreBuilder::new().map_err(|e| {
1930 AsxError::new(
1931 ErrorCode::InvalidInput,
1932 format!("failed to build X.509 trust store: {e}"),
1933 ErrorContext::new("session_build_x509_store"),
1934 )
1935 })?;
1936 for cert in &anchors {
1937 builder.add_cert(cert.clone()).map_err(|e| {
1938 AsxError::new(
1939 ErrorCode::InvalidInput,
1940 format!("failed to add trust anchor to X.509 store: {e}"),
1941 ErrorContext::new("session_build_x509_store"),
1942 )
1943 })?;
1944 }
1945 let store = Arc::new(builder.build());
1946 let _ = self.x509_store_cache.0.set(Arc::clone(&store));
1947 Ok(store)
1948 }
1949
1950 pub fn correlation_scope(&self) -> &CorrelationScope {
1951 &self.correlation_scope
1952 }
1953
1954 pub fn with_incoming_traceparent(mut self, traceparent: Option<&str>) -> Self {
1976 if let Some(tp) = traceparent {
1977 self.correlation_scope.traceparent = Some(Arc::from(tp));
1978 }
1979 self
1980 }
1981
1982 #[cfg(any(test, feature = "testing"))]
1987 pub fn for_testing(session_id: impl Into<String>, partner_id: impl Into<String>) -> Self {
1988 let session_id = session_id.into();
1989 let partner_id = partner_id.into();
1990 Self {
1991 metadata: SessionMetadata::default(),
1992 cert_handle: Arc::new(CertHandle {
1993 ocsp_mode: OcspMode::Disabled,
1994 ocsp_failure_mode: OcspFailureMode::SoftFail,
1995 ..CertHandle::new(format!("cert:{partner_id}"))
1996 }),
1997 correlation_scope: CorrelationScope {
1998 root_id: format!("corr:{session_id}"),
1999 parent_message_id: None,
2000 traceparent: None,
2001 },
2002 session_id,
2003 partner_id,
2004 profile_name: "test".into(),
2005 #[cfg(any(feature = "as2", feature = "as4"))]
2006 trust_anchors_cache: TrustAnchorCache::default(),
2007 #[cfg(any(feature = "as2", feature = "as4"))]
2008 x509_store_cache: X509StoreCache::default(),
2009 }
2010 }
2011}
2012
2013#[cfg(test)]
2014mod tests {
2015 use super::*;
2016
2017 #[test]
2018 fn escape_xml_prevents_injection() {
2019 assert_eq!(escape_xml("A&B"), "A&B");
2021 assert_eq!(escape_xml("A<B"), "A<B");
2023 assert_eq!(escape_xml("A>B"), "A>B");
2025 assert_eq!(escape_xml("A\"B"), "A"B");
2027 assert_eq!(
2029 escape_xml("msg<inject>B&C\"D"),
2030 "msg<inject>B&C"D"
2031 );
2032 assert_eq!(escape_xml(""), "");
2034 assert_eq!(escape_xml("hello-world"), "hello-world");
2036 assert_eq!(escape_xml("ab\x00cd"), "abcd");
2038 assert_eq!(escape_xml("\x01\x08\x0B\x0C\x0E\x1F\x7F"), "");
2039 assert_eq!(escape_xml("a\x00<b\x00>"), "a<b>");
2041 }
2042
2043 #[test]
2044 fn error_code_strings_are_stable() {
2045 assert_eq!(ErrorCode::TransportFailure.as_str(), "transport_failure");
2046 assert_eq!(ErrorCode::InteropViolation.as_str(), "interop_violation");
2047 }
2048
2049 #[test]
2050 fn session_context_validation_rejects_empty_values() {
2051 assert!(SessionContext::new("", "p", "strict").is_err());
2052 assert!(SessionContext::new("s", "", "strict").is_err());
2053 assert!(SessionContext::new("s", "p", "").is_err());
2054 }
2055
2056 #[test]
2057 fn session_context_has_deterministic_default_handles() {
2058 let session = SessionContext::new("s1", "partner-a", "strict").expect("session");
2059 assert_eq!(session.cert_handle().key_id, "cert:partner-a");
2060 assert_eq!(session.correlation_scope().root_id, "corr:s1");
2061 assert!(session.effective_policy_snapshot_json().is_none());
2062 }
2063
2064 #[test]
2065 fn session_context_builder_supports_incremental_configuration() {
2066 let cert = CertHandle {
2067 trust_anchor_pems: vec!["anchor-pem".into()],
2068 ..CertHandle::new("partner-key")
2069 };
2070
2071 let session = SessionContext::builder("s-builder", "partner-z")
2072 .profile_name("peppol")
2073 .cert_handle(cert)
2074 .effective_policy_snapshot_json("{\"mode\":\"Strict\"}")
2075 .correlation_scope("corr-custom", Some("parent-1".into()))
2076 .build()
2077 .expect("builder session");
2078
2079 assert_eq!(session.session_id(), "s-builder");
2080 assert_eq!(session.partner_id(), "partner-z");
2081 assert_eq!(session.profile_name(), "peppol");
2082 assert_eq!(session.cert_handle().key_id, "partner-key");
2083 assert_eq!(session.correlation_scope().root_id, "corr-custom");
2084 assert_eq!(
2085 session.correlation_scope().parent_message_id.as_deref(),
2086 Some("parent-1")
2087 );
2088 assert_eq!(
2089 session.effective_policy_snapshot_json(),
2090 Some("{\"mode\":\"Strict\"}")
2091 );
2092 }
2093
2094 #[test]
2095 fn session_context_builder_rejects_blank_correlation_root() {
2096 let err = SessionContext::builder("s-builder", "partner-z")
2097 .correlation_scope(" ", None)
2098 .build()
2099 .expect_err("must reject blank correlation root");
2100 assert_eq!(err.code, ErrorCode::InvalidInput);
2101 }
2102
2103 #[test]
2104 fn session_context_metadata_attaches_snapshot_json() {
2105 let session = SessionContext::new("s1", "partner-a", "strict")
2106 .expect("session")
2107 .with_effective_policy_snapshot_json("{\"resolved_mode\":\"Strict\"}")
2108 .expect("snapshot json");
2109
2110 assert_eq!(
2111 session.effective_policy_snapshot_json(),
2112 Some("{\"resolved_mode\":\"Strict\"}")
2113 );
2114 }
2115
2116 #[test]
2117 fn session_context_metadata_rejects_empty_snapshot_json() {
2118 let err = SessionContext::new("s1", "partner-a", "strict")
2119 .expect("session")
2120 .with_effective_policy_snapshot_json(" ")
2121 .expect_err("must reject blank snapshot");
2122 assert_eq!(err.code, ErrorCode::InvalidInput);
2123 }
2124
2125 #[test]
2126 fn cert_handle_new_has_expected_defaults() {
2127 let h = CertHandle::new("my-key");
2128 assert_eq!(h.key_id, "my-key");
2129 assert!(h.trust_anchor_pems.is_empty());
2130 assert_eq!(h.ocsp_mode, OcspMode::ResponderOnly);
2131 assert_eq!(h.ocsp_failure_mode, OcspFailureMode::HardFail);
2132 }
2133
2134 #[test]
2135 fn cert_handle_struct_update_syntax_works() {
2136 let base = CertHandle::new("base-key");
2137 let updated = CertHandle {
2138 trust_anchor_pems: vec!["fake-pem".into()],
2139 ocsp_mode: OcspMode::Disabled,
2140 ..base
2141 };
2142 assert_eq!(updated.key_id, "base-key");
2143 assert_eq!(updated.trust_anchor_pems, vec!["fake-pem".to_string()]);
2144 assert_eq!(updated.ocsp_mode, OcspMode::Disabled);
2145 }
2146
2147 #[test]
2148 fn rotate_cert_handle_preserves_session_identity() {
2149 let mut session =
2150 SessionContext::new("rotate-session", "partner-b", "strict").expect("session");
2151 let original_session_id = session.session_id().to_string();
2152 let original_partner_id = session.partner_id().to_string();
2153 let original_root_id = session.correlation_scope().root_id.clone();
2154
2155 let new_cert = CertHandle {
2156 trust_anchor_pems: vec!["new-anchor-pem".into()],
2157 ..CertHandle::new("new-key")
2158 };
2159 session.rotate_cert_handle(new_cert).expect("rotate");
2160
2161 assert_eq!(session.session_id(), original_session_id);
2162 assert_eq!(session.partner_id(), original_partner_id);
2163 assert_eq!(session.correlation_scope().root_id, original_root_id);
2164 assert_eq!(session.cert_handle().key_id, "new-key");
2165 assert_eq!(
2166 session.cert_handle().trust_anchor_pems,
2167 vec!["new-anchor-pem".to_string()]
2168 );
2169 }
2170
2171 #[test]
2172 fn rotate_cert_handle_rejects_empty_key_id() {
2173 let mut session = SessionContext::new("s1", "p1", "strict").expect("session");
2174 let bad_cert = CertHandle::new("");
2175 assert!(session.rotate_cert_handle(bad_cert).is_err());
2176 }
2177
2178 #[test]
2179 fn arc_cert_handle_clone_shares_same_pointer() {
2180 let session = SessionContext::new("s1", "p1", "strict").expect("session");
2181 let clone = session.clone();
2182 assert!(Arc::ptr_eq(&session.cert_handle, &clone.cert_handle));
2184 }
2185
2186 #[test]
2187 #[cfg(any(feature = "as2", feature = "as4"))]
2188 fn with_cert_handle_resets_trust_anchor_cache() {
2189 let session = SessionContext::new("s-cache", "partner-cache", "strict").expect("session");
2192 assert!(session.trust_anchors_cache.0.get().is_none());
2194
2195 let new_handle = CertHandle::new("partner-cache-cert");
2196 let session = session.with_cert_handle(new_handle).expect("set handle");
2199 assert!(session.trust_anchors_cache.0.get().is_none());
2200
2201 let handle2 = CertHandle {
2204 trust_anchor_pems: vec!["some-pem".into()],
2205 ..CertHandle::new("partner-cache-cert-2")
2206 };
2207 let _ = session.with_cert_handle(handle2);
2208 }
2209
2210 #[test]
2213 fn builder_with_trust_anchor_pem_does_not_leave_empty_key_id() {
2214 let result = SessionContextBuilder::new("s1", "partner-xyz")
2217 .with_trust_anchor_pem("fake-pem")
2218 .build();
2219 assert!(result.is_ok(), "build() must not fail: {:?}", result);
2220 let session = result.unwrap();
2221 assert_eq!(
2222 session.cert_handle().key_id,
2223 "cert:partner-xyz",
2224 "key_id should be auto-derived from partner_id"
2225 );
2226 }
2227
2228 #[test]
2229 fn builder_with_signing_cert_and_key_pem_do_not_leave_empty_key_id() {
2230 let builder =
2235 SessionContextBuilder::new("s1", "partner-abc").with_signing_cert_pem("not-real-pem");
2236 assert_eq!(
2237 builder.cert_handle.as_ref().expect("handle").key_id,
2238 "cert:partner-abc",
2239 );
2240 }
2241
2242 #[test]
2243 fn builder_with_fingerprint_sha256_sets_field() {
2244 let builder =
2245 SessionContextBuilder::new("s1", "partner-fp").with_fingerprint_sha256("aabbcc");
2246 assert_eq!(
2247 builder
2248 .cert_handle
2249 .as_ref()
2250 .expect("handle")
2251 .fingerprint_sha256,
2252 "aabbcc",
2253 );
2254 }
2255
2256 #[test]
2257 fn builder_with_signing_material_sets_both_fields() {
2258 let builder = SessionContextBuilder::new("s1", "partner-mat")
2259 .with_signing_material("cert-pem-value", "key-pem-value");
2260 let ch = builder.cert_handle.as_ref().expect("handle");
2261 assert_eq!(ch.signing_cert_pem.as_deref(), Some("cert-pem-value"));
2262 assert!(ch.signing_key_pem.is_some());
2263 assert_eq!(
2264 ch.signing_key_pem.as_ref().map(|s| s.as_str()),
2265 Some("key-pem-value")
2266 );
2267 }
2268
2269 #[test]
2270 fn cert_handle_set_signing_key_pem_avoids_zeroize_dep() {
2271 let mut ch = CertHandle::new("key");
2272 ch.set_signing_key_pem("my-private-key");
2273 assert!(ch.signing_key_pem.is_some());
2274 assert_eq!(
2275 ch.signing_key_pem.as_ref().map(|s| s.as_str()),
2276 Some("my-private-key")
2277 );
2278 }
2279}