pkix-lint 0.2.0

Lint engine for X.509 certificates — structured soft-fail and advisory results
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
#![forbid(unsafe_code)]
#![warn(missing_docs, rust_2018_idioms)]
#![cfg_attr(docsrs, feature(doc_cfg))]

//! Lint engine for X.509 certificate chains — structured soft-fail and advisory results.
//!
//! # What this crate provides
//!
//! `pkix-path` returns `Result<ValidatedPath, Error>` — hard pass or fail.
//! That model cannot express "this certificate is RFC 5280 valid but violates
//! CA/B Forum BR §7.1.4.2" without aborting the chain entirely.
//!
//! `pkix-lint` adds an advisory layer:
//!
//! - [`Lint`] — the unit of evaluation. Each lint has a stable ID, a normative
//!   citation, a severity, a scope (certificate vs. full chain path), and a
//!   subject-kind filter (leaf, intermediate CA, etc.).
//! - [`LintResult`] — `Pass | NotApplicable | Warn | Error | Fatal`. `Warn`
//!   and `Error` carry a `&'static str` detail message. `Fatal` within
//!   `pkix-lint` means "stop evaluating further lints" — it is **not** a TLS
//!   hard-fail. See the advisory-only contract below.
//! - [`Finding`] — a lint ID paired with a [`LintResult`], optionally referencing
//!   the chain index of the offending certificate.
//! - [`LintRunner`] — evaluates a slice of `dyn Lint` objects against a certificate
//!   or validated path and returns `Vec<Finding>`.
//! - [`LintProfile`] — extends [`pkix_path::Profile`] with a `lints()` method so
//!   that a profile can bundle its own lint set.
//!
//! # Finding ID stability
//!
//! Finding IDs (returned by [`Lint::id`]) are part of the public API.
//! They MUST NOT change between crate versions without a semver-major bump.
//! Format convention: `<regime>.<section>.<noun>`, e.g.:
//! - `"cabf.br.tls.validity.max"`
//! - `"cabf.smime.san.type"`
//! - `"rfc5280.basic_constraints.ca_flag"`
//!
//! # Advisory-only contract
//!
//! **`pkix-lint` findings never cause a certificate to be rejected.** All runner
//! methods return `Vec<Finding>` — they never return `Result::Err` and they never
//! cause a TLS stack to abort a connection. Findings are advisory signals.
//!
//! Whether to act on a finding (reject a TLS connection, block a cert, alert an
//! operator) is the caller's decision, configured per finding-ID at the integration
//! layer (e.g., `pkix-chain` or a TLS stack binding). This design is intentional:
//!
//! - `pkix-lint` does not know whether you are in audit, monitoring, or enforcement
//!   context. The caller does.
//! - Spec ambiguity (CA/B Forum CPs, FPKI CPs, etc.) means some findings require
//!   human judgment before enforcement. Hard-fail by default would cause outages.
//! - The deviation/waiver mechanism (PKIX-jge) operates at this layer, not in
//!   `pkix-lint` core.
//!
//! The only in-engine effect of [`LintResult::Fatal`] is stopping further lint
//! evaluation for the current item — it does not escape as an error.
//!
//! # Design rationale
//!
//! Inspired by zlint and certlint but with several deliberate differences:
//!
//! - **Trait-based, not enum-based**: external crates can implement [`Lint`] and
//!   pass `Box<dyn Lint>` to [`LintRunner`] without modifying this crate.
//! - **Static detail messages**: `LintResult::Warn` and `LintResult::Error` carry
//!   `&'static str` detail. This keeps the engine allocation-free in the common path.
//!   Dynamic messages are planned for v0.3 via `Cow<'static, str>`.
//! - **Temporality-aware**: [`LintRunner::run_cert`] takes `now_unix: u64` so lints
//!   can enforce rules that have effective dates (e.g., SC-081 validity caps).
//! - **Scope-separated**: certificate lints and path lints run in separate passes so
//!   path lints can see the full validated output.
//!
//! # Example
//!
//! ```rust,no_run
//! // `cert` and `now_unix` are obtained from the calling context (e.g., loaded
//! // from DER and current wall-clock time). They are not defined here so the
//! // example cannot be run in a doctest harness without external fixtures.
//! use pkix_lint::{Lint, LintResult, LintRunner, Scope, Severity, SubjectKind};
//! use x509_cert::Certificate;
//!
//! struct MyLint;
//! impl Lint for MyLint {
//!     fn id(&self) -> &'static str { "example.my_lint" }
//!     fn citation(&self) -> &'static str { "Example Corp Policy §1.2" }
//!     fn severity(&self) -> Severity { Severity::Warn }
//!     fn scope(&self) -> Scope { Scope::Certificate }
//!     fn applies_to(&self) -> SubjectKind { SubjectKind::Leaf }
//!     fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
//!         if cert.tbs_certificate.subject.to_string().is_empty() {
//!             LintResult::Warn("empty Subject DN")
//!         } else {
//!             LintResult::Pass
//!         }
//!     }
//! }
//!
//! let cert: Certificate = unimplemented!("load from DER");
//! let now_unix: u64 = unimplemented!("current Unix epoch seconds");
//! let runner = LintRunner::new(vec![Box::new(MyLint)]);
//! let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, now_unix);
//! for f in &findings {
//!     println!("{}: {:?}", f.lint_id, f.result);
//! }
//! ```

use x509_cert::Certificate;

// Re-export so callers only need to depend on pkix-lint, not pkix-path.
pub use pkix_path::{Profile, ValidatedPath, ValidationPolicy};

