Skip to main content

pkix_lint/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs, rust_2018_idioms)]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4
5//! Lint engine for X.509 certificate chains — structured soft-fail and advisory results.
6//!
7//! # What this crate provides
8//!
9//! `pkix-path` returns `Result<ValidatedPath, Error>` — hard pass or fail.
10//! That model cannot express "this certificate is RFC 5280 valid but violates
11//! CA/B Forum BR §7.1.4.2" without aborting the chain entirely.
12//!
13//! `pkix-lint` adds an advisory layer:
14//!
15//! - [`Lint`] — the unit of evaluation. Each lint has a stable ID, a normative
16//!   citation, a severity, a scope (certificate vs. full chain path), and a
17//!   subject-kind filter (leaf, intermediate CA, etc.).
18//! - [`LintResult`] — `Pass | NotApplicable | Warn | Error | Fatal`. `Warn`
19//!   and `Error` carry a `&'static str` detail message. `Fatal` within
20//!   `pkix-lint` means "stop evaluating further lints" — it is **not** a TLS
21//!   hard-fail. See the advisory-only contract below.
22//! - [`Finding`] — a lint ID paired with a [`LintResult`], optionally referencing
23//!   the chain index of the offending certificate.
24//! - [`LintRunner`] — evaluates a slice of `dyn Lint` objects against a certificate
25//!   or validated path and returns `Vec<Finding>`.
26//! - [`LintProfile`] — extends [`pkix_path::Profile`] with a `lints()` method so
27//!   that a profile can bundle its own lint set.
28//!
29//! # Finding ID stability
30//!
31//! Finding IDs (returned by [`Lint::id`]) are part of the public API.
32//! They MUST NOT change between crate versions without a semver-major bump.
33//! Format convention: `<regime>.<section>.<noun>`, e.g.:
34//! - `"cabf.br.tls.validity.max"`
35//! - `"cabf.smime.san.type"`
36//! - `"rfc5280.basic_constraints.ca_flag"`
37//!
38//! # Advisory-only contract
39//!
40//! **`pkix-lint` findings never cause a certificate to be rejected.** All runner
41//! methods return `Vec<Finding>` — they never return `Result::Err` and they never
42//! cause a TLS stack to abort a connection. Findings are advisory signals.
43//!
44//! Whether to act on a finding (reject a TLS connection, block a cert, alert an
45//! operator) is the caller's decision, configured per finding-ID at the integration
46//! layer (e.g., `pkix-chain` or a TLS stack binding). This design is intentional:
47//!
48//! - `pkix-lint` does not know whether you are in audit, monitoring, or enforcement
49//!   context. The caller does.
50//! - Spec ambiguity (CA/B Forum CPs, FPKI CPs, etc.) means some findings require
51//!   human judgment before enforcement. Hard-fail by default would cause outages.
52//! - The deviation/waiver mechanism (PKIX-jge) operates at this layer, not in
53//!   `pkix-lint` core.
54//!
55//! The only in-engine effect of [`LintResult::Fatal`] is stopping further lint
56//! evaluation for the current item — it does not escape as an error.
57//!
58//! # Design rationale
59//!
60//! Inspired by zlint and certlint but with several deliberate differences:
61//!
62//! - **Trait-based, not enum-based**: external crates can implement [`Lint`] and
63//!   pass `Box<dyn Lint>` to [`LintRunner`] without modifying this crate.
64//! - **Cow detail messages**: `LintResult::Warn`, `Error`, and `Fatal` carry
65//!   `Cow<'static, str>` detail. Static string literals are zero-allocation
66//!   (`Cow::Borrowed`); runtime-formatted strings such as `format!(...)` use
67//!   `Cow::Owned` without leaking memory.
68//! - **Temporality-aware**: [`LintRunner::run_cert`] takes `now_unix: u64` so lints
69//!   can enforce rules that have effective dates (e.g., SC-081 validity caps).
70//! - **Scope-separated**: certificate lints and path lints run in separate passes so
71//!   path lints can see the full validated output.
72//!
73//! # Example
74//!
75//! ```rust,no_run
76//! // `cert` and `now_unix` are obtained from the calling context (e.g., loaded
77//! // from DER and current wall-clock time). They are not defined here so the
78//! // example cannot be run in a doctest harness without external fixtures.
79//! use pkix_lint::{Lint, LintResult, LintRunner, Scope, Severity, SubjectKind};
80//! use x509_cert::Certificate;
81//!
82//! #[derive(Clone)]
83//! struct MyLint;
84//! impl Lint for MyLint {
85//!     fn id(&self) -> &'static str { "example.my_lint" }
86//!     fn citation(&self) -> &'static str { "Example Corp Policy §1.2" }
87//!     fn severity(&self) -> Severity { Severity::Warn }
88//!     fn scope(&self) -> Scope { Scope::Certificate }
89//!     fn applies_to(&self) -> SubjectKind { SubjectKind::Leaf }
90//!     fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
91//!         if cert.tbs_certificate.subject.to_string().is_empty() {
92//!             LintResult::warn("empty Subject DN")
93//!         } else {
94//!             LintResult::Pass
95//!         }
96//!     }
97//! }
98//!
99//! let cert: Certificate = unimplemented!("load from DER");
100//! let now_unix: u64 = unimplemented!("current Unix epoch seconds");
101//! let runner = LintRunner::new(vec![Box::new(MyLint)]);
102//! let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, now_unix);
103//! for f in &findings {
104//!     println!("{}: {:?}", f.lint_id, f.result);
105//! }
106//! ```
107//!
108//! # Limitations
109//!
110//! - **Framework, not a comprehensive rule set.** This crate ships the
111//!   [`Lint`] trait, [`LintRunner`], and a small RFC-conformance lint set
112//!   in the `rfc5280`, `rfc6125`, `rfc8398`, and `rfc8551` modules.
113//!   Comprehensive industry-forum lint coverage is the job of policy
114//!   adapter crates (`pkix-policy-zlint` for zlint's ~700 rules,
115//!   `pkix-policy-pkilint` for pkilint's S/MIME BR + ETSI coverage).
116//!   CA/B Forum reference lints live in the sibling `pkix-lint-cabf`
117//!   crate; that crate is also explicitly small and curated.
118//! - **Advisory-only.** Findings never cause a TLS rejection by themselves
119//!   (see the contract above). Plumbing findings into hard-fail or
120//!   waiver decisions is the integration layer's job.
121//! - **OSCAL adapter is one supported output, not the canonical format.**
122//!   The `oscal` feature emits OSCAL Assessment Results JSON and parses
123//!   OSCAL Risk-based deviations back into [`deviation::DeviationStore`].
124//!   The workspace does not prescribe OSCAL as a canonical inter-tool
125//!   wire format (AGENTS.md non-negotiable #5, three-mode policy
126//!   architecture); each policy-adapter crate consumes its upstream
127//!   tool's natural format.
128//! - **No site-local policy DSL.** Site-local policy is the deployer's
129//!   responsibility; implement [`Lint`] (or load lints from any
130//!   deployer-chosen format) and feed [`LintRunner`].
131
132use std::borrow::Cow;
133use x509_cert::Certificate;
134
135// Re-export so callers only need to depend on pkix-lint, not pkix-path.
136pub use pkix_path::{Profile, ValidatedPath, ValidationPolicy};
137
138/// Compute SHA-256 of a DER-encoded certificate.
139///
140/// Used by [`LintRunner::run_cert`] to stamp [`Finding::cert_sha256`] for
141/// evidence-pack provenance. `cert` is re-encoded to DER first so the
142/// hash is over the canonical re-encoded form (matching what serialisers
143/// downstream of pkix-lint would produce). For PKITS-style fixtures whose
144/// DER round-trips losslessly through `x509-cert`, this equals the hash
145/// of the original on-disk bytes.
146///
147/// Returns `None` when the certificate fails to re-encode to DER. This is
148/// rare but not theoretical — `x509-cert` accepts some inputs through
149/// `Decode` that its `Encode` impl cannot round-trip (DEFAULT-tagged
150/// fields, BMPString edge cases). Returning `None` here is preferable to
151/// silently stamping every finding with `SHA256(empty)`
152/// (`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`):
153/// two distinct un-encodable certs would otherwise produce findings that
154/// falsely claim identical provenance. Callers can distinguish
155/// "provenance unavailable" (`None`) from a legitimate hash (`Some(_)`).
156fn cert_sha256_of(cert: &Certificate) -> Option<[u8; 32]> {
157    use der::Encode as _;
158    use sha2::Digest as _;
159    let der = cert.to_der().ok()?;
160    let mut hasher = sha2::Sha256::new();
161    hasher.update(&der);
162    Some(hasher.finalize().into())
163}
164
165#[cfg(feature = "serde")]
166mod serde_helpers {
167    //! Serde adapters for fields that prefer a different on-wire form than
168    //! their in-memory shape.
169
170    /// Hex-string codec for `Option<[u8; 32]>` (Finding.cert_sha256).
171    ///
172    /// JSON: `Some([..])` ↔ `"<64 lowercase hex chars>"`, `None` ↔ `null`.
173    /// Binary serde formats fall through to the natural `Option<[u8; 32]>`
174    /// encoding because serializers like postcard treat `with =` modules as
175    /// JSON-style overrides only when wired this way. To be precise about
176    /// formats: this module emits a `String` for `Some` and a `None` for
177    /// `None`, in every serde format. For binary formats that's a length-
178    /// prefixed string carrying 64 hex chars — slightly less compact than
179    /// raw bytes, but consistent across formats and trivially decodable.
180    pub(crate) mod cert_sha256_hex {
181        use serde::{Deserialize as _, Deserializer, Serializer};
182
183        pub(crate) fn serialize<S: Serializer>(
184            v: &Option<[u8; 32]>,
185            s: S,
186        ) -> Result<S::Ok, S::Error> {
187            match v {
188                Some(bytes) => {
189                    let mut hex = String::with_capacity(64);
190                    for b in bytes {
191                        // Lowercase hex; no separators. Matches the digest
192                        // conventions used by OSCAL Link / Prop hrefs and
193                        // every Unix sha256sum tool.
194                        hex.push(char::from_digit(u32::from(b >> 4), 16).expect("hex high nibble"));
195                        hex.push(
196                            char::from_digit(u32::from(b & 0x0f), 16).expect("hex low nibble"),
197                        );
198                    }
199                    s.serialize_some(&hex)
200                }
201                None => s.serialize_none(),
202            }
203        }
204
205        pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
206            d: D,
207        ) -> Result<Option<[u8; 32]>, D::Error> {
208            let opt: Option<String> = Option::deserialize(d)?;
209            let Some(hex) = opt else {
210                return Ok(None);
211            };
212            // Operate on the byte view rather than `&str` slicing: the
213            // `hex.len() == 64` check below is a byte-length check, so a
214            // 64-byte string containing multi-byte UTF-8 (e.g., the JSON
215            // input `"\u00fc\u00fc..."` whose UTF-8 encoding happens to be
216            // 64 bytes) passes the length gate. `&hex[i..i+1]` on such an
217            // input would panic at the non-char-boundary inside
218            // `from_str_radix`. Iterate `hex.as_bytes()` instead and route
219            // each byte through `nibble()`, which rejects every non-ASCII-
220            // hex byte as a serde custom error rather than panicking.
221            if hex.len() != 64 {
222                return Err(serde::de::Error::invalid_length(
223                    hex.len(),
224                    &"64 lowercase hex chars (32-byte SHA-256)",
225                ));
226            }
227            let bytes = hex.as_bytes();
228            let mut out = [0u8; 32];
229            for (i, byte) in out.iter_mut().enumerate() {
230                let hi = nibble(bytes[i * 2])
231                    .ok_or_else(|| serde::de::Error::custom("non-hex char in cert_sha256"))?;
232                let lo = nibble(bytes[i * 2 + 1])
233                    .ok_or_else(|| serde::de::Error::custom("non-hex char in cert_sha256"))?;
234                *byte = (hi << 4) | lo;
235            }
236            Ok(Some(out))
237        }
238
239        /// Decode one ASCII hex byte to its 4-bit nibble value. Returns
240        /// `None` for any non-hex input — including non-ASCII bytes from
241        /// a multi-byte UTF-8 sequence, which is the panic surface this
242        /// helper was introduced to close (PKIX-7f92.1).
243        ///
244        /// Duplicates the helper in [`crate::oscal::parse`]; the broader
245        /// consolidation is tracked under PKIX-7f92.14.
246        fn nibble(b: u8) -> Option<u8> {
247            match b {
248                b'0'..=b'9' => Some(b - b'0'),
249                b'a'..=b'f' => Some(b - b'a' + 10),
250                b'A'..=b'F' => Some(b - b'A' + 10),
251                _ => None,
252            }
253        }
254    }
255}
256
257/// Serde deserializer helper for `Cow<'static, str>` fields.
258///
259/// Deserializes any string input as an owned `String` wrapped in `Cow::Owned`.
260/// This does not leak memory — the allocation is owned and freed when the
261/// containing struct is dropped.
262///
263/// Used for `Finding.lint_id`, `Finding.citation`, and `DeviatedFinding` fields
264/// that are `&'static str` at construction time (populated from lint metadata) but
265/// need to round-trip through serde without leaking.
266#[cfg(feature = "serde")]
267pub(crate) fn de_cow_static<'de, D>(deserializer: D) -> Result<Cow<'static, str>, D::Error>
268where
269    D: serde::Deserializer<'de>,
270{
271    use serde::Deserialize as _;
272    let s = String::deserialize(deserializer)?;
273    Ok(Cow::Owned(s))
274}
275
276/// Documented cap (bytes) for attacker-controlled string content
277/// interpolated into [`LintResult::error`] / [`LintResult::warn`]
278/// detail strings. PKIX-7f92.52 closed a memory-amplification surface:
279/// a malicious cert carrying a 100 MB rfc822Name SAN entry would
280/// otherwise produce 100 MB Findings traveling through every emit
281/// path (OSCAL serialization, evidence-pack persistence, downstream
282/// JSON consumers), turning a single bad cert into N-lints × emit-copies
283/// memory pressure.
284///
285/// 256 bytes is well above any legitimate RFC 5322 mailbox local-part
286/// (max 64), full mailbox address (typical ~100), X.500 DN attribute
287/// (typical ~64-128), or `der::Error` message (typical ~50). Truncated
288/// values are still useful for operator debugging — the leading bytes
289/// usually identify the offender uniquely.
290pub(crate) const DETAIL_MAX_BYTES: usize = 256;
291
292/// Truncate an attacker-controlled string for inclusion in a
293/// [`LintResult`] detail message. Values longer than [`DETAIL_MAX_BYTES`]
294/// are cut at the first valid UTF-8 boundary at or below that limit
295/// and suffixed with `... (truncated, N bytes total)` so operators
296/// can see that truncation happened and what the full size was.
297///
298/// Use for every cert-derived string flowing into a `format!` /
299/// `LintResult::error` / `LintResult::warn` call. See PKIX-7f92.52 for
300/// the threat model rationale.
301pub(crate) fn truncate_for_detail(s: &str) -> Cow<'_, str> {
302    if s.len() <= DETAIL_MAX_BYTES {
303        return Cow::Borrowed(s);
304    }
305    // Find the last UTF-8 char boundary at or below DETAIL_MAX_BYTES.
306    // `str::is_char_boundary` is true at indices 0..=s.len(), so the
307    // loop terminates on the longest valid prefix that fits.
308    let mut cut = DETAIL_MAX_BYTES;
309    while !s.is_char_boundary(cut) {
310        cut -= 1;
311    }
312    Cow::Owned(format!(
313        "{}... (truncated, {} bytes total)",
314        &s[..cut],
315        s.len()
316    ))
317}
318
319pub mod deviation;
320#[cfg(feature = "oscal")]
321#[cfg_attr(docsrs, doc(cfg(feature = "oscal")))]
322pub mod oscal;
323pub mod report;
324pub mod rfc5280;
325pub mod rfc6125;
326pub mod rfc8398;
327pub mod rfc8551;
328
329// ---------------------------------------------------------------------------
330// Severity
331// ---------------------------------------------------------------------------
332
333/// How seriously to treat a lint finding.
334///
335/// Severity is a property of the lint definition, not the result. A lint that
336/// checks a MUST requirement from a normative spec should be [`Severity::Error`].
337/// A lint that checks a SHOULD or advisory requirement should be [`Severity::Warn`].
338///
339/// # Ordering
340///
341/// `Severity` deliberately does NOT derive `PartialOrd` / `Ord`. Comparing
342/// severities directly with `<` / `>=` would couple every caller's
343/// comparison semantics to the source-order position of the variants —
344/// inserting a new variant in the middle of the enum (e.g., a future
345/// syslog-aligned `Critical` between `Error` and `Fatal`) would silently
346/// change the meaning of every `>= Severity::Warn` predicate in caller
347/// code. The `#[derive(Ord)] on a public enum` stability trap is a known
348/// hazard in the Rust ecosystem.
349///
350/// Use [`Severity::rank`] for ordering: it returns a documented per-variant
351/// `u8` where higher values are more severe. The ranks are stable across
352/// variant insertions — a new variant picks a free `u8` slot in the right
353/// semantic position without disturbing existing ranks.
354///
355/// Worked example: `if finding.severity.rank() >= Severity::Warn.rank()
356/// { ... }` retains its meaning across pkix-lint versions even if a new
357/// variant lands between `Info` and `Warn`.
358///
359/// The rank scale `Info=10, Notice=20, Warn=30, Error=40, Fatal=50` aligns
360/// with both the zlint catalog ranking (`pass(3) < notice(4) < warn(5) <
361/// error(6) < fatal(7)`, see `~/GIT/zlint/v3/lint/result.go`) and syslog
362/// RFC 5424 §6.2.1 severity ranking (Informational > Notice > Warning).
363/// Ranks are spaced by 10 to leave room for future insertions.
364#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
365#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
366#[non_exhaustive]
367pub enum Severity {
368    /// Advisory / best-practice — does not constitute a violation.
369    Info,
370    /// Minor observation — not a violation but worth surfacing.
371    ///
372    /// Aligns with zlint's `notice` verdict and syslog RFC 5424 §6.2.1
373    /// severity 5 (Notice). Used by the planned `pkix-zlint-bridge`
374    /// adapter to map zlint catalog notice-level checks into the
375    /// workspace severity model.
376    Notice,
377    /// Violation of a SHOULD or RECOMMENDED requirement.
378    Warn,
379    /// Violation of a MUST or REQUIRED requirement.
380    Error,
381    /// Violation so severe that further evaluation is meaningless.
382    ///
383    /// For example: malformed DER structure that prevents parsing subsequent fields.
384    Fatal,
385}
386
387impl Severity {
388    /// Documented `u8` rank for stable ordering across variant
389    /// insertions. Use this instead of comparing `Severity` values
390    /// directly. See the struct rustdoc for the rationale.
391    ///
392    /// Ranks are spaced by 10 so a future mid-scale insertion can
393    /// claim a free slot without disturbing existing ranks. For
394    /// example, a syslog-aligned `Critical` between `Error` and
395    /// `Fatal` would land at rank 45.
396    #[must_use]
397    pub const fn rank(self) -> u8 {
398        match self {
399            Self::Info => 10,
400            Self::Notice => 20,
401            Self::Warn => 30,
402            Self::Error => 40,
403            Self::Fatal => 50,
404        }
405    }
406}
407
408// ---------------------------------------------------------------------------
409// Scope
410// ---------------------------------------------------------------------------
411
412/// Whether a lint evaluates a single certificate or the complete validated path.
413#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
414#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
415#[non_exhaustive]
416pub enum Scope {
417    /// The lint evaluates one certificate in isolation.
418    Certificate,
419    /// The lint evaluates the full [`ValidatedPath`] and all certificates together.
420    Path,
421}
422
423// ---------------------------------------------------------------------------
424// SubjectKind
425// ---------------------------------------------------------------------------
426
427/// Which certificate positions in the chain a lint applies to.
428///
429/// Used both as a filter in [`Lint::applies_to`] (which certs the lint checks)
430/// and as the label in [`LintRunner`] when calling the lint (what cert we're at).
431#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
432#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
433#[non_exhaustive]
434pub enum SubjectKind {
435    /// End-entity (leaf) certificate — the subject of the chain.
436    Leaf,
437    /// Intermediate CA certificate — has `BasicConstraints` cA=TRUE, not a trust anchor.
438    IntermediateCa,
439    /// Any certificate issued directly by a trust anchor (the top intermediate).
440    AnchorIssued,
441    /// All certificate positions (lint applies universally).
442    Any,
443}
444
445impl SubjectKind {
446    /// Returns `true` if a lint declared for `filter` should run against `self`.
447    ///
448    /// Rules:
449    /// - `Any` filter matches everything.
450    /// - An exact match always returns `true`.
451    /// - `AnchorIssued` is a sub-category of `IntermediateCa`; a filter of
452    ///   `IntermediateCa` also matches `AnchorIssued` certificates.
453    #[must_use]
454    pub fn matches(self, filter: Self) -> bool {
455        match filter {
456            Self::Any => true,
457            Self::IntermediateCa => self == Self::IntermediateCa || self == Self::AnchorIssued,
458            other => self == other,
459        }
460    }
461}
462
463// ---------------------------------------------------------------------------
464// LintParameter (PKIX-9vnx.6.4)
465// ---------------------------------------------------------------------------
466
467/// Descriptor for a tunable parameter exposed by a [`Lint`] implementation.
468///
469/// `LintParameter` is the in-memory form of an OSCAL Catalog `Parameter` (see
470/// the [OSCAL Catalog model][oscal-cat]). Each parameter has a stable
471/// identifier (`id`), a human-readable label, and a default value rendered
472/// as a string for OSCAL interchange.
473///
474/// `LintParameter` is **descriptor-only**. It does not hold the lint's
475/// current value; the lint stores its own typed state (`usize`, `Duration`,
476/// etc.) and serialises through the descriptor at OSCAL boundaries. The
477/// "current value" plumbing happens via [`Lint::set_parameter`]:
478/// implementations parse the supplied `value: &str` into their typed state
479/// and update it in place. Callers wishing to inspect or override defaults
480/// at OSCAL Profile composition time use the `id` to address parameters
481/// across lint impls.
482///
483/// # OSCAL mapping
484///
485/// | `LintParameter` field | OSCAL `parameter` field |
486/// |-----------------------|-------------------------|
487/// | `id`                  | `id` (string token)     |
488/// | `label`               | `label` (human-readable)|
489/// | `default_value`       | `values[0]` (string)    |
490///
491/// OSCAL's richer parameter shape (constraint, guideline, select, link)
492/// is not modelled here; callers needing that surface should serialise
493/// `LintParameter` first and then graft on additional OSCAL fields at
494/// the Catalog-emit boundary.
495///
496/// [oscal-cat]: https://pages.nist.gov/OSCAL/concepts/layer/control/catalog/
497#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
498#[derive(Clone, Debug, PartialEq, Eq)]
499#[non_exhaustive]
500pub struct LintParameter {
501    /// Stable identifier — used to address the parameter in
502    /// [`Lint::set_parameter`] and in OSCAL Profile `modify` directives.
503    /// Example: `"max-octets"`.
504    pub id: Cow<'static, str>,
505
506    /// Human-readable label, suitable for UI display.
507    /// Example: `"Maximum allowed serial number length in octets"`.
508    pub label: Cow<'static, str>,
509
510    /// Default value rendered as a string. Lints parse this back into their
511    /// typed state if a caller has not supplied an override via
512    /// [`Lint::set_parameter`].
513    pub default_value: Cow<'static, str>,
514}
515
516impl LintParameter {
517    /// Construct a [`LintParameter`] with the three required fields.
518    ///
519    /// Use this constructor instead of struct-literal syntax so the
520    /// addition of future fields (OSCAL `constraint`, `guideline`,
521    /// `select`, `link` shape mentioned in the type-level rustdoc)
522    /// remains a non-breaking change. The struct carries
523    /// `#[non_exhaustive]`.
524    #[must_use]
525    pub fn new(
526        id: impl Into<Cow<'static, str>>,
527        label: impl Into<Cow<'static, str>>,
528        default_value: impl Into<Cow<'static, str>>,
529    ) -> Self {
530        Self {
531            id: id.into(),
532            label: label.into(),
533            default_value: default_value.into(),
534        }
535    }
536}
537
538/// Error reported by [`Lint::set_parameter`].
539#[non_exhaustive]
540#[derive(Clone, Debug, PartialEq, Eq)]
541#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
542pub enum ParameterError {
543    /// The lint does not expose a parameter with the supplied id.
544    UnknownParameter(String),
545    /// The supplied value failed to parse or violated a domain constraint
546    /// declared by the lint.
547    InvalidValue {
548        /// Parameter id the invalid value was supplied for.
549        id: String,
550        /// Human-readable explanation suitable for surfacing to operators.
551        reason: String,
552    },
553}
554
555impl core::fmt::Display for ParameterError {
556    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
557        match self {
558            Self::UnknownParameter(id) => write!(f, "unknown lint parameter '{id}'"),
559            Self::InvalidValue { id, reason } => {
560                write!(f, "invalid value for lint parameter '{id}': {reason}")
561            }
562        }
563    }
564}
565
566impl std::error::Error for ParameterError {}
567
568// ---------------------------------------------------------------------------
569// LintResult
570// ---------------------------------------------------------------------------
571
572/// The outcome of evaluating a single lint against a certificate or path.
573///
574/// # Stability
575///
576/// The variant names are stable. The detail field type is
577/// `Cow<'static, str>`, accepting both static string literals (zero-cost,
578/// via `Cow::Borrowed`) and runtime-formatted owned strings (via
579/// `Cow::Owned`).
580///
581/// # Constructing
582///
583/// Use the helpers [`LintResult::warn`], [`LintResult::error`], and
584/// [`LintResult::fatal`] for idiomatic construction from any
585/// `Into<Cow<'static, str>>` source — string literals or owned `String`
586/// values both work without explicit `Cow::Borrowed(...)` wrapping:
587///
588/// ```rust
589/// use pkix_lint::LintResult;
590///
591/// // Static literal — zero-allocation Cow::Borrowed.
592/// let r1 = LintResult::error("validity exceeds cap");
593///
594/// // Runtime-formatted — Cow::Owned, freed with the LintResult.
595/// let actual_days = 400u64;
596/// let r2 = LintResult::error(format!("validity {actual_days} days exceeds cap"));
597/// ```
598///
599/// Pattern matches use the variant constructors directly:
600///
601/// ```rust
602/// # use pkix_lint::LintResult;
603/// # let r = LintResult::warn("x");
604/// match &r {
605///     LintResult::Warn(detail) => println!("warn: {}", detail),
606///     LintResult::Error(detail) => println!("error: {}", detail),
607///     _ => {}
608/// }
609/// ```
610#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
611#[derive(Clone, Debug, PartialEq, Eq)]
612#[non_exhaustive]
613pub enum LintResult {
614    /// The lint check passed — no finding.
615    Pass,
616    /// The lint does not apply to this certificate or context.
617    ///
618    /// For example, a lint that checks SAN for leaves would return `NotApplicable`
619    /// when called against an intermediate CA certificate.
620    ///
621    /// `NotApplicable` is not an error; the runner records it for audit completeness
622    /// but it does not affect compliance status.
623    NotApplicable,
624    /// Advisory finding — the cert deviates from a SHOULD or best practice.
625    ///
626    /// The detail string is a human-readable explanation of the finding.
627    /// Use [`LintResult::warn`] for ergonomic construction.
628    Warn(Cow<'static, str>),
629    /// Error finding — the cert violates a MUST or REQUIRED requirement.
630    ///
631    /// The detail string is a human-readable explanation of the finding.
632    /// Use [`LintResult::error`] for ergonomic construction.
633    Error(Cow<'static, str>),
634    /// Fatal finding — further evaluation of this cert/path is not meaningful.
635    ///
636    /// The detail string is a human-readable explanation of the finding.
637    /// The runner stops evaluating remaining lints for the current item when
638    /// it encounters a `Fatal`. Use [`LintResult::fatal`] for ergonomic
639    /// construction.
640    ///
641    /// # `Fatal` is report-only
642    ///
643    /// **`Fatal` does NOT cause the TLS stack to reject the certificate.**
644    /// `pkix-lint` is an advisory layer only. All findings — including `Fatal` —
645    /// are reported in the `Vec<Finding>` returned by [`LintRunner`]. Whether to
646    /// act on a finding (e.g., reject a TLS connection, abort a certificate
647    /// issuance, or log a compliance event) is the caller's decision, made at the
648    /// integration boundary (e.g., `pkix-chain` or a TLS stack binding).
649    ///
650    /// The only effect of `Fatal` within `pkix-lint` itself is to stop evaluating
651    /// further lints for the current certificate or path — it does not propagate
652    /// as a `Result::Err` or cause any panic.
653    Fatal(Cow<'static, str>),
654}
655
656impl LintResult {
657    /// Returns `true` if this result represents a clean pass (no finding).
658    #[must_use]
659    pub const fn is_pass(&self) -> bool {
660        matches!(self, Self::Pass)
661    }
662
663    /// Returns `true` if this result represents a finding (Warn, Error, or Fatal).
664    #[must_use]
665    pub const fn is_finding(&self) -> bool {
666        matches!(self, Self::Warn(_) | Self::Error(_) | Self::Fatal(_))
667    }
668
669    /// Returns `true` if the runner should stop evaluating further lints for this item.
670    #[must_use]
671    pub const fn is_fatal(&self) -> bool {
672        matches!(self, Self::Fatal(_))
673    }
674
675    /// Returns the detail message for `Warn`, `Error`, or `Fatal`; `None` for `Pass`/`NotApplicable`.
676    ///
677    /// The returned reference is borrowed from `self` and lives only as long
678    /// as `self` does. To obtain an owned copy that outlives `self`, clone the
679    /// `Cow` directly from the matched variant.
680    #[must_use]
681    pub fn detail(&self) -> Option<&str> {
682        match self {
683            Self::Warn(d) | Self::Error(d) | Self::Fatal(d) => Some(d.as_ref()),
684            _ => None,
685        }
686    }
687
688    /// Construct a [`LintResult::Warn`] from any value convertible to
689    /// `Cow<'static, str>`.
690    ///
691    /// Accepts string literals (zero-allocation `Cow::Borrowed`) and owned
692    /// `String` values (allocated `Cow::Owned`). Use this for lints that
693    /// report runtime-formatted detail.
694    #[must_use]
695    pub fn warn(detail: impl Into<Cow<'static, str>>) -> Self {
696        Self::Warn(detail.into())
697    }
698
699    /// Construct a [`LintResult::Error`] from any value convertible to
700    /// `Cow<'static, str>`.
701    ///
702    /// Accepts string literals (zero-allocation `Cow::Borrowed`) and owned
703    /// `String` values (allocated `Cow::Owned`). Use this for lints that
704    /// report runtime-formatted detail.
705    #[must_use]
706    pub fn error(detail: impl Into<Cow<'static, str>>) -> Self {
707        Self::Error(detail.into())
708    }
709
710    /// Construct a [`LintResult::Fatal`] from any value convertible to
711    /// `Cow<'static, str>`.
712    ///
713    /// Accepts string literals (zero-allocation `Cow::Borrowed`) and owned
714    /// `String` values (allocated `Cow::Owned`). Use this for lints that
715    /// report runtime-formatted detail.
716    #[must_use]
717    pub fn fatal(detail: impl Into<Cow<'static, str>>) -> Self {
718        Self::Fatal(detail.into())
719    }
720}
721
722// ---------------------------------------------------------------------------
723// Display implementations
724// ---------------------------------------------------------------------------
725
726impl core::fmt::Display for Severity {
727    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
728        match self {
729            Self::Info => f.write_str("info"),
730            Self::Notice => f.write_str("notice"),
731            Self::Warn => f.write_str("warn"),
732            Self::Error => f.write_str("error"),
733            Self::Fatal => f.write_str("fatal"),
734        }
735    }
736}
737
738impl core::fmt::Display for LintResult {
739    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
740        match self {
741            Self::Pass => f.write_str("Pass"),
742            Self::NotApplicable => f.write_str("NotApplicable"),
743            Self::Warn(msg) => write!(f, "Warn: {msg}"),
744            Self::Error(msg) => write!(f, "Error: {msg}"),
745            Self::Fatal(msg) => write!(f, "Fatal: {msg}"),
746        }
747    }
748}
749
750impl core::fmt::Display for Finding {
751    /// Format: `"lint_id [severity]: message"` for findings, `"lint_id [pass]"` for non-findings.
752    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
753        let severity_label = match &self.result {
754            LintResult::Warn(_) => "warn",
755            LintResult::Error(_) => "error",
756            LintResult::Fatal(_) => "fatal",
757            LintResult::Pass => "pass",
758            LintResult::NotApplicable => "n/a",
759        };
760        match self.result.detail() {
761            Some(msg) => write!(f, "{} [{}]: {}", self.lint_id, severity_label, msg),
762            None => write!(f, "{} [{}]", self.lint_id, severity_label),
763        }
764    }
765}
766
767// ---------------------------------------------------------------------------
768// Lint trait
769// ---------------------------------------------------------------------------
770
771/// Supertrait of [`Lint`] that lets `Box<dyn Lint>` be cloned.
772///
773/// **Implementors of [`Lint`] do NOT implement `LintClone` directly.**
774/// A blanket impl provides `LintClone` for every type that is
775/// `Lint + Clone + 'static`. Concrete lint types just need to derive
776/// or implement [`Clone`].
777///
778/// The clone is used by [`LintRunner::apply_parameter_overrides`] to
779/// validate parameter values against a fresh copy of the lint before
780/// committing the change to the runner's registered state, giving
781/// atomic application semantics on `set_parameter` failures
782/// (PKIX-hy2e.6).
783pub trait LintClone {
784    /// Clone the lint into a fresh `Box<dyn Lint>`.
785    ///
786    /// The default blanket impl uses [`Clone::clone`] on the concrete
787    /// type; do not override this unless your lint has interior
788    /// mutability that needs special handling.
789    fn clone_box(&self) -> Box<dyn Lint>;
790}
791
792impl<T> LintClone for T
793where
794    T: Lint + Clone + 'static,
795{
796    fn clone_box(&self) -> Box<dyn Lint> {
797        Box::new(self.clone())
798    }
799}
800
801impl Clone for Box<dyn Lint> {
802    fn clone(&self) -> Self {
803        self.clone_box()
804    }
805}
806
807/// A single, independently evaluable lint check.
808///
809/// # Implementing `Lint`
810///
811/// Each lint must have a stable, globally unique ID (see crate-level doc for the
812/// naming convention). Both `check_cert` and `check_path` are provided so the
813/// same trait covers both certificate-scoped and path-scoped lints. Implement
814/// whichever method is appropriate for your lint and let the other return
815/// [`LintResult::NotApplicable`] (the default).
816///
817/// # Use-case applicability — operator contract
818///
819/// Many RFC-conformance lints check a property that only applies to a
820/// specific *use case* — e.g., TLS server, S/MIME, code-signing,
821/// OCSP responder. The trait does not encode use case in its type
822/// signature; instead, **use-case selection is the
823/// [`LintProfile`][crate::LintProfile] bundle's responsibility.**
824///
825/// Concretely:
826///
827/// - [`crate::rfc5280::Rfc5280EkuServerAuthLint`] and
828///   [`crate::rfc6125::Rfc6125TlsServerSanLint`] assert TLS-server-only
829///   properties. They are bundled by `pkix_profiles::BasicTlsProfile`.
830/// - [`crate::rfc8398::Rfc8398SmimeSanLint`] and
831///   [`crate::rfc8551::Rfc8551EkuEmailProtectionLint`] assert
832///   S/MIME-only properties. They are bundled by
833///   `pkix_profiles::BasicSmimeProfile`.
834///
835/// **These two bundles are mutually exclusive** — no single leaf
836/// certificate satisfies all four lints simultaneously, because the
837/// EKU requirements (`id-kp-serverAuth` vs `id-kp-emailProtection`) and
838/// SAN requirements (dNSName/iPAddress vs rfc822Name/SmtpUTF8Mailbox)
839/// describe different cert shapes.
840///
841/// **Operators MUST NOT create a single "all rfc-conformance lints"
842/// bundle that mixes TLS-server and S/MIME lints.** Doing so produces
843/// two or more mutually-contradictory `Error` findings on every leaf,
844/// regardless of cert shape. The correct pattern is to choose the
845/// `LintProfile` bundle that matches the cert's intended use case
846/// (`BasicTlsProfile`, `BasicSmimeProfile`, etc.) and call
847/// [`check_shape`][crate::check_shape] with it.
848///
849/// Each affected lint's struct-level rustdoc carries a
850/// `# Use-case applicability — operator contract` section reiterating
851/// this constraint and naming its canonical `LintProfile` bundler.
852///
853/// # Object safety
854///
855/// The trait is object-safe: `Box<dyn Lint>` and `&dyn Lint` both work.
856///
857/// # Cloneability
858///
859/// [`Lint`] requires the [`LintClone`] supertrait so `Box<dyn Lint>`
860/// can be cloned. A blanket impl provides `LintClone` for every
861/// `Lint + Clone + 'static`, so implementors just need to derive or
862/// implement [`Clone`] on their concrete type — they do NOT need to
863/// implement `LintClone` directly. This requirement is necessary for
864/// [`LintRunner::apply_parameter_overrides`] to provide atomic
865/// parameter-application semantics: the runner clones lints, applies
866/// overrides to the clones, and only swaps on success
867/// (PKIX-hy2e.6).
868pub trait Lint: LintClone + Send + Sync {
869    /// Globally unique, stable identifier for this lint.
870    ///
871    /// Format: `<regime>.<section>.<noun>` e.g. `"cabf.br.tls.validity.max"`.
872    /// This string is part of the public API — never change it once published.
873    fn id(&self) -> &'static str;
874
875    /// Human-readable citation: spec name, version, and section.
876    ///
877    /// Example: `"CA/B Forum TLS BR §6.3.2 (SC-081)"`.
878    /// Not parsed by the engine; used in reports and error messages.
879    fn citation(&self) -> &'static str;
880
881    /// The declared severity of a positive finding from this lint.
882    ///
883    /// Note: [`LintResult::Warn`] and [`LintResult::Error`] can be returned
884    /// regardless of the declared `severity()`. The declared severity is metadata
885    /// used by report renderers and compliance dashboards.
886    fn severity(&self) -> Severity;
887
888    /// Whether this lint operates on individual certificates or the full path.
889    fn scope(&self) -> Scope;
890
891    /// Which certificate positions this lint applies to.
892    ///
893    /// For [`Scope::Certificate`] lints, the runner uses this to skip
894    /// `check_cert` for positions that don't match, returning
895    /// [`LintResult::NotApplicable`] automatically.
896    ///
897    /// For [`Scope::Path`] lints, `applies_to()` is **not consulted** by the
898    /// runner — `check_path` is always called. Path-scope lints that need to
899    /// restrict themselves to certain chain configurations should implement
900    /// that logic inside `check_path` and return [`LintResult::NotApplicable`]
901    /// when the path does not qualify.
902    fn applies_to(&self) -> SubjectKind;
903
904    // -- Standards-body metadata (PKIX-9vnx.6.1, renamed in pkix-lint 0.6.0)
905    //
906    // The next six methods declare per-lint metadata that is useful for any
907    // report or serialization format. The OSCAL emit code in
908    // `pkix_lint::oscal` is one consumer that maps these fields to OSCAL
909    // Catalog Control properties, but the methods are not OSCAL-specific.
910    // All are default-provided so existing impls keep compiling; shipped
911    // lints should override at least `title` and one standards-body
912    // citation field for a usable catalog.
913
914    /// Short human-readable title for the lint.
915    ///
916    /// Default: returns [`id`](Self::id) verbatim. Override when the lint id
917    /// is a slug (e.g., `"cabf.br.tls.validity.max"`) and the title needs to
918    /// be a sentence (e.g., `"Leaf certificate validity must not exceed
919    /// SC-081 cap"`). The OSCAL emit code maps this to `control.title` when
920    /// producing an OSCAL Catalog.
921    fn title(&self) -> &str {
922        self.id()
923    }
924
925    /// Long-form description of the lint's purpose. Optional because not
926    /// every lint has more to say than its title and citation.
927    ///
928    /// Default: `None`. The OSCAL emit code maps this to
929    /// `control.parts[name=statement].prose` when producing an OSCAL Catalog.
930    fn description(&self) -> Option<&str> {
931        None
932    }
933
934    /// Standards-body section identifier in `<source>-<section>` shape.
935    ///
936    /// The slot accepts any standards-body section identifier — IETF RFC,
937    /// ITU-T X.509, CA/B Forum Baseline Requirements, NIST SP, etc.:
938    ///
939    /// * `"rfc5280-4.2.1.9"` — RFC 5280 §4.2.1.9.
940    /// * `"cabf-tls-br-6.3.2"` — CA/B Forum TLS BR §6.3.2.
941    /// * `"x509-ed4-section-8"` — ITU-T X.509 Edition 4 §8.
942    ///
943    /// When this returns `Some`, [`spec_url`](Self::spec_url) should also
944    /// return a permanent URL where one exists. The OSCAL emit code maps
945    /// this to a stable `control.prop` when producing an OSCAL Catalog.
946    ///
947    /// Renamed from `rfc_section_id` in pkix-lint 0.6.0 because the slot
948    /// was never RFC-specific. The deprecated alias remains, and the
949    /// default impl here delegates to it (PKIX-7f92.7) so pre-0.6.0
950    /// Lint impls that override only the deprecated method continue to
951    /// produce a non-None result through this canonical entry point.
952    ///
953    /// Default: `self.rfc_section_id()`. A pre-0.6.0 impl that
954    /// overrides `rfc_section_id` works through this entry point with
955    /// zero source changes; a 0.6.0+ impl that overrides
956    /// `spec_section_id` is the canonical form going forward.
957    fn spec_section_id(&self) -> Option<&str> {
958        #[allow(deprecated)]
959        self.rfc_section_id()
960    }
961
962    /// Deprecated alias for [`spec_section_id`](Self::spec_section_id).
963    ///
964    /// Renamed because the slot accepts CA/B Forum, ITU-T, NIST, and other
965    /// standards-body section identifiers in addition to IETF RFCs.
966    /// Override `spec_section_id` instead. The default impl here returns
967    /// `None` (it is not symmetric with `spec_section_id` — pre-0.6.0
968    /// impls override THIS method, and we cannot delegate back to
969    /// `spec_section_id` without risking infinite recursion when
970    /// neither is overridden).
971    ///
972    /// Scheduled removal target: pkix-lint 0.10.0 (a future minor at
973    /// least four minors after the 0.6.0 introduction). Consumers who
974    /// override this method should migrate to overriding
975    /// `spec_section_id` directly.
976    #[deprecated(
977        since = "0.6.0",
978        note = "renamed to `spec_section_id` because the slot accepts non-RFC ids (CA/B Forum, ITU-T, NIST); override `spec_section_id` instead. Scheduled removal target: 0.10.0."
979    )]
980    fn rfc_section_id(&self) -> Option<&str> {
981        None
982    }
983
984    /// Permanent URL to the standards-body section referenced by
985    /// [`spec_section_id`](Self::spec_section_id).
986    ///
987    /// For IETF RFCs the canonical form is
988    /// `"https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.9"`. CA/B
989    /// Forum has no stable per-section anchor URL (BR documents are
990    /// versioned and re-published frequently); leave this `None` and let
991    /// the citation carry the §-reference. The OSCAL emit code maps this
992    /// to `control.links[rel=reference]` when producing an OSCAL Catalog.
993    ///
994    /// Renamed from `rfc_url` in pkix-lint 0.6.0 alongside
995    /// `spec_section_id`. The deprecated alias remains, and the
996    /// default impl here delegates to it (PKIX-7f92.7) so pre-0.6.0
997    /// Lint impls that override only the deprecated method continue
998    /// to produce a non-None result through this canonical entry
999    /// point.
1000    ///
1001    /// Default: `self.rfc_url()`.
1002    fn spec_url(&self) -> Option<&str> {
1003        #[allow(deprecated)]
1004        self.rfc_url()
1005    }
1006
1007    /// Deprecated alias for [`spec_url`](Self::spec_url).
1008    ///
1009    /// Renamed alongside `spec_section_id`. Override `spec_url`
1010    /// instead. The default impl here returns `None` (asymmetric with
1011    /// `spec_url` for the same anti-recursion reason documented on
1012    /// [`rfc_section_id`](Self::rfc_section_id)).
1013    ///
1014    /// Scheduled removal target: pkix-lint 0.10.0.
1015    #[deprecated(
1016        since = "0.6.0",
1017        note = "renamed to `spec_url`; override `spec_url` instead. Scheduled removal target: 0.10.0."
1018    )]
1019    fn rfc_url(&self) -> Option<&str> {
1020        None
1021    }
1022
1023    // -- Tunable parameters (PKIX-9vnx.6.4) --------------------------------
1024    //
1025    // `parameters()` advertises tunable knobs the lint exposes;
1026    // `set_parameter` mutates the lint's typed internal state from a
1027    // string-rendered value. The OSCAL emit code maps `parameters()` to
1028    // `control.params[*]` and `set_parameter` is the bridge for OSCAL
1029    // Profile `modify` directives when consuming an OSCAL Profile; but
1030    // both methods are useful independent of OSCAL.
1031
1032    /// Tunable parameters exposed by this lint.
1033    ///
1034    /// Returns descriptors for every knob the lint exposes. Each
1035    /// descriptor names the parameter, gives a human-readable label, and
1036    /// renders its default value as a string.
1037    ///
1038    /// The descriptors do not carry the lint's current value — the lint
1039    /// stores typed state internally. To update a parameter at runtime,
1040    /// use [`set_parameter`](Self::set_parameter).
1041    ///
1042    /// Default: empty slice (no tunable parameters).
1043    fn parameters(&self) -> &[LintParameter] {
1044        &[]
1045    }
1046
1047    /// Update a tunable parameter from its string-rendered value.
1048    ///
1049    /// `id` is the [`LintParameter::id`] addressed by the caller; `value`
1050    /// is the string-rendered value the lint must parse back into its
1051    /// typed internal state. Returns [`ParameterError::UnknownParameter`]
1052    /// when the id is not exposed by this lint, and
1053    /// [`ParameterError::InvalidValue`] when the value fails to parse or
1054    /// violates a lint-defined constraint.
1055    ///
1056    /// Default: returns [`ParameterError::UnknownParameter`] for every
1057    /// call. Lints with non-empty [`parameters`](Self::parameters) MUST
1058    /// override this method.
1059    ///
1060    /// # Mutability
1061    ///
1062    /// `set_parameter` takes `&mut self` because parameter updates change
1063    /// the lint's behaviour. Callers typically configure parameters before
1064    /// installing the lint into a [`LintRunner`]; the runner itself stores
1065    /// `Box<dyn Lint>` and does not expose mutation.
1066    #[allow(unused_variables)]
1067    fn set_parameter(&mut self, id: &str, value: &str) -> Result<(), ParameterError> {
1068        Err(ParameterError::UnknownParameter(id.to_owned()))
1069    }
1070
1071    /// Evaluate the lint against a single certificate.
1072    ///
1073    /// `kind` is the role of this certificate in the chain (leaf, intermediate CA, etc.).
1074    /// `now_unix` is seconds since the Unix epoch at evaluation time.
1075    ///
1076    /// Default: returns [`LintResult::NotApplicable`].
1077    /// Lints with `scope() == Scope::Certificate` MUST override this method.
1078    #[allow(unused_variables)]
1079    fn check_cert(&self, cert: &Certificate, kind: SubjectKind, now_unix: u64) -> LintResult {
1080        LintResult::NotApplicable
1081    }
1082
1083    /// Evaluate the lint against the full validated path.
1084    ///
1085    /// `chain` is the full certificate chain (leaf-first). `path` is the
1086    /// [`ValidatedPath`] returned by `pkix_path::validate_path`.
1087    /// `now_unix` is seconds since the Unix epoch at evaluation time.
1088    ///
1089    /// Default: returns [`LintResult::NotApplicable`].
1090    /// Lints with `scope() == Scope::Path` MUST override this method.
1091    #[allow(unused_variables)]
1092    fn check_path(&self, chain: &[Certificate], path: &ValidatedPath, now_unix: u64) -> LintResult {
1093        LintResult::NotApplicable
1094    }
1095}
1096
1097// ---------------------------------------------------------------------------
1098// Finding
1099// ---------------------------------------------------------------------------
1100
1101/// A recorded lint outcome, associating a lint ID with its result.
1102///
1103/// # Evidence pack support
1104///
1105/// `Finding` carries the metadata needed to construct an evidence pack
1106/// (a bundle of cert + path + findings + citations exportable as structured JSON
1107/// or OSCAL Assessment Results). The `citation` field records the normative
1108/// citation from [`Lint::citation`]; `evaluated_at_unix` records when the lint
1109/// was run; `rule_bundle_version` records which version of the lint bundle was
1110/// active; `cert_sha256` pins the finding to a specific certificate by content
1111/// hash so the evidence is replayable.
1112///
1113/// # Serde deserialization
1114///
1115/// All string fields use `Cow<'static, str>` and deserialize as `Cow::Owned`
1116/// without leaking allocations. The earlier `'de: 'static` bound (required
1117/// when `LintResult` detail fields were `&'static str`) was removed in
1118/// pkix-lint 0.3.0 with the migration tracked as PKIX-ua6q.
1119///
1120/// `cert_sha256` is serialised as a lowercase hex string in JSON (`Option<String>`
1121/// shape; `None` for path-scope findings) so OSCAL `Link` / `Prop` consumers
1122/// see the conventional digest representation, while binary serde formats
1123/// (postcard, MessagePack) emit raw bytes via the same `Option<[u8; 32]>` field.
1124#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1125#[derive(Clone, Debug, PartialEq, Eq)]
1126#[non_exhaustive]
1127pub struct Finding {
1128    /// The stable ID of the lint that produced this finding (from [`Lint::id`]).
1129    #[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
1130    pub lint_id: Cow<'static, str>,
1131    /// The normative citation for this lint (from [`Lint::citation`]).
1132    ///
1133    /// Included here so consumers of `Vec<Finding>` do not need to re-look up
1134    /// the lint to get the citation for report generation and evidence packs.
1135    #[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
1136    pub citation: Cow<'static, str>,
1137    /// Version string of the rule bundle that produced this finding.
1138    ///
1139    /// Set by [`LintRunner::with_bundle_version`]. Defaults to `""` when the runner
1140    /// was constructed with [`LintRunner::new`] without a version.
1141    ///
1142    /// Example: `"pkix-lint-cabf/cabf_tls_br v0.2.0, sourced from TLS BR SC-081"`.
1143    ///
1144    /// This field enables the "yellow today, green tomorrow because we updated the
1145    /// rule bundle from v1.3 to v1.4" explanation that prevents operators from
1146    /// treating a finding change as a tool defect.
1147    #[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
1148    pub rule_bundle_version: Cow<'static, str>,
1149    /// The outcome of the lint evaluation.
1150    pub result: LintResult,
1151    /// For certificate-scope lints, the zero-based chain index of the evaluated cert.
1152    /// `None` for path-scope lints.
1153    pub cert_index: Option<usize>,
1154    /// Unix epoch seconds at which the lint was evaluated.
1155    ///
1156    /// For audit-mode evaluations (pass issuance time), this records the issuance time.
1157    /// For operational-mode evaluations (pass current time), this records the current time.
1158    /// Together with `cert_index` and the chain, this is sufficient to reconstruct
1159    /// the evaluation context in an evidence pack.
1160    pub evaluated_at_unix: u64,
1161    /// SHA-256 of the DER-encoded certificate that triggered this finding.
1162    ///
1163    /// Populated by [`LintRunner::run_cert`] for certificate-scope findings.
1164    /// `None` for path-scope findings (no single cert triggered the finding —
1165    /// the whole chain did). This is the canonical provenance field for
1166    /// evidence packs: a given (`lint_id`, `cert_sha256`) pair uniquely
1167    /// identifies which cert produced which finding, replayable against the
1168    /// same cert bytes years later.
1169    ///
1170    /// JSON serialisation uses a lowercase hex string (`Some` →
1171    /// `"abc...32hex..."`, `None` → `null`). Binary serde formats emit the
1172    /// raw 32-byte array.
1173    #[cfg_attr(
1174        feature = "serde",
1175        serde(default, with = "serde_helpers::cert_sha256_hex")
1176    )]
1177    pub cert_sha256: Option<[u8; 32]>,
1178}
1179
1180impl Finding {
1181    /// Returns `true` if this finding is actionable (Warn, Error, or Fatal).
1182    #[must_use]
1183    pub const fn is_finding(&self) -> bool {
1184        self.result.is_finding()
1185    }
1186
1187    /// Construct a [`Finding`] with the required fields.
1188    ///
1189    /// Use this constructor instead of struct-literal syntax so the
1190    /// addition of future fields (e.g., `path_position`,
1191    /// `severity_actual`, `evidence_chain_sha256`) remains a
1192    /// non-breaking change. The struct carries `#[non_exhaustive]`.
1193    ///
1194    /// Optional fields ([`Self::cert_index`], [`Self::cert_sha256`])
1195    /// default to `None`; set them via the corresponding `with_*`
1196    /// methods or via field assignment from within the defining crate.
1197    #[must_use]
1198    pub fn new(
1199        lint_id: impl Into<Cow<'static, str>>,
1200        citation: impl Into<Cow<'static, str>>,
1201        rule_bundle_version: impl Into<Cow<'static, str>>,
1202        result: LintResult,
1203        evaluated_at_unix: u64,
1204    ) -> Self {
1205        Self {
1206            lint_id: lint_id.into(),
1207            citation: citation.into(),
1208            rule_bundle_version: rule_bundle_version.into(),
1209            result,
1210            cert_index: None,
1211            evaluated_at_unix,
1212            cert_sha256: None,
1213        }
1214    }
1215
1216    /// Builder-style setter for [`Self::cert_index`]. Returns `self`
1217    /// for chaining.
1218    #[must_use]
1219    pub fn with_cert_index(mut self, cert_index: usize) -> Self {
1220        self.cert_index = Some(cert_index);
1221        self
1222    }
1223
1224    /// Builder-style setter for [`Self::cert_sha256`]. Returns `self`
1225    /// for chaining.
1226    #[must_use]
1227    pub fn with_cert_sha256(mut self, cert_sha256: [u8; 32]) -> Self {
1228        self.cert_sha256 = Some(cert_sha256);
1229        self
1230    }
1231}
1232
1233// ---------------------------------------------------------------------------
1234// LintRunner
1235// ---------------------------------------------------------------------------
1236
1237/// Evaluates a collection of [`Lint`]s against certificates or a validated path.
1238///
1239/// The runner is stateless beyond the lint set — construct once, call many times.
1240///
1241/// # Findings are advisory only
1242///
1243/// `LintRunner` methods return `Vec<Finding>` — they never return `Result::Err`
1244/// and they never cause a certificate to be rejected by a TLS stack. Findings
1245/// are an advisory layer. Whether to act on a finding (reject a connection,
1246/// block a cert, page an operator) is the caller's responsibility, configured
1247/// per finding-ID at the integration boundary.
1248///
1249/// This separation is intentional and must not be violated:
1250/// - `pkix-lint` does not know whether you are in an audit context, a
1251///   monitoring context, or an enforcement context. The caller does.
1252/// - Hard-fail behavior per finding-ID is configured in the integration layer
1253///   (e.g., `pkix-chain` or a TLS stack binding), not here.
1254/// - `pkix-lint` will never introduce a code path that returns `Err` or
1255///   panics based on lint findings.
1256///
1257/// # Evaluation order
1258///
1259/// Lints are evaluated in the order they were supplied to [`LintRunner::new`].
1260/// If a lint returns [`LintResult::Fatal`], the runner stops evaluating further
1261/// lints for the current item (cert or path) and records the fatal finding.
1262/// See [`LintResult::Fatal`] for the definition of "fatal within lint evaluation."
1263///
1264/// # Duplicate lint IDs
1265///
1266/// While the runner does not reject duplicate lint IDs, supplying lints with
1267/// duplicate IDs interacts poorly with the deviation mechanism: a deviation
1268/// keyed on a given ID will apply to every finding with that ID, which is
1269/// unlikely to be the intended behavior and makes the audit trail ambiguous.
1270/// Avoid duplicate IDs; in debug builds [`LintRunner::new`] asserts uniqueness.
1271///
1272/// # Thread safety
1273///
1274/// `LintRunner` is `Send + Sync` as long as all supplied lints are `Send + Sync`
1275/// (enforced by the `Lint: Send + Sync` bound).
1276pub struct LintRunner {
1277    lints: Vec<Box<dyn Lint>>,
1278    /// Version string stamped into every [`Finding`] produced by this runner.
1279    ///
1280    /// Set via [`LintRunner::with_bundle_version`]. Defaults to `""`.
1281    bundle_version: std::borrow::Cow<'static, str>,
1282}
1283
1284impl core::fmt::Debug for LintRunner {
1285    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1286        f.debug_struct("LintRunner")
1287            .field("lint_count", &self.lints.len())
1288            .field("bundle_version", &self.bundle_version)
1289            .finish()
1290    }
1291}
1292
1293impl LintRunner {
1294    /// Create a new runner from a set of lints, with no bundle version string.
1295    ///
1296    /// Lints are evaluated in the order supplied.
1297    ///
1298    /// # Panics
1299    ///
1300    /// Panics in **both debug and release builds** if any two lints share
1301    /// the same [`Lint::id`]. Duplicate IDs interact poorly with the
1302    /// deviation mechanism: a deviation keyed on a given ID would apply
1303    /// to every finding with that ID, silently halving the visible work
1304    /// and producing a false sense of compliance. The check is one sort +
1305    /// dedup over the lint id strings — `O(n log n)` over a typically
1306    /// small `n`, with no measurable cost for production lint bundles.
1307    ///
1308    /// To set a bundle version (recommended for production use), use
1309    /// [`LintRunner::with_bundle_version`].
1310    #[must_use]
1311    pub fn new(lints: Vec<Box<dyn Lint>>) -> Self {
1312        Self::check_unique_ids(&lints);
1313        Self {
1314            lints,
1315            bundle_version: std::borrow::Cow::Borrowed(""),
1316        }
1317    }
1318
1319    /// Panic with a duplicate-ID message if `lints` contains two lints
1320    /// sharing the same [`Lint::id`]. Called from [`LintRunner::new`] and
1321    /// [`LintRunner::with_bundle_version`] so both constructors enforce
1322    /// the same invariant in both debug and release builds.
1323    fn check_unique_ids(lints: &[Box<dyn Lint>]) {
1324        let mut ids: Vec<&str> = lints.iter().map(|l| l.id()).collect();
1325        let original_len = ids.len();
1326        ids.sort_unstable();
1327        ids.dedup();
1328        if ids.len() != original_len {
1329            // Re-walk to find the first duplicate so the panic message
1330            // names it.
1331            let mut seen: std::collections::HashSet<&str> =
1332                std::collections::HashSet::with_capacity(lints.len());
1333            for l in lints {
1334                let id = l.id();
1335                if !seen.insert(id) {
1336                    panic!(
1337                        "LintRunner constructed with duplicate lint id {id:?}; \
1338                         duplicate IDs interact incorrectly with deviation matching \
1339                         and silently produce ambiguous audit trails. Deduplicate \
1340                         the lint set before constructing the runner."
1341                    );
1342                }
1343            }
1344            // Unreachable: ids.len() != original_len implies a duplicate
1345            // exists, which the loop above would have found.
1346            unreachable!("duplicate detected by dedup but not by HashSet walk");
1347        }
1348    }
1349
1350    /// Create a new runner with an explicit bundle version string.
1351    ///
1352    /// The `version` string is stamped into every [`Finding`] produced by this runner
1353    /// as [`Finding::rule_bundle_version`]. Use this in production to record which
1354    /// version of the rule bundle was active when findings were generated.
1355    ///
1356    /// Accepts any value that converts to `Cow<'static, str>`: string literals
1357    /// (zero-copy) or owned `String` values (for runtime-constructed versions):
1358    ///
1359    /// ```rust,no_run
1360    /// use pkix_lint::LintRunner;
1361    /// // `lints` is a Vec<Box<dyn pkix_lint::Lint>> from the calling context.
1362    /// let lints: Vec<Box<dyn pkix_lint::Lint>> = vec![];
1363    ///
1364    /// // Static literal — zero allocation
1365    /// let runner = LintRunner::with_bundle_version(
1366    ///     lints,
1367    ///     "pkix-lint-cabf/cabf_tls_br v0.2.0, sourced from TLS BR SC-081",
1368    /// );
1369    ///
1370    /// // Runtime-constructed version — e.g., read from config
1371    /// let lints2: Vec<Box<dyn pkix_lint::Lint>> = vec![];
1372    /// let ver = format!("my-bundle v{}", env!("CARGO_PKG_VERSION"));
1373    /// let runner2 = LintRunner::with_bundle_version(lints2, ver);
1374    /// ```
1375    ///
1376    /// # Panics
1377    ///
1378    /// Same duplicate-ID precondition as [`LintRunner::new`].
1379    #[must_use]
1380    pub fn with_bundle_version(
1381        lints: Vec<Box<dyn Lint>>,
1382        version: impl Into<std::borrow::Cow<'static, str>>,
1383    ) -> Self {
1384        Self::check_unique_ids(&lints);
1385        Self {
1386            lints,
1387            bundle_version: version.into(),
1388        }
1389    }
1390
1391    /// Return a reference to the registered lints.
1392    #[must_use]
1393    pub fn lints(&self) -> &[Box<dyn Lint>] {
1394        &self.lints
1395    }
1396
1397    /// Return the bundle version string set on this runner.
1398    #[must_use]
1399    pub fn bundle_version(&self) -> &str {
1400        &self.bundle_version
1401    }
1402
1403    /// Return a new `LintRunner` containing only the lints whose
1404    /// [`Lint::id`] appears in `ids`, in the order `ids` lists them.
1405    ///
1406    /// This is the consumer half of the OSCAL Catalog round-trip
1407    /// introduced in PKIX-9vnx.6.3: a caller emits a Catalog via
1408    /// [`crate::oscal::catalog::catalog_from_lints`], serializes it to
1409    /// JSON, parses the JSON back, extracts the ids via
1410    /// [`crate::oscal::parse::lint_ids_from_catalog`], and reconstructs
1411    /// an equivalent runner with `filter_to_ids`. Running both runners
1412    /// on the same chain produces identical [`Finding`] sets — that is
1413    /// the closure the round-trip test pins.
1414    ///
1415    /// `bundle_version` is preserved unchanged.
1416    ///
1417    /// # Errors
1418    ///
1419    /// Returns [`crate::oscal::parse::ParseError::UnknownLintId`] on the
1420    /// first id in `ids` that does not match any registered lint. The
1421    /// caller is responsible for catching this when the Catalog and the
1422    /// registered lint set are not in sync (e.g., a Catalog was emitted
1423    /// from a newer pkix-lint with a lint that does not exist in the
1424    /// current runner's set).
1425    ///
1426    /// Lints whose id is registered but not present in `ids` are
1427    /// silently dropped — that is the intended filter semantic.
1428    #[cfg(feature = "oscal")]
1429    #[cfg_attr(docsrs, doc(cfg(feature = "oscal")))]
1430    pub fn filter_to_ids(
1431        self,
1432        ids: &[String],
1433    ) -> Result<LintRunner, crate::oscal::parse::ParseError> {
1434        // Index by id for O(1) lookup. We extract into Option<Box<dyn Lint>>
1435        // so we can take each lint out exactly once, preserving the original
1436        // Vec's ownership without cloning Box<dyn Lint> (which is not Clone
1437        // in general).
1438        let mut by_id: std::collections::HashMap<&'static str, Option<Box<dyn Lint>>> =
1439            std::collections::HashMap::with_capacity(self.lints.len());
1440        for lint in self.lints {
1441            by_id.insert(lint.id(), Some(lint));
1442        }
1443
1444        let mut filtered: Vec<Box<dyn Lint>> = Vec::with_capacity(ids.len());
1445        for id in ids {
1446            // Match against the registered id set first: lifetime juggling
1447            // — `by_id` keys are &'static str but `id` is a runtime String,
1448            // so we lift the lookup through the borrow.
1449            let key = by_id
1450                .keys()
1451                .copied()
1452                .find(|k| *k == id.as_str())
1453                .ok_or_else(|| crate::oscal::parse::ParseError::UnknownLintId { id: id.clone() })?;
1454            // `take` returns the lint exactly once; subsequent duplicates
1455            // in `ids` would silently get None — we treat that as "drop"
1456            // since OSCAL Catalogs forbid duplicate Control ids anyway.
1457            if let Some(lint) = by_id.get_mut(key).and_then(|slot| slot.take()) {
1458                filtered.push(lint);
1459            }
1460        }
1461
1462        Ok(LintRunner {
1463            lints: filtered,
1464            bundle_version: self.bundle_version,
1465        })
1466    }
1467
1468    /// Apply OSCAL Profile `modify.set-parameters` overrides to the
1469    /// registered lints in place.
1470    ///
1471    /// `overrides` is the list produced by
1472    /// [`crate::oscal::profile::resolve_profile`]. Each entry's
1473    /// [`crate::oscal::profile::ParameterOverride::param_id`] is the
1474    /// composite id emitted by
1475    /// [`crate::oscal::catalog::catalog_from_lints`] in the form
1476    /// `<lint_id>.<param_id>`. This method matches each composite id
1477    /// against the set of registered lints using **longest-prefix
1478    /// matching**: for each override, the registered lint whose
1479    /// [`Lint::id`] is the longest prefix of `param_id` followed by `.`
1480    /// owns the override; the remainder after the matched prefix and
1481    /// dot is the unqualified parameter id passed to
1482    /// [`Lint::set_parameter`]. This correctly handles lint ids
1483    /// containing dots (`rfc5280.cert.serial_number.max_octets`) AND
1484    /// parameter ids containing dots (`thresholds.warn`); the
1485    /// resolution depends only on the registered lints, not on a
1486    /// fixed convention about which side may contain dots.
1487    ///
1488    /// Longest-prefix matching disambiguates the (rare) case where one
1489    /// registered lint's id is a strict prefix of another. Operators
1490    /// are still expected to keep lint ids globally unique, but the
1491    /// resolution rule is well-defined when prefixes overlap.
1492    ///
1493    /// Overrides are applied in the order supplied. For composed
1494    /// Profiles where inner and outer layers both target the same
1495    /// parameter, [`crate::oscal::profile::resolve_profile`] orders
1496    /// inner overrides first; the outer Profile's value takes effect
1497    /// because it is applied last.
1498    ///
1499    /// Composite param ids that contain no `.` separator, or whose
1500    /// prefix does not match any registered lint id, are rejected as
1501    /// [`crate::oscal::parse::ParseError::UnknownParameterOverride`].
1502    ///
1503    /// # Errors
1504    ///
1505    /// * [`crate::oscal::parse::ParseError::UnknownParameterOverride`]
1506    ///   on the first override whose composite id has no matching
1507    ///   registered lint. **All `UnknownParameterOverride` errors are
1508    ///   surfaced before any mutation is applied** — the method
1509    ///   resolves every override to a `(lint_index, param_id)` pair
1510    ///   first and fails fast if any cannot be resolved.
1511    /// * [`crate::oscal::parse::ParseError::InvalidParameterOverride`]
1512    ///   when the matched lint's
1513    ///   [`Lint::set_parameter`] rejects the value (wrapping the
1514    ///   underlying [`ParameterError`]).
1515    ///
1516    /// **Atomic on either error variant** (PKIX-hy2e.6): the method
1517    /// clones every affected lint, applies `set_parameter` to the
1518    /// clones, and swaps the clones into the runner only after every
1519    /// override has succeeded. On any error the runner state is
1520    /// unchanged. The clone is via the [`LintClone`] supertrait
1521    /// (blanket-impl-ed for every `Lint + Clone + 'static`).
1522    #[cfg(feature = "oscal")]
1523    #[cfg_attr(docsrs, doc(cfg(feature = "oscal")))]
1524    pub fn apply_parameter_overrides(
1525        &mut self,
1526        overrides: &[crate::oscal::profile::ParameterOverride],
1527    ) -> Result<(), crate::oscal::parse::ParseError> {
1528        // Phase 1: resolve every override to (lint_index, param_id).
1529        // Fail fast on any UnknownParameterOverride before touching any
1530        // lint state. Phase 2 below then provides atomic application
1531        // on InvalidParameterOverride as well via clone-and-swap.
1532        let mut resolved: Vec<(usize, &str, &crate::oscal::profile::ParameterOverride)> =
1533            Vec::with_capacity(overrides.len());
1534        for over in overrides {
1535            let (lint_index, param_id) = self
1536                .lints
1537                .iter()
1538                .enumerate()
1539                .filter_map(|(i, l)| {
1540                    let lint_id = l.id();
1541                    over.param_id
1542                        .strip_prefix(lint_id)
1543                        .and_then(|rest| rest.strip_prefix('.'))
1544                        .map(|param_id| (i, lint_id.len(), param_id))
1545                })
1546                // Longest-prefix match wins. Stable order across calls
1547                // because iter().enumerate() walks lints in registration
1548                // order.
1549                .max_by_key(|(_, prefix_len, _)| *prefix_len)
1550                .map(|(i, _, param_id)| (i, param_id))
1551                .ok_or_else(|| crate::oscal::parse::ParseError::UnknownParameterOverride {
1552                    param_id: over.param_id.clone(),
1553                })?;
1554            resolved.push((lint_index, param_id, over));
1555        }
1556
1557        // Phase 2: clone-and-swap atomicity (PKIX-hy2e.6). Clone every
1558        // lint that any override targets, apply set_parameter on the
1559        // clones, and only commit on full success. Lints not targeted
1560        // by any override are left untouched.
1561        //
1562        // Memory cost: O(distinct_lint_indices) clones, which is at
1563        // most O(overrides.len()). For realistic OSCAL Profile sizes
1564        // this is single-digit megabytes worst-case (lints carry a
1565        // few hundred bytes of state each); for the typical few-
1566        // override case it is a handful of small clones.
1567        use std::collections::BTreeMap;
1568        let mut clones: BTreeMap<usize, Box<dyn Lint>> = BTreeMap::new();
1569        for (lint_index, param_id, over) in resolved {
1570            let clone = clones
1571                .entry(lint_index)
1572                .or_insert_with(|| self.lints[lint_index].clone_box());
1573            clone.set_parameter(param_id, &over.value).map_err(
1574                |source| crate::oscal::parse::ParseError::InvalidParameterOverride {
1575                    param_id: over.param_id.clone(),
1576                    source,
1577                },
1578            )?;
1579        }
1580
1581        // All clones accepted every set_parameter call. Commit by
1582        // swapping each clone into the registered slot.
1583        for (lint_index, clone) in clones {
1584            self.lints[lint_index] = clone;
1585        }
1586        Ok(())
1587    }
1588
1589    /// Evaluate all certificate-scope lints against `cert`.
1590    ///
1591    /// `kind` is the position of this certificate in the chain (leaf, intermediate, etc.).
1592    /// `now_unix` is the evaluation time (seconds since Unix epoch).
1593    ///
1594    /// # Evaluation modes
1595    ///
1596    /// Pass the **issuance time** (`cert.tbs_certificate.validity.not_before`) for
1597    /// audit-mode evaluation: "was this cert compliant when it was issued?"
1598    ///
1599    /// Pass the **current time** for operational-mode evaluation: "is this cert
1600    /// compliant under current rules?"
1601    ///
1602    /// Use [`LintRunner::run_cert_at_issuance`] as a convenience wrapper for audit mode.
1603    ///
1604    /// Both modes are valid and different — lints with effective dates (e.g., SC-081
1605    /// validity caps) produce different results depending on which mode is used.
1606    ///
1607    /// Lints whose `scope()` is not [`Scope::Certificate`] are skipped entirely
1608    /// (no finding recorded). Lints in scope but whose `applies_to()` does not
1609    /// match `kind` produce a [`LintResult::NotApplicable`] finding recorded for
1610    /// audit completeness.
1611    ///
1612    /// Evaluation stops early if any lint returns `Fatal`.
1613    #[must_use]
1614    pub fn run_cert(
1615        &self,
1616        cert: &Certificate,
1617        kind: SubjectKind,
1618        cert_index: usize,
1619        now_unix: u64,
1620    ) -> Vec<Finding> {
1621        // Compute the cert hash once per run_cert call, regardless of how
1622        // many lints fire on it. SHA-256 of the DER re-encoding is the
1623        // canonical provenance field for evidence packs (PKIX-a86q).
1624        // `cert_sha256_of` returns `None` if the cert fails to re-encode;
1625        // in that case the finding records "provenance unavailable" rather
1626        // than stamping a misleading hash.
1627        let cert_sha256 = cert_sha256_of(cert);
1628        let mut findings = Vec::new();
1629        for lint in &self.lints {
1630            if lint.scope() != Scope::Certificate {
1631                continue;
1632            }
1633            let result = if kind.matches(lint.applies_to()) {
1634                lint.check_cert(cert, kind, now_unix)
1635            } else {
1636                LintResult::NotApplicable
1637            };
1638            let is_fatal = result.is_fatal();
1639            findings.push(Finding {
1640                lint_id: std::borrow::Cow::Borrowed(lint.id()),
1641                citation: std::borrow::Cow::Borrowed(lint.citation()),
1642                rule_bundle_version: self.bundle_version.clone(),
1643                result,
1644                cert_index: Some(cert_index),
1645                evaluated_at_unix: now_unix,
1646                cert_sha256,
1647            });
1648            if is_fatal {
1649                break;
1650            }
1651        }
1652        findings
1653    }
1654
1655    /// Evaluate certificate-scope lints as of the certificate's issuance time.
1656    ///
1657    /// Convenience wrapper for **audit mode**: extracts `notBefore` from the cert
1658    /// and passes it as `now_unix` to `run_cert`. This answers: "was this cert
1659    /// compliant when it was issued?"
1660    ///
1661    /// For operational mode ("is it compliant under current rules?"), call `run_cert`
1662    /// directly with the current time.
1663    ///
1664    /// See `run_cert` for full documentation on evaluation modes.
1665    #[must_use]
1666    pub fn run_cert_at_issuance(
1667        &self,
1668        cert: &Certificate,
1669        kind: SubjectKind,
1670        cert_index: usize,
1671    ) -> Vec<Finding> {
1672        let issuance_unix = cert
1673            .tbs_certificate
1674            .validity
1675            .not_before
1676            .to_unix_duration()
1677            .as_secs();
1678        self.run_cert(cert, kind, cert_index, issuance_unix)
1679    }
1680
1681    /// Evaluate all certificate-scope lints against every certificate in `chain`.
1682    ///
1683    /// `kinds` maps chain index to [`SubjectKind`] and MUST have the
1684    /// same length as `chain`. Each `kinds[i]` is the classification
1685    /// for `chain[i]`.
1686    ///
1687    /// Returns a flat `Vec<Finding>` with `cert_index` set for each.
1688    ///
1689    /// # Panics
1690    ///
1691    /// Panics if `kinds.len() != chain.len()`. Earlier versions
1692    /// silently defaulted missing entries to
1693    /// [`SubjectKind::IntermediateCa`], which produced a class of audit
1694    /// hazards: a leaf-only lint silently returned `NotApplicable` on
1695    /// what was actually a leaf cert, and the compliance report
1696    /// looked clean until manual inspection found the misclassification.
1697    /// The PKIX-7f92.9 review concluded that a truncated kinds slice is
1698    /// almost certainly a caller bug, never a deliberate API use; the
1699    /// runner now fails loudly instead.
1700    ///
1701    /// # Determining the `AnchorIssued` position
1702    ///
1703    /// The `AnchorIssued` certificate is the one directly signed by the trust anchor —
1704    /// typically the last certificate in the chain before the anchor itself (i.e., the
1705    /// highest-index intermediate, `chain[chain.len() - 1]`).
1706    ///
1707    /// Callers are responsible for identifying this position and passing
1708    /// [`SubjectKind::AnchorIssued`] in `kinds`. The runner has no access to trust
1709    /// anchor information and cannot determine this automatically.
1710    ///
1711    /// To identify it: the anchor-issued cert is the one whose issuer DN matches a
1712    /// trust anchor's subject. Check via `pkix_path::names_match(cert.tbs_certificate.issuer,
1713    /// anchor.subject)` for each anchor in your trust store.
1714    ///
1715    /// # Fatal behavior across certificates
1716    ///
1717    /// Note: [`LintResult::Fatal`] stops lint evaluation for the *current certificate
1718    /// only*. Subsequent certificates in the chain continue to be evaluated.
1719    #[must_use]
1720    pub fn run_chain(
1721        &self,
1722        chain: &[Certificate],
1723        kinds: &[SubjectKind],
1724        now_unix: u64,
1725    ) -> Vec<Finding> {
1726        assert_eq!(
1727            kinds.len(),
1728            chain.len(),
1729            "LintRunner::run_chain requires kinds.len() == chain.len() \
1730             (got kinds={}, chain={}); see PKIX-7f92.9. A shorter `kinds` \
1731             slice is treated as a caller bug — provide an explicit \
1732             SubjectKind for every certificate.",
1733            kinds.len(),
1734            chain.len(),
1735        );
1736        let mut all = Vec::new();
1737        for (i, cert) in chain.iter().enumerate() {
1738            let kind = kinds[i];
1739            all.extend(self.run_cert(cert, kind, i, now_unix));
1740        }
1741        all
1742    }
1743
1744    /// Evaluate all path-scope lints against the full validated path.
1745    ///
1746    /// `chain` must be the same slice passed to `pkix_path::validate_path`.
1747    /// `path` is the [`ValidatedPath`] returned by that call.
1748    ///
1749    /// Evaluation stops early if any lint returns `Fatal`.
1750    #[must_use]
1751    pub fn run_path(
1752        &self,
1753        chain: &[Certificate],
1754        path: &ValidatedPath,
1755        now_unix: u64,
1756    ) -> Vec<Finding> {
1757        let mut findings = Vec::new();
1758        for lint in &self.lints {
1759            if lint.scope() != Scope::Path {
1760                continue;
1761            }
1762            let result = lint.check_path(chain, path, now_unix);
1763            let is_fatal = result.is_fatal();
1764            findings.push(Finding {
1765                lint_id: std::borrow::Cow::Borrowed(lint.id()),
1766                citation: std::borrow::Cow::Borrowed(lint.citation()),
1767                rule_bundle_version: self.bundle_version.clone(),
1768                result,
1769                cert_index: None,
1770                evaluated_at_unix: now_unix,
1771                // Path-scope findings have no single triggering cert. The
1772                // chain as a whole is the subject; downstream consumers
1773                // typically pair this finding with the path's
1774                // `chain_certs_sha256` summary rather than a single hash.
1775                cert_sha256: None,
1776            });
1777            if is_fatal {
1778                break;
1779            }
1780        }
1781        findings
1782    }
1783}
1784
1785// ---------------------------------------------------------------------------
1786// LintProfile trait
1787// ---------------------------------------------------------------------------
1788
1789/// A [`Profile`] that also bundles a set of lints.
1790///
1791/// This is the integration point between profile policy and the lint engine.
1792/// Implement `LintProfile` on a type that already implements [`Profile`] to
1793/// associate a set of lints with the profile.
1794///
1795/// # Why not on `Profile` directly?
1796///
1797/// Adding `lints()` to [`pkix_path::Profile`] would create a mandatory dep on
1798/// `pkix-lint` from `pkix-path`. That would violate `pkix-path`'s `no_std`
1799/// boundary and force the lint engine into every profile consumer.
1800/// `LintProfile` is a separate trait in `pkix-lint` that callers opt into.
1801pub trait LintProfile: Profile {
1802    /// Return the lints that this profile enforces.
1803    ///
1804    /// The returned slice owns `Box<dyn Lint>` values. The runner uses them
1805    /// directly — no cloning needed.
1806    fn lints(&self) -> &[Box<dyn Lint>];
1807
1808    /// Convenience: produce a [`LintRunner`] from this profile's lints.
1809    ///
1810    /// Implementors should document whether this method caches the runner or
1811    /// allocates fresh on each call. Callers that invoke this repeatedly should
1812    /// cache the returned [`LintRunner`] themselves.
1813    #[must_use]
1814    fn lint_runner(&self) -> LintRunner;
1815}
1816
1817// ---------------------------------------------------------------------------
1818// check_shape — single-cert convenience over the lint engine
1819// ---------------------------------------------------------------------------
1820
1821/// Run the certificate-scope lints of `profile` against `cert` and report
1822/// pass/fail without walking a chain or verifying signatures.
1823///
1824/// This is a fast single-cert convenience over the lint engine. It sits
1825/// between [`pkix_path::validate_path`] (the path-validation gate) and
1826/// [`crate::oscal::emit::assessment_results`] (compliance assertion).
1827///
1828/// # Layered semantics
1829///
1830/// | Layer | Use case | Cost |
1831/// |-------|----------|------|
1832/// | `check_shape` (this fn) | Single cert, structural sanity. \"Is this a sanely-shaped X cert?\" | O(number of lints), microseconds |
1833/// | [`pkix_path::validate_path`] | Full chain, RFC 5280 §6 path validation including signature checks | O(chain length × verifier cost), milliseconds |
1834/// | [`crate::oscal::emit::assessment_results`] | OSCAL Assessment Results JSON for compliance audit | Per-finding serialisation cost |
1835///
1836/// # Contract
1837///
1838/// * Returns `Ok(())` when no [`LintResult::Error`] or [`LintResult::Fatal`]
1839///   findings are produced.
1840/// * Returns `Err(findings)` with the **complete** `Vec<Finding>` from
1841///   [`LintRunner::run_cert`] otherwise. The returned list may include
1842///   [`LintResult::Warn`], [`LintResult::Pass`], and
1843///   [`LintResult::NotApplicable`] findings alongside the failing ones —
1844///   callers can filter as they need.
1845///
1846/// Findings on `Warn`-only certs are silently discarded by `check_shape`
1847/// (the `Ok` variant carries no findings). Callers that need access to
1848/// `Warn`-level findings even on pass should call [`LintRunner::run_cert`]
1849/// directly via `profile.lint_runner()`.
1850///
1851/// # Parameters
1852///
1853/// * `cert` — the parsed certificate under scrutiny.
1854/// * `kind` — the certificate's role ([`SubjectKind::Leaf`] for an end-entity
1855///   shape check; [`SubjectKind::IntermediateCa`] for an intermediate). Lints
1856///   whose [`Lint::applies_to`] does not match record
1857///   [`LintResult::NotApplicable`] (not a failure).
1858/// * `now_unix` — evaluation time, seconds since the Unix epoch. Lints with
1859///   effective dates (e.g., phased validity caps) use this; pass the
1860///   current time for operational-mode evaluation, or the cert's
1861///   `not_before` for audit-mode evaluation.
1862/// * `profile` — any [`LintProfile`] implementor.
1863///
1864/// # I/O and side effects
1865///
1866/// None. No chain walk, no signature verification, no file or network access.
1867/// Allocates one [`LintRunner`] and one `Vec<Finding>`.
1868#[must_use = "the returned Result reports whether the cert passed all Error/Fatal lints"]
1869pub fn check_shape(
1870    cert: &Certificate,
1871    kind: SubjectKind,
1872    now_unix: u64,
1873    profile: &dyn LintProfile,
1874) -> Result<(), Vec<Finding>> {
1875    let runner = profile.lint_runner();
1876    let findings = runner.run_cert(cert, kind, 0, now_unix);
1877    let failed = findings
1878        .iter()
1879        .any(|f| matches!(f.result, LintResult::Error(_) | LintResult::Fatal(_)));
1880    if failed {
1881        Err(findings)
1882    } else {
1883        Ok(())
1884    }
1885}
1886
1887// ---------------------------------------------------------------------------
1888// Send + Sync compile-time assertions (AGENTS.md non-negotiable #6, PKIX-2l0v.2)
1889// ---------------------------------------------------------------------------
1890
1891// Every load-bearing public type — results, errors, config carriers,
1892// runners, stores — is asserted Send+Sync at compile time. AGENTS.md
1893// non-negotiable #6 requires these types to admit cross-thread caching
1894// and batch evaluation. The const block fails the workspace build
1895// immediately if someone adds Rc<T>, RefCell<T>, or any non-Sync field
1896// to a covered type.
1897const _: fn() = || {
1898    fn _assert_send_sync<T: Send + Sync>() {}
1899
1900    // Lint-engine surface
1901    _assert_send_sync::<Finding>();
1902    _assert_send_sync::<LintRunner>();
1903    _assert_send_sync::<LintResult>();
1904    _assert_send_sync::<LintParameter>();
1905    _assert_send_sync::<ParameterError>();
1906
1907    // Deviation surface
1908    _assert_send_sync::<crate::deviation::Deviation>();
1909    _assert_send_sync::<crate::deviation::DeviationScope>();
1910    _assert_send_sync::<crate::deviation::DeviationStore>();
1911    _assert_send_sync::<crate::deviation::DeviationAddError>();
1912    _assert_send_sync::<crate::deviation::DeviationRunResult>();
1913    _assert_send_sync::<crate::deviation::DeviationRunner>();
1914    _assert_send_sync::<crate::deviation::ScopePropValue>();
1915
1916    // Report surface
1917    _assert_send_sync::<crate::deviation::DeviatedFinding>();
1918    _assert_send_sync::<crate::report::EvaluationReport>();
1919
1920    // OSCAL surface (oscal feature only — types are #[cfg]-gated).
1921    #[cfg(feature = "oscal")]
1922    _assert_send_sync::<crate::oscal::parse::ParseError>();
1923    #[cfg(feature = "oscal")]
1924    _assert_send_sync::<crate::oscal::profile::ResolvedProfile>();
1925    #[cfg(feature = "oscal")]
1926    _assert_send_sync::<crate::oscal::emit::AssessmentResultsOptions>();
1927};
1928
1929// ---------------------------------------------------------------------------
1930// Tests
1931// ---------------------------------------------------------------------------
1932
1933#[cfg(test)]
1934mod tests {
1935    use super::*;
1936
1937    // -----------------------------------------------------------------------
1938    // SubjectKind::matches tests
1939    //
1940    // Oracle: the filter/subject matching rules in the SubjectKind doc comment.
1941    // -----------------------------------------------------------------------
1942
1943    #[test]
1944    fn subject_kind_any_matches_all() {
1945        for &kind in &[
1946            SubjectKind::Leaf,
1947            SubjectKind::IntermediateCa,
1948            SubjectKind::AnchorIssued,
1949            SubjectKind::Any,
1950        ] {
1951            assert!(
1952                kind.matches(SubjectKind::Any),
1953                "{kind:?} must match filter Any"
1954            );
1955        }
1956    }
1957
1958    #[test]
1959    fn subject_kind_exact_matches_self() {
1960        assert!(SubjectKind::Leaf.matches(SubjectKind::Leaf));
1961        assert!(SubjectKind::IntermediateCa.matches(SubjectKind::IntermediateCa));
1962        assert!(SubjectKind::AnchorIssued.matches(SubjectKind::AnchorIssued));
1963    }
1964
1965    #[test]
1966    fn subject_kind_intermediate_filter_includes_anchor_issued() {
1967        // AnchorIssued is a sub-kind of IntermediateCa — an anchor-issued cert
1968        // is still a CA cert and should be checked by IntermediateCa lints.
1969        assert!(SubjectKind::AnchorIssued.matches(SubjectKind::IntermediateCa));
1970    }
1971
1972    #[test]
1973    fn subject_kind_leaf_does_not_match_intermediate() {
1974        assert!(!SubjectKind::Leaf.matches(SubjectKind::IntermediateCa));
1975        assert!(!SubjectKind::Leaf.matches(SubjectKind::AnchorIssued));
1976    }
1977
1978    #[test]
1979    fn subject_kind_intermediate_does_not_match_leaf() {
1980        assert!(!SubjectKind::IntermediateCa.matches(SubjectKind::Leaf));
1981    }
1982
1983    // -----------------------------------------------------------------------
1984    // truncate_for_detail tests (PKIX-7f92.52)
1985    // -----------------------------------------------------------------------
1986
1987    #[test]
1988    fn truncate_for_detail_passes_short_strings_through() {
1989        let s = "small string";
1990        let out = super::truncate_for_detail(s);
1991        assert_eq!(out, "small string");
1992        // Borrowed Cow — no allocation for the common case.
1993        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
1994    }
1995
1996    #[test]
1997    fn truncate_for_detail_passes_exact_cap_unchanged() {
1998        let s: String = "a".repeat(super::DETAIL_MAX_BYTES);
1999        let out = super::truncate_for_detail(&s);
2000        assert_eq!(out.len(), super::DETAIL_MAX_BYTES);
2001        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
2002    }
2003
2004    #[test]
2005    fn truncate_for_detail_truncates_over_cap() {
2006        let s: String = "a".repeat(super::DETAIL_MAX_BYTES + 100);
2007        let out = super::truncate_for_detail(&s);
2008        // Result starts with the prefix, then carries the marker with
2009        // the full original size.
2010        assert!(out.starts_with(&"a".repeat(super::DETAIL_MAX_BYTES)));
2011        assert!(out.contains("... (truncated, "));
2012        assert!(out.contains(&format!("{} bytes total)", s.len())));
2013        assert!(matches!(out, std::borrow::Cow::Owned(_)));
2014    }
2015
2016    /// Worst-case attacker input: a 100MB string. Must not produce
2017    /// a 100MB output; the result is bounded by
2018    /// `DETAIL_MAX_BYTES + len(marker + digits)`.
2019    #[test]
2020    fn truncate_for_detail_bounds_worst_case_attacker_input() {
2021        let mb_100: String = "X".repeat(100 * 1024 * 1024);
2022        let out = super::truncate_for_detail(&mb_100);
2023        // Bounded: prefix (256) + suffix marker (~50 chars).
2024        assert!(
2025            out.len() < super::DETAIL_MAX_BYTES + 100,
2026            "truncated output must be bounded near the cap; got len={}",
2027            out.len()
2028        );
2029        assert!(out.contains("(truncated"));
2030    }
2031
2032    /// Truncation must cut on a UTF-8 char boundary; otherwise the
2033    /// returned Cow could contain invalid UTF-8.
2034    #[test]
2035    fn truncate_for_detail_respects_utf8_char_boundaries() {
2036        // 'ü' (U+00FC) is 2 bytes in UTF-8. Build a string where the
2037        // 256th byte sits inside a multi-byte sequence.
2038        // Start with 255 ASCII bytes, then 'ü' (2 bytes) so position 256
2039        // is inside 'ü', then 100 more 'ü'.
2040        let mut s = String::with_capacity(super::DETAIL_MAX_BYTES * 2);
2041        s.push_str(&"a".repeat(super::DETAIL_MAX_BYTES - 1));
2042        s.push('ü'); // 2 bytes at indices DETAIL_MAX_BYTES-1 and DETAIL_MAX_BYTES
2043        for _ in 0..100 {
2044            s.push('ü');
2045        }
2046        let out = super::truncate_for_detail(&s);
2047        // The fact that this didn't panic during construction proves
2048        // the function respects char boundaries.
2049        assert!(out.len() < s.len(), "must have truncated");
2050        // The truncated prefix must itself be valid UTF-8 (Cow<str> guarantees this,
2051        // but assert the leading content is what we expect).
2052        assert!(out.starts_with(&"a".repeat(super::DETAIL_MAX_BYTES - 1)));
2053    }
2054
2055    // -----------------------------------------------------------------------
2056    // Severity rank stability tests
2057    //
2058    // Oracle: the rustdoc on `Severity::rank` pins the documented
2059    // per-variant u8 values (Info=10, Notice=20, Warn=30, Error=40,
2060    // Fatal=50). These ranks are part of the public API contract — a
2061    // caller that wrote `finding.severity.rank() >= 30` is encoding
2062    // "Warn or above" by rank value. Changing any rank would silently
2063    // break such callers.
2064    //
2065    // PKIX-7f92.24 dropped `derive(PartialOrd, Ord)` to avoid the
2066    // source-position-coupled comparison trap; this test now locks
2067    // the explicit rank values instead of the deprecated `<`-based
2068    // ordering check.
2069    // -----------------------------------------------------------------------
2070
2071    #[test]
2072    fn severity_rank_values_are_pinned() {
2073        assert_eq!(Severity::Info.rank(), 10);
2074        assert_eq!(Severity::Notice.rank(), 20);
2075        assert_eq!(Severity::Warn.rank(), 30);
2076        assert_eq!(Severity::Error.rank(), 40);
2077        assert_eq!(Severity::Fatal.rank(), 50);
2078    }
2079
2080    #[test]
2081    fn severity_rank_ordering_is_info_notice_warn_error_fatal() {
2082        // Rank-based ordering must align with the documented semantic
2083        // hierarchy. This protects against accidental rank renumbering
2084        // that would invert the conceptual relation.
2085        assert!(Severity::Info.rank() < Severity::Notice.rank());
2086        assert!(Severity::Notice.rank() < Severity::Warn.rank());
2087        assert!(Severity::Warn.rank() < Severity::Error.rank());
2088        assert!(Severity::Error.rank() < Severity::Fatal.rank());
2089    }
2090
2091    // -----------------------------------------------------------------------
2092    // LintResult helper method tests
2093    //
2094    // Oracle: the LintResult variant semantics in the doc comments.
2095    // -----------------------------------------------------------------------
2096
2097    #[test]
2098    fn lint_result_pass_is_pass() {
2099        assert!(LintResult::Pass.is_pass());
2100        assert!(!LintResult::Pass.is_finding());
2101        assert!(!LintResult::Pass.is_fatal());
2102        assert_eq!(LintResult::Pass.detail(), None);
2103    }
2104
2105    #[test]
2106    fn lint_result_not_applicable_is_not_pass_not_finding() {
2107        assert!(!LintResult::NotApplicable.is_pass());
2108        assert!(!LintResult::NotApplicable.is_finding());
2109        assert_eq!(LintResult::NotApplicable.detail(), None);
2110    }
2111
2112    #[test]
2113    fn lint_result_warn_is_finding() {
2114        let r = LintResult::warn("test warning");
2115        assert!(!r.is_pass());
2116        assert!(r.is_finding());
2117        assert!(!r.is_fatal());
2118        assert_eq!(r.detail(), Some("test warning"));
2119    }
2120
2121    #[test]
2122    fn lint_result_error_is_finding() {
2123        let r = LintResult::error("test error");
2124        assert!(!r.is_pass());
2125        assert!(r.is_finding());
2126        assert!(!r.is_fatal());
2127        assert_eq!(r.detail(), Some("test error"));
2128    }
2129
2130    #[test]
2131    fn lint_result_fatal_is_fatal_and_finding() {
2132        let r = LintResult::fatal("fatal error");
2133        assert!(!r.is_pass());
2134        assert!(r.is_finding());
2135        assert!(r.is_fatal());
2136        assert_eq!(r.detail(), Some("fatal error"));
2137    }
2138
2139    // -----------------------------------------------------------------------
2140    // LintRunner tests using a trivial in-test Lint implementation
2141    //
2142    // Oracle: the runner contract defined in LintRunner doc comments.
2143    // The test lints are independent oracles — they do not call other lints or
2144    // validate against the code under test.
2145    // -----------------------------------------------------------------------
2146
2147    /// A lint that always passes, used to verify the runner records Pass findings.
2148    #[derive(Clone)]
2149    struct AlwaysPass;
2150    impl Lint for AlwaysPass {
2151        fn id(&self) -> &'static str {
2152            "test.always_pass"
2153        }
2154        fn citation(&self) -> &'static str {
2155            "test"
2156        }
2157        fn severity(&self) -> Severity {
2158            Severity::Info
2159        }
2160        fn scope(&self) -> Scope {
2161            Scope::Certificate
2162        }
2163        fn applies_to(&self) -> SubjectKind {
2164            SubjectKind::Any
2165        }
2166        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
2167            LintResult::Pass
2168        }
2169    }
2170
2171    /// A lint that always warns, used to verify runner records Warn findings.
2172    #[derive(Clone)]
2173    struct AlwaysWarn;
2174    impl Lint for AlwaysWarn {
2175        fn id(&self) -> &'static str {
2176            "test.always_warn"
2177        }
2178        fn citation(&self) -> &'static str {
2179            "test"
2180        }
2181        fn severity(&self) -> Severity {
2182            Severity::Warn
2183        }
2184        fn scope(&self) -> Scope {
2185            Scope::Certificate
2186        }
2187        fn applies_to(&self) -> SubjectKind {
2188            SubjectKind::Any
2189        }
2190        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
2191            LintResult::warn("always warns")
2192        }
2193    }
2194
2195    /// A lint that always returns Fatal, used to test early-exit behavior.
2196    #[derive(Clone)]
2197    struct AlwaysFatal;
2198    impl Lint for AlwaysFatal {
2199        fn id(&self) -> &'static str {
2200            "test.always_fatal"
2201        }
2202        fn citation(&self) -> &'static str {
2203            "test"
2204        }
2205        fn severity(&self) -> Severity {
2206            Severity::Fatal
2207        }
2208        fn scope(&self) -> Scope {
2209            Scope::Certificate
2210        }
2211        fn applies_to(&self) -> SubjectKind {
2212            SubjectKind::Any
2213        }
2214        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
2215            LintResult::fatal("always fatal")
2216        }
2217    }
2218
2219    /// A lint scoped to leaves only, used to verify kind filtering.
2220    #[derive(Clone)]
2221    struct LeafOnlyLint;
2222    impl Lint for LeafOnlyLint {
2223        fn id(&self) -> &'static str {
2224            "test.leaf_only"
2225        }
2226        fn citation(&self) -> &'static str {
2227            "test"
2228        }
2229        fn severity(&self) -> Severity {
2230            Severity::Warn
2231        }
2232        fn scope(&self) -> Scope {
2233            Scope::Certificate
2234        }
2235        fn applies_to(&self) -> SubjectKind {
2236            SubjectKind::Leaf
2237        }
2238        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
2239            LintResult::warn("leaf lint fires")
2240        }
2241    }
2242
2243    /// A path-scope lint, used to verify `run_path`.
2244    #[derive(Clone)]
2245    struct PathDepthLint;
2246    impl Lint for PathDepthLint {
2247        fn id(&self) -> &'static str {
2248            "test.path_depth"
2249        }
2250        fn citation(&self) -> &'static str {
2251            "test"
2252        }
2253        fn severity(&self) -> Severity {
2254            Severity::Warn
2255        }
2256        fn scope(&self) -> Scope {
2257            Scope::Path
2258        }
2259        fn applies_to(&self) -> SubjectKind {
2260            SubjectKind::Any
2261        }
2262        fn check_path(
2263            &self,
2264            _chain: &[Certificate],
2265            path: &ValidatedPath,
2266            _now: u64,
2267        ) -> LintResult {
2268            if path.depth > 5 {
2269                LintResult::warn("chain depth exceeds 5")
2270            } else {
2271                LintResult::Pass
2272            }
2273        }
2274    }
2275
2276    // We need a minimal Certificate to call run_cert. Load from a real fixture.
2277    fn load_fixture_cert() -> Certificate {
2278        use der::Decode as _;
2279        Certificate::from_der(include_bytes!(
2280            "../../pkix-path/tests/fixtures/policy-checks/webpki-self-signed-365d.der"
2281        ))
2282        .expect("fixture is valid DER")
2283    }
2284
2285    #[test]
2286    fn runner_records_pass_finding() {
2287        let cert = load_fixture_cert();
2288        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2289        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2290        assert_eq!(findings.len(), 1);
2291        assert_eq!(findings[0].lint_id, "test.always_pass");
2292        assert_eq!(findings[0].result, LintResult::Pass);
2293        assert_eq!(findings[0].cert_index, Some(0));
2294    }
2295
2296    #[test]
2297    fn runner_records_warn_finding() {
2298        let cert = load_fixture_cert();
2299        let runner = LintRunner::new(vec![Box::new(AlwaysWarn)]);
2300        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2301        assert_eq!(findings.len(), 1);
2302        assert_eq!(findings[0].lint_id, "test.always_warn");
2303        assert!(matches!(findings[0].result, LintResult::Warn(_)));
2304        assert!(findings[0].is_finding());
2305    }
2306
2307    #[test]
2308    fn runner_stops_after_fatal() {
2309        // Fatal lint followed by another lint — the second must NOT be evaluated.
2310        let cert = load_fixture_cert();
2311        let runner = LintRunner::new(vec![
2312            Box::new(AlwaysFatal),
2313            Box::new(AlwaysWarn), // must not appear in findings
2314        ]);
2315        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2316        // Only one finding: the fatal. The warn is never reached.
2317        assert_eq!(findings.len(), 1, "runner must stop after Fatal");
2318        assert_eq!(findings[0].lint_id, "test.always_fatal");
2319        assert!(findings[0].result.is_fatal());
2320    }
2321
2322    #[test]
2323    fn runner_skips_non_applicable_scope_kind() {
2324        // LeafOnlyLint declares applies_to = Leaf.
2325        // Running it against IntermediateCa must return NotApplicable, not Warn.
2326        let cert = load_fixture_cert();
2327        let runner = LintRunner::new(vec![Box::new(LeafOnlyLint)]);
2328        let findings = runner.run_cert(&cert, SubjectKind::IntermediateCa, 1, 0);
2329        assert_eq!(findings.len(), 1);
2330        assert_eq!(findings[0].result, LintResult::NotApplicable);
2331    }
2332
2333    #[test]
2334    fn runner_applies_leaf_lint_to_leaf() {
2335        let cert = load_fixture_cert();
2336        let runner = LintRunner::new(vec![Box::new(LeafOnlyLint)]);
2337        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2338        assert_eq!(findings.len(), 1);
2339        assert!(matches!(findings[0].result, LintResult::Warn(_)));
2340    }
2341
2342    fn validated_path_for_self_signed() -> (Vec<Certificate>, ValidatedPath) {
2343        use pkix_path::{EcdsaP256Verifier, TrustAnchor, ValidationPolicy};
2344        let cert = load_fixture_cert();
2345        let anchor = TrustAnchor::from_cert(cert.clone());
2346        // 2026-01-01 = pre-SC-081, so 365-day cert passes the 398-day cap.
2347        let policy = ValidationPolicy::new(1_767_225_600);
2348        let path = pkix_path::validate_path(
2349            std::slice::from_ref(&cert),
2350            &[anchor],
2351            &policy,
2352            &EcdsaP256Verifier,
2353        )
2354        .expect("fixture cert must validate");
2355        (vec![cert], path)
2356    }
2357
2358    #[test]
2359    fn runner_skips_cert_lints_in_run_path() {
2360        // AlwaysWarn is a Certificate-scope lint; run_path must not invoke it.
2361        let (chain, path) = validated_path_for_self_signed();
2362        let runner = LintRunner::new(vec![Box::new(AlwaysWarn)]);
2363        let findings = runner.run_path(&chain, &path, 0);
2364        assert!(
2365            findings.is_empty(),
2366            "run_path must not invoke Certificate-scope lints"
2367        );
2368    }
2369
2370    #[test]
2371    fn runner_invokes_path_lint_in_run_path() {
2372        let (chain, path) = validated_path_for_self_signed();
2373        let runner = LintRunner::new(vec![Box::new(PathDepthLint)]);
2374        let findings = runner.run_path(&chain, &path, 0);
2375        assert_eq!(findings.len(), 1);
2376        assert_eq!(findings[0].lint_id, "test.path_depth");
2377        // Self-signed chain: depth=0, not > 5 → Pass.
2378        assert_eq!(findings[0].result, LintResult::Pass);
2379        assert_eq!(
2380            findings[0].cert_index, None,
2381            "path findings have no cert_index"
2382        );
2383    }
2384
2385    #[test]
2386    fn runner_run_chain_sets_cert_index() {
2387        let cert = load_fixture_cert();
2388        let chain = vec![cert.clone(), cert.clone(), cert];
2389        let kinds = vec![
2390            SubjectKind::Leaf,
2391            SubjectKind::IntermediateCa,
2392            SubjectKind::AnchorIssued,
2393        ];
2394        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2395        let findings = runner.run_chain(&chain, &kinds, 0);
2396        // One Pass finding per cert.
2397        assert_eq!(findings.len(), 3);
2398        assert_eq!(findings[0].cert_index, Some(0));
2399        assert_eq!(findings[1].cert_index, Some(1));
2400        assert_eq!(findings[2].cert_index, Some(2));
2401    }
2402
2403    /// Regression test for PKIX-7f92.9: passing a kinds slice shorter
2404    /// than the chain must panic with a message naming the lengths.
2405    /// Regression test for PKIX-7f92.7: a Lint impl that overrides only
2406    /// the deprecated `rfc_section_id` (pre-0.6.0 style) must still
2407    /// produce a non-None value through the canonical `spec_section_id`
2408    /// entry point. The delegation in the default `spec_section_id`
2409    /// impl is what closes the silent-divergence hazard.
2410    #[test]
2411    fn spec_section_id_default_delegates_to_deprecated_rfc_section_id_override() {
2412        #[derive(Clone)]
2413        struct PreV06Lint;
2414        #[allow(deprecated)]
2415        impl Lint for PreV06Lint {
2416            fn id(&self) -> &'static str { "test.pre-v06" }
2417            fn citation(&self) -> &'static str { "RFC 5280 §X.Y" }
2418            fn severity(&self) -> Severity { Severity::Warn }
2419            fn scope(&self) -> Scope { Scope::Certificate }
2420            fn applies_to(&self) -> SubjectKind { SubjectKind::Any }
2421            fn title(&self) -> &str { "Pre-v06 lint" }
2422            // Only the deprecated method is overridden — the 0.5.x shape.
2423            fn rfc_section_id(&self) -> Option<&str> { Some("rfc5280-x.y") }
2424            fn rfc_url(&self) -> Option<&str> { Some("https://example/x.y") }
2425            fn check_cert(&self, _: &Certificate, _: SubjectKind, _: u64) -> LintResult {
2426                LintResult::Pass
2427            }
2428        }
2429        let l = PreV06Lint;
2430        // Canonical entry points return the delegated values, NOT None.
2431        assert_eq!(l.spec_section_id(), Some("rfc5280-x.y"));
2432        assert_eq!(l.spec_url(), Some("https://example/x.y"));
2433    }
2434
2435    /// Symmetric case: a 0.6.0+ Lint impl that overrides only the new
2436    /// canonical methods. The deprecated alias returns None (default)
2437    /// because it cannot delegate back without infinite recursion;
2438    /// callers calling the deprecated alias must have migrated.
2439    #[test]
2440    fn deprecated_rfc_section_id_returns_none_for_v06_impl_overriding_canonical() {
2441        #[derive(Clone)]
2442        struct V06Lint;
2443        impl Lint for V06Lint {
2444            fn id(&self) -> &'static str { "test.v06" }
2445            fn citation(&self) -> &'static str { "RFC 5280 §X.Y" }
2446            fn severity(&self) -> Severity { Severity::Warn }
2447            fn scope(&self) -> Scope { Scope::Certificate }
2448            fn applies_to(&self) -> SubjectKind { SubjectKind::Any }
2449            fn title(&self) -> &str { "v06 lint" }
2450            // Only the canonical method is overridden — the 0.6.0+ shape.
2451            fn spec_section_id(&self) -> Option<&str> { Some("rfc5280-x.y") }
2452            fn spec_url(&self) -> Option<&str> { Some("https://example/x.y") }
2453            fn check_cert(&self, _: &Certificate, _: SubjectKind, _: u64) -> LintResult {
2454                LintResult::Pass
2455            }
2456        }
2457        let l = V06Lint;
2458        assert_eq!(l.spec_section_id(), Some("rfc5280-x.y"));
2459        // The deprecated alias returns None — calling it on a v0.6.0+ impl
2460        // is the migration-incomplete state the rustdoc documents.
2461        #[allow(deprecated)]
2462        let deprecated_value = l.rfc_section_id();
2463        assert_eq!(deprecated_value, None);
2464        #[allow(deprecated)]
2465        let deprecated_url = l.rfc_url();
2466        assert_eq!(deprecated_url, None);
2467    }
2468
2469    /// Pre-fix, this silently defaulted missing positions to
2470    /// IntermediateCa, producing the silent-misclassification audit
2471    /// hazard the bead flagged.
2472    #[test]
2473    #[should_panic(expected = "LintRunner::run_chain requires kinds.len() == chain.len()")]
2474    fn runner_run_chain_panics_on_kinds_shorter_than_chain() {
2475        let cert = load_fixture_cert();
2476        let chain = vec![cert.clone(), cert.clone(), cert];
2477        let kinds = vec![SubjectKind::Leaf]; // intentionally short
2478        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2479        let _ = runner.run_chain(&chain, &kinds, 0);
2480    }
2481
2482    #[test]
2483    #[should_panic(expected = "LintRunner::run_chain requires kinds.len() == chain.len()")]
2484    fn runner_run_chain_panics_on_kinds_longer_than_chain() {
2485        let cert = load_fixture_cert();
2486        let chain = vec![cert.clone()];
2487        let kinds = vec![SubjectKind::Leaf, SubjectKind::IntermediateCa];
2488        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2489        let _ = runner.run_chain(&chain, &kinds, 0);
2490    }
2491
2492    #[test]
2493    fn run_cert_stamps_cert_sha256_on_findings() {
2494        // Oracle: sha2 is the canonical SHA-256 implementation. Compute the
2495        // expected hash directly from the cert DER outside the lint engine,
2496        // then compare against the value stamped on the finding. This
2497        // avoids the "test the code with itself" anti-pattern.
2498        use der::Encode as _;
2499        use sha2::Digest as _;
2500
2501        let cert = load_fixture_cert();
2502        let der = cert.to_der().expect("encode fixture cert");
2503        let expected: [u8; 32] = sha2::Sha256::digest(&der).into();
2504
2505        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2506        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2507        assert_eq!(findings.len(), 1);
2508        assert_eq!(
2509            findings[0].cert_sha256,
2510            Some(expected),
2511            "run_cert must stamp SHA-256(DER) on every finding"
2512        );
2513    }
2514
2515    #[test]
2516    fn run_chain_stamps_per_cert_sha256_on_each_finding() {
2517        // Oracle: three findings, three identical cert_sha256 values (same
2518        // fixture cert used at every position). Tests that run_chain re-
2519        // computes the hash per position rather than caching one across.
2520        let cert = load_fixture_cert();
2521        let chain = vec![cert.clone(), cert.clone(), cert];
2522        let kinds = vec![
2523            SubjectKind::Leaf,
2524            SubjectKind::IntermediateCa,
2525            SubjectKind::AnchorIssued,
2526        ];
2527        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2528        let findings = runner.run_chain(&chain, &kinds, 0);
2529        assert_eq!(findings.len(), 3);
2530        assert!(
2531            findings[0].cert_sha256.is_some(),
2532            "leaf finding must carry cert_sha256"
2533        );
2534        assert_eq!(
2535            findings[0].cert_sha256, findings[1].cert_sha256,
2536            "same cert at index 0 and 1 must produce the same hash"
2537        );
2538        assert_eq!(
2539            findings[1].cert_sha256, findings[2].cert_sha256,
2540            "same cert at index 1 and 2 must produce the same hash"
2541        );
2542    }
2543
2544    #[test]
2545    fn run_path_leaves_cert_sha256_none_on_path_findings() {
2546        // Oracle: path-scoped findings have no single triggering cert; the
2547        // cert_sha256 field must be None. (Future evidence-pack consumers
2548        // would pair these findings with a separate per-chain hash list.)
2549        let (chain, path) = validated_path_for_self_signed();
2550        let runner = LintRunner::new(vec![Box::new(PathDepthLint)]);
2551        let findings = runner.run_path(&chain, &path, 0);
2552        assert_eq!(findings.len(), 1);
2553        assert_eq!(
2554            findings[0].cert_sha256, None,
2555            "path-scope findings must have cert_sha256 = None"
2556        );
2557    }
2558
2559    #[test]
2560    #[cfg(feature = "serde")]
2561    fn finding_cert_sha256_serde_round_trip_some() {
2562        // Oracle: serializing then deserializing a Finding preserves the
2563        // hash exactly. Independently constructed hash via sha2.
2564        use sha2::Digest as _;
2565        let bytes: [u8; 32] = sha2::Sha256::digest(b"fixture bytes for sha256").into();
2566        let f = Finding {
2567            lint_id: std::borrow::Cow::Borrowed("x"),
2568            citation: std::borrow::Cow::Borrowed("c"),
2569            rule_bundle_version: std::borrow::Cow::Borrowed(""),
2570            result: LintResult::Pass,
2571            cert_index: Some(0),
2572            evaluated_at_unix: 0,
2573            cert_sha256: Some(bytes),
2574        };
2575        let json = serde_json::to_string(&f).expect("serialize");
2576        // The on-wire form must be a lowercase hex string of length 64.
2577        let mut expected_hex = String::with_capacity(64);
2578        for b in &bytes {
2579            expected_hex.push(char::from_digit(u32::from(b >> 4), 16).unwrap());
2580            expected_hex.push(char::from_digit(u32::from(b & 0x0f), 16).unwrap());
2581        }
2582        assert!(
2583            json.contains(&expected_hex),
2584            "JSON must contain the lowercase hex hash; got: {json}"
2585        );
2586        let f2: Finding = serde_json::from_str(&json).expect("deserialize");
2587        assert_eq!(f2.cert_sha256, Some(bytes));
2588    }
2589
2590    #[test]
2591    #[cfg(feature = "serde")]
2592    fn finding_cert_sha256_serde_round_trip_none() {
2593        // Oracle: None serializes as JSON null and round-trips back to None.
2594        let f = Finding {
2595            lint_id: std::borrow::Cow::Borrowed("x"),
2596            citation: std::borrow::Cow::Borrowed("c"),
2597            rule_bundle_version: std::borrow::Cow::Borrowed(""),
2598            result: LintResult::Pass,
2599            cert_index: None,
2600            evaluated_at_unix: 0,
2601            cert_sha256: None,
2602        };
2603        let json = serde_json::to_string(&f).expect("serialize");
2604        assert!(
2605            json.contains("\"cert_sha256\":null"),
2606            "None must serialize as JSON null; got: {json}"
2607        );
2608        let f2: Finding = serde_json::from_str(&json).expect("deserialize");
2609        assert_eq!(f2.cert_sha256, None);
2610    }
2611
2612    #[test]
2613    #[cfg(feature = "serde")]
2614    fn finding_cert_sha256_rejects_non_hex_string() {
2615        // Oracle: a string of 64 chars that is not all hex must fail to
2616        // deserialize rather than silently truncating or zero-filling.
2617        let bad = r#"{"lint_id":"x","citation":"c","rule_bundle_version":"","result":"Pass","cert_index":null,"evaluated_at_unix":0,"cert_sha256":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"}"#;
2618        let err = serde_json::from_str::<Finding>(bad).expect_err("must reject non-hex chars");
2619        assert!(
2620            err.to_string().to_lowercase().contains("hex")
2621                || err.to_string().to_lowercase().contains("cert_sha256"),
2622            "error must mention hex / cert_sha256; got: {err}"
2623        );
2624    }
2625
2626    #[test]
2627    #[cfg(feature = "serde")]
2628    fn finding_cert_sha256_rejects_non_ascii_multibyte_utf8() {
2629        // Regression test for PKIX-7f92.1: a 64-BYTE JSON string composed
2630        // of multi-byte UTF-8 chars passes the `hex.len() == 64` length
2631        // gate but earlier slicing into the &str at non-char-boundary
2632        // positions panicked the deserializer. Oracle: 32 copies of the
2633        // 2-byte UTF-8 sequence for `ü` (U+00FC = 0xC3 0xBC) = 64 bytes,
2634        // 32 chars. The deserializer must return a serde Error, not
2635        // panic, on this input.
2636        let payload: String = "ü".repeat(32);
2637        assert_eq!(payload.len(), 64, "test oracle: payload is 64 bytes");
2638        assert_eq!(payload.chars().count(), 32, "test oracle: payload has 32 chars");
2639
2640        let json = format!(
2641            r#"{{"lint_id":"x","citation":"c","rule_bundle_version":"","result":"Pass","cert_index":null,"evaluated_at_unix":0,"cert_sha256":"{payload}"}}"#
2642        );
2643        let err = serde_json::from_str::<Finding>(&json)
2644            .expect_err("must reject multi-byte UTF-8 hex string");
2645        assert!(
2646            err.to_string().to_lowercase().contains("hex")
2647                || err.to_string().to_lowercase().contains("cert_sha256"),
2648            "error must mention hex / cert_sha256; got: {err}"
2649        );
2650    }
2651
2652    #[test]
2653    #[cfg(feature = "serde")]
2654    fn finding_cert_sha256_rejects_wrong_length() {
2655        // Oracle: a hex string of length != 64 must fail to deserialize.
2656        let bad = r#"{"lint_id":"x","citation":"c","rule_bundle_version":"","result":"Pass","cert_index":null,"evaluated_at_unix":0,"cert_sha256":"abc"}"#;
2657        let err = serde_json::from_str::<Finding>(bad).expect_err("must reject short hex string");
2658        assert!(
2659            err.to_string().to_lowercase().contains("length")
2660                || err.to_string().to_lowercase().contains("64"),
2661            "error must mention length; got: {err}"
2662        );
2663    }
2664
2665    #[test]
2666    fn finding_is_finding_reflects_result() {
2667        let f_pass = Finding {
2668            lint_id: std::borrow::Cow::Borrowed("x"),
2669            citation: std::borrow::Cow::Borrowed("test"),
2670            rule_bundle_version: std::borrow::Cow::Borrowed(""),
2671            result: LintResult::Pass,
2672            cert_index: None,
2673            evaluated_at_unix: 0,
2674            cert_sha256: None,
2675        };
2676        let f_warn = Finding {
2677            lint_id: std::borrow::Cow::Borrowed("x"),
2678            citation: std::borrow::Cow::Borrowed("test"),
2679            rule_bundle_version: std::borrow::Cow::Borrowed(""),
2680            result: LintResult::warn("w"),
2681            cert_index: None,
2682            evaluated_at_unix: 0,
2683            cert_sha256: None,
2684        };
2685        assert!(!f_pass.is_finding());
2686        assert!(f_warn.is_finding());
2687    }
2688
2689    #[test]
2690    fn finding_citation_is_threaded_from_lint() {
2691        let cert = load_fixture_cert();
2692        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2693        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 12345);
2694        assert_eq!(findings.len(), 1);
2695        // Citation must come from the lint's citation() method.
2696        assert_eq!(
2697            findings[0].citation, "test",
2698            "citation must be threaded from Lint::citation()"
2699        );
2700        assert_eq!(
2701            findings[0].evaluated_at_unix, 12345,
2702            "evaluated_at_unix must be the passed now_unix"
2703        );
2704    }
2705
2706    #[test]
2707    fn run_cert_at_issuance_uses_not_before() {
2708        let cert = load_fixture_cert();
2709        // Get the expected issuance time from the cert's notBefore.
2710        let expected_unix = cert
2711            .tbs_certificate
2712            .validity
2713            .not_before
2714            .to_unix_duration()
2715            .as_secs();
2716        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2717        let findings = runner.run_cert_at_issuance(&cert, SubjectKind::Leaf, 0);
2718        assert_eq!(findings.len(), 1);
2719        assert_eq!(
2720            findings[0].evaluated_at_unix, expected_unix,
2721            "run_cert_at_issuance must use cert notBefore as evaluated_at_unix"
2722        );
2723    }
2724
2725    #[test]
2726    fn bundle_version_stamped_into_findings() {
2727        let cert = load_fixture_cert();
2728        let runner = LintRunner::with_bundle_version(
2729            vec![Box::new(AlwaysPass)],
2730            "pkix-lint-cabf/cabf_tls_br v0.2.0",
2731        );
2732        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2733        assert_eq!(findings.len(), 1);
2734        assert_eq!(
2735            findings[0].rule_bundle_version.as_ref(),
2736            "pkix-lint-cabf/cabf_tls_br v0.2.0",
2737            "rule_bundle_version must be stamped from runner into Finding"
2738        );
2739    }
2740
2741    #[test]
2742    fn bundle_version_empty_by_default() {
2743        let cert = load_fixture_cert();
2744        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
2745        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2746        assert_eq!(findings[0].rule_bundle_version.as_ref(), "");
2747    }
2748
2749    // -----------------------------------------------------------------------
2750    // check_shape tests
2751    //
2752    // Oracle: the per-lint behaviour of AlwaysPass / AlwaysWarn / AlwaysError /
2753    // AlwaysFatal is fixed by their own check_cert implementations above. The
2754    // tests assert check_shape's Result projection: Ok when no Error / Fatal
2755    // findings, Err with the full Vec<Finding> otherwise.
2756    // -----------------------------------------------------------------------
2757
2758    /// A lint that always returns Error, used to drive check_shape's Err
2759    /// branch without triggering the runner's Fatal early-exit (which would
2760    /// truncate the findings list).
2761    #[derive(Clone)]
2762    struct AlwaysError;
2763    impl Lint for AlwaysError {
2764        fn id(&self) -> &'static str {
2765            "test.always_error"
2766        }
2767        fn citation(&self) -> &'static str {
2768            "test"
2769        }
2770        fn severity(&self) -> Severity {
2771            Severity::Error
2772        }
2773        fn scope(&self) -> Scope {
2774            Scope::Certificate
2775        }
2776        fn applies_to(&self) -> SubjectKind {
2777            SubjectKind::Any
2778        }
2779        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
2780            LintResult::error("always errors")
2781        }
2782    }
2783
2784    /// Test-side [`LintProfile`] that bundles an arbitrary lint set behind a
2785    /// minimal [`pkix_path::Profile`] facade. The Profile half is a no-op
2786    /// stub; `check_shape` only touches the lints.
2787    ///
2788    /// The `build_runner` field is a fn-pointer factory because
2789    /// [`LintProfile::lint_runner`] returns a fresh [`LintRunner`] by
2790    /// value, but [`Box<dyn Lint>`] is not [`Clone`] and the profile must
2791    /// be able to hand out a runner via an immutable `&self`. The
2792    /// alternatives (shared `Arc<dyn Lint>`, `OnceLock<LintRunner>`)
2793    /// add complexity that buys nothing in tests — a fn pointer is
2794    /// trivially [`Copy`] and lets each test wire a specific runner
2795    /// builder.
2796    struct TestLintProfile {
2797        lints: Vec<Box<dyn Lint>>,
2798        build_runner: fn() -> LintRunner,
2799    }
2800
2801    impl pkix_path::Profile for TestLintProfile {
2802        fn id(&self) -> &'static str {
2803            "test.profile"
2804        }
2805        fn version(&self) -> &'static str {
2806            "0.0.0"
2807        }
2808        fn policy(&self, now_unix: u64) -> ValidationPolicy {
2809            ValidationPolicy::new(now_unix)
2810        }
2811        fn policy_oids(&self) -> &[der::asn1::ObjectIdentifier] {
2812            &[]
2813        }
2814    }
2815
2816    impl LintProfile for TestLintProfile {
2817        fn lints(&self) -> &[Box<dyn Lint>] {
2818            &self.lints
2819        }
2820        fn lint_runner(&self) -> LintRunner {
2821            (self.build_runner)()
2822        }
2823    }
2824
2825    impl TestLintProfile {
2826        fn new(lints: Vec<Box<dyn Lint>>, build_runner: fn() -> LintRunner) -> Self {
2827            Self {
2828                lints,
2829                build_runner,
2830            }
2831        }
2832    }
2833
2834    fn build_always_pass_runner() -> LintRunner {
2835        LintRunner::new(vec![Box::new(AlwaysPass)])
2836    }
2837
2838    fn build_always_warn_runner() -> LintRunner {
2839        LintRunner::new(vec![Box::new(AlwaysWarn)])
2840    }
2841
2842    fn build_always_error_runner() -> LintRunner {
2843        LintRunner::new(vec![Box::new(AlwaysError)])
2844    }
2845
2846    fn build_always_fatal_runner() -> LintRunner {
2847        LintRunner::new(vec![Box::new(AlwaysFatal)])
2848    }
2849
2850    fn build_pass_plus_warn_runner() -> LintRunner {
2851        LintRunner::new(vec![Box::new(AlwaysPass), Box::new(AlwaysWarn)])
2852    }
2853
2854    fn build_pass_plus_error_runner() -> LintRunner {
2855        LintRunner::new(vec![Box::new(AlwaysPass), Box::new(AlwaysError)])
2856    }
2857
2858    #[test]
2859    fn check_shape_ok_when_all_lints_pass() {
2860        let cert = load_fixture_cert();
2861        let profile = TestLintProfile::new(vec![Box::new(AlwaysPass)], build_always_pass_runner);
2862        assert!(check_shape(&cert, SubjectKind::Leaf, 0, &profile).is_ok());
2863    }
2864
2865    #[test]
2866    fn check_shape_ok_when_only_warn_findings() {
2867        let cert = load_fixture_cert();
2868        let profile = TestLintProfile::new(vec![Box::new(AlwaysWarn)], build_always_warn_runner);
2869        // Warn-only must produce Ok per the contract: Warn is informational,
2870        // not a hard rejection. The Warn detail is silently dropped from the
2871        // Ok variant — callers needing it call run_cert directly.
2872        assert!(check_shape(&cert, SubjectKind::Leaf, 0, &profile).is_ok());
2873    }
2874
2875    #[test]
2876    fn check_shape_err_on_error_finding() {
2877        let cert = load_fixture_cert();
2878        let profile = TestLintProfile::new(vec![Box::new(AlwaysError)], build_always_error_runner);
2879        let result = check_shape(&cert, SubjectKind::Leaf, 0, &profile);
2880        let findings = result.expect_err("AlwaysError must produce Err");
2881        assert_eq!(findings.len(), 1);
2882        assert_eq!(findings[0].lint_id, "test.always_error");
2883        assert!(matches!(findings[0].result, LintResult::Error(_)));
2884    }
2885
2886    #[test]
2887    fn check_shape_err_on_fatal_finding() {
2888        let cert = load_fixture_cert();
2889        let profile = TestLintProfile::new(vec![Box::new(AlwaysFatal)], build_always_fatal_runner);
2890        let result = check_shape(&cert, SubjectKind::Leaf, 0, &profile);
2891        let findings = result.expect_err("AlwaysFatal must produce Err");
2892        assert_eq!(findings.len(), 1);
2893        assert_eq!(findings[0].lint_id, "test.always_fatal");
2894        assert!(findings[0].result.is_fatal());
2895    }
2896
2897    #[test]
2898    fn check_shape_err_carries_all_findings_including_pass() {
2899        // Two lints: one Pass, one Error. Err variant must carry both
2900        // findings (not just the failing one) so callers can audit the full
2901        // evaluation result.
2902        let cert = load_fixture_cert();
2903        let profile = TestLintProfile::new(
2904            vec![Box::new(AlwaysPass), Box::new(AlwaysError)],
2905            build_pass_plus_error_runner,
2906        );
2907        let result = check_shape(&cert, SubjectKind::Leaf, 0, &profile);
2908        let findings = result.expect_err("any Error must produce Err");
2909        assert_eq!(findings.len(), 2, "Err carries the full Vec<Finding>");
2910        assert!(findings.iter().any(|f| f.lint_id == "test.always_pass"));
2911        assert!(findings.iter().any(|f| f.lint_id == "test.always_error"));
2912    }
2913
2914    #[test]
2915    fn check_shape_ok_when_pass_plus_warn_no_error() {
2916        // Pass + Warn (no Error / Fatal) → Ok per the contract.
2917        let cert = load_fixture_cert();
2918        let profile = TestLintProfile::new(
2919            vec![Box::new(AlwaysPass), Box::new(AlwaysWarn)],
2920            build_pass_plus_warn_runner,
2921        );
2922        assert!(check_shape(&cert, SubjectKind::Leaf, 0, &profile).is_ok());
2923    }
2924
2925    // -----------------------------------------------------------------------
2926    // Use-case mutual-exclusion regression — operator contract on the Lint
2927    // trait rustdoc (PKIX-hy2e.1 + PKIX-hy2e.4)
2928    //
2929    // The Lint trait rustdoc states that the TLS-server lints
2930    // (Rfc5280EkuServerAuthLint, Rfc6125TlsServerSanLint) and the S/MIME
2931    // lints (Rfc8398SmimeSanLint, Rfc8551EkuEmailProtectionLint) describe
2932    // mutually-exclusive cert shapes — no single leaf certificate
2933    // satisfies all four simultaneously. This test asserts the claim
2934    // empirically on a real fixture so the rustdoc cannot drift.
2935    //
2936    // Independent oracle: openssl x509 -text on each fixture shows EKU
2937    // and SAN exactly as expected. webpki-self-signed-365d.der has EKU=
2938    // TLS Web Server Authentication and SAN=DNS:test.example.com (TLS
2939    // shape). smime-self-signed-365d.der has EKU=E-mail Protection and
2940    // SAN=email:test@example.com (S/MIME shape).
2941    // -----------------------------------------------------------------------
2942
2943    #[test]
2944    fn use_case_mutual_exclusion_tls_cert_fails_smime_lints() {
2945        // TLS-shaped cert: passes TLS lints, fails S/MIME lints.
2946        use der::Decode as _;
2947        let cert = Certificate::from_der(include_bytes!(
2948            "../../pkix-path/tests/fixtures/policy-checks/webpki-self-signed-365d.der"
2949        ))
2950        .expect("fixture is valid DER");
2951        let runner = LintRunner::new(vec![
2952            Box::new(crate::rfc5280::Rfc5280EkuServerAuthLint),
2953            Box::new(crate::rfc6125::Rfc6125TlsServerSanLint),
2954            Box::new(crate::rfc8398::Rfc8398SmimeSanLint),
2955            Box::new(crate::rfc8551::Rfc8551EkuEmailProtectionLint),
2956        ]);
2957        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2958        let errors_by_id: Vec<&str> = findings
2959            .iter()
2960            .filter(|f| matches!(f.result, LintResult::Error(_)))
2961            .map(|f| f.lint_id.as_ref())
2962            .collect();
2963        assert!(
2964            errors_by_id
2965                .iter()
2966                .any(|id| id.starts_with("rfc8398.") || id.starts_with("rfc8551.")),
2967            "TLS cert must produce Error findings from the S/MIME lints: \
2968             found error ids {errors_by_id:?}"
2969        );
2970        assert!(
2971            !errors_by_id
2972                .iter()
2973                .any(|id| id.starts_with("rfc5280.cert.eku.") || id.starts_with("rfc6125.")),
2974            "TLS cert must NOT produce Error findings from the TLS lints: \
2975             found error ids {errors_by_id:?}"
2976        );
2977    }
2978
2979    #[test]
2980    fn use_case_mutual_exclusion_smime_cert_fails_tls_lints() {
2981        // S/MIME-shaped cert: passes S/MIME lints, fails TLS lints.
2982        use der::Decode as _;
2983        let cert = Certificate::from_der(include_bytes!(
2984            "../../pkix-path/tests/fixtures/policy-checks/smime-self-signed-365d.der"
2985        ))
2986        .expect("fixture is valid DER");
2987        let runner = LintRunner::new(vec![
2988            Box::new(crate::rfc5280::Rfc5280EkuServerAuthLint),
2989            Box::new(crate::rfc6125::Rfc6125TlsServerSanLint),
2990            Box::new(crate::rfc8398::Rfc8398SmimeSanLint),
2991            Box::new(crate::rfc8551::Rfc8551EkuEmailProtectionLint),
2992        ]);
2993        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
2994        let errors_by_id: Vec<&str> = findings
2995            .iter()
2996            .filter(|f| matches!(f.result, LintResult::Error(_)))
2997            .map(|f| f.lint_id.as_ref())
2998            .collect();
2999        assert!(
3000            errors_by_id
3001                .iter()
3002                .any(|id| id.starts_with("rfc5280.cert.eku.") || id.starts_with("rfc6125.")),
3003            "S/MIME cert must produce Error findings from the TLS lints: \
3004             found error ids {errors_by_id:?}"
3005        );
3006        assert!(
3007            !errors_by_id
3008                .iter()
3009                .any(|id| id.starts_with("rfc8398.") || id.starts_with("rfc8551.")),
3010            "S/MIME cert must NOT produce Error findings from the S/MIME lints: \
3011             found error ids {errors_by_id:?}"
3012        );
3013    }
3014
3015    // -----------------------------------------------------------------------
3016    // PKIX-hy2e.7 regression — duplicate lint IDs must panic at runner
3017    // construction in BOTH debug and release builds.
3018    //
3019    // Previously LintRunner::new used #[cfg(debug_assertions)] to gate the
3020    // dup-ID check, so release builds (e.g., every cargo install user)
3021    // silently accepted duplicates and produced ambiguous audit trails
3022    // when deviations keyed on the duplicated id matched both findings.
3023    // The check is now unconditional. We exercise it via #[should_panic]
3024    // on a release-flavoured tests path so the test fails if the gate
3025    // ever returns.
3026    //
3027    // Oracle: AGENTS.md test discipline forbids using the code under test
3028    // as its own oracle. Here the oracle is the panic mechanism itself
3029    // and the message constant; the assertion verifies that the panic
3030    // payload names the duplicated id.
3031    // -----------------------------------------------------------------------
3032
3033    #[test]
3034    #[should_panic(expected = "duplicate lint id")]
3035    fn lint_runner_new_panics_on_duplicate_lint_ids() {
3036        // AlwaysPass.id() is "test.always_pass"; registering two copies
3037        // must panic.
3038        let lints: Vec<Box<dyn Lint>> = vec![Box::new(AlwaysPass), Box::new(AlwaysPass)];
3039        let _ = LintRunner::new(lints);
3040    }
3041
3042    #[test]
3043    #[should_panic(expected = "duplicate lint id")]
3044    fn lint_runner_with_bundle_version_panics_on_duplicate_lint_ids() {
3045        // The duplicate-ID precondition applies to the bundle-version
3046        // constructor too.
3047        let lints: Vec<Box<dyn Lint>> = vec![Box::new(AlwaysPass), Box::new(AlwaysPass)];
3048        let _ = LintRunner::with_bundle_version(lints, "x");
3049    }
3050
3051    #[test]
3052    fn lint_runner_new_accepts_distinct_ids() {
3053        // AlwaysPass.id() = "test.always_pass"; AlwaysWarn.id() =
3054        // "test.always_warn". Distinct ids must not panic.
3055        let lints: Vec<Box<dyn Lint>> = vec![Box::new(AlwaysPass), Box::new(AlwaysWarn)];
3056        let _runner = LintRunner::new(lints);
3057    }
3058}