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}