/// Serde deserializer helper for `&'static str` fields.
///
/// Deserializes any string input as an owned `String`, then leaks it to produce a
/// `&'static str`.  This is intentional: `LintResult` uses `&'static str` for its
/// detail fields because those are always compile-time constants in normal usage.
/// When deserializing from JSON (e.g., loading a saved evidence pack), we accept
/// the small allocation + leak to preserve the field type.
///
/// # Memory — important for long-running services
///
/// **This function leaks one heap allocation per unique deserialized string, permanently.**
/// In short-lived processes (CLI tools, test runners, single-request lambdas), this
/// is acceptable: the process exits before the leak matters.
///
/// **In long-running services** (e.g., a TLS policy daemon that continuously
/// deserializes `Finding` or `EvaluationReport` values from a database or message
/// queue), each unique `LintResult` detail string will permanently grow process
/// memory with no bound. If you are in that scenario:
///
/// - Use a separate short-lived process or worker for deserialization and pass
///   structured data across a process boundary.
/// - Or file an issue to accelerate the v0.3 migration of `LintResult` detail
///   fields from `&'static str` to `Cow<'static, str>`, which removes this
///   constraint.
///
/// All other string fields in `Finding` and `EvaluationReport` already use
/// `Cow<'static, str>` and do not leak.
#[cfg(feature = "serde")]
pub(crate) fn de_static_str<'de, D>(deserializer: D) -> Result<&'static str, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::Deserialize as _;
    let s = String::deserialize(deserializer)?;
    Ok(Box::leak(s.into_boxed_str()))
}

/// Serde deserializer helper for `Cow<'static, str>` fields.
///
/// Deserializes any string input as an owned `String` wrapped in `Cow::Owned`.
/// Unlike [`de_static_str`], this does not leak memory — the allocation is owned
/// and freed when the containing struct is dropped.
///
/// Used for `Finding.lint_id`, `Finding.citation`, and `DeviatedFinding` fields
/// that are `&'static str` at construction time (populated from lint metadata) but
/// need to round-trip through serde without leaking.
#[cfg(feature = "serde")]
pub(crate) fn de_cow_static<'de, D>(
    deserializer: D,
) -> Result<std::borrow::Cow<'static, str>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::Deserialize as _;
    let s = String::deserialize(deserializer)?;
    Ok(std::borrow::Cow::Owned(s))
}

pub mod cabf_tls_br;
pub mod deviation;
pub mod report;

// ---------------------------------------------------------------------------
// Severity
// ---------------------------------------------------------------------------

/// How seriously to treat a lint finding.
///
/// Severity is a property of the lint definition, not the result. A lint that
/// checks a MUST requirement from a normative spec should be [`Severity::Error`].
/// A lint that checks a SHOULD or advisory requirement should be [`Severity::Warn`].
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum Severity {
    /// Advisory / best-practice — does not constitute a violation.
    Info,
    /// Violation of a SHOULD or RECOMMENDED requirement.
    Warn,
    /// Violation of a MUST or REQUIRED requirement.
    Error,
    /// Violation so severe that further evaluation is meaningless.
    ///
    /// For example: malformed DER structure that prevents parsing subsequent fields.
    Fatal,
}

// ---------------------------------------------------------------------------
// Scope
// ---------------------------------------------------------------------------

/// Whether a lint evaluates a single certificate or the complete validated path.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Scope {
    /// The lint evaluates one certificate in isolation.
    Certificate,
    /// The lint evaluates the full [`ValidatedPath`] and all certificates together.
    Path,
}

// ---------------------------------------------------------------------------
// SubjectKind
// ---------------------------------------------------------------------------

/// Which certificate positions in the chain a lint applies to.
///
/// Used both as a filter in [`Lint::applies_to`] (which certs the lint checks)
/// and as the label in [`LintRunner`] when calling the lint (what cert we're at).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SubjectKind {
    /// End-entity (leaf) certificate — the subject of the chain.
    Leaf,
    /// Intermediate CA certificate — has `BasicConstraints` cA=TRUE, not a trust anchor.
    IntermediateCa,
    /// Any certificate issued directly by a trust anchor (the top intermediate).
    AnchorIssued,
    /// All certificate positions (lint applies universally).
    Any,
}

impl SubjectKind {
    /// Returns `true` if a lint declared for `filter` should run against `self`.
    ///
    /// Rules:
    /// - `Any` filter matches everything.
    /// - An exact match always returns `true`.
    /// - `AnchorIssued` is a sub-category of `IntermediateCa`; a filter of
    ///   `IntermediateCa` also matches `AnchorIssued` certificates.
    #[must_use]
    pub fn matches(self, filter: Self) -> bool {
        match filter {
            Self::Any => true,
            Self::IntermediateCa => self == Self::IntermediateCa || self == Self::AnchorIssued,
            other => self == other,
        }
    }
}

// ---------------------------------------------------------------------------
// LintResult
// ---------------------------------------------------------------------------

