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}