Skip to main content

asx_rs/
interop.rs

1use std::fmt;
2
3use crate::core::{AsxError, ErrorCode, ErrorContext, InteropMode, Result, SessionContext};
4use serde::{Deserialize, Serialize};
5
6#[cfg(feature = "as4")]
7use crate::crypto::wssec::WsSecCanonicalizationProfile;
8
9#[cfg(not(feature = "as4"))]
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum WsSecCanonicalizationKind {
12    Exclusive,
13    Inclusive,
14}
15
16#[cfg(not(feature = "as4"))]
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct WsSecCanonicalizationProfile {
19    pub kind: WsSecCanonicalizationKind,
20    pub include_comments: bool,
21    pub inclusive_ns_prefixes: Vec<String>,
22}
23
24#[cfg(not(feature = "as4"))]
25impl Default for WsSecCanonicalizationProfile {
26    fn default() -> Self {
27        Self {
28            kind: WsSecCanonicalizationKind::Exclusive,
29            include_comments: false,
30            inclusive_ns_prefixes: Vec::new(),
31        }
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct CanonicalizationPolicy {
37    pub wssec: WsSecCanonicalizationProfile,
38    pub normalize_mime_headers: bool,
39}
40
41impl Default for CanonicalizationPolicy {
42    fn default() -> Self {
43        Self {
44            wssec: WsSecCanonicalizationProfile::default(),
45            normalize_mime_headers: true,
46        }
47    }
48}
49
50/// One of the two message-level protections a [`SecurityPolicy`] can require.
51///
52/// Used to name *which* requirement a layer dropped, so that validation
53/// diagnostics can say `require_encryption` rather than dumping two booleans.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
55pub enum SecurityRequirement {
56    /// WS-Security XML Signature (AS4) / CMS signature (AS2).
57    Signature,
58    /// XML Encryption (AS4) / CMS enveloped-data (AS2).
59    Encryption,
60}
61
62impl SecurityRequirement {
63    /// Field name of this requirement on [`SecurityPolicy`].
64    ///
65    /// Chosen to match the struct field exactly so operators can grep a
66    /// validation message straight into their profile configuration.
67    pub fn as_str(self) -> &'static str {
68        match self {
69            Self::Signature => "require_signature",
70            Self::Encryption => "require_encryption",
71        }
72    }
73
74    /// Both requirements, in declaration order.
75    pub const ALL: [Self; 2] = [Self::Signature, Self::Encryption];
76}
77
78impl fmt::Display for SecurityRequirement {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.write_str(self.as_str())
81    }
82}
83
84/// Message-level protections a profile layer requires.
85///
86/// # This is independent of [`InteropMode`]
87///
88/// Neither the `interop-strict` Cargo feature nor [`InteropMode::Strict`]
89/// implies a strict *security* policy. Interop mode governs header handling and
90/// ambiguity tolerance; `SecurityPolicy` governs whether messages must be signed
91/// and encrypted. A profile can be `InteropMode::Strict` and still resolve to
92/// `require_encryption: false` — the two axes never constrain each other.
93///
94/// What does constrain the security axis is the profile's
95/// [security floor](BaseProfile::security_floor), which
96/// [`ProfileStack::validate`] enforces against every resolved layer.
97///
98/// # Lattice ordering
99///
100/// A policy is *stronger* than another when it requires everything the other
101/// requires and possibly more. [`Self::satisfies`] is that partial order,
102/// [`Self::strengthen`] its join, and [`Self::weaken`] its meet. Both defaults
103/// are `true`, so the fail-closed policy is also the strongest one.
104///
105/// ```
106/// use asx_rs::interop::{SecurityPolicy, SecurityRequirement};
107///
108/// let floor = SecurityPolicy::SIGN_AND_ENCRYPT;
109/// let partner = SecurityPolicy::SIGN_ONLY;
110///
111/// assert!(!partner.satisfies(floor));
112/// assert_eq!(floor.unmet_by(partner), vec![SecurityRequirement::Encryption]);
113/// ```
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115pub struct SecurityPolicy {
116    pub require_signature: bool,
117    pub require_encryption: bool,
118}
119
120impl SecurityPolicy {
121    /// Sign *and* encrypt — the fail-closed policy, and what
122    /// [`Default::default`] yields.
123    ///
124    /// Mandated by PEPPOL, CEF eDelivery and BDEW AS4-Profil v1.2 §2.2.6.2.2,
125    /// so it is also the default [security floor](BaseProfile::security_floor).
126    pub const SIGN_AND_ENCRYPT: Self = Self {
127        require_signature: true,
128        require_encryption: true,
129    };
130
131    /// Signature required, encryption optional.
132    pub const SIGN_ONLY: Self = Self {
133        require_signature: true,
134        require_encryption: false,
135    };
136
137    /// Encryption required, signature optional.
138    pub const ENCRYPT_ONLY: Self = Self {
139        require_signature: false,
140        require_encryption: true,
141    };
142
143    /// Neither protection required.
144    ///
145    /// Only meaningful as a *floor* (meaning "impose nothing beyond the
146    /// per-layer checks"). As an effective policy it is rejected by
147    /// [`ProfileStack::validate`] with
148    /// [`ProfileValidationCode::NoCriticalSecurityInvariant`].
149    pub const UNCONSTRAINED: Self = Self {
150        require_signature: false,
151        require_encryption: false,
152    };
153
154    /// Whether this policy requires `requirement`.
155    pub fn requires(self, requirement: SecurityRequirement) -> bool {
156        match requirement {
157            SecurityRequirement::Signature => self.require_signature,
158            SecurityRequirement::Encryption => self.require_encryption,
159        }
160    }
161
162    /// Whether this policy is at least as strong as `floor`.
163    pub fn satisfies(self, floor: Self) -> bool {
164        self.unmet_by_is_empty(floor)
165    }
166
167    fn unmet_by_is_empty(self, floor: Self) -> bool {
168        SecurityRequirement::ALL
169            .iter()
170            .all(|&req| !floor.requires(req) || self.requires(req))
171    }
172
173    /// Requirements that `self` (as a floor) demands but `candidate` does not
174    /// provide, in declaration order.
175    ///
176    /// Empty exactly when `candidate.satisfies(self)`.
177    pub fn unmet_by(self, candidate: Self) -> Vec<SecurityRequirement> {
178        SecurityRequirement::ALL
179            .into_iter()
180            .filter(|&req| self.requires(req) && !candidate.requires(req))
181            .collect()
182    }
183
184    /// Requirements that `self` demands but `next` drops — the monotonicity
185    /// violations of a `self -> next` transition, in declaration order.
186    ///
187    /// Empty exactly when `next` is at least as strong as `self`.
188    pub fn relaxations_to(self, next: Self) -> Vec<SecurityRequirement> {
189        self.unmet_by(next)
190    }
191
192    /// Lattice join: require everything either policy requires.
193    ///
194    /// Used to combine a [`BaseProfile::security_floor`] with a
195    /// deployment-imposed floor from [`ProfileValidationOptions`] — the
196    /// stricter of the two wins per requirement.
197    pub fn strengthen(self, other: Self) -> Self {
198        Self {
199            require_signature: self.require_signature || other.require_signature,
200            require_encryption: self.require_encryption || other.require_encryption,
201        }
202    }
203
204    /// Lattice meet: require only what both policies require.
205    pub fn weaken(self, other: Self) -> Self {
206        Self {
207            require_signature: self.require_signature && other.require_signature,
208            require_encryption: self.require_encryption && other.require_encryption,
209        }
210    }
211}
212
213impl Default for SecurityPolicy {
214    fn default() -> Self {
215        Self::SIGN_AND_ENCRYPT
216    }
217}
218
219impl fmt::Display for SecurityPolicy {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        write!(
222            f,
223            "require_signature={} require_encryption={}",
224            self.require_signature, self.require_encryption
225        )
226    }
227}
228
229/// Protocol-neutral validation knobs that apply to both AS2 and AS4.
230///
231/// AS2-only settings live in [`As2ValidationPolicy`] so that an AS4 profile
232/// never has to carry — or explicitly disable — a knob that cannot apply to it.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234pub struct ValidationPolicy {
235    pub reject_ambiguous_headers: bool,
236    pub enforce_payload_limits: bool,
237}
238
239impl Default for ValidationPolicy {
240    fn default() -> Self {
241        Self {
242            reject_ambiguous_headers: true,
243            enforce_payload_limits: true,
244        }
245    }
246}
247
248/// AS2-only validation knobs.
249///
250/// Kept out of [`ValidationPolicy`] because these concepts have no AS4
251/// equivalent: AS4 integrity is carried by the WS-Security XML Signature, not
252/// by an RFC 4130 MIC.
253///
254/// Only meaningful in profiles that carry AS2 traffic; AS4-only profiles can
255/// leave this at its default and ignore it entirely.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257pub struct As2ValidationPolicy {
258    /// Require an RFC 4130 §7.3 `Received-Content-MIC` on inbound MDNs.
259    pub require_mic: bool,
260}
261
262impl Default for As2ValidationPolicy {
263    fn default() -> Self {
264        Self { require_mic: true }
265    }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
269pub struct ProfilePolicyOverrides {
270    pub mode: Option<InteropMode>,
271    pub canonicalization: Option<CanonicalizationPolicy>,
272    pub security: Option<SecurityPolicy>,
273    pub validation: Option<ValidationPolicy>,
274    /// AS2-only overrides.  `None` on AS4 profiles, which is the common case.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub as2_validation: Option<As2ValidationPolicy>,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub struct BaseProfile {
281    /// Short human-readable name for this profile (e.g. `"peppol_as4_strict"`).
282    pub name: String,
283    /// Specification version string for this profile (e.g. `"2.0"`, `"1.14"`).
284    ///
285    /// Conveys the version of the underlying standard or network specification this
286    /// profile implements.  Informational only — used in diagnostics and profile
287    /// comparison but does not affect protocol behaviour.
288    pub version: String,
289    pub mode: InteropMode,
290    pub canonicalization: CanonicalizationPolicy,
291    pub security: SecurityPolicy,
292    /// Minimum security policy that **no layer may relax**.
293    ///
294    /// [`ProfileStack::overrides`] and [`ProfileStack::partner_overrides`] are
295    /// public, so any overlay can rewrite [`Self::security`]. The floor is the
296    /// invariant that survives that: [`ProfileStack::validate`] resolves the
297    /// stack for the deployment baseline *and for every declared partner*, and
298    /// rejects any resolved policy that does not
299    /// [satisfy](SecurityPolicy::satisfies) this floor.
300    ///
301    /// Defaults to [`SecurityPolicy::SIGN_AND_ENCRYPT`], which is what PEPPOL,
302    /// CEF eDelivery and BDEW AS4-Profil v1.2 §2.2.6.2.2 all mandate. Lower it
303    /// only for a profile that genuinely permits sign-only or plaintext
304    /// exchange, and expect the resulting relaxation to be reported as a
305    /// [`ProfileLintCode::SecurityRelaxation`] lint at
306    /// [`ProfileLintSeverity::Critical`].
307    pub security_floor: SecurityPolicy,
308    pub validation: ValidationPolicy,
309    /// AS2-only validation settings.  Ignored by AS4 profiles.
310    pub as2_validation: As2ValidationPolicy,
311}
312
313impl BaseProfile {
314    /// A strict, fail-closed base profile with the given name and version.
315    ///
316    /// Equivalent to [`BaseProfile::default`] with `name`/`version` set:
317    /// strict interop mode, sign-and-encrypt required, and a
318    /// [`security_floor`](Self::security_floor) that forbids any layer from
319    /// dropping either protection.
320    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
321        Self {
322            name: name.into(),
323            version: version.into(),
324            ..Self::default()
325        }
326    }
327
328    /// Replace the [`security_floor`](Self::security_floor).
329    pub fn with_security_floor(mut self, floor: SecurityPolicy) -> Self {
330        self.security_floor = floor;
331        self
332    }
333}
334
335impl Default for BaseProfile {
336    /// Strict interop mode, sign-and-encrypt required, and a matching
337    /// sign-and-encrypt floor — the fail-closed starting point.
338    fn default() -> Self {
339        Self {
340            name: "asx-base".to_string(),
341            version: "1.0".to_string(),
342            mode: InteropMode::Strict,
343            canonicalization: CanonicalizationPolicy::default(),
344            security: SecurityPolicy::default(),
345            security_floor: SecurityPolicy::default(),
346            validation: ValidationPolicy::default(),
347            as2_validation: As2ValidationPolicy::default(),
348        }
349    }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct ProfileExtension {
354    pub name: String,
355    pub overrides: ProfilePolicyOverrides,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct ProfileOverride {
360    pub name: String,
361    pub overrides: ProfilePolicyOverrides,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct PartnerProfileOverlay {
366    pub name: String,
367    pub partner_id: String,
368    pub overrides: ProfilePolicyOverrides,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct ProfileStack {
373    pub base: BaseProfile,
374    pub extensions: Vec<ProfileExtension>,
375    pub overrides: Vec<ProfileOverride>,
376    pub partner_overrides: Vec<PartnerProfileOverlay>,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct RegionalProfilePack {
381    pub pack_id: String,
382    pub version: String,
383    pub applies_to_base_profile: String,
384    pub overrides: ProfilePolicyOverrides,
385}
386
387impl RegionalProfilePack {
388    /// Maximum byte length accepted by [`Self::from_json`].
389    ///
390    /// Prevents allocation amplification from attacker-controlled JSON blobs.
391    pub const MAX_PACK_JSON_BYTES: usize = 512 * 1024; // 512 KiB
392
393    pub fn from_json(input: &str) -> Result<Self> {
394        if input.len() > Self::MAX_PACK_JSON_BYTES {
395            return Err(AsxError::new(
396                ErrorCode::PayloadTooLarge,
397                format!(
398                    "regional profile pack JSON exceeds maximum allowed size \
399                     ({} bytes, limit is {} bytes)",
400                    input.len(),
401                    Self::MAX_PACK_JSON_BYTES
402                ),
403                ErrorContext::new("interop_regional_pack_deserialize"),
404            ));
405        }
406        let pack: Self = serde_json::from_str(input).map_err(|err| {
407            AsxError::new(
408                ErrorCode::ParseFailed,
409                format!("failed to deserialize regional profile pack: {err}"),
410                ErrorContext::new("interop_regional_pack_deserialize"),
411            )
412        })?;
413        pack.validate()?;
414        Ok(pack)
415    }
416
417    fn validate(&self) -> Result<()> {
418        if self.pack_id.trim().is_empty() {
419            return Err(AsxError::new(
420                ErrorCode::InvalidInput,
421                "regional pack pack_id must not be empty",
422                ErrorContext::new("interop_regional_pack_validate"),
423            ));
424        }
425        if self.applies_to_base_profile.trim().is_empty() {
426            return Err(AsxError::new(
427                ErrorCode::InvalidInput,
428                format!(
429                    "regional pack {} has empty applies_to_base_profile",
430                    self.pack_id
431                ),
432                ErrorContext::new("interop_regional_pack_validate"),
433            ));
434        }
435        if !Self::is_semver_like(&self.version) {
436            return Err(AsxError::new(
437                ErrorCode::InvalidInput,
438                format!(
439                    "regional pack {} has invalid version {}; expected semver-like x.y.z",
440                    self.pack_id, self.version
441                ),
442                ErrorContext::new("interop_regional_pack_validate"),
443            ));
444        }
445        Ok(())
446    }
447
448    fn is_semver_like(version: &str) -> bool {
449        let mut parts = version.split('.');
450        let major = parts.next().unwrap_or("");
451        let minor = parts.next().unwrap_or("");
452        let patch = parts.next().unwrap_or("");
453        if parts.next().is_some() {
454            return false;
455        }
456        !major.is_empty()
457            && !minor.is_empty()
458            && !patch.is_empty()
459            && major.chars().all(|c| c.is_ascii_digit())
460            && minor.chars().all(|c| c.is_ascii_digit())
461            && patch.chars().all(|c| c.is_ascii_digit())
462    }
463
464    fn extension_name(&self) -> String {
465        format!("regional:{}@{}", self.pack_id, self.version)
466    }
467}
468
469#[derive(Debug, Clone, PartialEq, Eq)]
470pub struct ResolvedSessionProfile {
471    pub session: SessionContext,
472    pub effective_profile: EffectiveProfile,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub struct EffectiveProfile {
477    pub name: String,
478    pub mode: InteropMode,
479    pub canonicalization: CanonicalizationPolicy,
480    pub security: SecurityPolicy,
481    /// The [`BaseProfile::security_floor`] this policy was resolved under.
482    ///
483    /// Constant across resolution — the floor is deliberately not overridable,
484    /// since it is the invariant the overlays are checked against.
485    pub security_floor: SecurityPolicy,
486    pub validation: ValidationPolicy,
487    /// AS2-only validation settings.  Ignored by AS4 profiles.
488    pub as2_validation: As2ValidationPolicy,
489    pub snapshot: EffectivePolicySnapshot,
490}
491
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
493pub struct EffectivePolicySnapshot {
494    pub session_id: String,
495    pub partner_id: String,
496    pub profile_name: String,
497    pub resolved_mode: InteropMode,
498    pub canonicalization: CanonicalizationPolicy,
499    pub security: SecurityPolicy,
500    /// The [`BaseProfile::security_floor`] in force when this snapshot was
501    /// taken.
502    ///
503    /// Recorded so that [`diff_effective_policy_snapshots`] can flag a *lowered
504    /// floor* between releases. Without it a change from
505    /// [`SecurityPolicy::SIGN_AND_ENCRYPT`] to
506    /// [`SecurityPolicy::UNCONSTRAINED`] is invisible to the diff — the
507    /// resolved policy is unchanged, yet every future overlay has become free
508    /// to relax it.
509    pub security_floor: SecurityPolicy,
510    pub validation: ValidationPolicy,
511    #[serde(default)]
512    pub as2_validation: As2ValidationPolicy,
513    pub resolution_trace: Vec<String>,
514    pub resolution_diagnostics: Vec<ResolutionDiagnostic>,
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
518pub enum ResolutionLayer {
519    Extension,
520    Override,
521    PartnerOverride,
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
525#[non_exhaustive]
526pub enum ResolutionField {
527    Mode,
528    Canonicalization,
529    Security,
530    /// The profile's security floor. Never produced by layer resolution — the
531    /// floor is base-only — but reported by
532    /// [`diff_effective_policy_snapshots`] when it changes between releases.
533    SecurityFloor,
534    Validation,
535    /// AS2-only validation settings.
536    As2Validation,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540pub struct ResolutionDiagnostic {
541    pub layer: ResolutionLayer,
542    pub layer_name: String,
543    pub field: ResolutionField,
544    pub previous_value: String,
545    pub new_value: String,
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
549#[non_exhaustive]
550pub enum ProfileValidationCode {
551    /// A resolved layer requires neither a signature nor encryption.
552    ///
553    /// Only reachable when the [security floor](BaseProfile::security_floor)
554    /// is permissive enough not to have caught the layer first; a floor of
555    /// [`SecurityPolicy::SIGN_AND_ENCRYPT`] reports
556    /// [`Self::SecurityFloorViolation`] instead.
557    NoCriticalSecurityInvariant,
558    /// A resolved layer does not satisfy the profile's
559    /// [security floor](BaseProfile::security_floor).
560    SecurityFloorViolation,
561    /// A layer relaxed a security requirement that a lower layer had enabled,
562    /// while [`ProfileValidationOptions::forbid_security_relaxation`] was set.
563    ///
564    /// Without that option the same transition is reported as a
565    /// [`ProfileLintCode::SecurityRelaxation`] lint.
566    SecurityRelaxation,
567}
568
569impl ProfileValidationCode {
570    /// Stable snake_case identifier, suitable for log fields and alert rules.
571    pub fn as_str(self) -> &'static str {
572        match self {
573            Self::NoCriticalSecurityInvariant => "no_critical_security_invariant",
574            Self::SecurityFloorViolation => "security_floor_violation",
575            Self::SecurityRelaxation => "security_relaxation",
576        }
577    }
578}
579
580impl fmt::Display for ProfileValidationCode {
581    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
582        f.write_str(self.as_str())
583    }
584}
585
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
587#[non_exhaustive]
588pub enum ProfileLintCode {
589    /// A layer sets a field to the value already in effect.
590    DeadOverride,
591    /// A layer dropped a security requirement a lower layer had enabled, but
592    /// the result still satisfies the [security floor](BaseProfile::security_floor).
593    ///
594    /// Escalate to a hard error with
595    /// [`ProfileValidationOptions::forbid_security_relaxation`].
596    SecurityRelaxation,
597}
598
599impl ProfileLintCode {
600    /// Stable snake_case identifier, suitable for log fields and alert rules.
601    pub fn as_str(self) -> &'static str {
602        match self {
603            Self::DeadOverride => "dead_override",
604            Self::SecurityRelaxation => "security_relaxation",
605        }
606    }
607}
608
609impl fmt::Display for ProfileLintCode {
610    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
611        f.write_str(self.as_str())
612    }
613}
614
615/// How much attention a [`ProfileLintFinding`] deserves.
616///
617/// Lints never block [`ProfileStack::validate`]; the severity tells a CI gate
618/// or startup log which ones to treat as blocking anyway.
619#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
620pub enum ProfileLintSeverity {
621    /// Cosmetic — the profile behaves as intended but says something twice.
622    #[default]
623    Info,
624    /// Worth reviewing before release.
625    Warning,
626    /// Security-relevant. Blocks release in the shipped CI gates.
627    Critical,
628}
629
630impl ProfileLintSeverity {
631    /// Stable lowercase identifier, suitable for log fields and alert rules.
632    pub fn as_str(self) -> &'static str {
633        match self {
634            Self::Info => "info",
635            Self::Warning => "warning",
636            Self::Critical => "critical",
637        }
638    }
639}
640
641impl fmt::Display for ProfileLintSeverity {
642    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
643        f.write_str(self.as_str())
644    }
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
648pub struct ProfileValidationIssue {
649    pub code: ProfileValidationCode,
650    pub message: String,
651    pub remediation_hint: String,
652    /// Qualified layer identifier, e.g. `base:peppol` or
653    /// `partner_override:9900000000001:legacy`.
654    pub layer: String,
655    /// Partner whose resolved policy produced this issue.
656    ///
657    /// `None` for issues found on the deployment baseline (base profile,
658    /// extensions and global overrides), which apply to every partner.
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub partner_id: Option<String>,
661}
662
663impl fmt::Display for ProfileValidationIssue {
664    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
665        write!(f, "[{}] {}: {}", self.code, self.layer, self.message)?;
666        if !self.remediation_hint.is_empty() {
667            write!(f, " — hint: {}", self.remediation_hint)?;
668        }
669        Ok(())
670    }
671}
672
673impl std::error::Error for ProfileValidationIssue {}
674
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
676pub struct ProfileLintFinding {
677    pub code: ProfileLintCode,
678    pub severity: ProfileLintSeverity,
679    pub message: String,
680    pub remediation_hint: String,
681    /// Qualified layer identifier, e.g. `override:deployment-global`.
682    pub layer: String,
683    /// Partner whose resolved policy produced this lint; `None` on the
684    /// deployment baseline.
685    #[serde(default, skip_serializing_if = "Option::is_none")]
686    pub partner_id: Option<String>,
687}
688
689impl fmt::Display for ProfileLintFinding {
690    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691        write!(
692            f,
693            "[{}/{}] {}: {}",
694            self.severity, self.code, self.layer, self.message
695        )?;
696        if !self.remediation_hint.is_empty() {
697            write!(f, " — hint: {}", self.remediation_hint)?;
698        }
699        Ok(())
700    }
701}
702
703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
704pub struct ProfileValidationReport {
705    pub lints: Vec<ProfileLintFinding>,
706}
707
708impl ProfileValidationReport {
709    /// Highest severity across all lints, or `None` when the report is clean.
710    pub fn highest_lint_severity(&self) -> Option<ProfileLintSeverity> {
711        self.lints.iter().map(|lint| lint.severity).max()
712    }
713
714    /// Lints at or above `severity`.
715    pub fn lints_at_least(
716        &self,
717        severity: ProfileLintSeverity,
718    ) -> impl Iterator<Item = &ProfileLintFinding> {
719        self.lints.iter().filter(move |l| l.severity >= severity)
720    }
721}
722
723/// Errors (and any lints collected alongside them) from a failed
724/// [`ProfileStack::validate`].
725///
726/// Implements [`Display`](fmt::Display) and [`std::error::Error`], so it
727/// composes with `thiserror`'s `#[from]` / `#[error(transparent)]`, with `?`
728/// into `anyhow::Error`, and with `Box<dyn Error>`. [`Display`](fmt::Display)
729/// renders a bounded one-line summary suitable for a startup log; use
730/// [`Self::report`] for the full multi-line operator rendering.
731///
732/// ```
733/// use asx_rs::interop::{BaseProfile, ProfileStack, SecurityPolicy};
734///
735/// let stack = ProfileStack {
736///     base: BaseProfile {
737///         security: SecurityPolicy::UNCONSTRAINED,
738///         ..BaseProfile::new("demo", "1.0")
739///     },
740///     extensions: vec![],
741///     overrides: vec![],
742///     partner_overrides: vec![],
743/// };
744///
745/// let err = stack.validate().unwrap_err();
746/// assert!(err.to_string().starts_with("profile validation failed: 1 error"));
747/// let _boxed: Box<dyn std::error::Error> = Box::new(err);
748/// ```
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
750pub struct ProfileValidationFailure {
751    pub errors: Vec<ProfileValidationIssue>,
752    pub lints: Vec<ProfileLintFinding>,
753}
754
755impl ProfileValidationFailure {
756    /// Number of errors rendered inline by [`Display`](fmt::Display) before the
757    /// remainder is elided as `(+N more)`.
758    ///
759    /// One, deliberately. Each issue renders its message *and* remediation
760    /// hint, so a large stack failing wholesale would otherwise produce a
761    /// multi-kilobyte "one-line" log entry. [`Self::report`] always renders
762    /// everything.
763    pub const DISPLAY_ERROR_LIMIT: usize = 1;
764
765    /// First error, if any. Always `Some` for a value returned by
766    /// [`ProfileStack::validate`], which never fails with an empty error list.
767    pub fn first_error(&self) -> Option<&ProfileValidationIssue> {
768        self.errors.first()
769    }
770
771    /// Whether any error carries `code`.
772    pub fn has_code(&self, code: ProfileValidationCode) -> bool {
773        self.errors.iter().any(|issue| issue.code == code)
774    }
775
776    /// Partners whose resolved policy produced at least one error, deduplicated
777    /// in first-seen order. Baseline errors contribute no entry.
778    pub fn affected_partners(&self) -> Vec<&str> {
779        let mut seen: Vec<&str> = Vec::new();
780        for partner in self.errors.iter().filter_map(|e| e.partner_id.as_deref()) {
781            if !seen.contains(&partner) {
782                seen.push(partner);
783            }
784        }
785        seen
786    }
787
788    /// Full multi-line rendering: every error and lint on its own indented
789    /// line, with remediation hints.
790    ///
791    /// This is what belongs in a startup failure log; [`Display`](fmt::Display)
792    /// is the bounded one-line form for error-chain composition.
793    pub fn report(&self) -> String {
794        use fmt::Write as _;
795
796        let mut out = format!(
797            "profile validation failed: {} error(s), {} lint(s)",
798            self.errors.len(),
799            self.lints.len()
800        );
801        for (index, issue) in self.errors.iter().enumerate() {
802            // `write!` into a String is infallible.
803            let _ = write!(out, "\n  error {}. {issue}", index + 1);
804        }
805        for (index, lint) in self.lints.iter().enumerate() {
806            let _ = write!(out, "\n  lint  {}. {lint}", index + 1);
807        }
808        out
809    }
810}
811
812impl fmt::Display for ProfileValidationFailure {
813    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
814        write!(
815            f,
816            "profile validation failed: {} error(s), {} lint(s)",
817            self.errors.len(),
818            self.lints.len()
819        )?;
820
821        let shown = self.errors.len().min(Self::DISPLAY_ERROR_LIMIT);
822        for (index, issue) in self.errors.iter().take(shown).enumerate() {
823            let separator = if index == 0 { ": " } else { "; " };
824            write!(f, "{separator}{issue}")?;
825        }
826        if self.errors.len() > shown {
827            write!(f, "; (+{} more)", self.errors.len() - shown)?;
828        }
829        Ok(())
830    }
831}
832
833impl std::error::Error for ProfileValidationFailure {
834    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
835        self.errors
836            .first()
837            .map(|issue| issue as &(dyn std::error::Error + 'static))
838    }
839}
840
841impl From<ProfileValidationFailure> for AsxError {
842    /// Lets `stack.validate()?` be used directly inside any function returning
843    /// the crate's [`Result`], rendering through
844    /// [`ProfileValidationFailure::report`] so the full finding list survives.
845    fn from(failure: ProfileValidationFailure) -> Self {
846        AsxError::new(
847            ErrorCode::PolicyViolation,
848            failure.report(),
849            ErrorContext::new("interop_profile_validate"),
850        )
851    }
852}
853
854#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd)]
855pub enum DiffRiskLevel {
856    Low,
857    Medium,
858    High,
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
862pub enum DiffStage {
863    Resolution,
864    Security,
865    Validation,
866    Canonicalization,
867}
868
869#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
870pub struct EffectivePolicyDiffEntry {
871    pub field: ResolutionField,
872    pub stage: DiffStage,
873    pub previous_value: String,
874    pub new_value: String,
875    pub risk: DiffRiskLevel,
876    pub rationale: String,
877}
878
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880pub struct ProfileImpactReport {
881    pub before_profile_name: String,
882    pub after_profile_name: String,
883    pub before_session_id: String,
884    pub after_session_id: String,
885    pub changes: Vec<EffectivePolicyDiffEntry>,
886    pub highest_risk: DiffRiskLevel,
887    pub release_blocked: bool,
888}
889
890pub type ProfileValidationResult<T> = std::result::Result<T, ProfileValidationFailure>;
891
892/// Knobs for [`ProfileStack::validate_with`].
893///
894/// Construct with [`Default::default`] and the `with_*` methods; the struct is
895/// `#[non_exhaustive]` so future knobs do not break callers.
896///
897/// ```
898/// use asx_rs::interop::{ProfileValidationOptions, SecurityPolicy};
899///
900/// let options = ProfileValidationOptions::default()
901///     .with_security_floor(SecurityPolicy::SIGN_AND_ENCRYPT)
902///     .forbidding_security_relaxation();
903/// # let _ = options;
904/// ```
905#[derive(Debug, Clone, PartialEq, Eq, Default)]
906#[non_exhaustive]
907pub struct ProfileValidationOptions {
908    /// Floor imposed by the deployment, on top of [`BaseProfile::security_floor`].
909    ///
910    /// The two are combined with [`SecurityPolicy::strengthen`], so the
911    /// stricter value wins per requirement and this can only tighten
912    /// validation, never loosen it.
913    pub security_floor: Option<SecurityPolicy>,
914    /// Escalate every monotonic security relaxation from a
915    /// [`ProfileLintCode::SecurityRelaxation`] lint to a
916    /// [`ProfileValidationCode::SecurityRelaxation`] error, even when the
917    /// relaxed policy still clears the floor.
918    ///
919    /// Use this when overlays are expected to specialise *non-security* policy
920    /// only, and any security change at all should fail the build.
921    pub forbid_security_relaxation: bool,
922}
923
924impl ProfileValidationOptions {
925    /// Impose a deployment-wide security floor.
926    pub fn with_security_floor(mut self, floor: SecurityPolicy) -> Self {
927        self.security_floor = Some(floor);
928        self
929    }
930
931    /// Treat any security relaxation as a hard error.
932    pub fn forbidding_security_relaxation(mut self) -> Self {
933        self.forbid_security_relaxation = true;
934        self
935    }
936
937    fn resolve_against(&self, base: &BaseProfile) -> ResolvedValidationOptions {
938        ResolvedValidationOptions {
939            security_floor: self.security_floor.map_or(base.security_floor, |floor| {
940                floor.strengthen(base.security_floor)
941            }),
942            forbid_security_relaxation: self.forbid_security_relaxation,
943        }
944    }
945}
946
947/// Effective policy for one resolution scope, computed without a live session.
948///
949/// [`ProfileStack::resolve`] needs a [`SessionContext`], which startup
950/// validation does not have. This is the session-free counterpart: it answers
951/// "what policy will partner X actually get?" before the first message.
952///
953/// ```
954/// use asx_rs::interop::{
955///     BaseProfile, PartnerProfileOverlay, ProfilePolicyOverrides, ProfileStack, SecurityPolicy,
956/// };
957///
958/// let stack = ProfileStack {
959///     base: BaseProfile::new("bdew", "1.2").with_security_floor(SecurityPolicy::SIGN_ONLY),
960///     extensions: vec![],
961///     overrides: vec![],
962///     partner_overrides: vec![PartnerProfileOverlay {
963///         name: "legacy".into(),
964///         partner_id: "9900000000001".into(),
965///         overrides: ProfilePolicyOverrides {
966///             security: Some(SecurityPolicy::SIGN_ONLY),
967///             ..Default::default()
968///         },
969///     }],
970/// };
971///
972/// let weak: Vec<_> = stack
973///     .resolve_all_partners()
974///     .into_iter()
975///     .filter(|view| !view.security.satisfies(SecurityPolicy::SIGN_AND_ENCRYPT))
976///     .map(|view| view.scope_label().to_string())
977///     .collect();
978/// assert_eq!(weak, vec!["9900000000001"]);
979/// ```
980#[derive(Debug, Clone, PartialEq, Eq)]
981pub struct ResolvedPolicyView {
982    /// Partner this policy applies to, or `None` for the deployment baseline
983    /// (base + extensions + global overrides, no partner overlay).
984    pub partner_id: Option<String>,
985    pub mode: InteropMode,
986    pub canonicalization: CanonicalizationPolicy,
987    pub security: SecurityPolicy,
988    pub validation: ValidationPolicy,
989    /// AS2-only validation settings.  Ignored by AS4 profiles.
990    pub as2_validation: As2ValidationPolicy,
991    pub resolution_trace: Vec<String>,
992    pub resolution_diagnostics: Vec<ResolutionDiagnostic>,
993}
994
995impl ResolvedPolicyView {
996    /// `"<baseline>"` or the partner id — a stable label for logs and reports.
997    pub fn scope_label(&self) -> &str {
998        self.partner_id.as_deref().unwrap_or("<baseline>")
999    }
1000
1001    /// Whether this resolved policy is at least as strong as `floor`.
1002    pub fn satisfies(&self, floor: SecurityPolicy) -> bool {
1003        self.security.satisfies(floor)
1004    }
1005}
1006
1007#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1008pub enum InteropExceptionCode {
1009    As2AllowMissingMdnBoundary,
1010}
1011
1012impl InteropExceptionCode {
1013    pub fn reason_code(self) -> &'static str {
1014        match self {
1015            Self::As2AllowMissingMdnBoundary => "as2_missing_mdn_boundary",
1016        }
1017    }
1018}
1019
1020#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1021pub enum InteropGuardrailOutcome {
1022    Allowed,
1023    Denied,
1024}
1025
1026impl InteropGuardrailOutcome {
1027    /// Return a canonical `&'static str` label for this outcome.
1028    /// Avoids `format!("{:?}", ...)` heap allocation at event-emission sites.
1029    pub fn as_str(self) -> &'static str {
1030        match self {
1031            Self::Allowed => "Allowed",
1032            Self::Denied => "Denied",
1033        }
1034    }
1035}
1036
1037#[derive(Debug, Clone, PartialEq, Eq, Default)]
1038pub struct InteropExceptionPolicy {
1039    pub scoped_profile_name: Option<String>,
1040    pub allowed: Vec<InteropExceptionCode>,
1041}
1042
1043impl InteropExceptionPolicy {
1044    pub fn scoped(profile_name: impl Into<String>, allowed: Vec<InteropExceptionCode>) -> Self {
1045        Self {
1046            scoped_profile_name: Some(profile_name.into()),
1047            allowed,
1048        }
1049    }
1050
1051    pub fn allows(&self, session: &SessionContext, code: InteropExceptionCode) -> bool {
1052        match &self.scoped_profile_name {
1053            Some(scope) if scope == session.profile_name() => self.allowed.contains(&code),
1054            _ => false,
1055        }
1056    }
1057}
1058
1059#[derive(Debug, Clone, PartialEq, Eq)]
1060pub enum InteropDecision {
1061    RelaxedException { reason_code: &'static str },
1062}
1063
1064pub fn evaluate_exception_guardrail(
1065    session: &SessionContext,
1066    mode: InteropMode,
1067    policy: &InteropExceptionPolicy,
1068    code: InteropExceptionCode,
1069) -> InteropGuardrailOutcome {
1070    if mode == InteropMode::Strict {
1071        return InteropGuardrailOutcome::Denied;
1072    }
1073
1074    if policy.allows(session, code) {
1075        InteropGuardrailOutcome::Allowed
1076    } else {
1077        InteropGuardrailOutcome::Denied
1078    }
1079}
1080
1081pub fn enforce_exception(
1082    session: &SessionContext,
1083    mode: InteropMode,
1084    policy: &InteropExceptionPolicy,
1085    code: InteropExceptionCode,
1086    stage: &'static str,
1087    strict_message: impl Into<String>,
1088) -> Result<InteropDecision> {
1089    let strict_message = strict_message.into();
1090    match evaluate_exception_guardrail(session, mode, policy, code) {
1091        InteropGuardrailOutcome::Allowed => Ok(InteropDecision::RelaxedException {
1092            reason_code: code.reason_code(),
1093        }),
1094        InteropGuardrailOutcome::Denied => {
1095            let message = if mode == InteropMode::Strict {
1096                strict_message
1097            } else {
1098                format!(
1099                    "relaxed mode exception denied for reason {}; missing scoped exception policy",
1100                    code.reason_code()
1101                )
1102            };
1103            Err(AsxError::new(
1104                ErrorCode::InteropViolation,
1105                message,
1106                ErrorContext::for_session(stage, session),
1107            ))
1108        }
1109    }
1110}
1111
1112impl EffectivePolicySnapshot {
1113    pub fn as_event_detail(&self) -> String {
1114        let trace = if self.resolution_trace.is_empty() {
1115            "none".into()
1116        } else {
1117            self.resolution_trace.join(" > ")
1118        };
1119
1120        format!(
1121            "session={} partner={} profile={} mode={:?} trace={}",
1122            self.session_id, self.partner_id, self.profile_name, self.resolved_mode, trace
1123        )
1124    }
1125
1126    pub fn to_json_pretty(&self) -> Result<String> {
1127        serde_json::to_string_pretty(self).map_err(|err| {
1128            AsxError::new(
1129                ErrorCode::ParseFailed,
1130                format!("failed to serialize effective policy snapshot: {err}"),
1131                ErrorContext::new("interop_snapshot_serialize")
1132                    .with_session_and_partner(&self.session_id, &self.partner_id),
1133            )
1134        })
1135    }
1136
1137    pub fn from_json(input: &str) -> Result<Self> {
1138        serde_json::from_str(input).map_err(|err| {
1139            AsxError::new(
1140                ErrorCode::ParseFailed,
1141                format!("failed to deserialize effective policy snapshot: {err}"),
1142                ErrorContext::new("interop_snapshot_deserialize"),
1143            )
1144        })
1145    }
1146}
1147
1148impl ProfileImpactReport {
1149    pub fn to_json_pretty(&self) -> Result<String> {
1150        serde_json::to_string_pretty(self).map_err(|err| {
1151            AsxError::new(
1152                ErrorCode::ParseFailed,
1153                format!("failed to serialize profile impact report: {err}"),
1154                ErrorContext::new("interop_profile_diff_serialize"),
1155            )
1156        })
1157    }
1158}
1159
1160pub fn diff_effective_policy_snapshots(
1161    before: &EffectivePolicySnapshot,
1162    after: &EffectivePolicySnapshot,
1163) -> ProfileImpactReport {
1164    let mut changes = Vec::new();
1165
1166    if before.resolved_mode != after.resolved_mode {
1167        changes.push(EffectivePolicyDiffEntry {
1168            field: ResolutionField::Mode,
1169            stage: DiffStage::Resolution,
1170            previous_value: format!("{:?}", before.resolved_mode),
1171            new_value: format!("{:?}", after.resolved_mode),
1172            risk: DiffRiskLevel::Medium,
1173            rationale: "Interop mode changed; behavior may shift between strict and relaxed paths"
1174                .to_string(),
1175        });
1176    }
1177
1178    if before.canonicalization != after.canonicalization {
1179        changes.push(EffectivePolicyDiffEntry {
1180            field: ResolutionField::Canonicalization,
1181            stage: DiffStage::Canonicalization,
1182            previous_value: format!("{:?}", before.canonicalization),
1183            new_value: format!("{:?}", after.canonicalization),
1184            risk: DiffRiskLevel::Medium,
1185            rationale:
1186                "Canonicalization behavior changed; signature-reference interoperability may drift"
1187                    .to_string(),
1188        });
1189    }
1190
1191    if before.security != after.security {
1192        let risk = if (before.security.require_signature && !after.security.require_signature)
1193            || (before.security.require_encryption && !after.security.require_encryption)
1194        {
1195            DiffRiskLevel::High
1196        } else {
1197            DiffRiskLevel::Medium
1198        };
1199        changes.push(EffectivePolicyDiffEntry {
1200            field: ResolutionField::Security,
1201            stage: DiffStage::Security,
1202            previous_value: format!("{:?}", before.security),
1203            new_value: format!("{:?}", after.security),
1204            risk,
1205            rationale:
1206                "Security invariants changed; potential weakening of signature/encryption requirements"
1207                    .to_string(),
1208        });
1209    }
1210
1211    if before.security_floor != after.security_floor {
1212        // Lowering the floor is High even when the *resolved* policy is
1213        // unchanged: it is precisely the change that makes a future overlay
1214        // able to relax security without validation objecting.
1215        let relaxed = before.security_floor.relaxations_to(after.security_floor);
1216        let (risk, rationale) = if relaxed.is_empty() {
1217            (
1218                DiffRiskLevel::Medium,
1219                "Security floor raised; overlays that previously validated may now be rejected"
1220                    .to_string(),
1221            )
1222        } else {
1223            (
1224                DiffRiskLevel::High,
1225                format!(
1226                    "Security floor lowered ({} no longer enforced); overlays may now relax \
1227                     security without failing validation",
1228                    join_requirements(&relaxed)
1229                ),
1230            )
1231        };
1232        changes.push(EffectivePolicyDiffEntry {
1233            field: ResolutionField::SecurityFloor,
1234            stage: DiffStage::Security,
1235            previous_value: format!("{:?}", before.security_floor),
1236            new_value: format!("{:?}", after.security_floor),
1237            risk,
1238            rationale,
1239        });
1240    }
1241
1242    if before.validation != after.validation {
1243        let risk = if before.validation.enforce_payload_limits
1244            && !after.validation.enforce_payload_limits
1245        {
1246            DiffRiskLevel::High
1247        } else {
1248            DiffRiskLevel::Medium
1249        };
1250        changes.push(EffectivePolicyDiffEntry {
1251            field: ResolutionField::Validation,
1252            stage: DiffStage::Validation,
1253            previous_value: format!("{:?}", before.validation),
1254            new_value: format!("{:?}", after.validation),
1255            risk,
1256            rationale: "Validation constraints changed; malformed-input acceptance may differ"
1257                .to_string(),
1258        });
1259    }
1260
1261    if before.as2_validation != after.as2_validation {
1262        // Graded Medium (non-blocking), matching how this change was classified
1263        // when `require_mic` lived inside `ValidationPolicy`: only
1264        // `enforce_payload_limits` escalates the validation stage to High.
1265        changes.push(EffectivePolicyDiffEntry {
1266            field: ResolutionField::As2Validation,
1267            stage: DiffStage::Validation,
1268            previous_value: format!("{:?}", before.as2_validation),
1269            new_value: format!("{:?}", after.as2_validation),
1270            risk: DiffRiskLevel::Medium,
1271            rationale: "AS2 validation constraints changed; MDN integrity enforcement may differ"
1272                .to_string(),
1273        });
1274    }
1275
1276    let highest_risk = changes
1277        .iter()
1278        .map(|change| change.risk)
1279        .max()
1280        .unwrap_or(DiffRiskLevel::Low);
1281
1282    ProfileImpactReport {
1283        before_profile_name: before.profile_name.clone(),
1284        after_profile_name: after.profile_name.clone(),
1285        before_session_id: before.session_id.clone(),
1286        after_session_id: after.session_id.clone(),
1287        release_blocked: highest_risk == DiffRiskLevel::High,
1288        highest_risk,
1289        changes,
1290    }
1291}
1292
1293#[derive(Debug)]
1294struct ResolvedPolicyState {
1295    mode: InteropMode,
1296    canonicalization: CanonicalizationPolicy,
1297    security: SecurityPolicy,
1298    validation: ValidationPolicy,
1299    as2_validation: As2ValidationPolicy,
1300    trace: Vec<String>,
1301    diagnostics: Vec<ResolutionDiagnostic>,
1302}
1303
1304#[derive(Debug, Clone)]
1305struct EffectivePolicyState {
1306    mode: InteropMode,
1307    canonicalization: CanonicalizationPolicy,
1308    security: SecurityPolicy,
1309    validation: ValidationPolicy,
1310    as2_validation: As2ValidationPolicy,
1311}
1312
1313/// Findings sink for one resolution branch (the baseline, or one partner).
1314///
1315/// Carrying `partner_id` here is what lets every issue name the partner whose
1316/// resolved policy produced it without threading it through each call site.
1317struct LayerFindings<'a> {
1318    errors: &'a mut Vec<ProfileValidationIssue>,
1319    lints: &'a mut Vec<ProfileLintFinding>,
1320    partner_id: Option<&'a str>,
1321}
1322
1323impl LayerFindings<'_> {
1324    fn push_error(&mut self, issue: ProfileValidationIssue) {
1325        self.errors.push(issue);
1326    }
1327
1328    fn push_lint(&mut self, lint: ProfileLintFinding) {
1329        self.lints.push(lint);
1330    }
1331
1332    fn scope_label(&self) -> String {
1333        self.partner_id.map_or_else(
1334            || "deployment baseline".to_string(),
1335            |p| format!("partner {p}"),
1336        )
1337    }
1338}
1339
1340/// One validation scope in progress: the deployment baseline, or a fork of it
1341/// for a single partner.
1342///
1343/// Tracks the layer that last assigned `security` so the scope-level floor
1344/// error can point at the line to change, rather than at the base profile that
1345/// was overridden three layers down.
1346#[derive(Debug, Clone)]
1347struct ValidationScope {
1348    state: EffectivePolicyState,
1349    last_security_layer: String,
1350    previous_security: SecurityPolicy,
1351}
1352
1353impl ValidationScope {
1354    fn apply(&mut self, qualified_layer: &str, overrides: &ProfilePolicyOverrides) {
1355        self.previous_security = self.state.security;
1356        ProfileStack::apply_effective_state_overrides(&mut self.state, overrides);
1357        if overrides.security.is_some() {
1358            self.last_security_layer = qualified_layer.to_string();
1359        }
1360    }
1361}
1362
1363/// [`ProfileValidationOptions`] with the profile's own floor already folded in.
1364#[derive(Debug, Clone, Copy)]
1365struct ResolvedValidationOptions {
1366    security_floor: SecurityPolicy,
1367    forbid_security_relaxation: bool,
1368}
1369
1370/// Render a requirement list as `require_signature and require_encryption`.
1371fn join_requirements(requirements: &[SecurityRequirement]) -> String {
1372    requirements
1373        .iter()
1374        .map(|req| req.as_str())
1375        .collect::<Vec<_>>()
1376        .join(" and ")
1377}
1378
1379impl ProfileStack {
1380    pub fn apply_regional_pack(&self, pack: &RegionalProfilePack) -> Result<Self> {
1381        pack.validate()?;
1382
1383        if pack.applies_to_base_profile != self.base.name {
1384            return Err(AsxError::new(
1385                ErrorCode::PolicyViolation,
1386                format!(
1387                    "regional pack {}@{} targets base profile {} but active base is {}",
1388                    pack.pack_id, pack.version, pack.applies_to_base_profile, self.base.name
1389                ),
1390                ErrorContext::new("interop_regional_pack_apply"),
1391            ));
1392        }
1393
1394        let mut merged = self.clone();
1395        merged.extensions.push(ProfileExtension {
1396            name: pack.extension_name(),
1397            overrides: pack.overrides.clone(),
1398        });
1399        Ok(merged)
1400    }
1401
1402    pub fn apply_regional_packs(&self, packs: &[RegionalProfilePack]) -> Result<Self> {
1403        let mut merged = self.clone();
1404        for pack in packs {
1405            merged = merged.apply_regional_pack(pack)?;
1406        }
1407        Ok(merged)
1408    }
1409
1410    /// Report a layer that drops a security requirement its predecessors had
1411    /// enabled.
1412    ///
1413    /// This is a *lint* by default, not an error: a dip that a later layer
1414    /// restores has no runtime effect, so making it fatal would be a false
1415    /// positive. Whether the scope actually ends up insecure is decided once,
1416    /// against the resolved policy, by [`Self::validate_scope_security`].
1417    /// [`ProfileValidationOptions::forbid_security_relaxation`] escalates these
1418    /// to errors for deployments where overlays must not touch security at all.
1419    fn lint_security_relaxation(
1420        findings: &mut LayerFindings<'_>,
1421        layer: &str,
1422        previous: SecurityPolicy,
1423        next: SecurityPolicy,
1424        options: &ResolvedValidationOptions,
1425    ) {
1426        let relaxed = previous.relaxations_to(next);
1427        if relaxed.is_empty() {
1428            return;
1429        }
1430
1431        let message = format!(
1432            "{layer} relaxes {} relative to the underlying layers (was {previous}, now {next})",
1433            join_requirements(&relaxed)
1434        );
1435        let hint = format!(
1436            "Remove the relaxation, or raise BaseProfile::security_floor so the weaker \
1437             policy is rejected outright rather than silently accepted for {}",
1438            findings.scope_label()
1439        );
1440
1441        if options.forbid_security_relaxation {
1442            findings.push_error(ProfileValidationIssue {
1443                code: ProfileValidationCode::SecurityRelaxation,
1444                message,
1445                remediation_hint: hint,
1446                layer: layer.to_string(),
1447                partner_id: findings.partner_id.map(str::to_string),
1448            });
1449        } else {
1450            findings.push_lint(ProfileLintFinding {
1451                code: ProfileLintCode::SecurityRelaxation,
1452                severity: ProfileLintSeverity::Critical,
1453                message,
1454                remediation_hint: hint,
1455                layer: layer.to_string(),
1456                partner_id: findings.partner_id.map(str::to_string),
1457            });
1458        }
1459    }
1460
1461    /// Check the *resolved* policy for one scope against the floor.
1462    ///
1463    /// Runs once per scope rather than per layer, because the resolved policy
1464    /// is the only one the runtime ever applies. `layer` names the last layer
1465    /// that assigned `security`, so the error points at the line to change.
1466    fn validate_scope_security(
1467        findings: &mut LayerFindings<'_>,
1468        layer: &str,
1469        resolved: SecurityPolicy,
1470        options: &ResolvedValidationOptions,
1471    ) {
1472        let unmet = options.security_floor.unmet_by(resolved);
1473        if !unmet.is_empty() {
1474            let scope = findings.scope_label();
1475            findings.push_error(ProfileValidationIssue {
1476                code: ProfileValidationCode::SecurityFloorViolation,
1477                message: format!(
1478                    "{scope} resolves to a security policy below the profile floor: \
1479                     missing {}; effective [{resolved}], floor [{}]; last set by {layer}",
1480                    join_requirements(&unmet),
1481                    options.security_floor
1482                ),
1483                remediation_hint: format!(
1484                    "Set {} on {layer}, or lower BaseProfile::security_floor if the profile \
1485                     genuinely permits the weaker policy",
1486                    join_requirements(&unmet)
1487                ),
1488                layer: layer.to_string(),
1489                partner_id: findings.partner_id.map(str::to_string),
1490            });
1491            return;
1492        }
1493
1494        if resolved == SecurityPolicy::UNCONSTRAINED {
1495            let scope = findings.scope_label();
1496            findings.push_error(ProfileValidationIssue {
1497                code: ProfileValidationCode::NoCriticalSecurityInvariant,
1498                message: format!(
1499                    "{scope} resolves with neither signature nor encryption required; \
1500                     last set by {layer}"
1501                ),
1502                remediation_hint:
1503                    "Enable at least one critical security invariant: signature or encryption"
1504                        .to_string(),
1505                layer: layer.to_string(),
1506                partner_id: findings.partner_id.map(str::to_string),
1507            });
1508        }
1509    }
1510
1511    fn lint_override_layer(
1512        findings: &mut LayerFindings<'_>,
1513        layer_name: &str,
1514        current: &EffectivePolicyState,
1515        overrides: &ProfilePolicyOverrides,
1516    ) {
1517        let mut dead = |message: String, remediation_hint: &str| {
1518            findings.push_lint(ProfileLintFinding {
1519                code: ProfileLintCode::DeadOverride,
1520                severity: ProfileLintSeverity::Info,
1521                message,
1522                remediation_hint: remediation_hint.to_string(),
1523                layer: layer_name.to_string(),
1524                partner_id: findings.partner_id.map(str::to_string),
1525            });
1526        };
1527
1528        if let Some(mode) = overrides.mode
1529            && mode == current.mode
1530        {
1531            dead(
1532                format!(
1533                    "{layer_name} sets mode to {mode:?}, which matches already-effective value"
1534                ),
1535                "Remove redundant override or change it to a distinct value",
1536            );
1537        }
1538
1539        if let Some(c14n) = overrides.canonicalization.as_ref()
1540            && *c14n == current.canonicalization
1541        {
1542            dead(
1543                format!("{layer_name} sets canonicalization to current effective value"),
1544                "Remove redundant canonicalization override",
1545            );
1546        }
1547
1548        if let Some(security) = overrides.security
1549            && security == current.security
1550        {
1551            dead(
1552                format!("{layer_name} sets security policy to current effective value"),
1553                "Remove redundant security override",
1554            );
1555        }
1556
1557        if let Some(validation) = overrides.validation
1558            && validation == current.validation
1559        {
1560            dead(
1561                format!("{layer_name} sets validation policy to current effective value"),
1562                "Remove redundant validation override",
1563            );
1564        }
1565
1566        if let Some(as2_validation) = overrides.as2_validation
1567            && as2_validation == current.as2_validation
1568        {
1569            dead(
1570                format!("{layer_name} sets AS2 validation policy to current effective value"),
1571                "Remove redundant as2_validation override",
1572            );
1573        }
1574    }
1575
1576    fn apply_effective_state_overrides(
1577        current: &mut EffectivePolicyState,
1578        overrides: &ProfilePolicyOverrides,
1579    ) {
1580        if let Some(mode) = overrides.mode {
1581            current.mode = mode;
1582        }
1583        if let Some(c14n) = overrides.canonicalization.as_ref() {
1584            current.canonicalization = c14n.clone();
1585        }
1586        if let Some(security) = overrides.security {
1587            current.security = security;
1588        }
1589        if let Some(validation) = overrides.validation {
1590            current.validation = validation;
1591        }
1592        if let Some(as2_validation) = overrides.as2_validation {
1593            current.as2_validation = as2_validation;
1594        }
1595    }
1596
1597    /// Walk the deployment-wide layers: extensions, then global overrides.
1598    ///
1599    /// These apply to every partner, so both [`Self::validate_with`] and
1600    /// [`Self::resolve`] run them before any partner overlay.
1601    fn for_each_global_layer<F>(&self, mut f: F)
1602    where
1603        F: FnMut(ResolutionLayer, &'static str, &str, &ProfilePolicyOverrides),
1604    {
1605        for ext in &self.extensions {
1606            f(
1607                ResolutionLayer::Extension,
1608                "extension",
1609                &ext.name,
1610                &ext.overrides,
1611            );
1612        }
1613
1614        for ov in &self.overrides {
1615            f(
1616                ResolutionLayer::Override,
1617                "override",
1618                &ov.name,
1619                &ov.overrides,
1620            );
1621        }
1622    }
1623
1624    /// Walk the partner overlays for `partner_id`, in declaration order.
1625    ///
1626    /// `None` selects no overlays at all — the deployment baseline.
1627    fn for_each_partner_layer<F>(&self, partner_id: Option<&str>, mut f: F)
1628    where
1629        F: FnMut(ResolutionLayer, &'static str, String, &ProfilePolicyOverrides),
1630    {
1631        let Some(partner_id) = partner_id else {
1632            return;
1633        };
1634
1635        for pov in &self.partner_overrides {
1636            if pov.partner_id != partner_id {
1637                continue;
1638            }
1639            f(
1640                ResolutionLayer::PartnerOverride,
1641                "partner_override",
1642                format!("{}:{}", pov.partner_id, pov.name),
1643                &pov.overrides,
1644            );
1645        }
1646    }
1647
1648    /// Distinct partner identifiers that carry at least one overlay, in
1649    /// declaration order.
1650    pub fn partner_ids(&self) -> Vec<&str> {
1651        let mut ids: Vec<&str> = Vec::new();
1652        for pov in &self.partner_overrides {
1653            if !ids.contains(&pov.partner_id.as_str()) {
1654                ids.push(&pov.partner_id);
1655            }
1656        }
1657        ids
1658    }
1659
1660    /// Validate the stack with the default options.
1661    ///
1662    /// Equivalent to [`Self::validate_with`] with
1663    /// [`ProfileValidationOptions::default`]: the floor comes from
1664    /// [`BaseProfile::security_floor`] alone, and a relaxation that still
1665    /// clears the floor is a [`ProfileLintSeverity::Critical`] lint rather than
1666    /// an error.
1667    ///
1668    /// # Errors
1669    ///
1670    /// Returns [`ProfileValidationFailure`] when any resolved layer — for the
1671    /// deployment baseline or for any declared partner — violates the security
1672    /// floor or leaves no critical security invariant in place. Lints collected
1673    /// before the failure are carried in the failure value.
1674    pub fn validate(&self) -> ProfileValidationResult<ProfileValidationReport> {
1675        self.validate_with(&ProfileValidationOptions::default())
1676    }
1677
1678    /// Validate the stack against a deployment-imposed security floor.
1679    ///
1680    /// The effective floor is the stronger of `floor` and
1681    /// [`BaseProfile::security_floor`], per requirement. This is the
1682    /// startup-check form: a host that must guarantee sign-and-encrypt for
1683    /// every partner can assert it without trusting the profile's own floor.
1684    ///
1685    /// ```
1686    /// use asx_rs::interop::{
1687    ///     BaseProfile, PartnerProfileOverlay, ProfilePolicyOverrides, ProfileStack,
1688    ///     ProfileValidationCode, SecurityPolicy,
1689    /// };
1690    ///
1691    /// let stack = ProfileStack {
1692    ///     // A profile whose own floor permits sign-only …
1693    ///     base: BaseProfile::new("legacy", "1.0").with_security_floor(SecurityPolicy::SIGN_ONLY),
1694    ///     extensions: vec![],
1695    ///     overrides: vec![],
1696    ///     partner_overrides: vec![PartnerProfileOverlay {
1697    ///         name: "legacy-partner".into(),
1698    ///         partner_id: "9900000000001".into(),
1699    ///         overrides: ProfilePolicyOverrides {
1700    ///             security: Some(SecurityPolicy::SIGN_ONLY),
1701    ///             ..Default::default()
1702    ///         },
1703    ///     }],
1704    /// };
1705    ///
1706    /// // … still fails a host mandate of sign-and-encrypt.
1707    /// let failure = stack
1708    ///     .validate_with_floor(SecurityPolicy::SIGN_AND_ENCRYPT)
1709    ///     .unwrap_err();
1710    /// assert!(failure.has_code(ProfileValidationCode::SecurityFloorViolation));
1711    /// assert_eq!(failure.affected_partners(), vec!["9900000000001"]);
1712    /// ```
1713    ///
1714    /// # Errors
1715    ///
1716    /// As [`Self::validate`], additionally failing when any resolved layer
1717    /// falls below `floor`.
1718    pub fn validate_with_floor(
1719        &self,
1720        floor: SecurityPolicy,
1721    ) -> ProfileValidationResult<ProfileValidationReport> {
1722        self.validate_with(&ProfileValidationOptions::default().with_security_floor(floor))
1723    }
1724
1725    /// Validate the stack with explicit options.
1726    ///
1727    /// Resolution is forked per partner: the base, extensions and global
1728    /// overrides are walked once to produce the deployment baseline, then each
1729    /// distinct partner in [`Self::partner_overrides`] is resolved from a copy
1730    /// of that baseline. One partner's overlay therefore never contaminates
1731    /// another's findings, and every issue is attributed to the partner whose
1732    /// resolved policy produced it via [`ProfileValidationIssue::partner_id`].
1733    ///
1734    /// # Errors
1735    ///
1736    /// Returns [`ProfileValidationFailure`] when any resolved layer produces a
1737    /// validation error; see [`ProfileValidationCode`] for the cases.
1738    pub fn validate_with(
1739        &self,
1740        options: &ProfileValidationOptions,
1741    ) -> ProfileValidationResult<ProfileValidationReport> {
1742        let options = options.resolve_against(&self.base);
1743
1744        let mut errors = vec![];
1745        let mut lints = vec![];
1746
1747        // Pass 1: the deployment baseline — base + extensions + global
1748        // overrides. This is also the policy any partner without an overlay
1749        // receives, so it is validated as a scope in its own right.
1750        let baseline = {
1751            let mut findings = LayerFindings {
1752                errors: &mut errors,
1753                lints: &mut lints,
1754                partner_id: None,
1755            };
1756            let baseline = self.walk_baseline_layers(&mut findings, &options);
1757            Self::validate_scope_security(
1758                &mut findings,
1759                &baseline.last_security_layer,
1760                baseline.state.security,
1761                &options,
1762            );
1763            baseline
1764        };
1765
1766        // Pass 2: fork the baseline once per declared partner, so one
1767        // partner's overlay cannot contaminate another's findings.
1768        for partner_id in self.partner_ids() {
1769            let mut scope = baseline.clone();
1770            let mut findings = LayerFindings {
1771                errors: &mut errors,
1772                lints: &mut lints,
1773                partner_id: Some(partner_id),
1774            };
1775            self.for_each_partner_layer(
1776                Some(partner_id),
1777                |_, layer_kind, layer_name, overrides| {
1778                    let qualified_layer = format!("{layer_kind}:{layer_name}");
1779                    Self::lint_override_layer(
1780                        &mut findings,
1781                        &qualified_layer,
1782                        &scope.state,
1783                        overrides,
1784                    );
1785                    scope.apply(&qualified_layer, overrides);
1786                    Self::lint_security_relaxation(
1787                        &mut findings,
1788                        &qualified_layer,
1789                        scope.previous_security,
1790                        scope.state.security,
1791                        &options,
1792                    );
1793                },
1794            );
1795
1796            // Skip the scope check when no partner overlay touched security:
1797            // the resolved policy is byte-for-byte the baseline's, so any
1798            // finding here would restate the baseline error once per partner.
1799            // The baseline error already applies to every partner.
1800            if scope.last_security_layer == baseline.last_security_layer
1801                && scope.state.security == baseline.state.security
1802            {
1803                continue;
1804            }
1805
1806            Self::validate_scope_security(
1807                &mut findings,
1808                &scope.last_security_layer,
1809                scope.state.security,
1810                &options,
1811            );
1812        }
1813
1814        if errors.is_empty() {
1815            Ok(ProfileValidationReport { lints })
1816        } else {
1817            Err(ProfileValidationFailure { errors, lints })
1818        }
1819    }
1820
1821    /// Walk base + extensions + global overrides, recording per-layer findings,
1822    /// and return the resulting deployment baseline scope.
1823    fn walk_baseline_layers(
1824        &self,
1825        findings: &mut LayerFindings<'_>,
1826        options: &ResolvedValidationOptions,
1827    ) -> ValidationScope {
1828        let mut scope = ValidationScope {
1829            state: EffectivePolicyState {
1830                mode: self.base.mode,
1831                canonicalization: self.base.canonicalization.clone(),
1832                security: self.base.security,
1833                validation: self.base.validation,
1834                as2_validation: self.base.as2_validation,
1835            },
1836            last_security_layer: format!("base:{}", self.base.name),
1837            previous_security: self.base.security,
1838        };
1839
1840        self.for_each_global_layer(|_, layer_kind, layer_name, overrides| {
1841            let qualified_layer = format!("{layer_kind}:{layer_name}");
1842            Self::lint_override_layer(findings, &qualified_layer, &scope.state, overrides);
1843            scope.apply(&qualified_layer, overrides);
1844            Self::lint_security_relaxation(
1845                findings,
1846                &qualified_layer,
1847                scope.previous_security,
1848                scope.state.security,
1849                options,
1850            );
1851        });
1852
1853        scope
1854    }
1855
1856    fn apply_overrides(
1857        resolved: &mut ResolvedPolicyState,
1858        layer: ResolutionLayer,
1859        layer_kind: &'static str,
1860        layer_name: &str,
1861        overrides: &ProfilePolicyOverrides,
1862    ) {
1863        if let Some(override_mode) = overrides.mode {
1864            let previous_mode = resolved.mode;
1865            resolved.mode = override_mode;
1866            resolved.trace.push(format!(
1867                "{layer_kind}:{layer_name}.mode=>{:?}",
1868                override_mode
1869            ));
1870            resolved.diagnostics.push(ResolutionDiagnostic {
1871                layer,
1872                layer_name: layer_name.to_string(),
1873                field: ResolutionField::Mode,
1874                previous_value: format!("{:?}", previous_mode),
1875                new_value: format!("{:?}", override_mode),
1876            });
1877        }
1878        if let Some(ref override_c14n) = overrides.canonicalization {
1879            let previous_c14n = resolved.canonicalization.clone();
1880            resolved.canonicalization = override_c14n.clone();
1881            resolved.trace.push(format!(
1882                "{layer_kind}:{layer_name}.canonicalization=>{:?}",
1883                override_c14n
1884            ));
1885            resolved.diagnostics.push(ResolutionDiagnostic {
1886                layer,
1887                layer_name: layer_name.to_string(),
1888                field: ResolutionField::Canonicalization,
1889                previous_value: format!("{:?}", previous_c14n),
1890                new_value: format!("{:?}", override_c14n),
1891            });
1892        }
1893        if let Some(override_security) = overrides.security {
1894            let previous_security = resolved.security;
1895            resolved.security = override_security;
1896            resolved.trace.push(format!(
1897                "{layer_kind}:{layer_name}.security=>{:?}",
1898                override_security
1899            ));
1900            resolved.diagnostics.push(ResolutionDiagnostic {
1901                layer,
1902                layer_name: layer_name.to_string(),
1903                field: ResolutionField::Security,
1904                previous_value: format!("{:?}", previous_security),
1905                new_value: format!("{:?}", override_security),
1906            });
1907        }
1908        if let Some(override_validation) = overrides.validation {
1909            let previous_validation = resolved.validation;
1910            resolved.validation = override_validation;
1911            resolved.trace.push(format!(
1912                "{layer_kind}:{layer_name}.validation=>{:?}",
1913                override_validation
1914            ));
1915            resolved.diagnostics.push(ResolutionDiagnostic {
1916                layer,
1917                layer_name: layer_name.to_string(),
1918                field: ResolutionField::Validation,
1919                previous_value: format!("{:?}", previous_validation),
1920                new_value: format!("{:?}", override_validation),
1921            });
1922        }
1923        if let Some(override_as2_validation) = overrides.as2_validation {
1924            let previous_as2_validation = resolved.as2_validation;
1925            resolved.as2_validation = override_as2_validation;
1926            resolved.trace.push(format!(
1927                "{layer_kind}:{layer_name}.as2_validation=>{:?}",
1928                override_as2_validation
1929            ));
1930            resolved.diagnostics.push(ResolutionDiagnostic {
1931                layer,
1932                layer_name: layer_name.to_string(),
1933                field: ResolutionField::As2Validation,
1934                previous_value: format!("{:?}", previous_as2_validation),
1935                new_value: format!("{:?}", override_as2_validation),
1936            });
1937        }
1938    }
1939
1940    /// Resolve the stack for `partner_id`, or for the deployment baseline when
1941    /// it is `None`.
1942    ///
1943    /// Single source of truth for layer application — [`Self::resolve`],
1944    /// [`Self::resolve_baseline`] and [`Self::resolve_partner`] all go through
1945    /// it, so a session-free view can never drift from what a live session gets.
1946    fn resolve_state(&self, partner_id: Option<&str>) -> ResolvedPolicyState {
1947        let mut resolved = ResolvedPolicyState {
1948            mode: self.base.mode,
1949            canonicalization: self.base.canonicalization.clone(),
1950            security: self.base.security,
1951            validation: self.base.validation,
1952            as2_validation: self.base.as2_validation,
1953            trace: vec![
1954                format!("base:{}=>{:?}", self.base.name, self.base.mode),
1955                format!(
1956                    "base:{}.canonicalization=>{:?}",
1957                    self.base.name, self.base.canonicalization
1958                ),
1959                format!("base:{}.security=>{:?}", self.base.name, self.base.security),
1960                format!(
1961                    "base:{}.validation=>{:?}",
1962                    self.base.name, self.base.validation
1963                ),
1964            ],
1965            diagnostics: vec![],
1966        };
1967
1968        self.for_each_global_layer(|layer, layer_kind, layer_name, overrides| {
1969            Self::apply_overrides(&mut resolved, layer, layer_kind, layer_name, overrides);
1970        });
1971
1972        self.for_each_partner_layer(partner_id, |layer, layer_kind, layer_name, overrides| {
1973            Self::apply_overrides(&mut resolved, layer, layer_kind, &layer_name, overrides);
1974        });
1975
1976        resolved
1977    }
1978
1979    /// Effective policy with no partner overlay applied — what a partner
1980    /// without its own overlay receives.
1981    pub fn resolve_baseline(&self) -> ResolvedPolicyView {
1982        self.resolve_view(None)
1983    }
1984
1985    /// Effective policy for `partner_id`, resolved without a
1986    /// [`SessionContext`].
1987    ///
1988    /// A partner with no declared overlay resolves to the baseline policy,
1989    /// tagged with its id.
1990    pub fn resolve_partner(&self, partner_id: &str) -> ResolvedPolicyView {
1991        self.resolve_view(Some(partner_id))
1992    }
1993
1994    /// Every effective policy this stack can produce: the deployment baseline
1995    /// first, then one entry per distinct partner in
1996    /// [`Self::partner_overrides`], in declaration order.
1997    ///
1998    /// This is the startup-audit entry point — see [`ResolvedPolicyView`] for
1999    /// an example that asserts a security mandate across all partners. Prefer
2000    /// [`Self::validate_with_floor`] when the mandate is a plain security
2001    /// floor; use this when the assertion is over other policy fields.
2002    pub fn resolve_all_partners(&self) -> Vec<ResolvedPolicyView> {
2003        let mut views = Vec::with_capacity(self.partner_overrides.len() + 1);
2004        views.push(self.resolve_baseline());
2005        for partner_id in self.partner_ids() {
2006            views.push(self.resolve_partner(partner_id));
2007        }
2008        views
2009    }
2010
2011    fn resolve_view(&self, partner_id: Option<&str>) -> ResolvedPolicyView {
2012        let resolved = self.resolve_state(partner_id);
2013        ResolvedPolicyView {
2014            partner_id: partner_id.map(str::to_string),
2015            mode: resolved.mode,
2016            canonicalization: resolved.canonicalization,
2017            security: resolved.security,
2018            validation: resolved.validation,
2019            as2_validation: resolved.as2_validation,
2020            resolution_trace: resolved.trace,
2021            resolution_diagnostics: resolved.diagnostics,
2022        }
2023    }
2024
2025    pub fn resolve(&self, session: &SessionContext) -> EffectiveProfile {
2026        let resolved = self.resolve_state(Some(session.partner_id()));
2027
2028        EffectiveProfile {
2029            name: format!("{}@{}", self.base.name, session.profile_name()),
2030            mode: resolved.mode,
2031            canonicalization: resolved.canonicalization.clone(),
2032            security: resolved.security,
2033            security_floor: self.base.security_floor,
2034            validation: resolved.validation,
2035            as2_validation: resolved.as2_validation,
2036            snapshot: EffectivePolicySnapshot {
2037                session_id: session.session_id().to_string(),
2038                partner_id: session.partner_id().to_string(),
2039                profile_name: session.profile_name().to_string(),
2040                resolved_mode: resolved.mode,
2041                canonicalization: resolved.canonicalization.clone(),
2042                security: resolved.security,
2043                security_floor: self.base.security_floor,
2044                validation: resolved.validation,
2045                as2_validation: resolved.as2_validation,
2046                resolution_trace: resolved.trace,
2047                resolution_diagnostics: resolved.diagnostics,
2048            },
2049        }
2050    }
2051
2052    pub fn resolve_for_session(&self, session: &SessionContext) -> Result<ResolvedSessionProfile> {
2053        let effective_profile = self.resolve(session);
2054        let snapshot_json = effective_profile.snapshot.to_json_pretty()?;
2055        let attached_session = session
2056            .clone()
2057            .with_effective_policy_snapshot_json(snapshot_json)?;
2058
2059        Ok(ResolvedSessionProfile {
2060            session: attached_session,
2061            effective_profile,
2062        })
2063    }
2064}
2065
2066#[cfg(test)]
2067#[cfg_attr(not(feature = "interop-relaxed"), allow(unused_imports))]
2068mod tests;