/// The outcome of evaluating a single lint against a certificate or path.
///
/// # Stability
///
/// The variant names and associated `&'static str` detail fields are stable.
/// Dynamic `String` detail is planned for v0.3 via `Cow<'static, str>`.
///
/// # Serde memory note
///
/// When the `serde` feature is enabled, deserializing `LintResult::Warn`,
/// `Error`, or `Fatal` variants leaks the detail string (one allocation per
/// unique string, permanently). This is harmless in short-lived processes.
/// See `de_static_str` for details and the mitigation path for long-running
/// services.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(bound(deserialize = "")))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum LintResult {
    /// The lint check passed — no finding.
    Pass,
    /// The lint does not apply to this certificate or context.
    ///
    /// For example, a lint that checks SAN for leaves would return `NotApplicable`
    /// when called against an intermediate CA certificate.
    ///
    /// `NotApplicable` is not an error; the runner records it for audit completeness
    /// but it does not affect compliance status.
    NotApplicable,
    /// Advisory finding — the cert deviates from a SHOULD or best practice.
    ///
    /// The `&'static str` field is a human-readable explanation of the finding.
    Warn(#[cfg_attr(feature = "serde", serde(deserialize_with = "de_static_str"))] &'static str),
    /// Error finding — the cert violates a MUST or REQUIRED requirement.
    ///
    /// The `&'static str` field is a human-readable explanation of the finding.
    Error(#[cfg_attr(feature = "serde", serde(deserialize_with = "de_static_str"))] &'static str),
    /// Fatal finding — further evaluation of this cert/path is not meaningful.
    ///
    /// The `&'static str` field is a human-readable explanation of the finding.
    /// The runner stops evaluating remaining lints for the current item when
    /// it encounters a `Fatal`.
    ///
    /// # `Fatal` is report-only
    ///
    /// **`Fatal` does NOT cause the TLS stack to reject the certificate.**
    /// `pkix-lint` is an advisory layer only. All findings — including `Fatal` —
    /// are reported in the `Vec<Finding>` returned by [`LintRunner`]. Whether to
    /// act on a finding (e.g., reject a TLS connection, abort a certificate
    /// issuance, or log a compliance event) is the caller's decision, made at the
    /// integration boundary (e.g., `pkix-chain` or a TLS stack binding).
    ///
    /// The only effect of `Fatal` within `pkix-lint` itself is to stop evaluating
    /// further lints for the current certificate or path — it does not propagate
    /// as a `Result::Err` or cause any panic.
    Fatal(#[cfg_attr(feature = "serde", serde(deserialize_with = "de_static_str"))] &'static str),
}

impl LintResult {
    /// Returns `true` if this result represents a clean pass (no finding).
    #[must_use]
    pub const fn is_pass(&self) -> bool {
        matches!(self, Self::Pass)
    }

    /// Returns `true` if this result represents a finding (Warn, Error, or Fatal).
    #[must_use]
    pub const fn is_finding(&self) -> bool {
        matches!(self, Self::Warn(_) | Self::Error(_) | Self::Fatal(_))
    }

    /// Returns `true` if the runner should stop evaluating further lints for this item.
    #[must_use]
    pub const fn is_fatal(&self) -> bool {
        matches!(self, Self::Fatal(_))
    }

    /// Returns the detail message for `Warn`, `Error`, or `Fatal`; `None` for `Pass`/`NotApplicable`.
    #[must_use]
    pub const fn detail(&self) -> Option<&'static str> {
        match self {
            Self::Warn(d) | Self::Error(d) | Self::Fatal(d) => Some(d),
            _ => None,
        }
    }
}

// ---------------------------------------------------------------------------
// Display implementations
// ---------------------------------------------------------------------------

impl core::fmt::Display for Severity {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Info => f.write_str("info"),
            Self::Warn => f.write_str("warn"),
            Self::Error => f.write_str("error"),
            Self::Fatal => f.write_str("fatal"),
        }
    }
}

impl core::fmt::Display for LintResult {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Pass => f.write_str("Pass"),
            Self::NotApplicable => f.write_str("NotApplicable"),
            Self::Warn(msg) => write!(f, "Warn: {msg}"),
            Self::Error(msg) => write!(f, "Error: {msg}"),
            Self::Fatal(msg) => write!(f, "Fatal: {msg}"),
        }
    }
}

impl core::fmt::Display for Finding {
    /// Format: `"lint_id [severity]: message"` for findings, `"lint_id [pass]"` for non-findings.
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        let severity_label = match &self.result {
            LintResult::Warn(_) => "warn",
            LintResult::Error(_) => "error",
            LintResult::Fatal(_) => "fatal",
            LintResult::Pass => "pass",
            LintResult::NotApplicable => "n/a",
        };
        match self.result.detail() {
            Some(msg) => write!(f, "{} [{}]: {}", self.lint_id, severity_label, msg),
            None => write!(f, "{} [{}]", self.lint_id, severity_label),
        }
    }
}

// ---------------------------------------------------------------------------
// Lint trait
// ---------------------------------------------------------------------------

/// A single, independently evaluable lint check.
///
/// # Implementing `Lint`
///
/// Each lint must have a stable, globally unique ID (see crate-level doc for the
/// naming convention). Both `check_cert` and `check_path` are provided so the
/// same trait covers both certificate-scoped and path-scoped lints. Implement
/// whichever method is appropriate for your lint and let the other return
/// [`LintResult::NotApplicable`] (the default).
///
/// # Object safety
///
/// The trait is object-safe: `Box<dyn Lint>` and `&dyn Lint` both work.
pub trait Lint: Send + Sync {
    /// Globally unique, stable identifier for this lint.
    ///
    /// Format: `<regime>.<section>.<noun>` e.g. `"cabf.br.tls.validity.max"`.
    /// This string is part of the public API — never change it once published.
    fn id(&self) -> &'static str;

    /// Human-readable citation: spec name, version, and section.
    ///
    /// Example: `"CA/B Forum TLS BR §6.3.2 (SC-081)"`.
    /// Not parsed by the engine; used in reports and error messages.
    fn citation(&self) -> &'static str;

    /// The declared severity of a positive finding from this lint.
    ///
    /// Note: [`LintResult::Warn`] and [`LintResult::Error`] can be returned
    /// regardless of the declared `severity()`. The declared severity is metadata
    /// used by report renderers and compliance dashboards.
    fn severity(&self) -> Severity;

    /// Whether this lint operates on individual certificates or the full path.
    fn scope(&self) -> Scope;

    /// Which certificate positions this lint applies to.
    ///
    /// For [`Scope::Certificate`] lints, the runner uses this to skip
    /// `check_cert` for positions that don't match, returning
    /// [`LintResult::NotApplicable`] automatically.
    ///
    /// For [`Scope::Path`] lints, `applies_to()` is **not consulted** by the
    /// runner — `check_path` is always called. Path-scope lints that need to
    /// restrict themselves to certain chain configurations should implement
    /// that logic inside `check_path` and return [`LintResult::NotApplicable`]
    /// when the path does not qualify.
    fn applies_to(&self) -> SubjectKind;

    /// Evaluate the lint against a single certificate.
    ///
    /// `kind` is the role of this certificate in the chain (leaf, intermediate CA, etc.).
    /// `now_unix` is seconds since the Unix epoch at evaluation time.
    ///
    /// Default: returns [`LintResult::NotApplicable`].
    /// Lints with `scope() == Scope::Certificate` MUST override this method.
    #[allow(unused_variables)]
    fn check_cert(&self, cert: &Certificate, kind: SubjectKind, now_unix: u64) -> LintResult {
        LintResult::NotApplicable
    }

