Skip to main content

asx_rs/
core.rs

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
9/// Escapes the five canonical XML special characters (`&`, `<`, `>`, `"`, `'`)
10/// and strips XML 1.0 §2.2 forbidden control characters (including NUL bytes)
11/// to prevent injection into SOAP/SBDH envelopes.
12///
13/// # ⚠ Forbidden-character stripping
14///
15/// Bytes in the ranges `U+0000–U+0008`, `U+000B–U+000C`, `U+000E–U+001F`, and
16/// `U+007F` are **stripped** (not replaced) from the output.  If the input
17/// contains such bytes, the output will differ from the input — which can
18/// cause integrity failures that are hard to diagnose.  A `tracing::warn!`
19/// event is emitted (when the `trace` feature is enabled) so that callers
20/// can detect this condition via their observability pipeline.
21///
22/// If you need to **reject** inputs containing forbidden characters rather
23/// than silently strip them, validate `s` before calling this function.
24pub 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            // Forbidden XML 1.0 §2.2 characters — strip and warn.
32            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'&' => "&amp;",
43            b'<' => "&lt;",
44            b'>' => "&gt;",
45            b'"' => "&quot;",
46            b'\'' => "&apos;",
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        // Emit a warning observable via the tracing subscriber so that
63        // embedders can detect and alert on truncated XML field values.
64        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    // Slightly above core-count keeps throughput stable while bounding queueing.
76    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
100// ---------------------------------------------------------------------------
101// CryptoAdmissionControl — instance-scoped or process-global semaphore
102// ---------------------------------------------------------------------------
103
104/// Default inbound payload ceiling (256 MiB).
105///
106/// Production EDI batch files routinely exceed 50–100 MB; this default
107/// accommodates X12, EDIFACT, and Peppol BIS payloads without operator
108/// intervention.  Override via `StreamLimits` for resource-constrained
109/// deployments.
110pub const DEFAULT_MAX_BODY_BYTES: usize = 256 * 1024 * 1024;
111
112/// Controls how many concurrent CPU-heavy crypto/protocol tasks are admitted.
113///
114/// The default ([`CryptoAdmissionControl::process_global()`]) shares a
115/// process-wide semaphore across all sessions.  For multi-tenant embeddings,
116/// create a **per-tenant** instance so that one tenant's burst traffic cannot
117/// starve another's:
118///
119/// ```rust,ignore
120/// let control = Arc::new(CryptoAdmissionControl::new(32));
121/// // Store in your tenant context and call:
122/// // control.acquire(stage, session).await?
123/// ```
124///
125/// ## Choosing a concurrency limit
126///
127/// A value of `num_cpus * 2` (the default) is appropriate for workloads where
128/// crypto dominates.  For mixed workloads, set it to the number of Tokio
129/// blocking threads you are willing to dedicate to crypto work.
130#[derive(Clone, Debug)]
131pub struct CryptoAdmissionControl {
132    semaphore: Arc<Semaphore>,
133    /// Human-readable label used in error messages.
134    label: &'static str,
135}
136
137impl CryptoAdmissionControl {
138    /// Create a new **instance-scoped** admission controller with `concurrency`
139    /// permits.
140    ///
141    /// Use this for per-tenant or per-connection isolation.
142    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    /// Return the **process-global** admission controller.
151    ///
152    /// Concurrency is configured by the `ASX_BLOCKING_CRYPTO_CONCURRENCY` env var
153    /// or defaults to `num_cpus × 2`.
154    pub fn process_global() -> Self {
155        Self {
156            semaphore: blocking_crypto_semaphore(),
157            label: "process-global crypto semaphore",
158        }
159    }
160
161    /// Acquire one permit, waiting if all permits are currently held.
162    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/// Interpret a byte slice as UTF-8, returning a contextual error on failure.
181/// Avoids repeating the 4-line `from_utf8(...).map_err(|_| AsxError::new(...))` pattern.
182#[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    /// Create an `ErrorContext` pre-populated with session and partner IDs from a `SessionContext`.
217    /// Prefer this over chaining `with_session_id` + `with_partner_id` to avoid redundant clones.
218    #[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    /// Create an `ErrorContext` pre-populated with session, partner, and message IDs.
224    /// Prefer this over chaining three separate `with_*` calls.
225    #[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    /// A requested resource (e.g. SMP endpoint, profile) does not exist.
278    NotFound,
279    /// A bounded resource (e.g. conversation gate, connection pool) has no
280    /// remaining capacity.  Callers should shed load or retry after a delay.
281    CapacityExhausted,
282    /// The inbound request body exceeds the configured size limit.
283    ///
284    /// HTTP semantics: respond with 413 Content Too Large.
285    PayloadTooLarge,
286    /// A storage or infrastructure backend (dedup store, reconciliation queue,
287    /// audit sink) failed with an I/O or connectivity error.
288    ///
289    /// This is distinct from [`ReliabilityFailure`](Self::ReliabilityFailure)
290    /// (protocol-level duplicate/ordering failure) and from
291    /// [`TransportFailure`](Self::TransportFailure) (network/HTTP failure).
292    ///
293    /// HTTP semantics: respond with 503 Service Unavailable — the server is
294    /// temporarily unable to handle the request due to a backend outage.
295    StorageBackendFailure,
296    /// The partner's certificate has been revoked by its issuing CA.
297    ///
298    /// Distinct from [`SecurityVerificationFailed`](Self::SecurityVerificationFailed)
299    /// (which covers signature/chain errors) so that monitoring can page on revocation
300    /// separately from transient verification failures.
301    ///
302    /// HTTP semantics: 403 Forbidden — the certificate is permanently invalid.
303    CertificateRevoked,
304    /// The partner's certificate has passed its `notAfter` validity date.
305    ///
306    /// Distinct from [`SecurityVerificationFailed`](Self::SecurityVerificationFailed)
307    /// so that operations teams receive a targeted remediation hint (rotate the
308    /// partner cert) rather than a generic security failure alert.
309    ///
310    /// HTTP semantics: 403 Forbidden.
311    CertificateExpired,
312    /// A network or I/O operation timed out before completing.
313    ///
314    /// Distinct from [`TransportFailure`](Self::TransportFailure) (which covers
315    /// protocol/TLS errors) so that callers can apply timeout-specific retry
316    /// policies (e.g., exponential back-off with jitter rather than immediate retry).
317    ///
318    /// HTTP semantics: 504 Gateway Timeout when acting as a proxy/client.
319    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    /// Returns the canonical HTTP status code for this error.
344    ///
345    /// Embedders can use this in transport adapters to map `AsxError` to HTTP
346    /// responses without writing a custom match.  For errors that represent
347    /// an external failure (e.g., `TransportFailure`), the code is 5xx; for
348    /// caller-induced errors it is 4xx.
349    ///
350    /// | Variant                       | HTTP status |
351    /// |-------------------------------|-------------|
352    /// | `InvalidInput`                | 400         |
353    /// | `ParseFailed`                 | 400         |
354    /// | `SecurityVerificationFailed`  | 401         |
355    /// | `DecryptionFailed`            | 400         |
356    /// | `PolicyViolation`             | 422         |
357    /// | `TransportFailure`            | 502         |
358    /// | `InteropViolation`            | 400         |
359    /// | `ReliabilityFailure`          | 503         |
360    /// | `NotFound`                    | 404         |
361    /// | `CapacityExhausted`           | 429         |
362    /// | `PayloadTooLarge`             | 413         |
363    /// | `StorageBackendFailure`        | 503         |
364    /// | `CertificateRevoked`          | 403         |
365    /// | `CertificateExpired`          | 403         |
366    /// | `Timeout`                     | 504         |
367    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    /// A short operator-facing remediation hint for the most common failure
388    /// codes, or `None` for failures that require caller-specific diagnosis.
389    ///
390    /// Intended for structured logging and monitoring dashboards; not a
391    /// substitute for full error context.
392    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/// The crate's single error type.
447///
448/// `code` is the machine-readable part and is load-bearing: ingress handlers map
449/// it to an HTTP status, [`RetryDecision`](crate::reliability::RetryDecision)
450/// classifies on it, and the incident taxonomies group on it. `message` is for
451/// operators, never for matching.
452///
453/// The context is boxed. Every fallible function in this crate returns
454/// `Result<T, AsxError>`, so the error variant's size is paid on the success
455/// path too — an inline [`ErrorContext`] made *every* `Result` in the crate
456/// 120 bytes wide, including `Result<()>`. Boxing brings that to 40 while
457/// leaving `err.context.session_id` working through `Deref`.
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct AsxError {
460    /// Machine-readable classification. Match on this, not on `message`.
461    pub code: ErrorCode,
462    /// Operator-facing description of what went wrong.
463    pub message: String,
464    /// Correlation context: stage, and optionally session, partner and message id.
465    pub context: Box<ErrorContext>,
466}
467
468impl AsxError {
469    /// Construct an error.
470    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    /// Short operator-facing remediation hint, delegated from [`ErrorCode::remediation_hint`].
479    ///
480    /// Returns `None` for error codes that do not have a generic hint.
481    pub fn remediation_hint(&self) -> Option<&'static str> {
482        self.code.remediation_hint()
483    }
484
485    /// Enrich the error context with the partner ID for easier correlation in embedder logs.
486    ///
487    /// Delegates to [`ErrorContext::with_partner_id`]. Designed for use in `map_err` closures
488    /// where the full `ErrorContext` is not directly accessible:
489    ///
490    /// ```ignore
491    /// some_op().map_err(|e| e.with_partner_id(session.partner_id()))?;
492    /// ```
493    #[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    /// Enrich the error context with the session ID for easier correlation in embedder logs.
500    ///
501    /// Delegates to [`ErrorContext::with_session_id`].
502    #[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    /// Enrich the error context with both session and partner IDs in one call.
509    ///
510    /// Delegates to [`ErrorContext::with_session_and_partner`].
511    #[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    /// Enrich the error context with the message ID for easier correlation in embedder logs.
525    ///
526    /// Delegates to [`ErrorContext::with_message_id`].
527    #[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    /// Returns `true` if this error represents a replay-protection rejection
534    /// (the message was already seen by the dedup store).
535    ///
536    /// Equivalent to `err.code == ErrorCode::ReliabilityFailure && err.message contains "replay"`,
537    /// but stable across message-text changes.  Use this instead of matching on error text.
538    ///
539    /// # Example
540    /// ```rust,ignore
541    /// if let Err(e) = receive_push_with_dedup_async(...).await {
542    ///     if e.is_duplicate() {
543    ///         // idempotent: send receipt without re-processing
544    ///     }
545    /// }
546    /// ```
547    ///
548    /// **Prefer [`As4ReceiveOutcome`](crate::as4::As4ReceiveOutcome) over this method** — the discriminated
549    /// outcome enum is the primary API for duplicate detection on the receive path.
550    /// This method is provided for error-path scenarios (e.g., when a storage backend
551    /// fails-closed and propagates a duplicate as an error rather than an outcome).
552    #[inline]
553    pub fn is_duplicate(&self) -> bool {
554        self.code == ErrorCode::ReliabilityFailure && self.message.contains("replay")
555    }
556
557    /// Returns `true` if this error represents a transient storage or infrastructure failure.
558    #[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/// How tolerant the protocol layers are of header ambiguity and non-conformant
589/// partner behaviour.
590///
591/// # This does not govern message security
592///
593/// Neither this enum nor the `interop-strict` Cargo feature has any bearing on
594/// whether messages must be signed or encrypted. The name invites the opposite
595/// inference, so to be explicit:
596///
597/// | Axis | Controlled by | Governs |
598/// |---|---|---|
599/// | Interop | `InteropMode` / `interop-strict` | Header parsing, ambiguity tolerance, scoped exception guardrails |
600/// | Security | [`SecurityPolicy`] / [`BaseProfile::security_floor`] | Whether signature and encryption are required |
601///
602/// A profile can be [`InteropMode::Strict`] and still resolve to
603/// `require_encryption: false`; conversely a relaxed profile can mandate
604/// sign-and-encrypt. Enforce the security axis with
605/// [`ProfileStack::validate_with_floor`], not by selecting a strict mode.
606///
607/// [`SecurityPolicy`]: crate::interop::SecurityPolicy
608/// [`BaseProfile::security_floor`]: crate::interop::BaseProfile::security_floor
609/// [`ProfileStack::validate_with_floor`]: crate::interop::ProfileStack::validate_with_floor
610#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
611#[non_exhaustive]
612pub enum InteropMode {
613    /// Strict interoperability mode — RFC/spec behaviour enforced at every
614    /// decision point.
615    ///
616    /// Strict here means *strict about the wire format*. It says nothing about
617    /// the security policy; see the type-level documentation.
618    #[default]
619    Strict,
620    /// Relaxed interoperability mode — permits deviations from strict AS2/AS4
621    /// profile requirements to accommodate legacy or non-conformant partners.
622    ///
623    /// # Feature gate
624    ///
625    /// This variant is only available when the **`interop-relaxed`** Cargo
626    /// feature is enabled:
627    ///
628    /// ```toml
629    /// [dependencies]
630    /// asx-rs = { version = "0.14", features = ["interop-relaxed"] }
631    /// ```
632    #[cfg(feature = "interop-relaxed")]
633    Relaxed,
634}
635
636/// Per-partner session context — identity, trust configuration, and correlation
637/// metadata required by every send/receive operation in `asx`.
638///
639/// # Cardinality and Lifetime
640///
641/// `SessionContext` is scoped to **one trading-partner relationship** and
642/// represents a reusable, long-lived object — not a per-message allocation.
643/// Typical lifecycle patterns:
644///
645/// | Deployment style | Recommended granularity |
646/// |---|---|
647/// | Single fixed partner (e.g. one supplier) | One `SessionContext` per process; share via `Arc`. |
648/// | Multiple partners (hub/spoke) | One `SessionContext` per partner; keyed by `partner_id`. |
649/// | Short-lived CLI / batch | One `SessionContext` per batch run; `Clone` is O(1). |
650///
651/// Using a *new* `SessionContext` for every outbound message is valid but
652/// wasteful — it pays session-ID generation and cert-validation costs on every
653/// call.  Prefer reusing and, when certificates rotate, call
654/// [`rotate_cert_handle`] in place rather than rebuilding.
655///
656/// # Observability Impact
657///
658/// The `session_id` is emitted on every span, metric label, and error context
659/// produced during message processing.  Using inconsistent or randomly
660/// generated `session_id` values per message will fragment observability data
661/// in your monitoring backend and make per-partner dashboards unusable.
662/// Choose a stable, human-readable ID such as `"partner-acme-prod"`.
663///
664/// # Cloning
665///
666/// `Clone` is O(1) — all heavy data (certificates, trust anchors) is behind
667/// `Arc` and is not deep-copied.  Clones share the same `CertHandle` lineage
668/// until [`rotate_cert_handle`] is called on one of them.
669///
670/// # Construction
671///
672/// Prefer [`SessionContext::builder`] for incremental construction, or
673/// [`SessionContext::new`] for the minimal three-field shorthand.
674///
675/// [`rotate_cert_handle`]: SessionContext::rotate_cert_handle
676#[derive(Debug, Clone, PartialEq, Eq)]
677pub struct SessionContext {
678    session_id: String,
679    partner_id: String,
680    profile_name: String,
681    metadata: SessionMetadata,
682    /// The certificate/trust configuration for this session.
683    ///
684    /// Stored behind an `Arc` so that:
685    /// - `SessionContext::clone()` is O(1) — increments a refcount rather than
686    ///   deep-copying all PEM strings and DER blobs.
687    /// - In-flight verifications continue using the cert snapshot they started
688    ///   with even after a rotation (Arc keeps the old handle alive).
689    /// - [`rotate_cert_handle`] can swap the handle without rebuilding the session,
690    ///   preserving `session_id`, `partner_id`, reliability queues, and event
691    ///   subscriptions.
692    ///
693    /// Access via [`SessionContext::cert_handle`].
694    ///
695    /// [`rotate_cert_handle`]: SessionContext::rotate_cert_handle
696    cert_handle: Arc<CertHandle>,
697    correlation_scope: CorrelationScope,
698    /// Lazy-parsed trust-anchor cache.  Invalidated whenever `cert_handle` is
699    /// replaced via [`with_cert_handle`] or [`rotate_cert_handle`].
700    ///
701    /// [`with_cert_handle`]: SessionContext::with_cert_handle
702    /// [`rotate_cert_handle`]: SessionContext::rotate_cert_handle
703    #[cfg(any(feature = "as2", feature = "as4"))]
704    trust_anchors_cache: TrustAnchorCache,
705    /// Lazy-built X.509 store derived from `trust_anchor_pems`.
706    /// Invalidated whenever `cert_handle` is replaced.
707    #[cfg(any(feature = "as2", feature = "as4"))]
708    x509_store_cache: X509StoreCache,
709}
710
711/// Decode base64 that came out of an XML text node.
712///
713/// XML Signature §6.1 and XML Encryption both define their base64 content by
714/// reference to RFC 2045, which **line-wraps**, and any XML pretty-printer will
715/// happily fold a long `ds:DigestValue` or `ds:SignatureValue` anyway. The
716/// `base64` crate's standard engine rejects embedded whitespace, so decoding
717/// such a value directly turns a conformant counterparty into a parse failure —
718/// or, worse, into a digest "mismatch" reported as a security incident.
719///
720/// Strip ASCII whitespace first, then decode. Every base64 value that arrives
721/// inside an XML document goes through here.
722// XML-carried base64 only: the AS2 side unfolds and decodes inline, because a
723// folded MIME header is a different shape from a wrapped `ds:DigestValue`.
724#[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/// Render secret material for `Debug` without printing it.
740///
741/// Every type in this crate that holds a private key or a shared secret writes
742/// its `Debug` by hand and routes the secret-bearing fields through here. A
743/// `#[derive(Debug)]` on such a type writes the key into whatever consumed the
744/// output — a log line, a panic message, a `tracing` field, a bug report.
745#[must_use]
746pub(crate) fn redact_present(present: bool) -> &'static str {
747    if present { "<redacted>" } else { "<none>" }
748}
749
750/// Constant-time byte-slice equality.
751///
752/// The single implementation in the crate: digest comparison, fingerprint pins
753/// and shared-secret checks all route through it. Backed by
754/// [`subtle::ConstantTimeEq`], which is written to survive the optimiser — a
755/// hand-rolled XOR-accumulate loop is not, because nothing stops LLVM from
756/// turning it back into an early-exit `memcmp`.
757///
758/// Length is compared first and therefore leaks: constant-time equality is
759/// undefined for differing lengths, and in every use here the length is already
760/// public (a digest's length is fixed by its algorithm; an `AuthorizationInfo`
761/// value's length is visible on the wire).
762#[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/// Session-scoped metadata that is derived rather than configured.
773///
774/// The strict-runtime marker is deliberately **not** publicly settable: the
775/// only way to raise it is
776/// [`StrictRuntimeBootstrapToken::bind`], and the only way to obtain that token
777/// is [`StrictRuntimeBootstrap::validate`]. A marker any caller could set would
778/// assert that validation ran without it having run, which is worse than no
779/// marker at all.
780///
781/// [`StrictRuntimeBootstrapToken::bind`]: crate::presets::StrictRuntimeBootstrapToken::bind
782/// [`StrictRuntimeBootstrap::validate`]: crate::presets::StrictRuntimeBootstrap::validate
783#[derive(Debug, Clone, PartialEq, Eq, Default)]
784#[non_exhaustive]
785pub struct SessionMetadata {
786    /// The resolved effective-policy snapshot attached to this session, if any.
787    pub effective_policy_snapshot_json: Option<String>,
788    pub(crate) strict_runtime_bootstrap_validated: bool,
789}
790
791/// Builder for ergonomic incremental construction of [`SessionContext`].
792#[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    /// Create a new builder with required identity fields.
804    ///
805    /// `profile_name` defaults to `"strict"` and can be overridden with
806    /// [`profile_name`][Self::profile_name].
807    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    /// Override the profile name (defaults to `"strict"`).
819    pub fn profile_name(mut self, profile_name: impl Into<String>) -> Self {
820        self.profile_name = profile_name.into();
821        self
822    }
823
824    /// Set explicit certificate/trust material for the session.
825    pub fn cert_handle(mut self, cert_handle: CertHandle) -> Self {
826        self.cert_handle = Some(cert_handle);
827        self
828    }
829
830    /// Return a key_id string derived from the partner_id, matching the
831    /// pattern used by [`SessionContext::new`] for the default `CertHandle`.
832    fn default_cert_handle_key_id(&self) -> String {
833        format!("cert:{}", self.partner_id)
834    }
835
836    /// Get or lazily create the builder's CertHandle with a sensible key_id.
837    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    /// Append a single PEM-encoded trust anchor to the session's certificate store.
844    ///
845    /// This is a convenience alternative to constructing a [`CertHandle`] manually.
846    /// Can be called multiple times to build up a set of trusted root/intermediate CAs.
847    ///
848    /// # Example
849    /// ```rust,ignore
850    /// let session = SessionContextBuilder::new("s1", "partner-a")
851    ///     .with_trust_anchor_pem(root_ca_pem)
852    ///     .build()?;
853    /// ```
854    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    /// Append a certificate that signature verification may use for chain
862    /// building — an intermediate CA, or the partner's leaf signing
863    /// certificate.
864    ///
865    /// See [`CertHandle::intermediate_ca_pems`] for why the leaf usually has to
866    /// be supplied here rather than being taken from the message.
867    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    /// Append several chain-building certificates at once.
875    ///
876    /// Equivalent to calling
877    /// [`with_intermediate_ca_pem`][Self::with_intermediate_ca_pem] in a loop.
878    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    /// Append multiple PEM-encoded trust anchors to the session's certificate store.
889    ///
890    /// Equivalent to calling [`with_trust_anchor_pem`][Self::with_trust_anchor_pem]
891    /// in a loop.
892    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    /// Set the OCSP revocation-check mode for the session.
903    ///
904    /// Defaults to [`OcspMode::default()`] when not specified.
905    pub fn with_ocsp_mode(mut self, mode: OcspMode) -> Self {
906        self.cert_handle_or_init().ocsp_mode = mode;
907        self
908    }
909
910    /// Set the PEM-encoded X.509 certificate used to sign **outbound** messages.
911    ///
912    /// Must be called together with [`with_signing_key_pem`](Self::with_signing_key_pem).
913    /// [`build`](Self::build) will return an error when only one of the signing
914    /// pair is provided, or when the key does not match the certificate.
915    ///
916    /// Once set, `send_sync` / `send_async` use this certificate for every
917    /// message sent on this session — no per-request `As4SendCredentials` /
918    /// `As2SendCredentials` is required.  Per-request credentials remain
919    /// available as an explicit override via `Some(creds)` on the send request.
920    ///
921    /// # Example
922    /// ```rust,ignore
923    /// let session = SessionContextBuilder::new("s1", "partner-a")
924    ///     .with_signing_cert_pem(our_cert_pem)
925    ///     .with_signing_key_pem(our_key_pem)
926    ///     .with_trust_anchor_pem(partner_root_ca_pem)
927    ///     .build()?;
928    /// // All sends on this session are signed automatically.
929    /// asx_rs::as4::send_sync(&session, &bus, As4SendRequest {
930    ///     message_id,
931    ///     payload,
932    ///     policy,
933    ///     credentials: None,  // ← session certificate used
934    ///     payload_filename: None,
935    /// })?;
936    /// ```
937    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    /// Set the PEM-encoded private key used to sign **outbound** messages.
943    ///
944    /// Must match the certificate supplied via
945    /// [`with_signing_cert_pem`](Self::with_signing_cert_pem).  The raw key
946    /// bytes are **zeroized on drop** via the `CertHandle` destructor.
947    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    /// Set the PEM-encoded X.509 certificate belonging to **this partner**,
953    /// used to encrypt outbound messages when the send policy has
954    /// `encrypt = true`.
955    ///
956    /// When set, sends with `encrypt = true` do not require a per-request
957    /// `recipient_cert_pem` in `As4SendCredentials` / `As2SendCredentials`.
958    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    /// Set both signing certificate and key in one call.
964    ///
965    /// Equivalent to chaining [`with_signing_cert_pem`](Self::with_signing_cert_pem)
966    /// and [`with_signing_key_pem`](Self::with_signing_key_pem), but eliminates the
967    /// intermediate half-configured state where one of the pair is set and the other
968    /// is not.  [`build`](Self::build) validates that the cert and key match.
969    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    /// Pin the expected SHA-256 fingerprint (lower-case hex, no separators) of
981    /// the partner's signing certificate.
982    ///
983    /// When set, the WS-Security / S/MIME verifier rejects any message whose
984    /// signing certificate does not match this fingerprint, even when the
985    /// signature is cryptographically valid and the cert chains to a trust anchor.
986    ///
987    /// Useful for high-assurance deployments where the exact partner certificate
988    /// is known in advance (e.g. BDEW regulated partners, Peppol cornernodes).
989    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    /// Attach a serialized effective policy snapshot JSON string.
995    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    /// Override the default correlation scope.
1001    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    /// Build a validated [`SessionContext`].
1015    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            // Validate that signing_key_pem and signing_cert_pem are both
1020            // present or both absent.
1021            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            // Eagerly parse and validate PEM material when crypto features are
1032            // available.  This surfaces key/cert mismatches at session
1033            // construction time rather than deep inside the send pipeline.
1034            #[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/// Validate PEM-encoded outbound signing material stored in a `CertHandle`.
1059///
1060/// Called from `SessionContextBuilder::build` when `as2` or `as4` features
1061/// are active.  Parses the signing cert + key, verifies the key matches the
1062/// cert, and optionally parses the recipient cert if present.  All errors are
1063/// surfaced at session construction time rather than deep in the send pipeline.
1064#[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/// Lazy cache of trust-anchor X.509 certificates parsed from
1116/// [`CertHandle::trust_anchor_pems`].  Shared across clones of the same
1117/// [`CertHandle`] via an `Arc` so that clones see the same populated cache
1118/// rather than re-parsing independently.
1119///
1120/// Equality is always `true` — the cache is an implementation detail derived
1121/// from the authoritative `trust_anchor_pems` field; it does not contribute to
1122/// the identity of a [`CertHandle`].
1123#[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 // cache state is not part of CertHandle identity
1131    }
1132}
1133#[cfg(any(feature = "as2", feature = "as4"))]
1134impl Eq for TrustAnchorCache {}
1135
1136/// Lazy cache of the `X509Store` built from [`CertHandle::trust_anchor_pems`].
1137///
1138/// Caching avoids rebuilding an `X509Store` (O(n_anchors) OpenSSL allocations)
1139/// on every inbound message verification.  Shared across clones via `Arc`;
1140/// the store is built at most once per `CertHandle` lineage regardless of how
1141/// many clones exist.  Equality is always `true` (derived from `trust_anchor_pems`).
1142#[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    /// Additional certificates (PEM) made available for chain building during
1170    /// signature verification.
1171    ///
1172    /// AS2 verification runs OpenSSL with `PKCS7_NOINTERN`, so certificates
1173    /// **embedded in the inbound message are not used for path building** —
1174    /// otherwise a sender could influence its own chain. The practical
1175    /// consequence is that this list must contain every certificate needed to
1176    /// reach a trust anchor, including the partner's **leaf signing
1177    /// certificate** when it is not itself a configured anchor.
1178    ///
1179    /// Populate it with [`SessionContextBuilder::with_intermediate_ca_pem`].
1180    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    /// PEM-encoded X.509 certificate used to sign **outbound** messages to
1187    /// this partner.  When set via
1188    /// [`SessionContextBuilder::with_signing_cert_pem`], `send_sync` /
1189    /// `send_async` use this certificate automatically; no per-request
1190    /// `As4SendCredentials` / `As2SendCredentials` is required.
1191    ///
1192    /// Must be paired with [`signing_key_pem`](Self::signing_key_pem).
1193    /// [`SessionContextBuilder::build`] validates the pair and checks that
1194    /// the key matches the certificate.
1195    pub signing_cert_pem: Option<String>,
1196    /// PEM-encoded private key matching
1197    /// [`signing_cert_pem`](Self::signing_cert_pem).
1198    ///
1199    /// # Security
1200    ///
1201    /// The key bytes are **zeroized on drop** via the `Zeroizing` wrapper.
1202    /// Do not log or persist a `CertHandle` that contains live key material.
1203    pub signing_key_pem: Option<zeroize::Zeroizing<String>>,
1204    /// PEM-encoded X.509 certificate belonging to this partner, used to
1205    /// encrypt outbound AS4 / AS2 messages when the send policy has
1206    /// `encrypt = true` and no per-request credential is provided.
1207    pub recipient_cert_pem: Option<String>,
1208}
1209
1210// Hand-written `Debug` that never renders private key material. The derived
1211// `Debug` would forward through `Zeroizing<String>` and print the raw PEM (see
1212// the `# Security` note on `signing_key_pem`), so any `?cert_handle`/`?session`
1213// in a log line or panic message would leak the key. Presence is reported as a
1214// redacted marker so debugging stays useful without exposing secrets.
1215impl 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    /// Construct a `CertHandle` with sensible defaults for the given key ID.
1245    ///
1246    /// All PEM/DER fields default to empty (`Vec::new()`), with OCSP mode
1247    /// `ResponderOnly` and `HardFail`.  Override any field with struct update
1248    /// syntax before passing to [`SessionContext::with_cert_handle`] or
1249    /// [`SessionContext::rotate_cert_handle`]:
1250    ///
1251    /// ```rust,ignore
1252    /// let handle = CertHandle {
1253    ///     trust_anchor_pems: vec![root_pem],
1254    ///     ocsp_mode: OcspMode::Disabled,
1255    ///     ..CertHandle::new("partner-cert")
1256    /// };
1257    /// ```
1258    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    /// Set the PEM-encoded private key for outbound signing without requiring
1276    /// callers to depend on the `zeroize` crate directly.
1277    ///
1278    /// The key bytes are automatically wrapped in [`zeroize::Zeroizing`] and
1279    /// will be zeroized on drop.
1280    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    /// Inbound W3C Trace Context `traceparent` header value, if present and valid.
1308    ///
1309    /// Populated by the transport ingress layer for inbound messages and
1310    /// propagated into every [`ScopedAsxEvent`] emitted during the
1311    /// corresponding processing session, enabling distributed-trace correlation
1312    /// with upstream callers.
1313    ///
1314    /// [`ScopedAsxEvent`]: crate::observability::ScopedAsxEvent
1315    pub traceparent: Option<Arc<str>>,
1316}
1317
1318/// Payload bytes supplied to AS2/AS4 send/receive operations.
1319///
1320/// One payload type for both protocols. `Shared` avoids a copy when the caller
1321/// already holds an `Arc<[u8]>`.
1322#[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/// Receive-body abstraction used by verifier contracts.
1349///
1350/// In-memory handles avoid extra copies, while spooled handles keep large
1351/// messages out of RSS and make materialization an explicit decision point.
1352#[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    /// Start building a [`SessionContext`] incrementally.
1680    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    /// Validate and set the session's certificate/trust configuration (builder method).
1738    ///
1739    /// This is the primary way to attach certificate material when constructing a
1740    /// session.  For rotation on an already-live session, prefer
1741    /// [`rotate_cert_handle`] which takes `&mut self`.
1742    ///
1743    /// Both the trust-anchor parse cache and the X.509 store cache are reset so
1744    /// that the new handle's anchors are parsed fresh on first use.
1745    ///
1746    /// [`rotate_cert_handle`]: Self::rotate_cert_handle
1747    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    /// Atomically rotate the certificate/trust configuration on a live session.
1759    ///
1760    /// Unlike [`with_cert_handle`], this takes `&mut self` and therefore works
1761    /// on an already-constructed session without rebuilding it.  The `session_id`,
1762    /// `partner_id`, profile, reliability queues, and event subscriptions are
1763    /// preserved.
1764    ///
1765    /// Any in-flight verification calls that were already dispatched continue
1766    /// using the previous `Arc<CertHandle>` snapshot until they complete; newly
1767    /// accepted messages see the updated configuration immediately.
1768    ///
1769    /// # Errors
1770    ///
1771    /// Returns `InvalidInput` if `cert_handle` fails the same validation as
1772    /// [`with_cert_handle`].
1773    ///
1774    /// [`with_cert_handle`]: Self::with_cert_handle
1775    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    /// Return whether this session is explicitly marked as startup-validated
1845    /// for strict-runtime protocol entry point enforcement.
1846    pub fn strict_runtime_bootstrap_validated(&self) -> bool {
1847        self.metadata.strict_runtime_bootstrap_validated
1848    }
1849
1850    /// Mark this session as startup-validated **without** running startup
1851    /// validation.
1852    ///
1853    /// Test scaffolding only. It is gated behind the `testing` feature, which
1854    /// raises a `compile_error!` in release builds, so it cannot reach a
1855    /// production binary — the same guarantee that covers
1856    /// [`crate::as4::InsecureBypassAs4Verifier`].
1857    ///
1858    /// Production code marks a session by presenting the token that
1859    /// [`crate::presets::StrictRuntimeBootstrap`] mints.
1860    #[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    /// Return a cloned session carrying the strict-runtime bootstrap marker.
1867    ///
1868    /// Crate-internal on purpose. The public path is
1869    /// [`crate::presets::StrictRuntimeBootstrapToken::bind`], which demands the
1870    /// token that startup validation mints.
1871    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    /// Return the parsed trust-anchor X.509 certificates, parsing from
1893    /// `cert_handle.trust_anchor_pems` on first call and caching the result.
1894    ///
1895    /// Thread-safe: multiple concurrent callers share one parse via `OnceLock`.
1896    /// The cache is invalidated automatically when `with_cert_handle` or
1897    /// `rotate_cert_handle` is called on this session.
1898    #[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    /// Return an `Arc`-wrapped `X509Store` built from the trust-anchor PEMs.
1919    ///
1920    /// Built at most once per `(session, cert_handle)` pair and shared across
1921    /// clones.  The cache is invalidated automatically when `with_cert_handle`
1922    /// or `rotate_cert_handle` is called.
1923    #[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    /// Attach an inbound W3C Trace Context `traceparent` header value to this
1955    /// session so that every [`ScopedAsxEvent`] emitted during processing
1956    /// carries the upstream trace identifier.
1957    ///
1958    /// Typically called by the embedder's HTTP handler after parsing the
1959    /// inbound request with [`as2_ingress_from_http`] or
1960    /// [`as4_ingress_from_http`]:
1961    ///
1962    /// ```rust,ignore
1963    /// let ingress = as4_ingress_from_http(http_request)?;
1964    /// let session = SessionContext::new("s1", "partner", "strict")?
1965    ///     .with_incoming_traceparent(ingress.traceparent.as_deref());
1966    /// ```
1967    ///
1968    /// Passing `None` is a no-op (leaves any previously set value unchanged
1969    /// because inbound absence of the header should not clear a manually set
1970    /// value).
1971    ///
1972    /// [`ScopedAsxEvent`]: crate::observability::ScopedAsxEvent
1973    /// [`as2_ingress_from_http`]: crate::transport::ingress::as2_ingress_from_http
1974    /// [`as4_ingress_from_http`]: crate::transport::ingress::as4_ingress_from_http
1975    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    /// Construct a minimal `SessionContext` for unit tests.
1983    ///
1984    /// Uses `OcspMode::Disabled` and soft-fail to avoid network I/O in tests.
1985    /// Prefer this over manually building `SessionContext` with `new()` in test code.
1986    #[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        // Test ampersand
2020        assert_eq!(escape_xml("A&B"), "A&amp;B");
2021        // Test less-than
2022        assert_eq!(escape_xml("A<B"), "A&lt;B");
2023        // Test greater-than
2024        assert_eq!(escape_xml("A>B"), "A&gt;B");
2025        // Test double quote
2026        assert_eq!(escape_xml("A\"B"), "A&quot;B");
2027        // Test combined injection attempt
2028        assert_eq!(
2029            escape_xml("msg<inject>B&C\"D"),
2030            "msg&lt;inject&gt;B&amp;C&quot;D"
2031        );
2032        // Test empty string
2033        assert_eq!(escape_xml(""), "");
2034        // Test string with no special chars
2035        assert_eq!(escape_xml("hello-world"), "hello-world");
2036        // XML 1.0 §2.2: NUL and other forbidden control chars must be stripped.
2037        assert_eq!(escape_xml("ab\x00cd"), "abcd");
2038        assert_eq!(escape_xml("\x01\x08\x0B\x0C\x0E\x1F\x7F"), "");
2039        // NUL inside markup-requiring content
2040        assert_eq!(escape_xml("a\x00<b\x00>"), "a&lt;b&gt;");
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        // Both sessions share the same Arc<CertHandle> pointer — O(1) clone.
2183        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        // Verify that replacing cert_handle on a session resets the caches so
2190        // the new anchor PEMs are parsed fresh on next access.
2191        let session = SessionContext::new("s-cache", "partner-cache", "strict").expect("session");
2192        // The initial session has empty trust_anchor_pems — caches default.
2193        assert!(session.trust_anchors_cache.0.get().is_none());
2194
2195        let new_handle = CertHandle::new("partner-cache-cert");
2196        // with_cert_handle always installs a fresh default cache, irrespective
2197        // of what was in the provided CertHandle.
2198        let session = session.with_cert_handle(new_handle).expect("set handle");
2199        assert!(session.trust_anchors_cache.0.get().is_none());
2200
2201        // Struct update syntax now works from external callers too since
2202        // CertHandle has no pub(crate) fields.
2203        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    // ── BUG-1 regression: builder convenience setters must derive key_id ──────
2211
2212    #[test]
2213    fn builder_with_trust_anchor_pem_does_not_leave_empty_key_id() {
2214        // Any convenience builder setter should auto-derive key_id from partner_id,
2215        // so build() must not fail with "cert handle key_id must not be empty".
2216        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        // Regression for BUG-1: with_signing_cert_pem / with_signing_key_pem
2231        // previously initialised CertHandle with key_id="" which caused build() to fail.
2232        // We cannot use real PEM material here, so just verify that the cert_handle
2233        // key_id is derived from partner_id.
2234        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}