Skip to main content

pkix_lint/
deviation.rs

1//! Deviation (waiver) mechanism for `pkix-lint`.
2//!
3//! A [`Deviation`] is an operator-authored, scoped, time-bounded exception to a
4//! specific lint finding. Deviations are the only mechanism for suppressing or
5//! downgrading lint findings — there are no CLI flags or global overrides.
6//!
7//! # Design rationale
8//!
9//! The deviation mechanism is designed to:
10//! - Make suppression **explicit and attributable**: every deviation has an ID,
11//!   a justification, and an `authorized_by` field that appear in reports.
12//! - Force **scoping**: deviations match specific certs (by issuer DN, serial, etc.),
13//!   not all certs globally.
14//! - Enforce **expiry**: deviations with an `effective_end` re-activate findings
15//!   after they expire, forcing renewal and re-justification.
16//! - **Not launder violations**: a suppressed finding is recorded as a
17//!   [`DeviatedFinding`] in the output, not silently removed. Auditors can see it.
18//!
19//! # Verification via git, not signatures
20//!
21//! `authorized_by` is human-readable attribution (name or email), not a
22//! cryptographic signature. The audit trail comes from the git history of
23//! the deviation store: the git log records who committed the deviation file,
24//! when, and from which identity. Store deviation files in a git repository
25//! with appropriate access controls and signed commits. This provides the
26//! same audit properties as an in-band signature without requiring additional
27//! key infrastructure that most operators don't have wired into their PKI tooling.
28//!
29//! # No vendor deviation packs
30//!
31//! `pkix-lint` never ships deviation packs. CAs, vendors, or policy authorities
32//! who want to ship deviations for their customers must distribute them separately,
33//! and operators must explicitly load them into their own [`DeviationStore`]. This
34//! prevents the tool from becoming an instrument for CA-side laundering.
35//!
36//! # Usage
37//!
38//! ```rust,no_run
39//! // This example requires an external certificate fixture; it compiles but
40//! // cannot run in the doctest harness without DER fixtures on disk.
41//! use pkix_lint::deviation::{Deviation, DeviationAction, DeviationScope, DeviationStore};
42//! use pkix_lint::Severity;
43//!
44//! let mut store = DeviationStore::new();
45//! let dev = Deviation::new(
46//!     "agency-x-fpki-keyusage-2026-q1",
47//!     "fpki.common.6.1.5",
48//!     DeviationScope::issuer_dn_contains("agency x issuing ca"),
49//!     DeviationAction::DowngradeSeverityTo(Severity::Info),
50//!     "FPKIPA waiver memo 2025-11-03; see exception register entry 47",
51//!     "agency-x-ciso@agency.gov",
52//! )
53//! .expect("non-empty justification and authorized_by")
54//! .with_effective_end(1_767_225_600) // 2026-01-01
55//! // Optional: URI to the backing document. Git commit history is the audit trail.
56//! .with_evidence_uri("https://pkipolicy.agency.gov/waivers/2025-11-03");
57//! store.add(dev).unwrap();
58//!
59//! // Use a DeviationRunner (wraps LintRunner) to apply deviations automatically.
60//! ```
61
62use crate::Severity;
63use x509_cert::Certificate;
64
65#[cfg(feature = "serde")]
66use crate::de_cow_static;
67
68/// Error returned by [`DeviationStore::add`].
69#[derive(Clone, Debug, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum DeviationAddError {
72    /// A deviation with the same `id` already exists in the store.
73    DuplicateId(String),
74    /// A required string field (`justification` or `authorized_by`) was empty.
75    EmptyField(String),
76    /// The deviation's [`DeviationScope`] is structurally malformed:
77    /// the `kind` is recognized but a required prop is missing or
78    /// wrong-typed for that kind. Returned by [`DeviationStore::add`]
79    /// at insertion time so the operator sees a specific error
80    /// instead of a silent never-matches deviation (PKIX-hy2e.9).
81    ///
82    /// `kind` names the offending scope kind; `reason` describes the
83    /// structural problem (e.g. "missing required prop
84    /// 'pkix-lint.issuer-dn-substring'").
85    MalformedScope {
86        /// The offending scope's `kind` discriminator.
87        kind: String,
88        /// Human-readable description of the structural problem.
89        reason: String,
90    },
91}
92
93impl std::fmt::Display for DeviationAddError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        match self {
96            Self::DuplicateId(id) => {
97                write!(f, "deviation id '{id}' already exists in the store")
98            }
99            Self::EmptyField(field) => {
100                write!(f, "deviation field '{field}' must not be empty")
101            }
102            Self::MalformedScope { kind, reason } => {
103                write!(f, "deviation scope kind '{kind}' is malformed: {reason}")
104            }
105        }
106    }
107}
108
109impl std::error::Error for DeviationAddError {}
110
111/// A scoped, time-bounded exception to a specific lint finding.
112///
113/// See the module-level documentation for the design rationale and usage.
114///
115/// The struct carries `#[non_exhaustive]`: callers outside this crate
116/// must construct via [`Deviation::new`] (plus the `with_*` builder
117/// setters for optional fields) instead of struct-literal syntax, so
118/// future fields (e.g., `revoked_at`, `supersedes_id`, priority for
119/// PKIX-hy2e.10) remain non-breaking additions.
120#[cfg_attr(feature = "serde", derive(serde::Serialize))]
121#[derive(Clone, Debug, PartialEq, Eq)]
122#[non_exhaustive]
123pub struct Deviation {
124    /// Unique identifier for this deviation within the operator's store.
125    ///
126    /// Appears verbatim in finding output as `DEVIATION APPLIED by <id>`.
127    /// Must be unique within the [`DeviationStore`] that contains it.
128    pub id: String,
129
130    /// The stable lint ID this deviation applies to.
131    ///
132    /// Must exactly match the value returned by [`crate::Lint::id`] for the
133    /// target lint. Deviations are lint-ID scoped — they do not apply to all
134    /// findings of a given severity or category.
135    pub target_lint: String,
136
137    /// Which certificates this deviation applies to.
138    ///
139    /// Only certs that match the scope will have the deviation applied.
140    /// Use [`DeviationScope::any()`] only for internal CAs or test environments
141    /// where the profile itself is being applied informally.
142    pub scope: DeviationScope,
143
144    /// Unix epoch (seconds) after which this deviation becomes active.
145    ///
146    /// `None` means the deviation is active immediately (from the Unix epoch).
147    pub effective_start: Option<u64>,
148
149    /// Unix epoch (seconds) after which this deviation expires.
150    ///
151    /// `None` means the deviation never expires. This is strongly discouraged
152    /// for production deviations — omitting an end date removes the automatic
153    /// re-review trigger. Use `None` only for structural deviations that are
154    /// permanent by design (e.g., an internal CA that will never follow FPKI policy).
155    pub effective_end: Option<u64>,
156
157    /// What to do with a matching finding.
158    pub action: DeviationAction,
159
160    /// Human-readable justification for this deviation.
161    ///
162    /// Examples: "FPKIPA waiver memo 2025-11-03", "Internal CA not subject to FPKI",
163    /// "CA confirmed CP §6.1.5 interpreted as optional for HW tokens per guidance doc".
164    /// Appears in finding output and audit reports. Must be non-empty.
165    pub justification: String,
166
167    /// Who authorized this deviation.
168    ///
169    /// The name or email of the person with authority to approve the deviation.
170    /// Examples: `"agency-x-ciso@agency.gov"`, `"CN=PKI Officer, OU=CISO, O=Agency X"`.
171    ///
172    /// This is human-readable attribution, not a cryptographic signature.
173    /// The verification layer is the git commit history of the deviation store:
174    /// the git log records who committed the deviation file, when, and from
175    /// which identity. Store your deviation files in a git repository with
176    /// appropriate access controls and signed commits; that provides the
177    /// audit trail without requiring additional signing infrastructure here.
178    ///
179    /// Must be non-empty.
180    pub authorized_by: String,
181
182    /// Optional URI pointing to the backing waiver or authorization document.
183    ///
184    /// When present, this URI is included in [`DeviatedFinding`] output so that
185    /// operators can navigate directly to the authorization document when
186    /// reviewing or escalating a deviated finding.
187    ///
188    /// # Examples
189    ///
190    /// - `Some("file:///var/lib/agency-x-pki/waivers/2025-11-03.pdf")` — local file
191    /// - `Some("https://pkipolicy.agency.gov/waivers/2025-11-03")` — web document
192    /// - `Some("https://github.com/agency-x/pki-exceptions/issues/47")` — issue tracker
193    ///
194    /// `None` is acceptable but discouraged for production deviations in gov/mil
195    /// contexts where the IG may ask for the authorizing document.
196    pub evidence_uri: Option<String>,
197
198    /// Resolution priority when multiple deviations could apply to the
199    /// same (lint_id, cert) pair.
200    ///
201    /// Among the deviations matching a finding,
202    /// [`DeviationStore::find_deviation`] selects the one with the
203    /// **highest** priority. Ties are broken by store-insertion order
204    /// (the first-added wins). Default is `0`.
205    ///
206    /// # Operator guidance
207    ///
208    /// Use `priority` to express specificity when merging deviation
209    /// files from multiple authors (PKIX-hy2e.10). For example, a
210    /// site-local lab-specific waiver scoped
211    /// `issuer_dn_contains: internal-lab` should set
212    /// `priority = 100`; a workspace-wide waiver scoped
213    /// [`DeviationScope::any`] should leave `priority = 0`. The lab
214    /// waiver then wins for the lab CA's certs, while the wildcard
215    /// waiver applies elsewhere.
216    ///
217    /// Negative priorities are permitted (e.g., `-100` for an
218    /// "fallback" deviation that should only fire when no more
219    /// specific one matches), so the type is `i32` rather than `u32`.
220    pub priority: i32,
221}
222
223impl Deviation {
224    /// Construct a [`Deviation`] with the required fields.
225    ///
226    /// Use this constructor (with the `with_*` builder setters for
227    /// optional fields) instead of struct-literal syntax so future
228    /// fields remain non-breaking additions. The struct carries
229    /// `#[non_exhaustive]`.
230    ///
231    /// Required fields are positional; optional fields default to
232    /// `None` / not-set:
233    /// - [`Self::effective_start`] → `None` (active from epoch)
234    /// - [`Self::effective_end`] → `None` (never expires)
235    /// - [`Self::evidence_uri`] → `None`
236    /// - [`Self::priority`] → `0`
237    ///
238    /// `justification` and `authorized_by` MUST be non-empty.
239    /// Construction fails fast at this entry point, matching the
240    /// non-emptiness check that [`DeviationStore::add`] already
241    /// performed — PKIX-7f92.8 closed the gap where a Deviation
242    /// could be constructed with empty fields, inspected, serialized,
243    /// or OSCAL-round-tripped before the add-time failure surfaced.
244    ///
245    /// # Errors
246    ///
247    /// - [`DeviationAddError::EmptyField`] (`"justification"`) if
248    ///   `justification` is empty.
249    /// - [`DeviationAddError::EmptyField`] (`"authorized_by"`) if
250    ///   `authorized_by` is empty.
251    ///
252    /// `id` and `target_lint` non-emptiness is enforced here:
253    /// passing an empty string for either returns
254    /// [`DeviationAddError::EmptyField`].
255    pub fn new(
256        id: impl Into<String>,
257        target_lint: impl Into<String>,
258        scope: DeviationScope,
259        action: DeviationAction,
260        justification: impl Into<String>,
261        authorized_by: impl Into<String>,
262    ) -> Result<Self, DeviationAddError> {
263        let id: String = id.into();
264        let target_lint: String = target_lint.into();
265        let justification: String = justification.into();
266        let authorized_by: String = authorized_by.into();
267        if id.is_empty() {
268            return Err(DeviationAddError::EmptyField("id".into()));
269        }
270        if target_lint.is_empty() {
271            return Err(DeviationAddError::EmptyField("target_lint".into()));
272        }
273        if justification.is_empty() {
274            return Err(DeviationAddError::EmptyField("justification".into()));
275        }
276        if authorized_by.is_empty() {
277            return Err(DeviationAddError::EmptyField("authorized_by".into()));
278        }
279        Ok(Self {
280            id,
281            target_lint,
282            scope,
283            effective_start: None,
284            effective_end: None,
285            action,
286            justification,
287            authorized_by,
288            evidence_uri: None,
289            priority: 0,
290        })
291    }
292
293    /// Builder-style setter for [`Self::effective_start`]. Returns
294    /// `self` for chaining.
295    #[must_use]
296    pub fn with_effective_start(mut self, unix_seconds: u64) -> Self {
297        self.effective_start = Some(unix_seconds);
298        self
299    }
300
301    /// Builder-style setter for [`Self::effective_end`]. Returns
302    /// `self` for chaining.
303    #[must_use]
304    pub fn with_effective_end(mut self, unix_seconds: u64) -> Self {
305        self.effective_end = Some(unix_seconds);
306        self
307    }
308
309    /// Builder-style setter for [`Self::evidence_uri`]. Returns `self`
310    /// for chaining.
311    #[must_use]
312    pub fn with_evidence_uri(mut self, uri: impl Into<String>) -> Self {
313        self.evidence_uri = Some(uri.into());
314        self
315    }
316
317    /// Builder-style setter for [`Self::priority`]. Returns `self`
318    /// for chaining.
319    #[must_use]
320    pub fn with_priority(mut self, priority: i32) -> Self {
321        self.priority = priority;
322        self
323    }
324
325    /// Returns `true` if this deviation is active at `now_unix`.
326    ///
327    /// A deviation is active when:
328    /// - `effective_start` is `None` or `<= now_unix`
329    /// - `effective_end` is `None` or `> now_unix`
330    ///
331    /// The `>` comparison on `effective_end` means a deviation expires at
332    /// the second it reaches its end timestamp, not one second after.
333    #[must_use]
334    pub fn is_active_at(&self, now_unix: u64) -> bool {
335        let after_start = self.effective_start.map_or(true, |start| now_unix >= start);
336        let before_end = self.effective_end.map_or(true, |end| now_unix < end);
337        after_start && before_end
338    }
339
340    /// Returns `true` if this deviation applies to `cert` at `now_unix`.
341    ///
342    /// Both the time-active check and the scope check must pass.
343    #[must_use]
344    pub fn applies_to(&self, cert: &Certificate, now_unix: u64) -> bool {
345        if !self.is_active_at(now_unix) {
346            return false;
347        }
348        self.scope.matches(cert)
349    }
350}
351
352// ---------------------------------------------------------------------------
353// DeviationScope: open-ended kind + props bag
354//
355// PKIX-9vnx.11: replaces the closed enum with a `kind: String` discriminator
356// plus a `props: Vec<(String, ScopePropValue)>` typed bag, mirroring the
357// OSCAL Subject shape. Constructors retain ergonomic, type-safe construction
358// of the four canonical kinds; future scope axes (PKIX-8mzp's
359// `SubjectDnContains`, `PolicyOid`, etc.) are expressible via new `kind`
360// strings + props without growing the public enum surface.
361// ---------------------------------------------------------------------------
362
363/// Canonical kind discriminator: deviation applies to all certificates.
364///
365/// Used as the value of [`DeviationScope::kind`]. Has no props.
366pub const SCOPE_KIND_ANY: &str = "pkix-lint.scope.any";
367
368/// Canonical kind discriminator: deviation applies to certs whose issuer DN
369/// (RFC 4514 string form) contains a substring (case-insensitive).
370///
371/// Carries one prop: [`PROP_ISSUER_DN_SUBSTRING`] (a `Text` prop).
372pub const SCOPE_KIND_ISSUER_DN_CONTAINS: &str = "pkix-lint.scope.issuer-dn-contains";
373
374/// Canonical kind discriminator: deviation applies to certs whose issuer DN
375/// matches exactly (RFC 4518 normalized comparison via
376/// `pkix_path::names_match`).
377///
378/// Carries one prop: [`PROP_ISSUER_DN_DER`] (a `Bytes` prop holding the DER
379/// encoding of the issuer `Name`).
380pub const SCOPE_KIND_ISSUER_DN_EXACT: &str = "pkix-lint.scope.issuer-dn-exact";
381
382/// Canonical kind discriminator: deviation applies to certs issued by a
383/// specific CA within a serial number range (inclusive on both ends).
384///
385/// Carries three props: [`PROP_ISSUER_DN_DER`] (Bytes, DER of issuer),
386/// [`PROP_SERIAL_START`] (Bytes), [`PROP_SERIAL_END`] (Bytes).
387pub const SCOPE_KIND_SERIAL_RANGE: &str = "pkix-lint.scope.serial-range";
388
389/// Prop name: the substring used by [`SCOPE_KIND_ISSUER_DN_CONTAINS`].
390///
391/// Value is a [`ScopePropValue::Text`] (pre-lowercased by
392/// [`DeviationStore::add`]).
393pub const PROP_ISSUER_DN_SUBSTRING: &str = "pkix-lint.issuer-dn-substring";
394
395/// Prop name: the DER encoding of an issuer `Name`, used by
396/// [`SCOPE_KIND_ISSUER_DN_EXACT`] and [`SCOPE_KIND_SERIAL_RANGE`].
397///
398/// Value is a [`ScopePropValue::Bytes`].
399pub const PROP_ISSUER_DN_DER: &str = "pkix-lint.issuer-dn-der";
400
401/// Prop name: the inclusive lower bound of the serial-range, used by
402/// [`SCOPE_KIND_SERIAL_RANGE`].
403///
404/// Value is a [`ScopePropValue::Bytes`].
405pub const PROP_SERIAL_START: &str = "pkix-lint.serial-start";
406
407/// Prop name: the inclusive upper bound of the serial-range, used by
408/// [`SCOPE_KIND_SERIAL_RANGE`].
409///
410/// Value is a [`ScopePropValue::Bytes`].
411pub const PROP_SERIAL_END: &str = "pkix-lint.serial-end";
412
413/// A typed value in a [`DeviationScope`] props bag.
414///
415/// The two current variants cover the four canonical scope kinds:
416/// - [`ScopePropValue::Text`] for human-readable strings (e.g. a substring of
417///   an issuer DN).
418/// - [`ScopePropValue::Bytes`] for binary data (e.g. a DER-encoded `Name` or
419///   a DER positive-integer serial number).
420///
421/// `#[non_exhaustive]` so future scope axes can introduce additional variants
422/// (e.g. an `Oid` variant for `PKIX-8mzp`'s planned `PolicyOid` scope)
423/// without a breaking change.
424#[non_exhaustive]
425#[derive(Clone, Debug, PartialEq, Eq)]
426pub enum ScopePropValue {
427    /// A text value. Used for human-readable strings such as the lowercased
428    /// substring of an issuer DN.
429    Text(String),
430    /// A binary value. Used for DER-encoded structures (e.g. `Name`) and for
431    /// DER positive-integer serial numbers (big-endian minimal encoding).
432    Bytes(Vec<u8>),
433}
434
435/// Specifies which certificates a [`Deviation`] applies to.
436///
437/// `DeviationScope` is an open-ended discriminator + typed-props bag, mirroring
438/// the OSCAL Subject shape. The discriminator [`Self::kind`] selects the
439/// matching algorithm; [`Self::props`] carries the parameters for that
440/// algorithm.
441///
442/// # Canonical kinds (built in)
443///
444/// Four kinds ship in `pkix-lint`. Use the matching constructor rather than
445/// constructing the struct directly:
446///
447/// | Constructor | Kind constant | Matches |
448/// |-------------|---------------|---------|
449/// | [`Self::any`] | [`SCOPE_KIND_ANY`] | All certificates |
450/// | [`Self::issuer_dn_contains`] | [`SCOPE_KIND_ISSUER_DN_CONTAINS`] | Issuer DN string contains a substring (case-insensitive) |
451/// | [`Self::issuer_dn_exact`] | [`SCOPE_KIND_ISSUER_DN_EXACT`] | Issuer DN matches exactly (RFC 4518 normalized) |
452/// | [`Self::serial_range`] | [`SCOPE_KIND_SERIAL_RANGE`] | Issuer DN + serial in inclusive byte-lex range |
453///
454/// # Choosing a scope
455///
456/// Use the narrowest scope that resolves the actual problem:
457/// - Prefer [`Self::serial_range`] when the deviation covers a specific
458///   issuance batch.
459/// - Prefer [`Self::issuer_dn_exact`] when all certs from a given CA are
460///   affected.
461/// - Use [`Self::issuer_dn_contains`] for human-readable convenience scoping
462///   in dev/test.
463/// - Use [`Self::any`] only for internal CAs or test environments where the
464///   profile is intentionally not applicable.
465///
466/// # Open-ended extensibility
467///
468/// Additional scope axes (e.g. `PKIX-8mzp`'s planned `SubjectDnContains`,
469/// `PolicyOid`) are expressible via new `kind` strings + props without
470/// modifying this struct. [`Self::matches`] short-circuits to `false` for
471/// unknown kinds (fail-closed).
472///
473/// The struct carries `#[non_exhaustive]`: callers outside this crate
474/// must construct via [`Self::any`], [`Self::issuer_dn_contains`],
475/// [`Self::issuer_dn_exact`], or [`Self::serial_range`] instead of
476/// struct-literal syntax. This lets future fields (e.g., normalized
477/// substring cache) be added non-breakingly.
478#[derive(Clone, Debug, PartialEq, Eq)]
479#[non_exhaustive]
480pub struct DeviationScope {
481    /// The subject-type discriminator. One of the `SCOPE_KIND_*` constants for
482    /// the canonical kinds, or a custom kind string for caller-defined axes.
483    pub kind: String,
484    /// Typed props that parameterize the scope. Property names are
485    /// kind-specific; see the `SCOPE_KIND_*` constants for the props each
486    /// canonical kind expects.
487    ///
488    /// Stored as a `Vec` rather than a map to preserve insertion order. Linear
489    /// scans by name are O(N) but N is small (≤3 today, ≤a handful even for
490    /// future scope axes).
491    pub props: Vec<(String, ScopePropValue)>,
492}
493
494impl DeviationScope {
495    /// Construct a [`SCOPE_KIND_ANY`] scope (matches all certificates).
496    #[must_use]
497    pub fn any() -> Self {
498        Self {
499            kind: SCOPE_KIND_ANY.to_string(),
500            props: Vec::new(),
501        }
502    }
503
504    /// Construct a [`SCOPE_KIND_ISSUER_DN_CONTAINS`] scope.
505    ///
506    /// `substring` is matched (case-insensitively) against the RFC 4514
507    /// string form of the certificate's issuer DN, as produced by
508    /// `x509_cert::name::Name::to_string()`.
509    ///
510    /// # Matching strategy (pinned contract)
511    ///
512    /// The match is computed as:
513    ///
514    /// ```text
515    /// cert.tbs_certificate.issuer.to_string().to_lowercase().contains(substring)
516    /// ```
517    ///
518    /// where `substring` is also pre-lowercased (via [`DeviationStore::add`]
519    /// at insertion time, or by the caller if invoking
520    /// [`DeviationScope::matches`] directly on a bare scope). The
521    /// matcher is a UTF-8 byte-substring check on the lowercase-folded
522    /// rendering — no parsing of the RDN structure, no RFC 4518
523    /// normalization, no whitespace canonicalization.
524    ///
525    /// # RFC 4514 rendering, in concrete terms
526    ///
527    /// `Name::to_string()` emits attribute-value pairs separated by
528    /// commas, **in RDN-reverse order** (most-specific RDN first). For
529    /// a typical CA DN the rendering looks like:
530    ///
531    /// ```text
532    /// CN=Good CA,O=Test Certificates 2011,C=US
533    /// ```
534    ///
535    /// Operators authoring substrings should:
536    ///
537    /// - Prefer single-RDN substrings (`"good ca"`, `"trust anchor"`)
538    ///   over multi-RDN substrings that span commas. Single-RDN
539    ///   patterns are robust to attribute-order changes in the DN
540    ///   encoding and to x509-cert's RFC 4514 rendering choices.
541    /// - Avoid embedding `,` or `=` in substrings — those are RFC 4514
542    ///   structural delimiters, not free text. A substring like
543    ///   `"good ca,o=test"` makes assumptions about the precise
544    ///   rendering that may not hold across encoder versions.
545    /// - Remember that RFC 4514 escapes certain characters (`,`, `+`,
546    ///   `"`, `\`, `<`, `>`, `;`, leading `#`, leading/trailing space)
547    ///   with backslashes. A CN that literally contains a comma
548    ///   renders as `CN=Comma\, In Name`; a naive substring
549    ///   `"comma, in"` matches but the backslash makes anchored
550    ///   patterns brittle.
551    /// - Use [`Self::issuer_dn_exact`] when precise DN identity matters
552    ///   (RFC 4518 normalized comparison via
553    ///   [`pkix_path::names_match`]) — that path does not depend on
554    ///   `Name::to_string()` at all.
555    ///
556    /// # Stability commitment
557    ///
558    /// The rendering is `x509_cert::name::Name::to_string()`'s output.
559    /// x509-cert is currently pre-1.0; changes there (escape rules,
560    /// attribute display names, RDN ordering) change which certs
561    /// match without a pkix-lint version bump. The pkix-lint workspace
562    /// pins to a specific x509-cert minor; operators tracking match
563    /// behavior across pkix-lint upgrades should treat encoder-level
564    /// rendering changes as a possible cause of newly-included or
565    /// newly-excluded certs.
566    ///
567    /// # Case folding
568    ///
569    /// Case folding uses [`str::to_lowercase`] (Unicode-aware default
570    /// case mapping). Accented Latin, CJK kana, and other non-ASCII
571    /// characters fold according to the Unicode case mapping tables on
572    /// both the stored substring side and the cert-issuer-DN side, so
573    /// scoping a deviation by `"agency müller ca"` matches a cert whose
574    /// issuer DN renders as `"CN=Agency Müller CA"`. The fold allocates
575    /// a fresh `String` per match; caller-side caching is recommended
576    /// for hot paths if profiling shows the cost.
577    #[must_use]
578    pub fn issuer_dn_contains(substring: impl Into<String>) -> Self {
579        Self {
580            kind: SCOPE_KIND_ISSUER_DN_CONTAINS.to_string(),
581            props: vec![(
582                PROP_ISSUER_DN_SUBSTRING.to_string(),
583                ScopePropValue::Text(substring.into()),
584            )],
585        }
586    }
587
588    /// Construct a [`SCOPE_KIND_ISSUER_DN_EXACT`] scope from the DER
589    /// encoding of the issuer Name.
590    ///
591    /// `issuer_der` is the DER encoding of the issuer Name (the value
592    /// of `TBSCertificate.issuer` — equivalently
593    /// `cert.tbs_certificate.issuer.to_der().unwrap()` for callers
594    /// holding an x509-cert `Name`). The bytes are stored verbatim and
595    /// compared byte-equal during matching.
596    ///
597    /// Construction does not validate that the bytes form a valid DER
598    /// Name; malformed scope props fail closed at
599    /// [`DeviationScope::matches`] time. This matches the lazy
600    /// fail-closed contract that already covers raw OSCAL-parsed
601    /// scopes.
602    ///
603    /// The issuer DN is matched using
604    /// [`pkix_path::names_match`] (RFC 4518 normalization).
605    ///
606    /// # Migration note (PKIX-7f92.30)
607    ///
608    /// Previously this constructor took `&x509_cert::name::Name` and
609    /// returned `Result<Self, der::Error>`. Callers holding a `Name`
610    /// now pass `&name.to_der().expect("Name::to_der is infallible")[..]`
611    /// at the call site, which keeps the x509-cert dependency
612    /// off pkix-lint's public surface. Callers holding raw DER bytes
613    /// (the common case for OSCAL-imported policy) save a
614    /// parse-and-re-encode round-trip.
615    #[must_use]
616    pub fn issuer_dn_exact(issuer_der: impl Into<Vec<u8>>) -> Self {
617        Self {
618            kind: SCOPE_KIND_ISSUER_DN_EXACT.to_string(),
619            props: vec![(
620                PROP_ISSUER_DN_DER.to_string(),
621                ScopePropValue::Bytes(issuer_der.into()),
622            )],
623        }
624    }
625
626    /// Construct a [`SCOPE_KIND_SERIAL_RANGE`] scope from the DER
627    /// encoding of the issuer Name plus the inclusive serial range.
628    ///
629    /// `issuer_der` is the DER encoding of the issuer Name (see
630    /// [`Self::issuer_dn_exact`] for the contract on these bytes).
631    /// `start` and `end` are the serial-number bounds (inclusive) as
632    /// raw bytes in DER positive-integer encoding.
633    ///
634    /// # Migration note (PKIX-7f92.30)
635    ///
636    /// Previously this constructor took `&x509_cert::name::Name` and
637    /// returned `Result<Self, der::Error>`. See [`Self::issuer_dn_exact`]
638    /// for the migration rationale.
639    #[must_use]
640    pub fn serial_range(
641        issuer_der: impl Into<Vec<u8>>,
642        start: Vec<u8>,
643        end: Vec<u8>,
644    ) -> Self {
645        Self {
646            kind: SCOPE_KIND_SERIAL_RANGE.to_string(),
647            props: vec![
648                (
649                    PROP_ISSUER_DN_DER.to_string(),
650                    ScopePropValue::Bytes(issuer_der.into()),
651                ),
652                (PROP_SERIAL_START.to_string(), ScopePropValue::Bytes(start)),
653                (PROP_SERIAL_END.to_string(), ScopePropValue::Bytes(end)),
654            ],
655        }
656    }
657
658    /// Get a prop value by name, or `None` if no such prop exists.
659    ///
660    /// Used internally by [`Self::matches`] and by the OSCAL emit/parse layer.
661    #[must_use]
662    pub fn get_prop(&self, name: &str) -> Option<&ScopePropValue> {
663        self.props
664            .iter()
665            .find_map(|(k, v)| (k == name).then_some(v))
666    }
667
668    fn get_text(&self, name: &str) -> Option<&str> {
669        match self.get_prop(name)? {
670            ScopePropValue::Text(s) => Some(s.as_str()),
671            ScopePropValue::Bytes(_) => None,
672        }
673    }
674
675    fn get_bytes(&self, name: &str) -> Option<&[u8]> {
676        match self.get_prop(name)? {
677            ScopePropValue::Bytes(b) => Some(b.as_slice()),
678            ScopePropValue::Text(_) => None,
679        }
680    }
681
682    /// Returns `true` if `cert` is within this scope.
683    ///
684    /// Dispatches on [`Self::kind`]. Unknown kinds return `false`
685    /// (fail-closed). Within each known kind, missing-or-wrong-typed props
686    /// also return `false` rather than panicking — the constructors prevent
687    /// this for code-built scopes, and the OSCAL parser rejects malformed
688    /// input before any [`DeviationScope`] is constructed.
689    #[must_use]
690    pub fn matches(&self, cert: &Certificate) -> bool {
691        match self.kind.as_str() {
692            SCOPE_KIND_ANY => true,
693            SCOPE_KIND_ISSUER_DN_CONTAINS => {
694                let Some(substring) = self.get_text(PROP_ISSUER_DN_SUBSTRING) else {
695                    return false;
696                };
697                // `substring` is pre-lowercased by `DeviationStore::add`
698                // using the same `str::to_lowercase` Unicode-aware fold
699                // we apply here. Cross-side consistency is the
700                // correctness invariant.
701                //
702                // `str::to_lowercase` uses Unicode case mapping tables
703                // (default casing per the Unicode standard) and folds
704                // accented Latin, CJK kana, etc. correctly. CA DN
705                // strings in non-Western European jurisdictions
706                // (Bundesdruckerei, Caisse des Dépôts, Polish/Czech
707                // CAs) routinely use non-ASCII characters; ASCII-only
708                // folding silently failed these matches.
709                //
710                // This allocates a fresh String each call. For
711                // realistic deviation-store sizes the cost is
712                // immaterial; a caller-side cache of `(deviation_id,
713                // cert_sha256) → bool` is the recommended remedy if
714                // profiling ever shows otherwise.
715                let issuer_str = cert.tbs_certificate.issuer.to_string().to_lowercase();
716                issuer_str.contains(substring)
717            }
718            SCOPE_KIND_ISSUER_DN_EXACT => {
719                let Some(der) = self.get_bytes(PROP_ISSUER_DN_DER) else {
720                    return false;
721                };
722                use der::Decode as _;
723                let Ok(name) = x509_cert::name::Name::from_der(der) else {
724                    return false;
725                };
726                pkix_path::names_match(&name, &cert.tbs_certificate.issuer)
727            }
728            SCOPE_KIND_SERIAL_RANGE => {
729                let Some(der) = self.get_bytes(PROP_ISSUER_DN_DER) else {
730                    return false;
731                };
732                let Some(start) = self.get_bytes(PROP_SERIAL_START) else {
733                    return false;
734                };
735                let Some(end) = self.get_bytes(PROP_SERIAL_END) else {
736                    return false;
737                };
738                use der::Decode as _;
739                let Ok(issuer) = x509_cert::name::Name::from_der(der) else {
740                    return false;
741                };
742                if !pkix_path::names_match(&issuer, &cert.tbs_certificate.issuer) {
743                    return false;
744                }
745                let serial = cert.tbs_certificate.serial_number.as_bytes();
746                let cmp_start = serial_cmp(serial, start);
747                let cmp_end = serial_cmp(serial, end);
748                cmp_start.is_ge() && cmp_end.is_le()
749            }
750            // Unknown kind: fail-closed.
751            _ => false,
752        }
753    }
754}
755
756/// Validate that a [`DeviationScope`] is structurally well-formed for
757/// its declared `kind`. Called by [`DeviationStore::add`] so the
758/// operator sees [`DeviationAddError::MalformedScope`] at insertion
759/// time rather than a silent never-match at evaluation time
760/// (PKIX-hy2e.9).
761///
762/// Unknown / custom kinds are accepted without inspection — they
763/// fail-closed at match time, which is the documented contract for
764/// caller-defined scope axes. Callers extending the scope model
765/// should plumb their own validation into this helper.
766fn validate_scope(scope: &DeviationScope) -> Result<(), DeviationAddError> {
767    let missing = |prop: &str| -> DeviationAddError {
768        DeviationAddError::MalformedScope {
769            kind: scope.kind.clone(),
770            reason: format!("missing required prop '{prop}'"),
771        }
772    };
773    let wrong_type = |prop: &str, expected: &str| -> DeviationAddError {
774        DeviationAddError::MalformedScope {
775            kind: scope.kind.clone(),
776            reason: format!("prop '{prop}' has wrong type (expected {expected})"),
777        }
778    };
779
780    match scope.kind.as_str() {
781        SCOPE_KIND_ANY => Ok(()),
782        SCOPE_KIND_ISSUER_DN_CONTAINS => match scope.get_prop(PROP_ISSUER_DN_SUBSTRING) {
783            None => Err(missing(PROP_ISSUER_DN_SUBSTRING)),
784            Some(ScopePropValue::Text(_)) => Ok(()),
785            Some(_) => Err(wrong_type(PROP_ISSUER_DN_SUBSTRING, "Text")),
786        },
787        SCOPE_KIND_ISSUER_DN_EXACT => match scope.get_prop(PROP_ISSUER_DN_DER) {
788            None => Err(missing(PROP_ISSUER_DN_DER)),
789            Some(ScopePropValue::Bytes(_)) => Ok(()),
790            Some(_) => Err(wrong_type(PROP_ISSUER_DN_DER, "Bytes")),
791        },
792        SCOPE_KIND_SERIAL_RANGE => {
793            for prop in [PROP_ISSUER_DN_DER, PROP_SERIAL_START, PROP_SERIAL_END] {
794                match scope.get_prop(prop) {
795                    None => return Err(missing(prop)),
796                    Some(ScopePropValue::Bytes(_)) => {}
797                    Some(_) => return Err(wrong_type(prop, "Bytes")),
798                }
799            }
800            Ok(())
801        }
802        // Custom kinds defined by callers are accepted without
803        // inspection. Per the DeviationScope rustdoc, unknown kinds
804        // fail-closed at DeviationScope::matches time; that's the
805        // documented extensibility contract.
806        _ => Ok(()),
807    }
808}
809
810// ---------------------------------------------------------------------------
811// serde::Serialize for DeviationScope and ScopePropValue
812//
813// `Name` (DER-encoded) is the only field that does not have a built-in serde
814// impl. Bytes are serialized as hex strings to keep the JSON readable.
815// Deserialization is not provided; round-tripping requires going through the
816// OSCAL parser (see `pkix_lint::oscal::parse`).
817// ---------------------------------------------------------------------------
818
819#[cfg(feature = "serde")]
820impl serde::Serialize for ScopePropValue {
821    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
822        use serde::ser::SerializeStructVariant as _;
823        match self {
824            Self::Text(s) => serializer.serialize_newtype_variant("ScopePropValue", 0, "Text", s),
825            Self::Bytes(b) => {
826                let mut sv =
827                    serializer.serialize_struct_variant("ScopePropValue", 1, "Bytes", 1)?;
828                sv.serialize_field("hex", &hex_encode(b))?;
829                sv.end()
830            }
831        }
832    }
833}
834
835#[cfg(feature = "serde")]
836impl serde::Serialize for DeviationScope {
837    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
838        use serde::ser::SerializeStruct as _;
839        let mut st = serializer.serialize_struct("DeviationScope", 2)?;
840        st.serialize_field("kind", &self.kind)?;
841        st.serialize_field("props", &self.props)?;
842        st.end()
843    }
844}
845
846/// Lowercase-hex encode bytes (no separator). Used by serde::Serialize for
847/// [`ScopePropValue::Bytes`] to keep the JSON form human-readable.
848#[cfg(feature = "serde")]
849fn hex_encode(bytes: &[u8]) -> String {
850    let mut s = String::with_capacity(bytes.len() * 2);
851    for b in bytes {
852        s.push_str(&format!("{b:02x}"));
853    }
854    s
855}
856
857/// Compare two byte slices as DER positive-integer serial numbers.
858///
859/// DER positive integers are big-endian with a leading 0x00 byte only when the
860/// high bit would otherwise be set (sign-bit convention). Leading zeros are
861/// stripped before comparing; longer (after stripping) is greater, equal length
862/// falls through to lexicographic byte comparison.
863///
864/// Call sites use `.is_ge()` / `.is_le()` for "in range" checks.
865fn serial_cmp(a: &[u8], b: &[u8]) -> core::cmp::Ordering {
866    let a = strip_leading_zeros(a);
867    let b = strip_leading_zeros(b);
868    a.len().cmp(&b.len()).then_with(|| a.cmp(b))
869}
870
871fn strip_leading_zeros(bytes: &[u8]) -> &[u8] {
872    let first_nonzero = bytes.iter().position(|&b| b != 0).unwrap_or(bytes.len());
873    &bytes[first_nonzero..]
874}
875
876/// What a [`Deviation`] does to a matching finding.
877#[non_exhaustive]
878#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
879#[derive(Clone, Debug, PartialEq, Eq)]
880pub enum DeviationAction {
881    /// Change the finding's severity to the specified level.
882    ///
883    /// The finding is still recorded in the output — it is not removed.
884    /// The deviation ID appears in the [`DeviatedFinding`] so auditors can see it.
885    DowngradeSeverityTo(Severity),
886
887    /// Mark the finding as suppressed (effectively `NotApplicable` for reporting).
888    ///
889    /// The finding is still recorded as a [`DeviatedFinding`] with
890    /// `action: DeviationAction::Suppress` so auditors can see that the deviation
891    /// was applied. It does not appear as a normal finding.
892    ///
893    /// Use only when `DowngradeSeverityTo(Severity::Info)` is not sufficient
894    /// (e.g., the finding would be incorrectly categorized as Info in reports).
895    Suppress,
896}
897
898/// A finding with a deviation applied.
899///
900/// The underlying lint ID, original result, and deviation metadata are all
901/// preserved for audit purposes. A `DeviatedFinding` is never silently hidden.
902///
903/// # Operator UI guidance
904///
905/// Display deviated findings as "DEVIATION APPLIED" rather than green/pass.
906/// Show `deviation_id`, `justification`, and `evidence_uri` (when present) so
907/// operators can navigate to the backing waiver document without a second lookup.
908///
909/// The struct carries `#[non_exhaustive]`. External callers consume
910/// `DeviatedFinding` values produced by [`DeviationRunner`]; they do
911/// not construct them directly. Adding `#[non_exhaustive]` documents
912/// this engine-output role and keeps the door open for future fields
913/// (e.g., per-deviation provenance, applied-at timestamp) without
914/// requiring a major version bump.
915#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
916#[derive(Clone, Debug, PartialEq, Eq)]
917#[non_exhaustive]
918pub struct DeviatedFinding {
919    /// The stable lint ID of the lint that produced this finding.
920    #[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
921    pub lint_id: std::borrow::Cow<'static, str>,
922    /// The citation for the lint that produced this finding.
923    #[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
924    pub citation: std::borrow::Cow<'static, str>,
925    /// The original lint result before the deviation was applied.
926    pub original_result: crate::LintResult,
927    /// The deviation ID that was applied.
928    pub deviation_id: String,
929    /// The action taken by the deviation.
930    pub action: DeviationAction,
931    /// Human-readable justification from the deviation.
932    pub justification: String,
933    /// URI pointing to the backing waiver document, if one was provided.
934    ///
935    /// `None` if the deviation did not include an `evidence_uri`.
936    pub evidence_uri: Option<String>,
937    /// For certificate-scope findings, the zero-based chain index.
938    pub cert_index: Option<usize>,
939    /// Unix epoch seconds at which the lint was evaluated.
940    ///
941    /// Propagated from [`crate::Finding::evaluated_at_unix`] when the deviation
942    /// is applied. Matches the `now_unix` passed to the runner method.
943    pub evaluated_at_unix: u64,
944}
945
946impl DeviatedFinding {
947    /// Returns the effective severity after the deviation was applied.
948    ///
949    /// - `DowngradeSeverityTo(s)` returns `s`.
950    /// - `Suppress` returns `None` (the finding is suppressed from normal output).
951    #[must_use]
952    pub const fn effective_severity(&self) -> Option<Severity> {
953        match &self.action {
954            DeviationAction::DowngradeSeverityTo(s) => Some(*s),
955            DeviationAction::Suppress => None,
956        }
957    }
958}
959
960/// An in-memory collection of [`Deviation`]s.
961///
962/// The store is currently append-only. Future versions may add update/delete
963/// and persistence (file-backed JSON/OSCAL format) — tracked as PKIX-dbhe.
964#[cfg_attr(feature = "serde", derive(serde::Serialize))]
965#[derive(Clone, Debug, Default, PartialEq, Eq)]
966pub struct DeviationStore {
967    deviations: Vec<Deviation>,
968}
969
970impl DeviationStore {
971    /// Create an empty store.
972    #[must_use]
973    pub const fn new() -> Self {
974        Self {
975            deviations: Vec::new(),
976        }
977    }
978
979    /// Add a deviation to the store.
980    ///
981    /// # Errors
982    ///
983    /// - [`DeviationAddError::EmptyField`] if `deviation.justification` or
984    ///   `deviation.authorized_by` is empty.
985    /// - [`DeviationAddError::DuplicateId`] if a deviation with the same
986    ///   `id` already exists in the store.
987    pub fn add(&mut self, mut deviation: Deviation) -> Result<(), DeviationAddError> {
988        if deviation.justification.is_empty() {
989            return Err(DeviationAddError::EmptyField("justification".into()));
990        }
991        if deviation.authorized_by.is_empty() {
992            return Err(DeviationAddError::EmptyField("authorized_by".into()));
993        }
994        if self.deviations.iter().any(|d| d.id == deviation.id) {
995            return Err(DeviationAddError::DuplicateId(deviation.id.clone()));
996        }
997        // Validate scope structure (PKIX-hy2e.9). The built-in
998        // constructors (DeviationScope::any/issuer_dn_contains/
999        // issuer_dn_exact/serial_range) always produce well-formed
1000        // scopes, but the pre-#[non_exhaustive] code accepted direct
1001        // struct literals with missing/wrong-typed props that silently
1002        // never matched at runtime. Even with #[non_exhaustive] on the
1003        // struct, the public field shape still permits internal
1004        // callers (and hand-constructed test fixtures) to assemble
1005        // malformed scopes. Rejecting at insertion time gives the
1006        // operator a specific error rather than a silent never-match.
1007        validate_scope(&deviation.scope)?;
1008        // Normalize the issuer-dn-contains substring to lowercase at
1009        // insertion time so that matching logic does not need to re-normalize
1010        // on every call. This prevents a silent no-match when callers pass a
1011        // mixed-case substring.
1012        //
1013        // Use `str::to_lowercase` (Unicode-aware) consistent with the
1014        // matching code in `DeviationScope::matches`. Cross-side
1015        // consistency is the correctness invariant: any byte sequence
1016        // that lowercases to itself on both sides matches; any
1017        // sequence that lowercases differently on the two sides
1018        // silently no-matches. The pre-fix `make_ascii_lowercase`
1019        // call left non-ASCII characters (e.g., 'ü' in 'Müller')
1020        // untouched on both sides; lowercase user input 'müller'
1021        // could never match cert-side 'Müller'. (PKIX-hy2e.8)
1022        if deviation.scope.kind == SCOPE_KIND_ISSUER_DN_CONTAINS {
1023            for (name, value) in &mut deviation.scope.props {
1024                if name == PROP_ISSUER_DN_SUBSTRING {
1025                    if let ScopePropValue::Text(s) = value {
1026                        *s = s.to_lowercase();
1027                    }
1028                }
1029            }
1030        }
1031        self.deviations.push(deviation);
1032        Ok(())
1033    }
1034
1035    /// Return all deviations in the store.
1036    #[must_use]
1037    pub fn all(&self) -> &[Deviation] {
1038        &self.deviations
1039    }
1040
1041    /// Return all deviations that are active at `now_unix`.
1042    #[must_use = "iterator is lazy; collect or iterate to use results"]
1043    pub fn active_at(&self, now_unix: u64) -> impl Iterator<Item = &Deviation> {
1044        self.deviations
1045            .iter()
1046            .filter(move |d| d.is_active_at(now_unix))
1047    }
1048
1049    /// Return all deviations targeting `lint_id` that are active at `now_unix`.
1050    #[must_use = "iterator is lazy; collect or iterate to use results"]
1051    pub fn active_for_lint<'a>(
1052        &'a self,
1053        lint_id: &'a str,
1054        now_unix: u64,
1055    ) -> impl Iterator<Item = &'a Deviation> {
1056        self.deviations
1057            .iter()
1058            .filter(move |d| d.target_lint.as_str() == lint_id && d.is_active_at(now_unix))
1059    }
1060
1061    /// Return all deviations that have expired as of `now_unix`.
1062    ///
1063    /// Used by corpus-reporting tools to surface deviations that need renewal.
1064    #[must_use = "iterator is lazy; collect or iterate to use results"]
1065    pub fn expired_at(&self, now_unix: u64) -> impl Iterator<Item = &Deviation> {
1066        self.deviations
1067            .iter()
1068            .filter(move |d| d.effective_end.is_some_and(|end| now_unix >= end))
1069    }
1070
1071    /// Check whether a specific finding should be deviated.
1072    ///
1073    /// Returns the active deviation that matches `cert` and `lint_id`
1074    /// at `now_unix`, or `None` if no deviation applies.
1075    ///
1076    /// # Resolution rule (PKIX-hy2e.10)
1077    ///
1078    /// Among all matching deviations, the one with the highest
1079    /// [`Deviation::priority`] wins. Ties are broken by
1080    /// store-insertion order — the first-added deviation at the
1081    /// winning priority wins.
1082    ///
1083    /// Operators merging deviation files from multiple authors should
1084    /// set [`Deviation::priority`] explicitly to express specificity:
1085    /// site-local / lab-scoped waivers get higher priorities than
1086    /// workspace-wide wildcard waivers. The default priority is `0`,
1087    /// so a single-author store behaves identically to the pre-PKIX-
1088    /// hy2e.10 "first-match-wins" rule.
1089    #[must_use]
1090    pub fn find_deviation(
1091        &self,
1092        lint_id: &str,
1093        cert: &Certificate,
1094        now_unix: u64,
1095    ) -> Option<&Deviation> {
1096        // max_by_key on (priority, -index) would also work, but
1097        // iter().enumerate() lets us tie-break by insertion order
1098        // (low index wins for equal priority) via a stable max walk.
1099        let mut best: Option<&Deviation> = None;
1100        for d in &self.deviations {
1101            if d.target_lint.as_str() == lint_id && d.applies_to(cert, now_unix) {
1102                match best {
1103                    None => best = Some(d),
1104                    Some(prev) if d.priority > prev.priority => best = Some(d),
1105                    // priority <= prev.priority: keep prev (earlier
1106                    // insertion order wins for ties).
1107                    Some(_) => {}
1108                }
1109            }
1110        }
1111        best
1112    }
1113
1114    /// Returns the active deviation that matches `lint_id` and at
1115    /// least one certificate in `chain` at `now_unix`, or `None` if no
1116    /// deviation applies.
1117    ///
1118    /// Used by [`DeviationRunner::run_path`] to apply path-scope
1119    /// deviations that target an intermediate CA's properties (rather
1120    /// than the leaf's). Per RFC 5280 §6.1, a path-scope finding can
1121    /// fire because of any cert in the chain, including intermediate
1122    /// CAs; a deviation scoped to an intermediate must be applicable
1123    /// even though the path finding has no single "owning" cert.
1124    ///
1125    /// # Resolution rule (PKIX-hy2e.10 + PKIX-hy2e.11)
1126    ///
1127    /// 1. A deviation matches if [`Deviation::target_lint`] equals
1128    ///    `lint_id` AND at least one cert in `chain` is in scope.
1129    /// 2. Among all matching deviations, the highest
1130    ///    [`Deviation::priority`] wins.
1131    /// 3. Priority ties are broken by store-insertion order.
1132    #[must_use]
1133    pub fn find_deviation_for_chain(
1134        &self,
1135        lint_id: &str,
1136        chain: &[Certificate],
1137        now_unix: u64,
1138    ) -> Option<&Deviation> {
1139        let mut best: Option<&Deviation> = None;
1140        for d in &self.deviations {
1141            if d.target_lint.as_str() == lint_id
1142                && chain.iter().any(|cert| d.applies_to(cert, now_unix))
1143            {
1144                match best {
1145                    None => best = Some(d),
1146                    Some(prev) if d.priority > prev.priority => best = Some(d),
1147                    Some(_) => {}
1148                }
1149            }
1150        }
1151        best
1152    }
1153}
1154
1155// ---------------------------------------------------------------------------
1156// DeviationRunner
1157// ---------------------------------------------------------------------------
1158
1159/// The output of a [`DeviationRunner`] evaluation: findings with deviations applied.
1160///
1161/// Findings where a deviation was applied are moved from `findings` to `deviated`.
1162/// Callers can use `findings` for normal compliance reporting and `deviated`
1163/// for audit/transparency reporting.
1164///
1165/// # Stability
1166///
1167/// This struct is `#[non_exhaustive]`: new fields may be added in future minor
1168/// versions (e.g., a `suppressed` list for audit purposes). Do not construct
1169/// `DeviationRunResult` directly with struct literal syntax; use
1170/// [`DeviationRunResult::default()`] or obtain it from [`DeviationRunner`].
1171#[non_exhaustive]
1172#[derive(Clone, Debug, Default, PartialEq, Eq)]
1173pub struct DeviationRunResult {
1174    /// Findings that were not affected by any deviation.
1175    ///
1176    /// Contains the full output of the inner [`crate::LintRunner`] minus any
1177    /// findings that were moved to [`Self::deviated`]. This includes
1178    /// [`crate::LintResult::Pass`] and [`crate::LintResult::NotApplicable`]
1179    /// findings as well as actionable ones — mirroring the behaviour of
1180    /// [`crate::LintRunner::run_cert`]. Callers that want only actionable
1181    /// results should filter with [`crate::Finding::is_finding`].
1182    pub findings: Vec<crate::Finding>,
1183
1184    /// Findings that had a deviation applied.
1185    ///
1186    /// These are always included in output (never silently hidden) so that
1187    /// auditors can see what was deviated and why. If `action` is
1188    /// [`DeviationAction::Suppress`], `effective_severity()` returns `None`;
1189    /// the caller can display these with a "DEVIATION APPLIED" tag rather than
1190    /// as normal findings.
1191    pub deviated: Vec<DeviatedFinding>,
1192}
1193
1194/// A lint runner that applies [`DeviationStore`] logic to findings.
1195///
1196/// `DeviationRunner` wraps a [`crate::LintRunner`] and a [`DeviationStore`].
1197/// After each lint evaluation, it checks whether a deviation applies to the
1198/// finding. If one does, the finding is moved to [`DeviationRunResult::deviated`];
1199/// otherwise it stays in [`DeviationRunResult::findings`].
1200///
1201/// # Transparency guarantee
1202///
1203/// `DeviationRunner` **never silently drops findings**. Every finding — including
1204/// deviated ones — appears in [`DeviationRunResult`]. Operators see what was
1205/// deviated; auditors can enumerate deviations via [`DeviationStore::all`].
1206///
1207/// # Usage
1208///
1209/// ```rust,no_run
1210/// // `cert` and `now_unix` are obtained from the calling context.
1211/// use pkix_lint::deviation::{DeviationRunner, DeviationStore};
1212/// use pkix_lint::{LintRunner, SubjectKind};
1213/// use x509_cert::Certificate;
1214///
1215/// let cert: Certificate = unimplemented!("load from DER");
1216/// let now_unix: u64 = unimplemented!("current Unix epoch seconds");
1217/// let store = DeviationStore::new(); // populate with operator deviations
1218/// let runner = LintRunner::new(vec![/* your lints */]);
1219/// let dev_runner = DeviationRunner::new(runner, store);
1220///
1221/// let result = dev_runner.run_cert(&cert, SubjectKind::Leaf, 0, now_unix);
1222/// // result.findings — normal findings
1223/// // result.deviated — deviated findings (always included for auditability)
1224/// ```
1225pub struct DeviationRunner {
1226    runner: crate::LintRunner,
1227    store: DeviationStore,
1228}
1229
1230impl DeviationRunner {
1231    /// Create a new deviation runner from a lint runner and a deviation store.
1232    #[must_use]
1233    pub const fn new(runner: crate::LintRunner, store: DeviationStore) -> Self {
1234        Self { runner, store }
1235    }
1236
1237    /// Return a reference to the inner [`crate::LintRunner`].
1238    #[must_use]
1239    pub const fn lint_runner(&self) -> &crate::LintRunner {
1240        &self.runner
1241    }
1242
1243    /// Return a reference to the [`DeviationStore`].
1244    #[must_use]
1245    pub const fn deviation_store(&self) -> &DeviationStore {
1246        &self.store
1247    }
1248
1249    /// Evaluate certificate-scope lints and apply deviations.
1250    ///
1251    /// Same semantics as [`crate::LintRunner::run_cert`], but findings are
1252    /// partitioned into `findings` (no deviation) and `deviated` (deviation applied).
1253    #[must_use]
1254    pub fn run_cert(
1255        &self,
1256        cert: &Certificate,
1257        kind: crate::SubjectKind,
1258        cert_index: usize,
1259        now_unix: u64,
1260    ) -> DeviationRunResult {
1261        let raw = self.runner.run_cert(cert, kind, cert_index, now_unix);
1262        self.apply_deviations(raw, cert, now_unix)
1263    }
1264
1265    /// Evaluate certificate-scope lints as of the cert's `notBefore` date and
1266    /// apply deviations.
1267    ///
1268    /// Mirrors [`crate::LintRunner::run_cert_at_issuance`]: extracts the
1269    /// `notBefore` timestamp and calls `run_cert` with that value as `now_unix`.
1270    /// This answers "was this cert compliant when it was issued?"
1271    #[must_use]
1272    pub fn run_cert_at_issuance(
1273        &self,
1274        cert: &Certificate,
1275        kind: crate::SubjectKind,
1276        cert_index: usize,
1277    ) -> DeviationRunResult {
1278        let issuance_unix = cert
1279            .tbs_certificate
1280            .validity
1281            .not_before
1282            .to_unix_duration()
1283            .as_secs();
1284        self.run_cert(cert, kind, cert_index, issuance_unix)
1285    }
1286
1287    /// Evaluate certificate-scope lints on every cert in `chain` and apply deviations.
1288    ///
1289    /// `kinds` maps chain index to [`crate::SubjectKind`] and MUST
1290    /// have the same length as `chain`. Each `kinds[i]` is the
1291    /// classification for `chain[i]`.
1292    ///
1293    /// # Panics
1294    ///
1295    /// Panics if `kinds.len() != chain.len()`. See
1296    /// [`crate::LintRunner::run_chain`] for the rationale — the
1297    /// silently-default-to-IntermediateCa behavior was removed under
1298    /// PKIX-7f92.9 because it caused silent leaf-cert misclassification.
1299    #[must_use]
1300    pub fn run_chain(
1301        &self,
1302        chain: &[Certificate],
1303        kinds: &[crate::SubjectKind],
1304        now_unix: u64,
1305    ) -> DeviationRunResult {
1306        assert_eq!(
1307            kinds.len(),
1308            chain.len(),
1309            "DeviationRunner::run_chain requires kinds.len() == chain.len() \
1310             (got kinds={}, chain={}); see PKIX-7f92.9.",
1311            kinds.len(),
1312            chain.len(),
1313        );
1314        let mut result = DeviationRunResult::default();
1315        for (i, cert) in chain.iter().enumerate() {
1316            let kind = kinds[i];
1317            let raw = self.runner.run_cert(cert, kind, i, now_unix);
1318            let partial = self.apply_deviations(raw, cert, now_unix);
1319            result.findings.extend(partial.findings);
1320            result.deviated.extend(partial.deviated);
1321        }
1322        result
1323    }
1324
1325    /// Evaluate path-scope lints and apply deviations.
1326    ///
1327    /// Path-scope findings have no single "owning" certificate — they
1328    /// fire because of the chain as a whole. For deviation matching,
1329    /// this method scans **every certificate in the chain** in chain
1330    /// order (leaf first) and applies a deviation if any cert matches
1331    /// the deviation's scope. This admits deviations scoped to an
1332    /// intermediate CA's DN, which is essential for waiving path
1333    /// findings that fire because of intermediate-CA properties
1334    /// (chain depth, name constraints, key usage chaining).
1335    ///
1336    /// Resolution rule: within `store.find_deviation_for_chain`,
1337    /// deviations are tested in store-insertion order; for each
1338    /// deviation, certs are tested in chain order. The first matching
1339    /// (deviation, cert) pair wins.
1340    ///
1341    /// PKIX-hy2e.11 — the previous leaf-only scope match silently
1342    /// dropped deviations targeting intermediate CAs.
1343    #[must_use]
1344    pub fn run_path(
1345        &self,
1346        chain: &[Certificate],
1347        path: &crate::ValidatedPath,
1348        now_unix: u64,
1349    ) -> DeviationRunResult {
1350        let raw = self.runner.run_path(chain, path, now_unix);
1351        self.apply_deviations_for_chain(raw, chain, now_unix)
1352    }
1353
1354    /// Internal: partition a per-cert `Vec<Finding>` by whether a
1355    /// deviation applies. Used by [`Self::run_cert`] and
1356    /// [`Self::run_chain`] — both fire findings tied to a specific
1357    /// cert, so scope matching is against that single cert.
1358    fn apply_deviations(
1359        &self,
1360        raw: Vec<crate::Finding>,
1361        cert: &Certificate,
1362        now_unix: u64,
1363    ) -> DeviationRunResult {
1364        let mut result = DeviationRunResult::default();
1365        for finding in raw {
1366            // Only attempt to apply deviations to actionable findings.
1367            // Pass and NotApplicable findings are never waived.
1368            if !finding.result.is_finding() {
1369                result.findings.push(finding);
1370                continue;
1371            }
1372            match self.store.find_deviation(&finding.lint_id, cert, now_unix) {
1373                None => {
1374                    result.findings.push(finding);
1375                }
1376                Some(dev) => {
1377                    result.deviated.push(make_deviated(finding, dev));
1378                }
1379            }
1380        }
1381        result
1382    }
1383
1384    /// Internal: partition a path-scope `Vec<Finding>` by whether a
1385    /// deviation applies to *any* cert in the chain. Used by
1386    /// [`Self::run_path`] (PKIX-hy2e.11). The "any cert in the chain"
1387    /// rule is necessary because path-scope findings fire from
1388    /// properties of the chain as a whole, including intermediate
1389    /// CAs.
1390    fn apply_deviations_for_chain(
1391        &self,
1392        raw: Vec<crate::Finding>,
1393        chain: &[Certificate],
1394        now_unix: u64,
1395    ) -> DeviationRunResult {
1396        let mut result = DeviationRunResult::default();
1397        for finding in raw {
1398            if !finding.result.is_finding() {
1399                result.findings.push(finding);
1400                continue;
1401            }
1402            match self
1403                .store
1404                .find_deviation_for_chain(&finding.lint_id, chain, now_unix)
1405            {
1406                None => result.findings.push(finding),
1407                Some(dev) => result.deviated.push(make_deviated(finding, dev)),
1408            }
1409        }
1410        result
1411    }
1412}
1413
1414/// Construct a [`DeviatedFinding`] from a triggered [`crate::Finding`]
1415/// and the [`Deviation`] that applied to it. Used by both
1416/// `apply_deviations` (per-cert) and `apply_deviations_for_chain`
1417/// (path-scope) to keep the construction logic identical.
1418fn make_deviated(finding: crate::Finding, dev: &Deviation) -> DeviatedFinding {
1419    DeviatedFinding {
1420        lint_id: finding.lint_id,
1421        citation: finding.citation,
1422        original_result: finding.result,
1423        deviation_id: dev.id.clone(),
1424        action: dev.action.clone(),
1425        justification: dev.justification.clone(),
1426        evidence_uri: dev.evidence_uri.clone(),
1427        cert_index: finding.cert_index,
1428        evaluated_at_unix: finding.evaluated_at_unix,
1429    }
1430}
1431
1432// ---------------------------------------------------------------------------
1433// Tests
1434// ---------------------------------------------------------------------------
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::*;
1439    use crate::LintResult;
1440
1441    fn make_deviation(id: &str, lint_id: &str) -> Deviation {
1442        Deviation {
1443            id: id.to_string(),
1444            target_lint: lint_id.to_string(),
1445            scope: DeviationScope::any(),
1446            effective_start: None,
1447            effective_end: None,
1448            action: DeviationAction::DowngradeSeverityTo(Severity::Info),
1449            justification: "test justification".to_string(),
1450            authorized_by: "test-author@example.com".to_string(),
1451            evidence_uri: None,
1452            priority: 0,
1453        }
1454    }
1455
1456    fn load_cert() -> Certificate {
1457        use der::Decode as _;
1458        Certificate::from_der(include_bytes!(
1459            "../../pkix-path/tests/fixtures/policy-checks/webpki-self-signed-365d.der"
1460        ))
1461        .expect("fixture is valid DER")
1462    }
1463
1464    // -----------------------------------------------------------------------
1465    // is_active_at tests
1466    // Oracle: the time-range semantics in Deviation::is_active_at doc comment.
1467    // -----------------------------------------------------------------------
1468
1469    #[test]
1470    fn deviation_active_at_no_bounds() {
1471        let d = make_deviation("d1", "test.lint");
1472        // No bounds: always active.
1473        assert!(d.is_active_at(0));
1474        assert!(d.is_active_at(u64::MAX));
1475    }
1476
1477    #[test]
1478    fn deviation_active_after_start() {
1479        let d = Deviation {
1480            effective_start: Some(100),
1481            effective_end: None,
1482            ..make_deviation("d2", "test.lint")
1483        };
1484        assert!(!d.is_active_at(99), "before start must not be active");
1485        assert!(d.is_active_at(100), "at start must be active");
1486        assert!(d.is_active_at(200), "after start must be active");
1487    }
1488
1489    #[test]
1490    fn deviation_expires_at_end() {
1491        let d = Deviation {
1492            effective_start: None,
1493            effective_end: Some(200),
1494            ..make_deviation("d3", "test.lint")
1495        };
1496        assert!(d.is_active_at(199), "before end must be active");
1497        assert!(
1498            !d.is_active_at(200),
1499            "at end must NOT be active (exclusive)"
1500        );
1501        assert!(!d.is_active_at(201), "after end must not be active");
1502    }
1503
1504    #[test]
1505    fn deviation_active_within_range() {
1506        let d = Deviation {
1507            effective_start: Some(100),
1508            effective_end: Some(200),
1509            ..make_deviation("d4", "test.lint")
1510        };
1511        assert!(!d.is_active_at(99));
1512        assert!(d.is_active_at(100));
1513        assert!(d.is_active_at(150));
1514        assert!(d.is_active_at(199));
1515        assert!(!d.is_active_at(200));
1516    }
1517
1518    // -----------------------------------------------------------------------
1519    // DeviationScope::matches tests
1520    // Oracle: the scope-matching rules in the DeviationScope doc comment.
1521    // -----------------------------------------------------------------------
1522
1523    #[test]
1524    fn scope_any_matches_any_cert() {
1525        let cert = load_cert();
1526        assert!(DeviationScope::any().matches(&cert));
1527    }
1528
1529    #[test]
1530    fn scope_issuer_dn_contains_case_insensitive() {
1531        let cert = load_cert();
1532        // The webpki-self-signed-365d cert has a CN we can match.
1533        // Get the issuer string to find what's in it.
1534        let issuer = cert.tbs_certificate.issuer.to_string();
1535        // Take the first word of the issuer for a partial match.
1536        let word = issuer.split_whitespace().next().unwrap_or("cert");
1537        // issuer_dn_contains requires a pre-lowercased substring; the match
1538        // is case-insensitive because the cert's issuer string is lowercased
1539        // at match time. Both lowercase and originally-cased input must match
1540        // once lowercased at construction.
1541        let scope_lower = DeviationScope::issuer_dn_contains(word.to_lowercase());
1542        let scope_upper = DeviationScope::issuer_dn_contains(word.to_uppercase().to_lowercase());
1543        assert!(scope_lower.matches(&cert), "lowercase match must succeed");
1544        assert!(
1545            scope_upper.matches(&cert),
1546            "lowercased-at-construction match must succeed"
1547        );
1548    }
1549
1550    /// Regression test for PKIX-7f92.10: pin the documented matching
1551    /// strategy against a multi-RDN issuer DN. The PKITS GoodCACert has
1552    /// issuer DN `CN=Trust Anchor,O=Test Certificates 2011,C=US` (CN-first
1553    /// RFC 4514 rendering); confirms that:
1554    ///
1555    /// - Single-RDN-value substrings match (`"trust anchor"`,
1556    ///   `"test certificates 2011"`, `"us"`).
1557    /// - A cross-RDN substring that happens to span the
1558    ///   x509-cert-rendered `,` separator matches if and only if the
1559    ///   rendered bytes literally contain it
1560    ///   (`"trust anchor,o=test"` works ONLY because x509-cert renders
1561    ///   without space after the comma). Document via this test that
1562    ///   the renderer's spacing choice is part of the contract.
1563    /// - A reordered substring that assumes a different RDN ordering
1564    ///   does NOT match (e.g., `"c=us,cn=trust"`) — the renderer is
1565    ///   CN-first, not C-first.
1566    /// - An attribute name from a non-rendered attribute type (e.g.,
1567    ///   `"countryName="`) does NOT match — x509-cert uses short
1568    ///   names (`C=`), not long names.
1569    #[test]
1570    fn scope_issuer_dn_contains_pinned_multi_rdn_behavior() {
1571        // Load PKITS GoodCACert — only multi-RDN fixture available.
1572        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1573            .join("../pkix-path/tests/pkits/certs/GoodCACert.crt");
1574        let Ok(bytes) = std::fs::read(&path) else {
1575            eprintln!("PKITS GoodCACert not available — skipping multi-RDN regression test");
1576            return;
1577        };
1578        use der::Decode as _;
1579        let cert = Certificate::from_der(&bytes).expect("decode PKITS GoodCACert");
1580
1581        // External oracle: the rendered DN is the literal x509-cert
1582        // RFC 4514 output. Both sides of the contains() check use this
1583        // exact rendering.
1584        let rendered = cert.tbs_certificate.issuer.to_string();
1585        assert_eq!(
1586            rendered, "CN=Trust Anchor,O=Test Certificates 2011,C=US",
1587            "test oracle: x509-cert renders the PKITS GoodCACert issuer in CN-first RFC 4514 form"
1588        );
1589
1590        let lower = rendered.to_lowercase();
1591
1592        // Positive: single-RDN-value substrings match. These are the
1593        // operator-author-friendly patterns the rustdoc recommends.
1594        for substring in ["trust anchor", "test certificates 2011", "c=us"] {
1595            assert!(
1596                lower.contains(substring),
1597                "rendered lower must contain {substring:?} (oracle)"
1598            );
1599            let scope = DeviationScope::issuer_dn_contains(substring);
1600            assert!(
1601                scope.matches(&cert),
1602                "single-value substring {substring:?} must match"
1603            );
1604        }
1605
1606        // Positive: a cross-RDN substring that LITERALLY appears in
1607        // the lowercase rendering matches. Note the no-space-after-comma
1608        // — this is x509-cert's rendering choice, and the bead warns
1609        // that operators relying on this are coupled to the renderer.
1610        let cross = "trust anchor,o=test certificates";
1611        assert!(
1612            lower.contains(cross),
1613            "rendered lower must literally contain {cross:?}"
1614        );
1615        let scope_cross = DeviationScope::issuer_dn_contains(cross);
1616        assert!(
1617            scope_cross.matches(&cert),
1618            "cross-RDN substring matches when it tracks the renderer literally"
1619        );
1620
1621        // Negative: substring with a space after the comma (a natural
1622        // human-author shape) does NOT match because x509-cert renders
1623        // without that space.
1624        let cross_with_space = "trust anchor, o=test certificates";
1625        assert!(
1626            !lower.contains(cross_with_space),
1627            "rendered lower does NOT contain {cross_with_space:?} — renderer omits space after comma"
1628        );
1629        let scope_space = DeviationScope::issuer_dn_contains(cross_with_space);
1630        assert!(
1631            !scope_space.matches(&cert),
1632            "space-after-comma substring must not match (renderer-coupling hazard)"
1633        );
1634
1635        // Negative: reordered substring assuming C-first RDN order
1636        // (most-significant first per X.500) does NOT match — x509-cert
1637        // uses RFC 4514 CN-first order.
1638        let reordered = "c=us,o=test certificates 2011,cn=trust anchor";
1639        let scope_reordered = DeviationScope::issuer_dn_contains(reordered);
1640        assert!(
1641            !scope_reordered.matches(&cert),
1642            "C-first-ordered substring must not match CN-first rendering"
1643        );
1644
1645        // Negative: long attribute name (`countryName`) does NOT match
1646        // because x509-cert renders short names (`C`).
1647        let long_name = "countryname=us";
1648        let scope_long = DeviationScope::issuer_dn_contains(long_name);
1649        assert!(
1650            !scope_long.matches(&cert),
1651            "long attribute name must not match x509-cert's short-name rendering"
1652        );
1653    }
1654
1655    #[test]
1656    fn scope_issuer_dn_contains_no_match() {
1657        let cert = load_cert();
1658        let scope = DeviationScope::issuer_dn_contains("XYZ_NONEXISTENT_ISSUER_9999");
1659        assert!(!scope.matches(&cert));
1660    }
1661
1662    /// `DeviationStore::add` normalizes `IssuerDnContains` to lowercase so that
1663    /// callers who pass a mixed-case substring get a working deviation rather than
1664    /// a silently inactive one.
1665    #[test]
1666    fn deviation_store_add_normalizes_issuer_dn_contains_to_lowercase() {
1667        let cert = load_cert();
1668        let issuer = cert.tbs_certificate.issuer.to_string();
1669        let word = issuer
1670            .split(|c: char| !c.is_alphanumeric())
1671            .find(|w| !w.is_empty())
1672            .unwrap_or("test");
1673        let uppercase_word = word.to_uppercase();
1674
1675        // Only run the assertion when the word has a meaningful uppercase form.
1676        if uppercase_word == word.to_lowercase() {
1677            return;
1678        }
1679
1680        // Add a deviation whose scope uses an UPPERCASE substring.
1681        let mut store = DeviationStore::new();
1682        let deviation = Deviation {
1683            scope: DeviationScope::issuer_dn_contains(uppercase_word.clone()),
1684            ..make_deviation("norm-test", "test.lint")
1685        };
1686        store.add(deviation).expect("add must succeed");
1687
1688        // The stored substring must have been normalized to lowercase.
1689        let stored = &store.all()[0].scope;
1690        assert_eq!(stored.kind, SCOPE_KIND_ISSUER_DN_CONTAINS);
1691        let stored_substring = match stored.get_prop(PROP_ISSUER_DN_SUBSTRING) {
1692            Some(ScopePropValue::Text(s)) => s,
1693            other => panic!("expected Text substring prop, got {other:?}"),
1694        };
1695        assert_eq!(
1696            *stored_substring,
1697            uppercase_word.to_lowercase(),
1698            "DeviationStore::add must lowercase issuer-dn-substring prop"
1699        );
1700
1701        // And the normalized deviation must match the cert.
1702        assert!(
1703            stored.matches(&cert),
1704            "normalized issuer-dn-contains scope must match cert"
1705        );
1706    }
1707
1708    /// Regression for PKIX-hy2e.8: case folding must be Unicode-aware
1709    /// on both sides (DeviationStore::add and DeviationScope::matches).
1710    /// The pre-fix `make_ascii_lowercase` left non-ASCII characters
1711    /// untouched on both sides; lowercase user input 'müller' could
1712    /// never match cert-side 'Müller' because the cert's 'ü' was not
1713    /// folded to match the stored substring's 'ü'. (Well, the bug was
1714    /// inverted: stored substring "müller" preserves 'ü'; cert side
1715    /// "Müller" leaves 'M' uppercase. With `to_lowercase` both fold to
1716    /// the same form.)
1717    ///
1718    /// Independent oracle: Unicode 15.1 default case mapping table.
1719    /// "Müller".to_lowercase() == "müller". "MÜLLER".to_lowercase() ==
1720    /// "müller". This is the property `to_lowercase` was designed to
1721    /// provide; `make_ascii_lowercase` does not.
1722    #[test]
1723    fn case_folding_is_unicode_aware_for_store_normalization() {
1724        // The store-side normalization happens in DeviationStore::add.
1725        let mut store = DeviationStore::new();
1726        let deviation = Deviation {
1727            scope: DeviationScope::issuer_dn_contains("MÜLLER"),
1728            ..make_deviation("muller-test", "test.lint")
1729        };
1730        store.add(deviation).expect("add must succeed");
1731
1732        let stored = &store.all()[0].scope;
1733        let stored_substring = match stored.get_prop(PROP_ISSUER_DN_SUBSTRING) {
1734            Some(ScopePropValue::Text(s)) => s,
1735            other => panic!("expected Text substring prop, got {other:?}"),
1736        };
1737        assert_eq!(
1738            *stored_substring, "müller",
1739            "DeviationStore::add must Unicode-lowercase the issuer-dn-substring; \
1740             pre-fix make_ascii_lowercase produced \"mÜller\" (Ü untouched)"
1741        );
1742    }
1743
1744    #[test]
1745    fn case_folding_is_unicode_aware_via_to_lowercase() {
1746        // Confirm the std::str::to_lowercase oracle behaves as
1747        // expected for the worked example in the rustdoc. This is the
1748        // independent oracle for the regression — if Rust's
1749        // to_lowercase ever changed semantics for "Müller" → "müller"
1750        // we would need to revisit the deviation matching strategy.
1751        assert_eq!("Müller".to_lowercase(), "müller");
1752        assert_eq!("MÜLLER".to_lowercase(), "müller");
1753        // ASCII-only fold leaves Ü/ü unchanged — that is the bug shape.
1754        let mut s = String::from("MÜLLER");
1755        s.make_ascii_lowercase();
1756        assert_eq!(s, "mÜller", "ASCII-only fold is documented to leave non-ASCII alone");
1757        // The pre-fix code on the cert side did exactly this fold, so
1758        // a lowercase stored substring 'müller' could not match 'mÜller'.
1759    }
1760
1761    // -----------------------------------------------------------------------
1762    // IssuerDnExact scope tests
1763    //
1764    // Oracle: IssuerDnExact uses pkix_path::names_match (RFC 4518 normalization).
1765    // A cert's issuer DN must match the stored DN via that same function.
1766    // -----------------------------------------------------------------------
1767
1768    #[test]
1769    fn scope_issuer_dn_exact_matches_cert_issuer() {
1770        use der::Encode as _;
1771        let cert = load_cert();
1772        // Use the cert's own issuer DN as the exact match — must succeed.
1773        let issuer_der = cert
1774            .tbs_certificate
1775            .issuer
1776            .to_der()
1777            .expect("Name::to_der is infallible for a parsed Name");
1778        let scope = DeviationScope::issuer_dn_exact(issuer_der);
1779        assert!(
1780            scope.matches(&cert),
1781            "issuer_dn_exact with cert's own issuer must match"
1782        );
1783    }
1784
1785    #[test]
1786    fn scope_issuer_dn_exact_does_not_match_different_dn() {
1787        use der::{Decode as _, Encode as _};
1788        let cert = load_cert();
1789        // Use the cert's subject DN as the "issuer" — for a self-signed cert subject==issuer,
1790        // so use a different cert's issuer if available. Since we only have one fixture
1791        // that is self-signed (subject == issuer), we test non-match by constructing
1792        // an issuer_dn_exact with a DIFFERENT cert's issuer.
1793        //
1794        // Load the smime fixture (different cert, different DN).
1795        let other_cert = Certificate::from_der(include_bytes!(
1796            "../../pkix-path/tests/fixtures/policy-checks/smime-self-signed-365d.der"
1797        ))
1798        .expect("fixture is valid DER");
1799        // Use smime cert's issuer as the scope — should not match the webpki cert.
1800        let other_issuer_der = other_cert
1801            .tbs_certificate
1802            .issuer
1803            .to_der()
1804            .expect("Name::to_der is infallible for a parsed Name");
1805        let scope = DeviationScope::issuer_dn_exact(other_issuer_der);
1806        // If both certs have the same issuer DN, the test is vacuous. Check first.
1807        let same = pkix_path::names_match(
1808            &cert.tbs_certificate.issuer,
1809            &other_cert.tbs_certificate.issuer,
1810        );
1811        if !same {
1812            assert!(
1813                !scope.matches(&cert),
1814                "issuer_dn_exact with different issuer must not match"
1815            );
1816        }
1817        // If same (both self-signed with identical DNs), the test passes vacuously —
1818        // the fixtures happen to have the same issuer, and that's acceptable.
1819    }
1820
1821    // -----------------------------------------------------------------------
1822    // SerialRange scope tests
1823    //
1824    // Oracle: serial_cmp implements DER positive integer comparison.
1825    // Boundary conditions are tested independently of the cert fixture.
1826    // -----------------------------------------------------------------------
1827
1828    #[test]
1829    fn serial_cmp_greater() {
1830        use core::cmp::Ordering;
1831        // 0x02 > 0x01
1832        assert_eq!(serial_cmp(&[0x02], &[0x01]), Ordering::Greater);
1833        // longer byte sequence (more digits) is larger
1834        assert_eq!(serial_cmp(&[0x01, 0x00], &[0xFF]), Ordering::Greater);
1835    }
1836
1837    #[test]
1838    fn serial_cmp_less() {
1839        use core::cmp::Ordering;
1840        // 0x01 < 0x02
1841        assert_eq!(serial_cmp(&[0x01], &[0x02]), Ordering::Less);
1842        // shorter (after strip) is smaller
1843        assert_eq!(serial_cmp(&[0xFF], &[0x01, 0x00]), Ordering::Less);
1844    }
1845
1846    #[test]
1847    fn serial_cmp_equal() {
1848        use core::cmp::Ordering;
1849        // identical
1850        assert_eq!(serial_cmp(&[0x05], &[0x05]), Ordering::Equal);
1851    }
1852
1853    #[test]
1854    fn serial_cmp_leading_zeros_stripped() {
1855        use core::cmp::Ordering;
1856        // 0x00 0x01 = 1, 0x01 = 1 — equal after stripping leading zero on a.
1857        assert_eq!(serial_cmp(&[0x00, 0x01], &[0x01]), Ordering::Equal);
1858        // is_ge / is_le on Equal are both true (matches old serial_lex_{ge,le} behavior).
1859        assert!(serial_cmp(&[0x00, 0x01], &[0x01]).is_ge());
1860        assert!(serial_cmp(&[0x00, 0x01], &[0x01]).is_le());
1861    }
1862
1863    #[test]
1864    fn scope_serial_range_matches_cert_in_range() {
1865        use der::Encode as _;
1866        let cert = load_cert();
1867        let serial = cert.tbs_certificate.serial_number.as_bytes().to_vec();
1868        let issuer_der = cert
1869            .tbs_certificate
1870            .issuer
1871            .to_der()
1872            .expect("Name::to_der is infallible for a parsed Name");
1873        // Range is [serial, serial] — cert's own serial, must match.
1874        let scope = DeviationScope::serial_range(issuer_der, serial.clone(), serial);
1875        assert!(
1876            scope.matches(&cert),
1877            "cert's own serial must be within [serial, serial]"
1878        );
1879    }
1880
1881    #[test]
1882    fn scope_serial_range_excludes_cert_outside_range() {
1883        use der::Encode as _;
1884        let cert = load_cert();
1885        let serial = cert.tbs_certificate.serial_number.as_bytes();
1886        // Range is [serial+1, serial+2] — cert's serial is below, must not match.
1887        // Construct a start that is definitely higher: 0xFF repeated.
1888        let start = vec![0xFF; serial.len() + 1]; // much larger than any fixed serial
1889        let end = vec![0xFF; serial.len() + 2];
1890        let issuer_der = cert
1891            .tbs_certificate
1892            .issuer
1893            .to_der()
1894            .expect("Name::to_der is infallible for a parsed Name");
1895        let scope = DeviationScope::serial_range(issuer_der, start, end);
1896        assert!(
1897            !scope.matches(&cert),
1898            "cert serial below range start must not match"
1899        );
1900    }
1901
1902    #[test]
1903    fn scope_serial_range_wrong_issuer_no_match() {
1904        use der::{Decode as _, Encode as _};
1905        let cert = load_cert();
1906        let other_cert = Certificate::from_der(include_bytes!(
1907            "../../pkix-path/tests/fixtures/policy-checks/smime-self-signed-365d.der"
1908        ))
1909        .expect("fixture is valid DER");
1910        let serial = cert.tbs_certificate.serial_number.as_bytes().to_vec();
1911        // Use the other cert's issuer — should not match cert.
1912        let other_issuer_der = other_cert
1913            .tbs_certificate
1914            .issuer
1915            .to_der()
1916            .expect("Name::to_der is infallible for a parsed Name");
1917        let scope = DeviationScope::serial_range(
1918            other_issuer_der,
1919            vec![0x00],
1920            vec![0xFF; serial.len() + 2],
1921        );
1922        let same_issuer = pkix_path::names_match(
1923            &cert.tbs_certificate.issuer,
1924            &other_cert.tbs_certificate.issuer,
1925        );
1926        if !same_issuer {
1927            assert!(
1928                !scope.matches(&cert),
1929                "wrong issuer in serial_range must not match"
1930            );
1931        }
1932    }
1933
1934    // -----------------------------------------------------------------------
1935    // PKIX-9vnx.11: open-ended kind discriminator
1936    //
1937    // Verifies that unknown kinds and props-bag malformations fail closed.
1938    // -----------------------------------------------------------------------
1939
1940    #[test]
1941    fn scope_unknown_kind_fails_closed() {
1942        let cert = load_cert();
1943        let scope = DeviationScope {
1944            kind: "pkix-lint.scope.future-axis-not-yet-defined".to_string(),
1945            props: vec![],
1946        };
1947        assert!(
1948            !scope.matches(&cert),
1949            "unknown kind must fail-closed (return false)"
1950        );
1951    }
1952
1953    #[test]
1954    fn scope_issuer_dn_contains_missing_prop_fails_closed() {
1955        let cert = load_cert();
1956        // Hand-built scope with kind set but the substring prop missing.
1957        let scope = DeviationScope {
1958            kind: SCOPE_KIND_ISSUER_DN_CONTAINS.to_string(),
1959            props: vec![],
1960        };
1961        assert!(
1962            !scope.matches(&cert),
1963            "missing substring prop must fail-closed"
1964        );
1965    }
1966
1967    #[test]
1968    fn scope_issuer_dn_exact_wrong_typed_prop_fails_closed() {
1969        let cert = load_cert();
1970        // Hand-built scope where the DER prop is Text instead of Bytes.
1971        let scope = DeviationScope {
1972            kind: SCOPE_KIND_ISSUER_DN_EXACT.to_string(),
1973            props: vec![(
1974                PROP_ISSUER_DN_DER.to_string(),
1975                ScopePropValue::Text("not bytes".to_string()),
1976            )],
1977        };
1978        assert!(
1979            !scope.matches(&cert),
1980            "wrong-typed issuer-dn-der prop must fail-closed"
1981        );
1982    }
1983
1984    #[test]
1985    fn scope_issuer_dn_exact_malformed_der_fails_closed() {
1986        let cert = load_cert();
1987        let scope = DeviationScope {
1988            kind: SCOPE_KIND_ISSUER_DN_EXACT.to_string(),
1989            props: vec![(
1990                PROP_ISSUER_DN_DER.to_string(),
1991                // Random bytes that do not decode as a Name.
1992                ScopePropValue::Bytes(vec![0xFF, 0xFE, 0xFD]),
1993            )],
1994        };
1995        assert!(
1996            !scope.matches(&cert),
1997            "malformed issuer-dn-der bytes must fail-closed"
1998        );
1999    }
2000
2001    #[test]
2002    fn scope_constructor_kinds_match_constants() {
2003        use der::Encode as _;
2004        // The constructors must produce scopes whose `kind` field matches the
2005        // corresponding `SCOPE_KIND_*` constant. This is an invariant the OSCAL
2006        // emit/parse layer relies on.
2007        assert_eq!(DeviationScope::any().kind, SCOPE_KIND_ANY);
2008        assert_eq!(
2009            DeviationScope::issuer_dn_contains("x").kind,
2010            SCOPE_KIND_ISSUER_DN_CONTAINS
2011        );
2012        let cert = load_cert();
2013        let issuer_der = cert
2014            .tbs_certificate
2015            .issuer
2016            .to_der()
2017            .expect("Name::to_der is infallible for a parsed Name");
2018        let exact = DeviationScope::issuer_dn_exact(issuer_der.clone());
2019        assert_eq!(exact.kind, SCOPE_KIND_ISSUER_DN_EXACT);
2020        let range = DeviationScope::serial_range(issuer_der, vec![0x01], vec![0x02]);
2021        assert_eq!(range.kind, SCOPE_KIND_SERIAL_RANGE);
2022    }
2023
2024    // -----------------------------------------------------------------------
2025    // DeviationStore tests
2026    // Oracle: the store contract in DeviationStore doc comments.
2027    // -----------------------------------------------------------------------
2028
2029    #[test]
2030    fn store_add_and_retrieve() {
2031        let mut store = DeviationStore::new();
2032        store
2033            .add(make_deviation("d1", "test.lint.a"))
2034            .expect("add should succeed");
2035        store
2036            .add(make_deviation("d2", "test.lint.b"))
2037            .expect("add should succeed");
2038        assert_eq!(store.all().len(), 2);
2039    }
2040
2041    /// Regression test for PKIX-7f92.8: Deviation::new returns
2042    /// EmptyField at construction time on empty justification, not at
2043    /// store.add() time. Locks the fail-fast contract that closes the
2044    /// "config-string-driven builder slips past, gets serialized, fails
2045    /// only at add() much later" hazard.
2046    #[test]
2047    fn new_rejects_empty_justification() {
2048        let err = Deviation::new(
2049            "d1",
2050            "test.lint",
2051            DeviationScope::any(),
2052            DeviationAction::Suppress,
2053            "",
2054            "ops@example.com",
2055        )
2056        .expect_err("empty justification must fail at construction");
2057        assert_eq!(err, DeviationAddError::EmptyField("justification".into()));
2058    }
2059
2060    #[test]
2061    fn new_rejects_empty_authorized_by() {
2062        let err = Deviation::new(
2063            "d1",
2064            "test.lint",
2065            DeviationScope::any(),
2066            DeviationAction::Suppress,
2067            "valid justification",
2068            "",
2069        )
2070        .expect_err("empty authorized_by must fail at construction");
2071        assert_eq!(err, DeviationAddError::EmptyField("authorized_by".into()));
2072    }
2073
2074    /// new() returning Ok for non-empty fields must produce a Deviation
2075    /// that store.add() accepts. Locks the symmetry between the two
2076    /// entry points.
2077    #[test]
2078    fn new_accepts_non_empty_fields_and_add_succeeds() {
2079        let dev = Deviation::new(
2080            "d1",
2081            "test.lint",
2082            DeviationScope::any(),
2083            DeviationAction::Suppress,
2084            "valid justification",
2085            "ops@example.com",
2086        )
2087        .expect("non-empty fields");
2088        let mut store = DeviationStore::new();
2089        store.add(dev).expect("add must succeed");
2090    }
2091
2092    #[test]
2093    fn store_rejects_empty_justification() {
2094        let mut store = DeviationStore::new();
2095        let result = store.add(Deviation {
2096            justification: String::new(),
2097            ..make_deviation("d1", "test.lint")
2098        });
2099        assert_eq!(
2100            result,
2101            Err(DeviationAddError::EmptyField("justification".into())),
2102            "empty justification must return EmptyField error"
2103        );
2104    }
2105
2106    #[test]
2107    fn store_rejects_empty_authorized_by() {
2108        let mut store = DeviationStore::new();
2109        let result = store.add(Deviation {
2110            authorized_by: String::new(),
2111            ..make_deviation("d1", "test.lint")
2112        });
2113        assert_eq!(
2114            result,
2115            Err(DeviationAddError::EmptyField("authorized_by".into())),
2116            "empty authorized_by must return EmptyField error"
2117        );
2118    }
2119
2120    #[test]
2121    fn store_rejects_duplicate_id() {
2122        let mut store = DeviationStore::new();
2123        store
2124            .add(make_deviation("d1", "test.lint.a"))
2125            .expect("first add should succeed");
2126        let result = store.add(make_deviation("d1", "test.lint.b")); // same id → error
2127        assert!(result.is_err(), "duplicate id must return Err");
2128        assert_eq!(
2129            result.unwrap_err(),
2130            DeviationAddError::DuplicateId("d1".to_string())
2131        );
2132    }
2133
2134    // -----------------------------------------------------------------------
2135    // PKIX-hy2e.9 regression — DeviationStore::add rejects structurally
2136    // malformed scopes with DeviationAddError::MalformedScope, so the
2137    // operator sees a specific error at insertion time rather than a
2138    // silent never-match at evaluation time.
2139    // -----------------------------------------------------------------------
2140
2141    #[test]
2142    fn store_rejects_issuer_dn_contains_missing_substring_prop() {
2143        let mut store = DeviationStore::new();
2144        let bad = Deviation {
2145            scope: DeviationScope {
2146                kind: SCOPE_KIND_ISSUER_DN_CONTAINS.to_string(),
2147                props: vec![], // missing PROP_ISSUER_DN_SUBSTRING
2148            },
2149            ..make_deviation("malformed", "test.lint")
2150        };
2151        let err = store.add(bad).expect_err("malformed scope must be rejected");
2152        match err {
2153            DeviationAddError::MalformedScope { kind, reason } => {
2154                assert_eq!(kind, SCOPE_KIND_ISSUER_DN_CONTAINS);
2155                assert!(
2156                    reason.contains(PROP_ISSUER_DN_SUBSTRING),
2157                    "reason must name the missing prop; got: {reason}"
2158                );
2159            }
2160            other => panic!("expected MalformedScope, got: {other:?}"),
2161        }
2162    }
2163
2164    #[test]
2165    fn store_rejects_issuer_dn_contains_wrong_typed_substring_prop() {
2166        let mut store = DeviationStore::new();
2167        let bad = Deviation {
2168            scope: DeviationScope {
2169                kind: SCOPE_KIND_ISSUER_DN_CONTAINS.to_string(),
2170                props: vec![(
2171                    PROP_ISSUER_DN_SUBSTRING.to_string(),
2172                    ScopePropValue::Bytes(vec![0x00]), // wrong type — should be Text
2173                )],
2174            },
2175            ..make_deviation("malformed", "test.lint")
2176        };
2177        let err = store.add(bad).expect_err("wrong-typed prop must be rejected");
2178        match err {
2179            DeviationAddError::MalformedScope { kind, reason } => {
2180                assert_eq!(kind, SCOPE_KIND_ISSUER_DN_CONTAINS);
2181                assert!(
2182                    reason.contains("Text"),
2183                    "reason must name the expected type; got: {reason}"
2184                );
2185            }
2186            other => panic!("expected MalformedScope, got: {other:?}"),
2187        }
2188    }
2189
2190    #[test]
2191    fn store_rejects_issuer_dn_exact_missing_der_prop() {
2192        let mut store = DeviationStore::new();
2193        let bad = Deviation {
2194            scope: DeviationScope {
2195                kind: SCOPE_KIND_ISSUER_DN_EXACT.to_string(),
2196                props: vec![], // missing PROP_ISSUER_DN_DER
2197            },
2198            ..make_deviation("malformed", "test.lint")
2199        };
2200        let err = store.add(bad).expect_err("malformed scope must be rejected");
2201        assert!(matches!(err, DeviationAddError::MalformedScope { .. }));
2202    }
2203
2204    #[test]
2205    fn store_rejects_serial_range_missing_serial_end_prop() {
2206        let mut store = DeviationStore::new();
2207        let bad = Deviation {
2208            scope: DeviationScope {
2209                kind: SCOPE_KIND_SERIAL_RANGE.to_string(),
2210                props: vec![
2211                    (
2212                        PROP_ISSUER_DN_DER.to_string(),
2213                        ScopePropValue::Bytes(vec![0x30, 0x00]),
2214                    ),
2215                    (
2216                        PROP_SERIAL_START.to_string(),
2217                        ScopePropValue::Bytes(vec![0x01]),
2218                    ),
2219                    // missing PROP_SERIAL_END
2220                ],
2221            },
2222            ..make_deviation("malformed", "test.lint")
2223        };
2224        let err = store.add(bad).expect_err("malformed scope must be rejected");
2225        match err {
2226            DeviationAddError::MalformedScope { reason, .. } => {
2227                assert!(
2228                    reason.contains(PROP_SERIAL_END),
2229                    "reason must name the missing prop; got: {reason}"
2230                );
2231            }
2232            other => panic!("expected MalformedScope, got: {other:?}"),
2233        }
2234    }
2235
2236    #[test]
2237    fn store_accepts_well_formed_scopes_from_constructors() {
2238        // Positive control: the canonical scope constructors produce
2239        // well-formed scopes that pass validate_scope.
2240        let mut store = DeviationStore::new();
2241        store
2242            .add(Deviation {
2243                scope: DeviationScope::any(),
2244                ..make_deviation("d-any", "lint.a")
2245            })
2246            .expect("any() scope must be well-formed");
2247        store
2248            .add(Deviation {
2249                scope: DeviationScope::issuer_dn_contains("foo"),
2250                ..make_deviation("d-contains", "lint.b")
2251            })
2252            .expect("issuer_dn_contains() scope must be well-formed");
2253    }
2254
2255    #[test]
2256    fn store_accepts_custom_scope_kind_without_inspection() {
2257        // Per the DeviationScope rustdoc, unknown / custom kinds are
2258        // accepted (and fail-closed at match time). Validate that the
2259        // store does not reject them at add time.
2260        let mut store = DeviationStore::new();
2261        let custom = Deviation {
2262            scope: DeviationScope {
2263                kind: "custom.policy-bundle.org/some-axis".to_string(),
2264                props: vec![],
2265            },
2266            ..make_deviation("d-custom", "lint.c")
2267        };
2268        store.add(custom).expect(
2269            "custom scope kinds are caller-defined extensibility; \
2270             validate_scope must not reject them",
2271        );
2272    }
2273
2274    #[test]
2275    fn store_find_deviation_matches() {
2276        let cert = load_cert();
2277        let now: u64 = 1_000_000;
2278        let mut store = DeviationStore::new();
2279        store
2280            .add(Deviation {
2281                effective_start: None,
2282                effective_end: None,
2283                ..make_deviation("d1", "test.lint.a")
2284            })
2285            .expect("add should succeed");
2286        let found = store.find_deviation("test.lint.a", &cert, now);
2287        assert!(found.is_some());
2288        assert_eq!(found.unwrap().id, "d1");
2289    }
2290
2291    #[test]
2292    fn store_find_deviation_no_match_wrong_lint() {
2293        let cert = load_cert();
2294        let now: u64 = 1_000_000;
2295        let mut store = DeviationStore::new();
2296        store
2297            .add(make_deviation("d1", "test.lint.a"))
2298            .expect("add should succeed");
2299        assert!(store.find_deviation("test.lint.b", &cert, now).is_none());
2300    }
2301
2302    #[test]
2303    fn store_find_deviation_expired_not_matched() {
2304        let cert = load_cert();
2305        let now: u64 = 1_000;
2306        let mut store = DeviationStore::new();
2307        store
2308            .add(Deviation {
2309                effective_end: Some(500), // expired at 500
2310                ..make_deviation("d1", "test.lint.a")
2311            })
2312            .expect("add should succeed");
2313        // At now=1000, the deviation has expired.
2314        assert!(store.find_deviation("test.lint.a", &cert, now).is_none());
2315    }
2316
2317    // -----------------------------------------------------------------------
2318    // PKIX-hy2e.11 regression — find_deviation_for_chain scans every cert
2319    // in the chain, not just chain[0]. The pre-fix DeviationRunner::run_path
2320    // applied scope matching only to the leaf, silently dropping deviations
2321    // scoped to intermediate-CA DNs (e.g., "issuer_dn_contains: intermediate-x"
2322    // against a path finding triggered by Intermediate-X's properties).
2323    // -----------------------------------------------------------------------
2324
2325    fn load_cert_at(path: &str) -> Certificate {
2326        use der::Decode as _;
2327        let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {path}: {e}"));
2328        Certificate::from_der(&bytes).unwrap_or_else(|e| panic!("decode {path}: {e}"))
2329    }
2330
2331    fn cert_webpki() -> Certificate {
2332        // Issuer DN: CN=PKIX-webpki-self
2333        load_cert_at("../pkix-path/tests/fixtures/policy-checks/webpki-self-signed-365d.der")
2334    }
2335
2336    fn cert_smime() -> Certificate {
2337        // Issuer DN: CN=PKIX-smime-self
2338        load_cert_at("../pkix-path/tests/fixtures/policy-checks/smime-self-signed-365d.der")
2339    }
2340
2341    #[test]
2342    fn find_deviation_for_chain_matches_when_intermediate_in_scope() {
2343        // Build a deviation scoped to a substring that appears in the
2344        // INTERMEDIATE's issuer DN but NOT the leaf's.
2345        //
2346        // The chain has two distinct certs: chain[0] is the webpki-self
2347        // cert (issuer DN contains "webpki"), chain[1] is the smime-self
2348        // cert (issuer DN contains "smime"). A deviation scoped
2349        // "issuer_dn_contains: smime" must match via chain[1], not via
2350        // chain[0]. The pre-fix run_path / find_deviation-only-on-leaf
2351        // would have missed this case.
2352        let leaf = cert_webpki();
2353        let intermediate = cert_smime();
2354        let chain = [leaf, intermediate];
2355
2356        let mut store = DeviationStore::new();
2357        store
2358            .add(Deviation {
2359                scope: DeviationScope::issuer_dn_contains("smime"),
2360                ..make_deviation("dev-intermediate-scope", "test.path.lint")
2361            })
2362            .expect("add must succeed");
2363
2364        let found = store.find_deviation_for_chain("test.path.lint", &chain, 1_000_000);
2365        let dev = found.expect(
2366            "deviation scoped to intermediate-cert issuer DN must match via chain[1]; \
2367             pre-fix run_path / find_deviation only on chain[0] would miss this",
2368        );
2369        assert_eq!(dev.id, "dev-intermediate-scope");
2370    }
2371
2372    #[test]
2373    fn find_deviation_for_chain_returns_none_when_no_cert_in_scope() {
2374        // Negative control: a deviation whose scope substring matches
2375        // none of the chain certs must not fire.
2376        let leaf = cert_webpki();
2377        let intermediate = cert_smime();
2378        let chain = [leaf, intermediate];
2379
2380        let mut store = DeviationStore::new();
2381        store
2382            .add(Deviation {
2383                scope: DeviationScope::issuer_dn_contains("xyz-nonexistent-dn"),
2384                ..make_deviation("dev-no-match", "test.path.lint")
2385            })
2386            .expect("add must succeed");
2387
2388        assert!(
2389            store
2390                .find_deviation_for_chain("test.path.lint", &chain, 1_000_000)
2391                .is_none(),
2392            "deviation scoped to a non-matching substring must not fire on any chain cert"
2393        );
2394    }
2395
2396    // -----------------------------------------------------------------------
2397    // PKIX-hy2e.10 regression — Deviation.priority resolution. Among
2398    // matching deviations, the highest priority wins; insertion order
2399    // breaks ties. Documented contract on DeviationStore::find_deviation
2400    // and DeviationStore::find_deviation_for_chain.
2401    // -----------------------------------------------------------------------
2402
2403    #[test]
2404    fn find_deviation_higher_priority_wins() {
2405        let cert = load_cert();
2406        let now: u64 = 1_000_000;
2407        let mut store = DeviationStore::new();
2408        // First-added has priority 0 (default), should lose.
2409        store
2410            .add(
2411                Deviation::new(
2412                    "wildcard",
2413                    "test.lint",
2414                    DeviationScope::any(),
2415                    DeviationAction::Suppress,
2416                    "wildcard waiver",
2417                    "ops@example.com",
2418                )
2419                .expect("non-empty fields")
2420                .with_priority(0),
2421            )
2422            .expect("add wildcard");
2423        // Second-added has priority 100, should win even though added
2424        // later (insertion order is the tie-breaker, not the primary).
2425        store
2426            .add(
2427                Deviation::new(
2428                    "specific",
2429                    "test.lint",
2430                    DeviationScope::any(),
2431                    DeviationAction::DowngradeSeverityTo(Severity::Info),
2432                    "lab-specific waiver",
2433                    "lab-lead@example.com",
2434                )
2435                .expect("non-empty fields")
2436                .with_priority(100),
2437            )
2438            .expect("add specific");
2439
2440        let found = store.find_deviation("test.lint", &cert, now);
2441        let dev = found.expect("at least one deviation must match");
2442        assert_eq!(
2443            dev.id, "specific",
2444            "higher priority must win regardless of insertion order; \
2445             got dev.id={} priority={}",
2446            dev.id, dev.priority
2447        );
2448    }
2449
2450    #[test]
2451    fn find_deviation_priority_tie_breaks_by_insertion_order() {
2452        let cert = load_cert();
2453        let now: u64 = 1_000_000;
2454        let mut store = DeviationStore::new();
2455        store
2456            .add(Deviation {
2457                id: "first".to_string(),
2458                priority: 50,
2459                ..make_deviation("first", "test.lint")
2460            })
2461            .expect("add first");
2462        store
2463            .add(Deviation {
2464                id: "second".to_string(),
2465                priority: 50,
2466                ..make_deviation("second", "test.lint")
2467            })
2468            .expect("add second");
2469
2470        let found = store.find_deviation("test.lint", &cert, now);
2471        assert_eq!(
2472            found.expect("at least one match").id,
2473            "first",
2474            "insertion order is the documented tie-breaker for equal priority"
2475        );
2476    }
2477
2478    #[test]
2479    fn find_deviation_negative_priority_loses_to_default() {
2480        let cert = load_cert();
2481        let now: u64 = 1_000_000;
2482        let mut store = DeviationStore::new();
2483        store
2484            .add(Deviation {
2485                id: "fallback".to_string(),
2486                priority: -100,
2487                ..make_deviation("fallback", "test.lint")
2488            })
2489            .expect("add fallback");
2490        store
2491            .add(Deviation {
2492                id: "normal".to_string(),
2493                priority: 0,
2494                ..make_deviation("normal", "test.lint")
2495            })
2496            .expect("add normal");
2497
2498        let found = store.find_deviation("test.lint", &cert, now);
2499        assert_eq!(
2500            found.expect("at least one match").id,
2501            "normal",
2502            "default priority 0 must outrank negative -100"
2503        );
2504    }
2505
2506    #[test]
2507    fn find_deviation_for_chain_first_match_wins_in_store_order() {
2508        // Two deviations both targeting the same lint id; both could
2509        // theoretically match via the chain. The first added wins (same
2510        // rule as find_deviation per-cert). Tests that store order
2511        // determines resolution, not chain order.
2512        let leaf = cert_webpki();
2513        let intermediate = cert_smime();
2514        let chain = [leaf, intermediate];
2515
2516        let mut store = DeviationStore::new();
2517        store
2518            .add(Deviation {
2519                id: "dev-first".to_string(),
2520                scope: DeviationScope::issuer_dn_contains("smime"),
2521                ..make_deviation("dev-first", "test.path.lint")
2522            })
2523            .expect("add must succeed");
2524        store
2525            .add(Deviation {
2526                id: "dev-second".to_string(),
2527                scope: DeviationScope::issuer_dn_contains("webpki"),
2528                ..make_deviation("dev-second", "test.path.lint")
2529            })
2530            .expect("add must succeed");
2531
2532        let found = store
2533            .find_deviation_for_chain("test.path.lint", &chain, 1_000_000)
2534            .expect("at least one deviation must match");
2535        assert_eq!(
2536            found.id, "dev-first",
2537            "store-insertion order is the tie-breaker, not chain-iteration order"
2538        );
2539    }
2540
2541    #[test]
2542    fn store_expired_at_reports_expired_deviations() {
2543        let mut store = DeviationStore::new();
2544        store
2545            .add(Deviation {
2546                effective_end: Some(500),
2547                ..make_deviation("d1", "test.lint.a")
2548            })
2549            .expect("add should succeed");
2550        store
2551            .add(Deviation {
2552                effective_end: None, // never expires
2553                ..make_deviation("d2", "test.lint.b")
2554            })
2555            .expect("add should succeed");
2556        let expired: Vec<_> = store.expired_at(1000).collect();
2557        assert_eq!(expired.len(), 1);
2558        assert_eq!(expired[0].id, "d1");
2559    }
2560
2561    #[test]
2562    fn deviated_finding_effective_severity() {
2563        let f = DeviatedFinding {
2564            lint_id: std::borrow::Cow::Borrowed("test.lint"),
2565            citation: std::borrow::Cow::Borrowed("test citation"),
2566            original_result: LintResult::error("original"),
2567            deviation_id: "d1".to_string(),
2568            action: DeviationAction::DowngradeSeverityTo(Severity::Info),
2569            justification: "test justification".to_string(),
2570            evidence_uri: None,
2571            cert_index: None,
2572            evaluated_at_unix: 0,
2573        };
2574        assert_eq!(f.effective_severity(), Some(Severity::Info));
2575
2576        let f2 = DeviatedFinding {
2577            action: DeviationAction::Suppress,
2578            ..f
2579        };
2580        assert_eq!(f2.effective_severity(), None);
2581    }
2582
2583    // -----------------------------------------------------------------------
2584    // DeviationRunner tests
2585    // Oracle: DeviationRunner contract from doc comments.
2586    // -----------------------------------------------------------------------
2587
2588    /// A lint that always returns Error — used to test deviation application.
2589    #[derive(Clone)]
2590    struct AlwaysError;
2591    impl crate::Lint for AlwaysError {
2592        fn id(&self) -> &'static str {
2593            "test.always_error"
2594        }
2595        fn citation(&self) -> &'static str {
2596            "test"
2597        }
2598        fn severity(&self) -> crate::Severity {
2599            crate::Severity::Error
2600        }
2601        fn scope(&self) -> crate::Scope {
2602            crate::Scope::Certificate
2603        }
2604        fn applies_to(&self) -> crate::SubjectKind {
2605            crate::SubjectKind::Any
2606        }
2607        fn check_cert(
2608            &self,
2609            _cert: &Certificate,
2610            _kind: crate::SubjectKind,
2611            _now: u64,
2612        ) -> crate::LintResult {
2613            crate::LintResult::error("always errors")
2614        }
2615    }
2616
2617    /// A lint that always passes — used to verify non-deviated findings stay in findings.
2618    #[derive(Clone)]
2619    struct AlwaysPass;
2620    impl crate::Lint for AlwaysPass {
2621        fn id(&self) -> &'static str {
2622            "test.always_pass"
2623        }
2624        fn citation(&self) -> &'static str {
2625            "test"
2626        }
2627        fn severity(&self) -> crate::Severity {
2628            crate::Severity::Info
2629        }
2630        fn scope(&self) -> crate::Scope {
2631            crate::Scope::Certificate
2632        }
2633        fn applies_to(&self) -> crate::SubjectKind {
2634            crate::SubjectKind::Any
2635        }
2636        fn check_cert(
2637            &self,
2638            _cert: &Certificate,
2639            _kind: crate::SubjectKind,
2640            _now: u64,
2641        ) -> crate::LintResult {
2642            crate::LintResult::Pass
2643        }
2644    }
2645
2646    #[test]
2647    fn deviation_runner_moves_deviated_finding_to_deviated() {
2648        let cert = load_cert();
2649        let now: u64 = 1_000_000;
2650
2651        let mut store = DeviationStore::new();
2652        store
2653            .add(Deviation {
2654                target_lint: "test.always_error".to_string(),
2655                ..make_deviation("d1", "test.always_error")
2656            })
2657            .expect("add should succeed");
2658
2659        let runner = crate::LintRunner::new(vec![Box::new(AlwaysError)]);
2660        let dev_runner = DeviationRunner::new(runner, store);
2661        let result = dev_runner.run_cert(&cert, crate::SubjectKind::Leaf, 0, now);
2662
2663        // The error finding must be deviated, not in normal findings.
2664        assert!(
2665            result.findings.is_empty(),
2666            "deviated finding must not be in findings"
2667        );
2668        assert_eq!(
2669            result.deviated.len(),
2670            1,
2671            "deviated finding must be in deviated"
2672        );
2673        assert_eq!(result.deviated[0].lint_id, "test.always_error");
2674        assert_eq!(result.deviated[0].deviation_id, "d1");
2675        // Original result is preserved.
2676        assert!(matches!(
2677            result.deviated[0].original_result,
2678            crate::LintResult::Error(_)
2679        ));
2680    }
2681
2682    #[test]
2683    fn deviation_runner_non_deviated_finding_stays_in_findings() {
2684        let cert = load_cert();
2685        let now: u64 = 1_000_000;
2686
2687        // Deviation targets a different lint than what we're running.
2688        let mut store = DeviationStore::new();
2689        store
2690            .add(make_deviation("d1", "test.different_lint"))
2691            .expect("add should succeed");
2692
2693        let runner = crate::LintRunner::new(vec![Box::new(AlwaysPass)]);
2694        let dev_runner = DeviationRunner::new(runner, store);
2695        let result = dev_runner.run_cert(&cert, crate::SubjectKind::Leaf, 0, now);
2696
2697        // Pass finding not matched by deviation: stays in findings.
2698        assert_eq!(result.findings.len(), 1);
2699        assert!(result.deviated.is_empty());
2700    }
2701
2702    #[test]
2703    fn deviation_runner_expired_deviation_does_not_apply() {
2704        let cert = load_cert();
2705        let now: u64 = 2_000_000;
2706
2707        let mut store = DeviationStore::new();
2708        store
2709            .add(Deviation {
2710                effective_end: Some(1_000_000), // expired before now
2711                ..make_deviation("d1", "test.always_error")
2712            })
2713            .expect("add should succeed");
2714
2715        let runner = crate::LintRunner::new(vec![Box::new(AlwaysError)]);
2716        let dev_runner = DeviationRunner::new(runner, store);
2717        let result = dev_runner.run_cert(&cert, crate::SubjectKind::Leaf, 0, now);
2718
2719        // Expired deviation: error finding stays in findings (not deviated).
2720        assert_eq!(result.findings.len(), 1);
2721        assert!(result.deviated.is_empty());
2722    }
2723
2724    #[test]
2725    fn deviation_runner_suppress_action_sets_effective_severity_none() {
2726        let cert = load_cert();
2727        let now: u64 = 1_000_000;
2728
2729        let mut store = DeviationStore::new();
2730        store
2731            .add(Deviation {
2732                action: DeviationAction::Suppress,
2733                ..make_deviation("d1", "test.always_error")
2734            })
2735            .expect("add should succeed");
2736
2737        let runner = crate::LintRunner::new(vec![Box::new(AlwaysError)]);
2738        let dev_runner = DeviationRunner::new(runner, store);
2739        let result = dev_runner.run_cert(&cert, crate::SubjectKind::Leaf, 0, now);
2740
2741        assert!(result.findings.is_empty());
2742        assert_eq!(result.deviated.len(), 1);
2743        // Suppressed findings have no effective severity.
2744        assert_eq!(result.deviated[0].effective_severity(), None);
2745    }
2746
2747    /// `evidence_uri` flows from Deviation through to `DeviatedFinding`.
2748    ///
2749    /// Oracle: `DeviatedFinding.evidence_uri` must equal `Deviation.evidence_uri`.
2750    /// This is the field operators use to navigate to the waiver document.
2751    #[test]
2752    fn evidence_uri_flows_to_deviated_finding() {
2753        let cert = load_cert();
2754        let now: u64 = 1_000_000;
2755        let uri = "https://pkipolicy.agency.gov/waivers/2025-11-03";
2756
2757        let mut store = DeviationStore::new();
2758        store
2759            .add(Deviation {
2760                evidence_uri: Some(uri.to_string()),
2761                ..make_deviation("d1", "test.always_error")
2762            })
2763            .expect("add should succeed");
2764
2765        let runner = crate::LintRunner::new(vec![Box::new(AlwaysError)]);
2766        let dev_runner = DeviationRunner::new(runner, store);
2767        let result = dev_runner.run_cert(&cert, crate::SubjectKind::Leaf, 0, now);
2768
2769        assert_eq!(result.deviated.len(), 1);
2770        assert_eq!(
2771            result.deviated[0].evidence_uri.as_deref(),
2772            Some(uri),
2773            "evidence_uri must flow from Deviation to DeviatedFinding"
2774        );
2775        // justification also flows through.
2776        assert_eq!(result.deviated[0].justification, "test justification");
2777    }
2778
2779    /// When `evidence_uri` is None, `DeviatedFinding.evidence_uri` is None.
2780    #[test]
2781    fn evidence_uri_none_when_deviation_has_no_uri() {
2782        let cert = load_cert();
2783        let now: u64 = 1_000_000;
2784
2785        let mut store = DeviationStore::new();
2786        store
2787            .add(make_deviation("d1", "test.always_error"))
2788            .expect("add should succeed"); // evidence_uri: None
2789
2790        let runner = crate::LintRunner::new(vec![Box::new(AlwaysError)]);
2791        let dev_runner = DeviationRunner::new(runner, store);
2792        let result = dev_runner.run_cert(&cert, crate::SubjectKind::Leaf, 0, now);
2793
2794        assert_eq!(result.deviated.len(), 1);
2795        assert_eq!(result.deviated[0].evidence_uri, None);
2796    }
2797}