    /// Evaluate the lint against the full validated path.
    ///
    /// `chain` is the full certificate chain (leaf-first). `path` is the
    /// [`ValidatedPath`] returned by `pkix_path::validate_path`.
    /// `now_unix` is seconds since the Unix epoch at evaluation time.
    ///
    /// Default: returns [`LintResult::NotApplicable`].
    /// Lints with `scope() == Scope::Path` MUST override this method.
    #[allow(unused_variables)]
    fn check_path(&self, chain: &[Certificate], path: &ValidatedPath, now_unix: u64) -> LintResult {
        LintResult::NotApplicable
    }
}

// ---------------------------------------------------------------------------
// Finding
// ---------------------------------------------------------------------------

/// A recorded lint outcome, associating a lint ID with its result.
///
/// # Evidence pack support
///
/// `Finding` carries the metadata needed to construct an evidence pack
/// (a bundle of cert + path + findings + citations exportable as structured JSON
/// or OSCAL Assessment Results). The `citation` field records the normative
/// citation from [`Lint::citation`]; `evaluated_at_unix` records when the lint
/// was run; `rule_bundle_version` records which version of the lint bundle was active.
///
/// # Planned fields (v0.3)
///
/// - `cert_sha256: [u8; 32]` — SHA-256 of the DER cert that triggered this finding.
///   Deferred to avoid adding a SHA-256 dependency to the engine core.
/// # Serde deserialization bound
///
/// When `serde` is enabled, deserializing `Finding` requires `'de: 'static`
/// because `LintResult::Warn/Error/Fatal` detail fields are `&'static str`
/// (deserialized via `de_static_str`, which leaks the allocation). This
/// constraint will be removed when `LintResult` migrates to `Cow<'static, str>`
/// in v0.3. Until then, callers must deserialize from a `'static` source
/// (e.g., `serde_json::from_str` on a `&'static str` or `Box::leak`'d string).
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(bound(deserialize = "'de: 'static")))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Finding {
    /// The stable ID of the lint that produced this finding (from [`Lint::id`]).
    #[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
    pub lint_id: std::borrow::Cow<'static, str>,
    /// The normative citation for this lint (from [`Lint::citation`]).
    ///
    /// Included here so consumers of `Vec<Finding>` do not need to re-look up
    /// the lint to get the citation for report generation and evidence packs.
    #[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
    pub citation: std::borrow::Cow<'static, str>,
    /// Version string of the rule bundle that produced this finding.
    ///
    /// Set by [`LintRunner::with_bundle_version`]. Defaults to `""` when the runner
    /// was constructed with [`LintRunner::new`] without a version.
    ///
    /// Example: `"pkix-lint/cabf_tls_br v0.2.0, sourced from TLS BR SC-081"`.
    ///
    /// This field enables the "yellow today, green tomorrow because we updated the
    /// rule bundle from v1.3 to v1.4" explanation that prevents operators from
    /// treating a finding change as a tool defect.
    #[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
    pub rule_bundle_version: std::borrow::Cow<'static, str>,
    /// The outcome of the lint evaluation.
    pub result: LintResult,
    /// For certificate-scope lints, the zero-based chain index of the evaluated cert.
    /// `None` for path-scope lints.
    pub cert_index: Option<usize>,
    /// Unix epoch seconds at which the lint was evaluated.
    ///
    /// For audit-mode evaluations (pass issuance time), this records the issuance time.
    /// For operational-mode evaluations (pass current time), this records the current time.
    /// Together with `cert_index` and the chain, this is sufficient to reconstruct
    /// the evaluation context in an evidence pack.
    pub evaluated_at_unix: u64,
}

impl Finding {
    /// Returns `true` if this finding is actionable (Warn, Error, or Fatal).
    #[must_use]
    pub const fn is_finding(&self) -> bool {
        self.result.is_finding()
    }
}

// ---------------------------------------------------------------------------
// LintRunner
// ---------------------------------------------------------------------------

/// Evaluates a collection of [`Lint`]s against certificates or a validated path.
///
/// The runner is stateless beyond the lint set — construct once, call many times.
///
/// # Findings are advisory only
///
/// `LintRunner` methods return `Vec<Finding>` — they never return `Result::Err`
/// and they never cause a certificate to be rejected by a TLS stack. Findings
/// are an advisory layer. Whether to act on a finding (reject a connection,
/// block a cert, page an operator) is the caller's responsibility, configured
/// per finding-ID at the integration boundary.
///
/// This separation is intentional and must not be violated:
/// - `pkix-lint` does not know whether you are in an audit context, a
///   monitoring context, or an enforcement context. The caller does.
/// - Hard-fail behavior per finding-ID is configured in the integration layer
///   (e.g., `pkix-chain` or a TLS stack binding), not here.
/// - `pkix-lint` will never introduce a code path that returns `Err` or
///   panics based on lint findings.
///
/// # Evaluation order
///
/// Lints are evaluated in the order they were supplied to [`LintRunner::new`].
/// If a lint returns [`LintResult::Fatal`], the runner stops evaluating further
/// lints for the current item (cert or path) and records the fatal finding.
/// See [`LintResult::Fatal`] for the definition of "fatal within lint evaluation."
///
/// # Duplicate lint IDs
///
/// While the runner does not reject duplicate lint IDs, supplying lints with
/// duplicate IDs interacts poorly with the deviation mechanism: a deviation
/// keyed on a given ID will apply to every finding with that ID, which is
/// unlikely to be the intended behavior and makes the audit trail ambiguous.
/// Avoid duplicate IDs; in debug builds [`LintRunner::new`] asserts uniqueness.
///
/// # Thread safety
///
/// `LintRunner` is `Send + Sync` as long as all supplied lints are `Send + Sync`
/// (enforced by the `Lint: Send + Sync` bound).
pub struct LintRunner {
    lints: Vec<Box<dyn Lint>>,
    /// Version string stamped into every [`Finding`] produced by this runner.
    ///
    /// Set via [`LintRunner::with_bundle_version`]. Defaults to `""`.
    bundle_version: std::borrow::Cow<'static, str>,
}

impl core::fmt::Debug for LintRunner {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("LintRunner")
            .field("lint_count", &self.lints.len())
            .field("bundle_version", &self.bundle_version)
            .finish()
    }
}

impl LintRunner {
    /// Create a new runner from a set of lints, with no bundle version string.
    ///
    /// Lints are evaluated in the order supplied. Duplicate lint IDs interact
    /// poorly with the deviation mechanism — see the [`LintRunner`] doc for
    /// details. In debug builds a `debug_assert!` fires if duplicates are found.
    ///
    /// To set a bundle version (recommended for production use), use
    /// [`LintRunner::with_bundle_version`].
    #[must_use]
    pub fn new(lints: Vec<Box<dyn Lint>>) -> Self {
        #[cfg(debug_assertions)]
        {
            let mut ids: Vec<_> = lints.iter().map(|l| l.id()).collect();
            let original_len = ids.len();
            ids.sort_unstable();
            ids.dedup();
            assert_eq!(
                ids.len(),
                original_len,
                "duplicate lint IDs will produce confusing deviation behavior"
            );
        }
        Self {
            lints,
            bundle_version: std::borrow::Cow::Borrowed(""),
        }
    }

    /// Create a new runner with an explicit bundle version string.
    ///
    /// The `version` string is stamped into every [`Finding`] produced by this runner
    /// as [`Finding::rule_bundle_version`]. Use this in production to record which
    /// version of the rule bundle was active when findings were generated.
    ///
    /// Accepts any value that converts to `Cow<'static, str>`: string literals
    /// (zero-copy) or owned `String` values (for runtime-constructed versions):
    ///
    /// ```rust,no_run
    /// use pkix_lint::LintRunner;
    /// // `lints` is a Vec<Box<dyn pkix_lint::Lint>> from the calling context.
    /// let lints: Vec<Box<dyn pkix_lint::Lint>> = vec![];
    ///
    /// // Static literal — zero allocation
    /// let runner = LintRunner::with_bundle_version(
    ///     lints,
    ///     "pkix-lint/cabf_tls_br v0.2.0, sourced from TLS BR SC-081",
    /// );
    ///
    /// // Runtime-constructed version — e.g., read from config
    /// let lints2: Vec<Box<dyn pkix_lint::Lint>> = vec![];
    /// let ver = format!("my-bundle v{}", env!("CARGO_PKG_VERSION"));
    /// let runner2 = LintRunner::with_bundle_version(lints2, ver);
    /// ```
    #[must_use]
    pub fn with_bundle_version(
        lints: Vec<Box<dyn Lint>>,
        version: impl Into<std::borrow::Cow<'static, str>>,
    ) -> Self {
        Self {
            lints,
            bundle_version: version.into(),
        }
    }

    /// Return a reference to the registered lints.
    #[must_use]
    pub fn lints(&self) -> &[Box<dyn Lint>] {
        &self.lints
    }

    /// Return the bundle version string set on this runner.
    #[must_use]
    pub fn bundle_version(&self) -> &str {
        &self.bundle_version
    }

    /// Evaluate all certificate-scope lints against `cert`.
    ///
    /// `kind` is the position of this certificate in the chain (leaf, intermediate, etc.).
    /// `now_unix` is the evaluation time (seconds since Unix epoch).
    ///
    /// # Evaluation modes
    ///
    /// Pass the **issuance time** (`cert.tbs_certificate.validity.not_before`) for
    /// audit-mode evaluation: "was this cert compliant when it was issued?"
    ///
    /// Pass the **current time** for operational-mode evaluation: "is this cert
    /// compliant under current rules?"
    ///
    /// Use [`LintRunner::run_cert_at_issuance`] as a convenience wrapper for audit mode.
    ///
    /// Both modes are valid and different — lints with effective dates (e.g., SC-081
    /// validity caps) produce different results depending on which mode is used.
    ///
    /// Lints whose `scope()` is not [`Scope::Certificate`] are skipped entirely
    /// (no finding recorded). Lints in scope but whose `applies_to()` does not
    /// match `kind` produce a [`LintResult::NotApplicable`] finding recorded for
    /// audit completeness.
    ///
    /// Evaluation stops early if any lint returns `Fatal`.
    #[must_use]
    pub fn run_cert(
        &self,
        cert: &Certificate,
        kind: SubjectKind,
        cert_index: usize,
        now_unix: u64,
    ) -> Vec<Finding> {
        let mut findings = Vec::new();
        for lint in &self.lints {
            if lint.scope() != Scope::Certificate {
                continue;
            }
            let result = if kind.matches(lint.applies_to()) {
                lint.check_cert(cert, kind, now_unix)
            } else {
                LintResult::NotApplicable
            };
            let is_fatal = result.is_fatal();
            findings.push(Finding {
                lint_id: std::borrow::Cow::Borrowed(lint.id()),
                citation: std::borrow::Cow::Borrowed(lint.citation()),
                rule_bundle_version: self.bundle_version.clone(),
                result,
                cert_index: Some(cert_index),
                evaluated_at_unix: now_unix,
            });
            if is_fatal {
                break;
            }
        }
        findings
    }

    /// Evaluate certificate-scope lints as of the certificate's issuance time.
    ///
    /// Convenience wrapper for **audit mode**: extracts `notBefore` from the cert
    /// and passes it as `now_unix` to `run_cert`. This answers: "was this cert
    /// compliant when it was issued?"
    ///
    /// For operational mode ("is it compliant under current rules?"), call `run_cert`
    /// directly with the current time.
    ///
    /// See `run_cert` for full documentation on evaluation modes.
    #[must_use]
    pub fn run_cert_at_issuance(
        &self,
        cert: &Certificate,
        kind: SubjectKind,
        cert_index: usize,
    ) -> Vec<Finding> {
        let issuance_unix = cert
            .tbs_certificate
            .validity
            .not_before
            .to_unix_duration()
            .as_secs();
        self.run_cert(cert, kind, cert_index, issuance_unix)
    }

    /// Evaluate all certificate-scope lints against every certificate in `chain`.
    ///
    /// `kinds` maps chain index to [`SubjectKind`]. If `kinds` is shorter than
    /// `chain`, remaining certs are treated as [`SubjectKind::IntermediateCa`].
    ///
    /// Returns a flat `Vec<Finding>` with `cert_index` set for each.
    ///
    /// # Determining the `AnchorIssued` position
    ///
    /// The `AnchorIssued` certificate is the one directly signed by the trust anchor —
    /// typically the last certificate in the chain before the anchor itself (i.e., the
    /// highest-index intermediate, `chain[chain.len() - 1]`).
    ///
    /// Callers are responsible for identifying this position and passing
    /// [`SubjectKind::AnchorIssued`] in `kinds`. The runner has no access to trust
    /// anchor information and cannot determine this automatically.
    ///
    /// To identify it: the anchor-issued cert is the one whose issuer DN matches a
    /// trust anchor's subject. Check via `pkix_path::names_match(cert.tbs_certificate.issuer,
    /// anchor.subject)` for each anchor in your trust store.
    ///
    /// # Fatal behavior across certificates
    ///
    /// Note: [`LintResult::Fatal`] stops lint evaluation for the *current certificate
    /// only*. Subsequent certificates in the chain continue to be evaluated.
    #[must_use]
    pub fn run_chain(
        &self,
        chain: &[Certificate],
        kinds: &[SubjectKind],
        now_unix: u64,
    ) -> Vec<Finding> {
        let mut all = Vec::new();
        for (i, cert) in chain.iter().enumerate() {
            let kind = kinds.get(i).copied().unwrap_or(SubjectKind::IntermediateCa);
            all.extend(self.run_cert(cert, kind, i, now_unix));
        }
        all
    }

    /// Evaluate all path-scope lints against the full validated path.
    ///
    /// `chain` must be the same slice passed to `pkix_path::validate_path`.
    /// `path` is the [`ValidatedPath`] returned by that call.
    ///
    /// Evaluation stops early if any lint returns `Fatal`.
    #[must_use]
    pub fn run_path(
        &self,
        chain: &[Certificate],
        path: &ValidatedPath,
        now_unix: u64,
    ) -> Vec<Finding> {
        let mut findings = Vec::new();
        for lint in &self.lints {
            if lint.scope() != Scope::Path {
                continue;
            }
            let result = lint.check_path(chain, path, now_unix);
            let is_fatal = result.is_fatal();
            findings.push(Finding {
                lint_id: std::borrow::Cow::Borrowed(lint.id()),
                citation: std::borrow::Cow::Borrowed(lint.citation()),
                rule_bundle_version: self.bundle_version.clone(),
                result,
                cert_index: None,
                evaluated_at_unix: now_unix,
            });
            if is_fatal {
                break;
            }
        }
        findings
    }
}

// ---------------------------------------------------------------------------
// LintProfile trait
// ---------------------------------------------------------------------------

/// A [`Profile`] that also bundles a set of lints.
///
/// This is the integration point between profile policy and the lint engine.
/// Implement `LintProfile` on a type that already implements [`Profile`] to
/// associate a set of lints with the profile.
///
/// # Why not on `Profile` directly?
///
/// Adding `lints()` to [`pkix_path::Profile`] would create a mandatory dep on
/// `pkix-lint` from `pkix-path`. That would violate `pkix-path`'s `no_std`
/// boundary and force the lint engine into every profile consumer.
/// `LintProfile` is a separate trait in `pkix-lint` that callers opt into.
pub trait LintProfile: Profile {
    /// Return the lints that this profile enforces.
    ///
    /// The returned slice owns `Box<dyn Lint>` values. The runner uses them
    /// directly — no cloning needed.
    fn lints(&self) -> &[Box<dyn Lint>];

    /// Convenience: produce a [`LintRunner`] from this profile's lints.
    ///
    /// Implementors should document whether this method caches the runner or
    /// allocates fresh on each call. Callers that invoke this repeatedly should
    /// cache the returned [`LintRunner`] themselves.
    #[must_use]
    fn lint_runner(&self) -> LintRunner;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    // -----------------------------------------------------------------------
    // SubjectKind::matches tests
    //
    // Oracle: the filter/subject matching rules in the SubjectKind doc comment.
    // -----------------------------------------------------------------------

    #[test]
    fn subject_kind_any_matches_all() {
        for &kind in &[
            SubjectKind::Leaf,
            SubjectKind::IntermediateCa,
            SubjectKind::AnchorIssued,
            SubjectKind::Any,
        ] {
            assert!(
                kind.matches(SubjectKind::Any),
                "{kind:?} must match filter Any"
            );
        }
    }

    #[test]
    fn subject_kind_exact_matches_self() {
        assert!(SubjectKind::Leaf.matches(SubjectKind::Leaf));
        assert!(SubjectKind::IntermediateCa.matches(SubjectKind::IntermediateCa));
        assert!(SubjectKind::AnchorIssued.matches(SubjectKind::AnchorIssued));
    }

    #[test]
    fn subject_kind_intermediate_filter_includes_anchor_issued() {
        // AnchorIssued is a sub-kind of IntermediateCa — an anchor-issued cert
        // is still a CA cert and should be checked by IntermediateCa lints.
        assert!(SubjectKind::AnchorIssued.matches(SubjectKind::IntermediateCa));
    }

    #[test]
    fn subject_kind_leaf_does_not_match_intermediate() {
        assert!(!SubjectKind::Leaf.matches(SubjectKind::IntermediateCa));
        assert!(!SubjectKind::Leaf.matches(SubjectKind::AnchorIssued));
    }

    #[test]
    fn subject_kind_intermediate_does_not_match_leaf() {
        assert!(!SubjectKind::IntermediateCa.matches(SubjectKind::Leaf));
    }

    // -----------------------------------------------------------------------
    // LintResult helper method tests
    //
    // Oracle: the LintResult variant semantics in the doc comments.
    // -----------------------------------------------------------------------

    #[test]
    fn lint_result_pass_is_pass() {
        assert!(LintResult::Pass.is_pass());
        assert!(!LintResult::Pass.is_finding());
        assert!(!LintResult::Pass.is_fatal());
        assert_eq!(LintResult::Pass.detail(), None);
    }

    #[test]
    fn lint_result_not_applicable_is_not_pass_not_finding() {
        assert!(!LintResult::NotApplicable.is_pass());
        assert!(!LintResult::NotApplicable.is_finding());
        assert_eq!(LintResult::NotApplicable.detail(), None);
    }

    #[test]
    fn lint_result_warn_is_finding() {
        let r = LintResult::Warn("test warning");
        assert!(!r.is_pass());
        assert!(r.is_finding());
        assert!(!r.is_fatal());
        assert_eq!(r.detail(), Some("test warning"));
    }

    #[test]
    fn lint_result_error_is_finding() {
        let r = LintResult::Error("test error");
        assert!(!r.is_pass());
        assert!(r.is_finding());
        assert!(!r.is_fatal());
        assert_eq!(r.detail(), Some("test error"));
    }

    #[test]
    fn lint_result_fatal_is_fatal_and_finding() {
        let r = LintResult::Fatal("fatal error");
        assert!(!r.is_pass());
        assert!(r.is_finding());
        assert!(r.is_fatal());
        assert_eq!(r.detail(), Some("fatal error"));
    }

    // -----------------------------------------------------------------------
    // LintRunner tests using a trivial in-test Lint implementation
    //
    // Oracle: the runner contract defined in LintRunner doc comments.
    // The test lints are independent oracles — they do not call other lints or
    // validate against the code under test.
    // -----------------------------------------------------------------------

    /// A lint that always passes, used to verify the runner records Pass findings.
    struct AlwaysPass;
    impl Lint for AlwaysPass {
        fn id(&self) -> &'static str {
            "test.always_pass"
        }
        fn citation(&self) -> &'static str {
            "test"
        }
        fn severity(&self) -> Severity {
            Severity::Info
        }
        fn scope(&self) -> Scope {
            Scope::Certificate
        }
        fn applies_to(&self) -> SubjectKind {
            SubjectKind::Any
        }
        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
            LintResult::Pass
        }
    }

    /// A lint that always warns, used to verify runner records Warn findings.
    struct AlwaysWarn;
    impl Lint for AlwaysWarn {
        fn id(&self) -> &'static str {
            "test.always_warn"
        }
        fn citation(&self) -> &'static str {
            "test"
        }
        fn severity(&self) -> Severity {
            Severity::Warn
        }
        fn scope(&self) -> Scope {
            Scope::Certificate
        }
        fn applies_to(&self) -> SubjectKind {
            SubjectKind::Any
        }
        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
            LintResult::Warn("always warns")
        }
    }

    /// A lint that always returns Fatal, used to test early-exit behavior.
    struct AlwaysFatal;
    impl Lint for AlwaysFatal {
        fn id(&self) -> &'static str {
            "test.always_fatal"
        }
        fn citation(&self) -> &'static str {
            "test"
        }
        fn severity(&self) -> Severity {
            Severity::Fatal
        }
        fn scope(&self) -> Scope {
            Scope::Certificate
        }
        fn applies_to(&self) -> SubjectKind {
            SubjectKind::Any
        }
        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
            LintResult::Fatal("always fatal")
        }
    }

    /// A lint scoped to leaves only, used to verify kind filtering.
    struct LeafOnlyLint;
    impl Lint for LeafOnlyLint {
        fn id(&self) -> &'static str {
            "test.leaf_only"
        }
        fn citation(&self) -> &'static str {
            "test"
        }
        fn severity(&self) -> Severity {
            Severity::Warn
        }
        fn scope(&self) -> Scope {
            Scope::Certificate
        }
        fn applies_to(&self) -> SubjectKind {
            SubjectKind::Leaf
        }
        fn check_cert(&self, _cert: &Certificate, _kind: SubjectKind, _now: u64) -> LintResult {
            LintResult::Warn("leaf lint fires")
        }
    }

    /// A path-scope lint, used to verify `run_path`.
    struct PathDepthLint;
    impl Lint for PathDepthLint {
        fn id(&self) -> &'static str {
            "test.path_depth"
        }
        fn citation(&self) -> &'static str {
            "test"
        }
        fn severity(&self) -> Severity {
            Severity::Warn
        }
        fn scope(&self) -> Scope {
            Scope::Path
        }
        fn applies_to(&self) -> SubjectKind {
            SubjectKind::Any
        }
        fn check_path(
            &self,
            _chain: &[Certificate],
            path: &ValidatedPath,
            _now: u64,
        ) -> LintResult {
            if path.depth > 5 {
                LintResult::Warn("chain depth exceeds 5")
            } else {
                LintResult::Pass
            }
        }
    }

    // We need a minimal Certificate to call run_cert. Load from a real fixture.
    fn load_fixture_cert() -> Certificate {
        use der::Decode as _;
        Certificate::from_der(include_bytes!(
            "../../pkix-path/tests/fixtures/policy-checks/webpki-self-signed-365d.der"
        ))
        .expect("fixture is valid DER")
    }

    #[test]
    fn runner_records_pass_finding() {
        let cert = load_fixture_cert();
        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].lint_id, "test.always_pass");
        assert_eq!(findings[0].result, LintResult::Pass);
        assert_eq!(findings[0].cert_index, Some(0));
    }

    #[test]
    fn runner_records_warn_finding() {
        let cert = load_fixture_cert();
        let runner = LintRunner::new(vec![Box::new(AlwaysWarn)]);
        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].lint_id, "test.always_warn");
        assert!(matches!(findings[0].result, LintResult::Warn(_)));
        assert!(findings[0].is_finding());
    }

    #[test]
    fn runner_stops_after_fatal() {
        // Fatal lint followed by another lint — the second must NOT be evaluated.
        let cert = load_fixture_cert();
        let runner = LintRunner::new(vec![
            Box::new(AlwaysFatal),
            Box::new(AlwaysWarn), // must not appear in findings
        ]);
        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
        // Only one finding: the fatal. The warn is never reached.
        assert_eq!(findings.len(), 1, "runner must stop after Fatal");
        assert_eq!(findings[0].lint_id, "test.always_fatal");
        assert!(findings[0].result.is_fatal());
    }

    #[test]
    fn runner_skips_non_applicable_scope_kind() {
        // LeafOnlyLint declares applies_to = Leaf.
        // Running it against IntermediateCa must return NotApplicable, not Warn.
        let cert = load_fixture_cert();
        let runner = LintRunner::new(vec![Box::new(LeafOnlyLint)]);
        let findings = runner.run_cert(&cert, SubjectKind::IntermediateCa, 1, 0);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].result, LintResult::NotApplicable);
    }

    #[test]
    fn runner_applies_leaf_lint_to_leaf() {
        let cert = load_fixture_cert();
        let runner = LintRunner::new(vec![Box::new(LeafOnlyLint)]);
        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
        assert_eq!(findings.len(), 1);
        assert!(matches!(findings[0].result, LintResult::Warn(_)));
    }

    fn validated_path_for_self_signed() -> (Vec<Certificate>, ValidatedPath) {
        use pkix_path::{EcdsaP256Verifier, TrustAnchor, ValidationPolicy};
        let cert = load_fixture_cert();
        let anchor = TrustAnchor::from_cert(cert.clone());
        // 2026-01-01 = pre-SC-081, so 365-day cert passes the 398-day cap.
        let policy = ValidationPolicy::new(1_767_225_600);
        let path = pkix_path::validate_path(
            std::slice::from_ref(&cert),
            &[anchor],
            &policy,
            &EcdsaP256Verifier,
        )
        .expect("fixture cert must validate");
        (vec![cert], path)
    }

    #[test]
    fn runner_skips_cert_lints_in_run_path() {
        // AlwaysWarn is a Certificate-scope lint; run_path must not invoke it.
        let (chain, path) = validated_path_for_self_signed();
        let runner = LintRunner::new(vec![Box::new(AlwaysWarn)]);
        let findings = runner.run_path(&chain, &path, 0);
        assert!(
            findings.is_empty(),
            "run_path must not invoke Certificate-scope lints"
        );
    }

    #[test]
    fn runner_invokes_path_lint_in_run_path() {
        let (chain, path) = validated_path_for_self_signed();
        let runner = LintRunner::new(vec![Box::new(PathDepthLint)]);
        let findings = runner.run_path(&chain, &path, 0);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].lint_id, "test.path_depth");
        // Self-signed chain: depth=0, not > 5 → Pass.
        assert_eq!(findings[0].result, LintResult::Pass);
        assert_eq!(
            findings[0].cert_index, None,
            "path findings have no cert_index"
        );
    }

    #[test]
    fn runner_run_chain_sets_cert_index() {
        let cert = load_fixture_cert();
        let chain = vec![cert.clone(), cert.clone(), cert];
        let kinds = vec![
            SubjectKind::Leaf,
            SubjectKind::IntermediateCa,
            SubjectKind::AnchorIssued,
        ];
        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
        let findings = runner.run_chain(&chain, &kinds, 0);
        // One Pass finding per cert.
        assert_eq!(findings.len(), 3);
        assert_eq!(findings[0].cert_index, Some(0));
        assert_eq!(findings[1].cert_index, Some(1));
        assert_eq!(findings[2].cert_index, Some(2));
    }

    #[test]
    fn finding_is_finding_reflects_result() {
        let f_pass = Finding {
            lint_id: std::borrow::Cow::Borrowed("x"),
            citation: std::borrow::Cow::Borrowed("test"),
            rule_bundle_version: std::borrow::Cow::Borrowed(""),
            result: LintResult::Pass,
            cert_index: None,
            evaluated_at_unix: 0,
        };
        let f_warn = Finding {
            lint_id: std::borrow::Cow::Borrowed("x"),
            citation: std::borrow::Cow::Borrowed("test"),
            rule_bundle_version: std::borrow::Cow::Borrowed(""),
            result: LintResult::Warn("w"),
            cert_index: None,
            evaluated_at_unix: 0,
        };
        assert!(!f_pass.is_finding());
        assert!(f_warn.is_finding());
    }

    #[test]
    fn finding_citation_is_threaded_from_lint() {
        let cert = load_fixture_cert();
        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 12345);
        assert_eq!(findings.len(), 1);
        // Citation must come from the lint's citation() method.
        assert_eq!(
            findings[0].citation, "test",
            "citation must be threaded from Lint::citation()"
        );
        assert_eq!(
            findings[0].evaluated_at_unix, 12345,
            "evaluated_at_unix must be the passed now_unix"
        );
    }

    #[test]
    fn run_cert_at_issuance_uses_not_before() {
        let cert = load_fixture_cert();
        // Get the expected issuance time from the cert's notBefore.
        let expected_unix = cert
            .tbs_certificate
            .validity
            .not_before
            .to_unix_duration()
            .as_secs();
        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
        let findings = runner.run_cert_at_issuance(&cert, SubjectKind::Leaf, 0);
        assert_eq!(findings.len(), 1);
        assert_eq!(
            findings[0].evaluated_at_unix, expected_unix,
            "run_cert_at_issuance must use cert notBefore as evaluated_at_unix"
        );
    }

    #[test]
    fn bundle_version_stamped_into_findings() {
        let cert = load_fixture_cert();
        let runner = LintRunner::with_bundle_version(
            vec![Box::new(AlwaysPass)],
            "pkix-lint/cabf_tls_br v0.2.0",
        );
        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
        assert_eq!(findings.len(), 1);
        assert_eq!(
            findings[0].rule_bundle_version.as_ref(),
            "pkix-lint/cabf_tls_br v0.2.0",
            "rule_bundle_version must be stamped from runner into Finding"
        );
    }

    #[test]
    fn bundle_version_empty_by_default() {
        let cert = load_fixture_cert();
        let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
        let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
        assert_eq!(findings[0].rule_bundle_version.as_ref(), "");
    }
}