pkix-lint 0.9.1

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
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
//! RFC 5280 conformance lints.
//!
//! This module ships [`Lint`] implementations that enforce structural and
//! semantic rules from [RFC 5280][rfc5280]. Each lint is keyed by an OSCAL
//! Control-id-shaped identifier (`rfc5280-<section>`) and cites the
//! relevant section in its rustdoc.
//!
//! The lints here are intentionally lean and parameterless except where
//! the underlying RFC explicitly admits an operator-tunable threshold;
//! see [`Rfc5280MaxSerialLengthLint`] for the first such case.
//!
//! [rfc5280]: https://www.rfc-editor.org/rfc/rfc5280
//!
//! # Provenance
//!
//! Added in PKIX-9vnx.6.4 as the demonstration vehicle for the
//! [`Lint::parameters`] / [`Lint::set_parameter`] OSCAL Parameter
//! mechanism. CABF-shaped lints now live in the `pkix-lint-cabf`
//! crate ([`cabf_tls_br`][cabf_tls_br]); project policy (see workspace
//! `AGENTS.md` and the PKIX-amgn / PKIX-9vnx alignment epics) is that
//! RFC-conformance lints stay in `pkix-lint` while CA/B Forum policy
//! lints live in `pkix-lint-cabf`.
//!
//! [cabf_tls_br]: https://docs.rs/pkix-lint-cabf/latest/pkix_lint_cabf/cabf_tls_br/

use std::borrow::Cow;

use der::{asn1::ObjectIdentifier, Decode as _};
use x509_cert::Certificate;

use crate::{
    truncate_for_detail, Lint, LintParameter, LintResult, ParameterError, Scope, Severity,
    SubjectKind,
};

// ---------------------------------------------------------------------------
// OID constants (RFC 5280 §4.2.1 — standard certificate extensions)
// ---------------------------------------------------------------------------

/// `BasicConstraints` extension OID — RFC 5280 §4.2.1.9.
const OID_BASIC_CONSTRAINTS: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.29.19");

/// `ExtendedKeyUsage` extension OID — RFC 5280 §4.2.1.12.
const OID_EXTENDED_KEY_USAGE: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.29.37");

/// `SubjectAltName` extension OID — RFC 5280 §4.2.1.6.
const OID_SUBJECT_ALT_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.29.17");

/// id-kp-serverAuth — RFC 5280 §4.2.1.12 (TLS WWW server authentication).
const ID_KP_SERVER_AUTH: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.3.1");

// ---------------------------------------------------------------------------
// rfc5280.cert.serial_number.max_octets
// ---------------------------------------------------------------------------

/// RFC 5280 §4.1.2.2: serialNumber must not exceed 20 octets.
///
/// > Conforming CAs MUST NOT use serialNumber values longer than 20
/// > octets.  Conforming CAs MUST NOT use the value zero for the
/// > serialNumber field.
///
/// This lint enforces the upper-bound clause. The 20-octet cap is the
/// default; operators may tighten it (e.g., to model a more restrictive
/// in-house issuance policy) or set it to 21 to switch to the
/// unsigned-value reading documented below. The accepted range is
/// `1..=21` — anything outside is rejected by both
/// [`Self::with_max_octets`] (panics) and [`Lint::set_parameter`]
/// (returns [`ParameterError::InvalidValue`]). PKIX-7f92.5 closed the
/// path that previously accepted nonsense values (max-octets=64,
/// max-octets=usize::MAX) and silently weakened the RFC baseline;
/// operators who genuinely want a wider cap should wire their own
/// `Lint` impl rather than relaxing this lint's contract.
///
/// # Encoded-content interpretation
///
/// "20 octets" in RFC 5280 §4.1.2.2 is ambiguous between two readings:
///
/// 1. **Encoded INTEGER content** (length of the DER INTEGER value
///    octets, including any leading `0x00` sign-padding byte required
///    by DER for positive values whose high bit is set).
/// 2. **Unsigned-value length** (length of the unsigned integer
///    representation after stripping the sign-padding byte).
///
/// These differ by 1 octet when the unsigned high bit is set: a
/// 20-byte unsigned value of the form `0x80..` encodes to a 21-byte
/// INTEGER content (`0x00 0x80 ..`). Both readings are defensible, and
/// x509-cert itself acknowledges the ambiguity — its `SerialNumber`
/// type rejects 21-byte values on construction (encoder side, the
/// unsigned-value reading) but accepts up to 21 bytes on decode
/// (permissive of the encoded-content reading).
///
/// **This lint uses the encoded-content reading**, matching the
/// dominant community interpretation (zlint, pkilint) and the literal
/// text of the RFC ("serialNumber values" naturally referring to the
/// encoded field value). Under this reading, a CA that issues a
/// 20-byte unsigned high-bit-set serial — encoded as 21 octets — is
/// flagged non-conforming. Operators wanting the unsigned-value
/// reading can override `max-octets` to 21.
///
/// The mechanism is direct: `x509_cert::SerialNumber::as_bytes()` is
/// backed by `der::asn1::Int::as_bytes()`, which returns the decoded
/// INTEGER content bytes unchanged (it strips leading `0xFF` sign
/// extension for negative values but does NOT strip leading `0x00`
/// sign padding for positive values). The lint compares this length
/// to `max_octets` directly. The DER round-trip test below empirically
/// confirms this behavior on a hand-crafted 21-byte-content serial.
///
/// # OSCAL parameters
///
/// | id            | label                                            | default |
/// |---------------|--------------------------------------------------|--------:|
/// | `max-octets`  | Maximum allowed serial number length in octets   | `20`    |
///
/// # Provenance
///
/// First parametric Lint added under PKIX-9vnx.6.4. Doubles as the
/// pkix-lint built-in fixture that tests OSCAL parameter overrides.
/// Encoded-content interpretation locked in under PKIX-7f92.2 with a
/// negative-path test that exercises a 21-byte-content serial.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rfc5280MaxSerialLengthLint {
    max_octets: usize,
    parameters: Vec<LintParameter>,
}

impl Default for Rfc5280MaxSerialLengthLint {
    fn default() -> Self {
        // RFC 5280 §4.1.2.2 baseline cap.
        Self::with_max_octets(20)
    }
}

/// Valid range for `max-octets`: `1..=21`. The lower bound (1) is
/// mandated by RFC 5280 §4.1.2.2 (positive non-zero serials); a
/// 0-cap rejects every cert. The upper bound (21) accommodates both
/// readings of "20 octets" the RFC tolerates (see the
/// `Encoded-content interpretation` section of the lint rustdoc):
/// 20 for the encoded-content reading (the lint's default), 21 for
/// the unsigned-value reading that an operator may prefer.
///
/// Values outside this range are rejected by both constructor paths
/// to prevent silent misconfiguration (PKIX-7f92.5, PKIX-7f92.39).
/// Operators wanting a substantially wider or narrower cap (an
/// in-house policy that diverges from RFC 5280) should wire their
/// own [`Lint`] impl rather than relaxing this lint's contract.
const MAX_OCTETS_RANGE: std::ops::RangeInclusive<usize> = 1..=21;

/// Shared validator for the `max-octets` parameter. Returns a typed
/// error reason string suitable for either the
/// [`ParameterError::InvalidValue`] reason field or a `panic!` message.
fn validate_max_octets(value: usize) -> Result<(), String> {
    if MAX_OCTETS_RANGE.contains(&value) {
        Ok(())
    } else {
        Err(format!(
            "max-octets must be in the range {}..={} (got {value}); \
             RFC 5280 §4.1.2.2 mandates serials of 1..=20 octets (encoded-content reading) \
             or 1..=21 octets (unsigned-value reading). Wire a custom Lint impl for wider/narrower bounds.",
            MAX_OCTETS_RANGE.start(),
            MAX_OCTETS_RANGE.end(),
        ))
    }
}

impl Rfc5280MaxSerialLengthLint {
    /// Construct the lint with an explicit `max_octets` cap.
    ///
    /// Equivalent to calling [`Default::default`] followed by
    /// [`Lint::set_parameter`] with id `"max-octets"`; provided as a typed
    /// constructor for callers configuring the lint at compile time.
    ///
    /// # Panics
    ///
    /// Panics if `max_octets` is outside the valid range `1..=21`
    /// (see [`MAX_OCTETS_RANGE`] for the rationale on each bound).
    /// PKIX-7f92.5 / PKIX-7f92.39 closed the two-paths-different-
    /// validation gap by routing both this constructor and
    /// [`Lint::set_parameter`] through the same range check. The panic
    /// here matches [`crate::LintRunner::new`]'s panic-on-duplicate-id
    /// precedent: an out-of-range constant at compile time is a
    /// programmer bug, not a runtime input.
    ///
    /// Operators who genuinely need a wider or narrower bound should
    /// wire their own [`Lint`] impl rather than weakening this lint's
    /// RFC-baseline contract.
    #[must_use]
    pub fn with_max_octets(max_octets: usize) -> Self {
        if let Err(reason) = validate_max_octets(max_octets) {
            panic!("Rfc5280MaxSerialLengthLint::with_max_octets: {reason}");
        }
        let parameters = vec![LintParameter {
            id: Cow::Borrowed("max-octets"),
            label: Cow::Borrowed("Maximum allowed serial number length in octets"),
            default_value: Cow::Borrowed("20"),
        }];
        Self {
            max_octets,
            parameters,
        }
    }

    /// Current `max_octets` cap. Useful for round-trip OSCAL emit tests.
    #[must_use]
    pub fn max_octets(&self) -> usize {
        self.max_octets
    }
}

impl Lint for Rfc5280MaxSerialLengthLint {
    fn id(&self) -> &'static str {
        "rfc5280.cert.serial_number.max_octets"
    }

    fn citation(&self) -> &'static str {
        "RFC 5280 §4.1.2.2"
    }

    fn severity(&self) -> Severity {
        Severity::Error
    }

    fn scope(&self) -> Scope {
        Scope::Certificate
    }

    fn applies_to(&self) -> SubjectKind {
        SubjectKind::Any
    }

    fn title(&self) -> &str {
        "Certificate serialNumber must not exceed 20 octets"
    }

    fn spec_section_id(&self) -> Option<&str> {
        Some("rfc5280-4.1.2.2")
    }

    fn spec_url(&self) -> Option<&str> {
        Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.1.2.2")
    }

    fn parameters(&self) -> &[LintParameter] {
        &self.parameters
    }

    fn set_parameter(&mut self, id: &str, value: &str) -> Result<(), ParameterError> {
        match id {
            "max-octets" => {
                let parsed: usize = value.parse().map_err(|_| ParameterError::InvalidValue {
                    id: id.to_owned(),
                    reason: format!("expected non-negative integer, got '{value}'"),
                })?;
                validate_max_octets(parsed).map_err(|reason| ParameterError::InvalidValue {
                    id: id.to_owned(),
                    reason,
                })?;
                self.max_octets = parsed;
                Ok(())
            }
            other => Err(ParameterError::UnknownParameter(other.to_owned())),
        }
    }

    fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
        // `SerialNumber::as_bytes` is backed by `der::asn1::Int::as_bytes`,
        // which returns the decoded INTEGER content bytes — including any
        // leading `0x00` sign-padding byte present in the wire encoding
        // (positive values whose high bit is set). The lint uses the
        // encoded-content reading of RFC 5280 §4.1.2.2 "20 octets"; see the
        // `Encoded-content interpretation` section on this struct's
        // rustdoc, and the DER round-trip negative-path test below.
        let len = cert.tbs_certificate.serial_number.as_bytes().len();
        let cap = self.max_octets;
        if len > cap {
            // Dynamic detail (Cow::Owned): include the actual length so
            // the audit trail attributes the failure to a specific value.
            LintResult::error(format!(
                "certificate serialNumber is {len} octets, exceeds cap of {cap} octets"
            ))
        } else {
            LintResult::Pass
        }
    }
}

// ---------------------------------------------------------------------------
// rfc5280.cert.bc.ca_false_for_leaf
// ---------------------------------------------------------------------------

/// RFC 5280 §4.2.1.9: end-entity certificates MUST NOT assert `cA=TRUE`.
///
/// > The cA boolean indicates whether the certified public key may be used
/// > to verify certificate signatures.  If the cA boolean is not asserted,
/// > then the keyCertSign bit in the key usage extension MUST NOT be
/// > asserted.  If the basic constraints extension is not present in a
/// > version 3 certificate, or the extension is present but the cA boolean
/// > is not asserted, then the certified public key MUST NOT be used to
/// > verify certificate signatures.
///
/// The complement of `pkix_lint_cabf::cabf_tls_br::BcCaFlagLint`, which
/// requires `cA=TRUE` on intermediate CAs. This lint requires `cA=FALSE`
/// (or `BasicConstraints` absent, which has the same meaning per the spec)
/// on end-entity (`SubjectKind::Leaf`) certificates.
///
/// # Behavior
///
/// - `BasicConstraints` extension absent → `Pass` (defaults to cA=FALSE).
/// - `BasicConstraints` present with cA=FALSE → `Pass`.
/// - `BasicConstraints` present with cA=TRUE → `Error`.
/// - `BasicConstraints` extension value is malformed → `Error` (cannot
///   confirm cA=FALSE).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Rfc5280BasicConstraintsCaLeafLint;

impl Lint for Rfc5280BasicConstraintsCaLeafLint {
    fn id(&self) -> &'static str {
        "rfc5280.cert.bc.ca_false_for_leaf"
    }

    fn citation(&self) -> &'static str {
        "RFC 5280 §4.2.1.9"
    }

    fn severity(&self) -> Severity {
        Severity::Error
    }

    fn scope(&self) -> Scope {
        Scope::Certificate
    }

    fn applies_to(&self) -> SubjectKind {
        SubjectKind::Leaf
    }

    fn title(&self) -> &str {
        "End-entity certificate must not assert BasicConstraints.cA=TRUE"
    }

    fn spec_section_id(&self) -> Option<&str> {
        Some("rfc5280-4.2.1.9")
    }

    fn spec_url(&self) -> Option<&str> {
        Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.9")
    }

    fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
        // No extensions at all is fine: BasicConstraints is implicitly absent
        // and defaults to cA=FALSE per the spec.
        let Some(extensions) = &cert.tbs_certificate.extensions else {
            return LintResult::Pass;
        };

        // BasicConstraints absent is the same as cA=FALSE.
        let Some(bc_ext) = extensions
            .iter()
            .find(|e| e.extn_id == OID_BASIC_CONSTRAINTS)
        else {
            return LintResult::Pass;
        };

        match x509_cert::ext::pkix::BasicConstraints::from_der(bc_ext.extn_value.as_bytes()) {
            Ok(bc) => {
                if bc.ca {
                    LintResult::error(
                        "end-entity certificate asserts BasicConstraints.cA=TRUE; \
                         only CA certificates may assert cA",
                    )
                } else {
                    LintResult::Pass
                }
            }
            Err(e) => {
                let e_str = e.to_string();
                let safe_e = truncate_for_detail(&e_str);
                LintResult::error(format!(
                    "BasicConstraints extension value is malformed DER: {safe_e}"
                ))
            }
        }
    }
}

// ---------------------------------------------------------------------------
// rfc5280.cert.eku.server_auth_required
// ---------------------------------------------------------------------------

/// RFC 5280 §4.2.1.12: TLS server end-entity certificates MUST assert
/// `id-kp-serverAuth` in `ExtendedKeyUsage`.
///
/// > KeyPurposeId ::= OBJECT IDENTIFIER
/// >
/// > -- TLS WWW server authentication
/// > -- Key usage bits that may be consistent: digitalSignature,
/// > -- keyEncipherment or keyAgreement
/// > id-kp-serverAuth             OBJECT IDENTIFIER ::= { id-kp 1 }
///
/// This is the RFC-conformance variant of the CA/B Forum
/// [`cabf.br.tls.eku.server_auth`][cabf-eku] lint. Identical logic, RFC
/// 5280 citation. The lint is leaf-only because EKU on intermediates is
/// constrained by the chain-walking name-space (RFC 5280 §4.2.1.12 second
/// paragraph) and is checked at path-validation time, not as a leaf shape
/// requirement.
///
/// [cabf-eku]: https://docs.rs/pkix-lint-cabf/latest/pkix_lint_cabf/cabf_tls_br/struct.EkuServerAuthLint.html
///
/// # Use-case applicability — operator contract
///
/// This lint is **use-case specific** to TLS server certificates. It
/// asserts a property the RFC requires of TLS server certs and **only**
/// TLS server certs. Registering it against arbitrary leaves produces
/// false-positive `Error` findings on S/MIME, code-signing, OCSP-responder,
/// or any other non-TLS-server end-entity certificate.
///
/// **Operators MUST register this lint only through a use-case-specific
/// [`LintProfile`][crate::LintProfile] that bundles it with other
/// TLS-server lints (SAN dNSName, etc.).**
/// `pkix_profiles::BasicTlsProfile` is the canonical bundler. There is no
/// "generic rfc5280-conformance" bundle that mixes this lint with
/// `Rfc8551EkuEmailProtectionLint` or `Rfc8398SmimeSanLint`: those four
/// lints assert mutually-exclusive shape requirements (no leaf cert
/// satisfies all four simultaneously) and must be selected by use case.
///
/// The lint trait deliberately does not encode use case in its type
/// signature; use-case selection is the `LintProfile` bundle's
/// responsibility. See [`crate::Lint`] trait rustdoc for the contract.
///
/// # Behavior
///
/// - `ExtendedKeyUsage` extension absent → `Error`.
/// - `ExtendedKeyUsage` present and contains `id-kp-serverAuth` → `Pass`.
/// - `ExtendedKeyUsage` present but does not contain `id-kp-serverAuth` →
///   `Error`.
/// - `ExtendedKeyUsage` extension value is malformed → `Error`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Rfc5280EkuServerAuthLint;

impl Lint for Rfc5280EkuServerAuthLint {
    fn id(&self) -> &'static str {
        "rfc5280.cert.eku.server_auth_required"
    }

    fn citation(&self) -> &'static str {
        "RFC 5280 §4.2.1.12"
    }

    fn severity(&self) -> Severity {
        Severity::Error
    }

    fn scope(&self) -> Scope {
        Scope::Certificate
    }

    fn applies_to(&self) -> SubjectKind {
        SubjectKind::Leaf
    }

    fn title(&self) -> &str {
        "TLS server certificate must include id-kp-serverAuth EKU"
    }

    fn spec_section_id(&self) -> Option<&str> {
        Some("rfc5280-4.2.1.12")
    }

    fn spec_url(&self) -> Option<&str> {
        Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12")
    }

    fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
        let Some(extensions) = &cert.tbs_certificate.extensions else {
            return LintResult::error(
                "leaf certificate has no extensions; ExtendedKeyUsage absent",
            );
        };

        let Some(eku_ext) = extensions
            .iter()
            .find(|e| e.extn_id == OID_EXTENDED_KEY_USAGE)
        else {
            return LintResult::error("ExtendedKeyUsage extension absent from leaf certificate");
        };

        match x509_cert::ext::pkix::ExtendedKeyUsage::from_der(eku_ext.extn_value.as_bytes()) {
            Ok(eku) => {
                if eku.0.contains(&ID_KP_SERVER_AUTH) {
                    LintResult::Pass
                } else {
                    LintResult::error(
                        "ExtendedKeyUsage does not include id-kp-serverAuth (1.3.6.1.5.5.7.3.1)",
                    )
                }
            }
            Err(e) => {
                let e_str = e.to_string();
                let safe_e = truncate_for_detail(&e_str);
                LintResult::error(format!(
                    "ExtendedKeyUsage extension value is malformed DER: {safe_e}"
                ))
            }
        }
    }
}

// ---------------------------------------------------------------------------
// rfc5280.cert.san.required_when_subject_empty
// ---------------------------------------------------------------------------

/// RFC 5280 §4.2.1.6: certificates with an empty subject MUST include a
/// critical `subjectAltName` extension.
///
/// > If the subject field contains an empty sequence, then the issuing CA
/// > MUST include a subjectAltName extension that is marked as critical.
///
/// The lint enforces both clauses on certificates whose Subject is the
/// zero-length `RDNSequence`:
///
/// 1. `subjectAltName` MUST be present.
/// 2. The `subjectAltName` extension MUST be marked `critical`.
///
/// Certificates with a non-empty Subject are out of scope (`Pass`) — the
/// criticality of SAN on those is governed by separate rules (RFC 5280
/// §4.2.1.6 says SAN SHOULD be marked non-critical when the Subject is
/// also present; that SHOULD-clause is not covered here).
///
/// # Behavior
///
/// - Subject non-empty → `Pass` (this lint does not apply).
/// - Subject empty, `SubjectAltName` extension absent → `Error`.
/// - Subject empty, `SubjectAltName` present but not critical → `Error`.
/// - Subject empty, `SubjectAltName` present and critical → `Pass`.
///
/// # Provenance
///
/// Filed under PKIX-9vnx.9.2.1.1 as one of the three RFC-conformance
/// lints deferred from the initial PKIX-9vnx.9.2.1 batch. Negative-test
/// fixture (empty-subject leaf with missing-or-non-critical SAN) is not
/// yet present in the workspace fixture corpus; the lint ships with a
/// positive test only (existing fixtures all have non-empty Subjects).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Rfc5280SanRequiredWhenSubjectEmptyLint;

impl Lint for Rfc5280SanRequiredWhenSubjectEmptyLint {
    fn id(&self) -> &'static str {
        "rfc5280.cert.san.required_when_subject_empty"
    }

    fn citation(&self) -> &'static str {
        "RFC 5280 §4.2.1.6"
    }

    fn severity(&self) -> Severity {
        Severity::Error
    }

    fn scope(&self) -> Scope {
        Scope::Certificate
    }

    fn applies_to(&self) -> SubjectKind {
        SubjectKind::Any
    }

    fn title(&self) -> &str {
        "Empty-subject certificate must include a critical subjectAltName"
    }

    fn spec_section_id(&self) -> Option<&str> {
        Some("rfc5280-4.2.1.6")
    }

    fn spec_url(&self) -> Option<&str> {
        Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.6")
    }

    fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
        // The lint is conditional on an empty Subject. Non-empty Subjects
        // are out of scope here — they Pass even if SAN is absent.
        if !cert.tbs_certificate.subject.is_empty() {
            return LintResult::Pass;
        }

        let Some(extensions) = &cert.tbs_certificate.extensions else {
            return LintResult::error(
                "empty-subject certificate has no extensions; \
                 RFC 5280 §4.2.1.6 requires a critical subjectAltName",
            );
        };

        let Some(san_ext) = extensions
            .iter()
            .find(|e| e.extn_id == OID_SUBJECT_ALT_NAME)
        else {
            return LintResult::error(
                "empty-subject certificate omits subjectAltName; \
                 RFC 5280 §4.2.1.6 requires it to be present and critical",
            );
        };

        if !san_ext.critical {
            return LintResult::error(
                "empty-subject certificate carries subjectAltName but it is not marked critical; \
                 RFC 5280 §4.2.1.6 requires the extension to be critical when the Subject is empty",
            );
        }

        LintResult::Pass
    }
}

// ---------------------------------------------------------------------------
// rfc5280.cert.signature_algorithm_match
// ---------------------------------------------------------------------------

/// RFC 5280 §4.1.1.2: outer `signatureAlgorithm` MUST equal inner
/// `tbsCertificate.signature`.
///
/// > This field MUST contain the same algorithm identifier as the
/// > signature field in the sequence tbsCertificate (Section 4.1.2.3).
/// > The contents of the optional parameters field will vary according to
/// > the algorithm identified.  This field is used as a redundant
/// > consistency check.
///
/// The two `AlgorithmIdentifier` values are compared structurally: the
/// algorithm OID and the optional parameters field must both match. The
/// comparison uses x509-cert's `AlgorithmIdentifier` `PartialEq` impl,
/// which compares both the OID and the encoded parameters value.
///
/// # Note on NULL vs absent parameters
///
/// RFC 4055 §2.1 mandates a NULL parameters value for RSA-PKCS1 signature
/// algorithms; some real-world certificates omit the parameters entirely.
/// This lint reports such intra-certificate inconsistency: if the outer
/// `signatureAlgorithm` has `parameters: NULL` and the inner
/// `tbsCertificate.signature` has `parameters: absent` (or vice versa),
/// the lint fires `Error`. That is the correct reading of §4.1.1.2's
/// "MUST contain the same algorithm identifier" wording — the redundancy
/// check exists precisely to catch encoder bugs that produce non-matching
/// outer/inner identifiers.
///
/// # Behavior
///
/// - Outer and inner `AlgorithmIdentifier` byte-equal → `Pass`.
/// - Otherwise → `Error` with both OIDs in the detail string.
///
/// # x509-cert representation
///
/// `x509_cert::AlgorithmIdentifier` is `AlgorithmIdentifier<Any>` where
/// `Any` owns a `(Tag, BytesOwned)` pair. The derived `PartialEq`
/// compares the OID and the `Option<Any>` field pairwise: `Some(Null)`
/// and `None` differ at the `Option` discriminant; `Some(Null)` and
/// `Some(other)` differ in the inner `Any`'s tag/value bytes. Decoding
/// preserves the `Option` distinction — the field's `DecodeValue` impl
/// reads the OPTIONAL directly without normalizing absent to `Some(Null)`.
/// The negative-path tests below lock this behavior in.
///
/// # Provenance
///
/// Filed under PKIX-9vnx.9.2.1.1 as one of three deferred lints from the
/// PKIX-9vnx.9.2.1 batch. Negative-path tests added under PKIX-7f92.3
/// construct mismatched-AlgorithmIdentifier certs in-memory and via DER
/// round-trip; both lock the lint's `Error` behavior on the three
/// distinguishable mismatch cases (OID mismatch, parameters absent vs
/// NULL, parameters NULL vs absent).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Rfc5280SignatureAlgorithmMatchLint;

impl Lint for Rfc5280SignatureAlgorithmMatchLint {
    fn id(&self) -> &'static str {
        "rfc5280.cert.signature_algorithm_match"
    }

    fn citation(&self) -> &'static str {
        "RFC 5280 §4.1.1.2"
    }

    fn severity(&self) -> Severity {
        Severity::Error
    }

    fn scope(&self) -> Scope {
        Scope::Certificate
    }

    fn applies_to(&self) -> SubjectKind {
        SubjectKind::Any
    }

    fn title(&self) -> &str {
        "Outer signatureAlgorithm must equal tbsCertificate.signature"
    }

    fn spec_section_id(&self) -> Option<&str> {
        Some("rfc5280-4.1.1.2")
    }

    fn spec_url(&self) -> Option<&str> {
        Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.1.1.2")
    }

    fn check_cert(&self, cert: &Certificate, _kind: SubjectKind, _now_unix: u64) -> LintResult {
        let outer = &cert.signature_algorithm;
        let inner = &cert.tbs_certificate.signature;
        if outer == inner {
            LintResult::Pass
        } else {
            LintResult::error(format!(
                "outer signatureAlgorithm ({}) does not match \
                 tbsCertificate.signature ({}); \
                 RFC 5280 §4.1.1.2 requires both to be identical",
                outer.oid, inner.oid
            ))
        }
    }
}

#[cfg(test)]
mod tests {
    //! Independent oracle for serial-length assertions:
    //!
    //! ```text
    //! openssl x509 -in <fixture> -inform DER -noout -serial \
    //!   | sed 's/serial=//' \
    //!   | awk '{ print length($0)/2 " octets" }'
    //! ```
    //!
    //! Fixture serial-length oracle values (verified 2026-05-11):
    //!
    //! | fixture                                 | serial octets |
    //! |-----------------------------------------|--------------:|
    //! | leaf-rsa2047-365d-san-eku.der           |             3 |
    //! | leaf-p256-50d-post-sc081-100d.der       |            20 |
    //! | leaf-rsa2048-sha1.der                   |            20 |
    //!
    //! These values are the OS `openssl` reading of the certificate's
    //! `tbsCertificate.serialNumber` INTEGER content, independent of the
    //! code under test (which uses the `x509-cert` Rust crate's parser).
    //!
    //! Note on serial-length encoding semantics: `x509_cert::SerialNumber`
    //! is backed by `der::asn1::Int`, whose `as_bytes()` returns the
    //! decoded INTEGER content bytes including any leading `0x00`
    //! sign-padding byte (positive values whose high bit is set). The
    //! `Rfc5280` profile permits decode of up to 21 bytes of INTEGER
    //! content (acknowledging the RFC ambiguity between unsigned-value
    //! and encoded-content readings of "20 octets"); the encoder-side
    //! `SerialNumber::new` constructor caps at 20 bytes unsigned (which
    //! refuses 20-byte high-bit-set inputs that would encode to 21
    //! bytes). See `serial_length_lint_rejects_21_octet_high_bit_set_serial`
    //! below for the negative-path oracle that locks the lint's chosen
    //! encoded-content interpretation.
    //!
    //! Most tests in this module exercise the `> max_octets` branch by
    //! tightening the parameter (cap < the fixture's serial length)
    //! rather than constructing oversize synthetic certs; the
    //! comparison is the same `len > self.max_octets` integer test
    //! either way. The 21-byte-content negative test uses a hand-crafted
    //! serial via DER decode to exercise the high-bit-set encoding case
    //! that no in-repo fixture provides.
    //!
    //! No test uses the code under test as its own oracle.

    use super::*;
    use x509_cert::Certificate;

    fn load_cert(name: &str) -> Certificate {
        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("../pkix-path/tests/fixtures/policy-checks/")
            .join(name);
        let der =
            std::fs::read(&path).unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()));
        <Certificate as der::Decode>::from_der(&der)
            .unwrap_or_else(|e| panic!("decode fixture {name}: {e}"))
    }

    #[test]
    fn default_lint_accepts_20_octet_serial() {
        // Oracle: `openssl x509 -serial` on leaf-p256-50d-post-sc081-100d.der
        // reports 20 octets.
        let lint = Rfc5280MaxSerialLengthLint::default();
        assert_eq!(lint.max_octets(), 20);
        let cert = load_cert("leaf-p256-50d-post-sc081-100d.der");
        assert_eq!(cert.tbs_certificate.serial_number.as_bytes().len(), 20);
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Leaf, 0),
            LintResult::Pass
        );
    }

    #[test]
    fn default_lint_accepts_short_serial() {
        // Oracle: leaf-rsa2047-365d-san-eku.der has a 3-octet serial.
        let lint = Rfc5280MaxSerialLengthLint::default();
        let cert = load_cert("leaf-rsa2047-365d-san-eku.der");
        assert_eq!(cert.tbs_certificate.serial_number.as_bytes().len(), 3);
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Leaf, 0),
            LintResult::Pass
        );
    }

    #[test]
    fn tightened_cap_rejects_20_octet_serial_with_attribution() {
        // Tighten the cap below the fixture's 20-octet serial — same code
        // path as the RFC-baseline rejection of a hypothetical > 20-octet
        // serial, but constructible from real fixtures (see module-level
        // oracle note).
        let mut lint = Rfc5280MaxSerialLengthLint::default();
        lint.set_parameter("max-octets", "10")
            .expect("set_parameter ok");
        let cert = load_cert("leaf-rsa2048-sha1.der");
        assert_eq!(cert.tbs_certificate.serial_number.as_bytes().len(), 20);
        match lint.check_cert(&cert, SubjectKind::Leaf, 0) {
            LintResult::Error(detail) => {
                assert!(
                    detail.contains("20 octets"),
                    "error detail must name the actual length 20; got: {detail}"
                );
                assert!(
                    detail.contains("10 octets"),
                    "error detail must name the cap 10; got: {detail}"
                );
            }
            other => panic!("expected Error, got: {other:?}"),
        }
    }

    #[test]
    fn set_parameter_to_3_keeps_3_octet_serial_passing() {
        // Boundary case: a 3-octet serial must pass when the cap is set
        // to exactly 3. Verifies the comparison is `>` not `>=`.
        let mut lint = Rfc5280MaxSerialLengthLint::default();
        lint.set_parameter("max-octets", "3").expect("set ok");
        let cert = load_cert("leaf-rsa2047-365d-san-eku.der");
        assert_eq!(cert.tbs_certificate.serial_number.as_bytes().len(), 3);
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Leaf, 0),
            LintResult::Pass
        );
    }

    /// Negative path: a 20-byte unsigned high-bit-set serial encodes to
    /// 21 octets of INTEGER content (sign-byte prepended). Under the
    /// encoded-content reading of RFC 5280 §4.1.2.2, this exceeds the
    /// 20-octet cap.
    ///
    /// Oracle: the test constructs the DER INTEGER content
    /// `0x00 0x80 0x00 0x00 ... 0x00` (21 bytes) externally — the test
    /// knows by construction that the encoded content is 21 octets and
    /// that the lint must report 21. This is independent of the code
    /// under test.
    ///
    /// Locks two behaviors simultaneously: (1) x509-cert's `Int::as_bytes`
    /// does NOT strip the leading `0x00` sign byte (the lint's premise),
    /// and (2) the lint reports the encoded-content length, not the
    /// stripped unsigned-value length.
    #[test]
    fn serial_length_lint_rejects_21_octet_high_bit_set_serial() {
        use der::{Decode, Encode};
        use x509_cert::serial_number::SerialNumber;

        // Hand-craft a DER INTEGER: tag=0x02, length=0x15 (21), content
        // = 0x00 (sign byte) || 0x80 0x00..0x00 (20 unsigned bytes).
        let mut serial_der: Vec<u8> = vec![0x02, 0x15, 0x00, 0x80];
        serial_der.extend(std::iter::repeat(0x00).take(19));
        assert_eq!(serial_der.len(), 2 + 21, "test oracle: 21 octets of INTEGER content");

        let synthetic_serial: SerialNumber = SerialNumber::from_der(&serial_der)
            .expect("Rfc5280 profile permits decode of up to 21 octets");

        // Independent length check on the constructed serial value:
        // this asserts x509-cert's documented behavior (Int::as_bytes
        // returns encoded content) before we exercise the lint.
        assert_eq!(
            synthetic_serial.as_bytes().len(),
            21,
            "x509-cert decoded the 21-byte INTEGER content without stripping the sign byte"
        );

        // Splice the synthetic serial into a real cert and re-decode
        // through the full Certificate path. The round-trip pins
        // end-to-end behavior, not just SerialNumber's surface.
        let mut cert = load_cert("leaf-rsa2048-365d-san-eku.der");
        cert.tbs_certificate.serial_number = synthetic_serial;
        let cert_der = cert.to_der().expect("re-encode mutated cert");
        let decoded = <Certificate as der::Decode>::from_der(&cert_der)
            .expect("decode mutated cert through Rfc5280 profile");
        assert_eq!(decoded.tbs_certificate.serial_number.as_bytes().len(), 21);

        let lint = Rfc5280MaxSerialLengthLint::default();
        assert_eq!(lint.max_octets(), 20);
        match lint.check_cert(&decoded, SubjectKind::Any, 0) {
            LintResult::Error(detail) => {
                assert!(
                    detail.contains("21 octets"),
                    "error detail must report the actual encoded length 21; got: {detail}"
                );
                assert!(
                    detail.contains("20 octets"),
                    "error detail must report the cap 20; got: {detail}"
                );
            }
            other => panic!(
                "encoded-content reading: 21-octet INTEGER content must trigger Error; got: {other:?}"
            ),
        }
    }

    #[test]
    fn set_parameter_to_2_rejects_3_octet_serial() {
        // Tighten one octet below the fixture; expect Error with both
        // actual (3) and cap (2) reported.
        let mut lint = Rfc5280MaxSerialLengthLint::default();
        lint.set_parameter("max-octets", "2").expect("set ok");
        let cert = load_cert("leaf-rsa2047-365d-san-eku.der");
        match lint.check_cert(&cert, SubjectKind::Leaf, 0) {
            LintResult::Error(detail) => {
                assert!(detail.contains("3 octets"));
                assert!(detail.contains("2 octets"));
            }
            other => panic!("expected Error, got: {other:?}"),
        }
    }

    #[test]
    fn set_parameter_unknown_id_errors() {
        let mut lint = Rfc5280MaxSerialLengthLint::default();
        let err = lint
            .set_parameter("not-a-real-parameter", "1")
            .expect_err("unknown id must error");
        match err {
            ParameterError::UnknownParameter(id) => {
                assert_eq!(id, "not-a-real-parameter");
            }
            other => panic!("expected UnknownParameter, got: {other:?}"),
        }
    }

    #[test]
    fn set_parameter_invalid_value_errors() {
        let mut lint = Rfc5280MaxSerialLengthLint::default();
        // Non-numeric input.
        let err = lint
            .set_parameter("max-octets", "not-a-number")
            .expect_err("non-numeric value must error");
        match err {
            ParameterError::InvalidValue { id, .. } => assert_eq!(id, "max-octets"),
            other => panic!("expected InvalidValue, got: {other:?}"),
        }
        // Zero is rejected (a zero cap would reject every serialNumber,
        // including valid ones — almost certainly an operator typo).
        // Validator emits a "1..=21" range message; assert the range
        // bounds appear, not a specific phrasing.
        let err_zero = lint
            .set_parameter("max-octets", "0")
            .expect_err("zero value must error");
        match err_zero {
            ParameterError::InvalidValue { id, reason } => {
                assert_eq!(id, "max-octets");
                assert!(
                    reason.contains("1..=21"),
                    "error reason must name the valid range; got: {reason}"
                );
                assert!(
                    reason.contains("got 0"),
                    "error reason must include the offending input; got: {reason}"
                );
            }
            other => panic!("expected InvalidValue, got: {other:?}"),
        }
    }

    /// PKIX-7f92.5 regression: max-octets > 21 must be rejected by
    /// set_parameter (closing the silent-weakening path).
    #[test]
    fn set_parameter_rejects_values_above_21() {
        let mut lint = Rfc5280MaxSerialLengthLint::default();
        for bogus in ["22", "64", "100", "18446744073709551615" /* usize::MAX */] {
            let err = lint
                .set_parameter("max-octets", bogus)
                .expect_err("out-of-range value must error");
            match err {
                ParameterError::InvalidValue { id, reason } => {
                    assert_eq!(id, "max-octets");
                    assert!(
                        reason.contains("1..=21"),
                        "error reason must name the valid range; got: {reason}"
                    );
                }
                other => panic!("expected InvalidValue, got: {other:?}"),
            }
        }
    }

    /// PKIX-7f92.5 regression: 21 (the unsigned-value reading) is
    /// the maximum still permitted; 20 (encoded-content reading) is
    /// the default.
    #[test]
    fn set_parameter_accepts_21_for_unsigned_value_reading() {
        let mut lint = Rfc5280MaxSerialLengthLint::default();
        lint.set_parameter("max-octets", "21")
            .expect("21 is permitted (unsigned-value reading)");
        assert_eq!(lint.max_octets(), 21);
    }

    /// PKIX-7f92.39 regression: with_max_octets(0) must panic, not
    /// silently construct a lint that rejects every cert.
    #[test]
    #[should_panic(expected = "max-octets must be in the range 1..=21")]
    fn with_max_octets_zero_panics() {
        let _ = Rfc5280MaxSerialLengthLint::with_max_octets(0);
    }

    /// PKIX-7f92.39 regression: with_max_octets(22) must panic, not
    /// silently weaken the RFC baseline. Matches set_parameter's
    /// rejection semantics.
    #[test]
    #[should_panic(expected = "max-octets must be in the range 1..=21")]
    fn with_max_octets_above_21_panics() {
        let _ = Rfc5280MaxSerialLengthLint::with_max_octets(22);
    }

    #[test]
    fn parameters_advertises_max_octets() {
        let lint = Rfc5280MaxSerialLengthLint::default();
        let params = lint.parameters();
        assert_eq!(params.len(), 1);
        assert_eq!(params[0].id, "max-octets");
        assert_eq!(params[0].default_value, "20");
        assert!(!params[0].label.is_empty());
    }

    #[test]
    fn metadata_matches_rfc_section() {
        let lint = Rfc5280MaxSerialLengthLint::default();
        assert_eq!(lint.id(), "rfc5280.cert.serial_number.max_octets");
        assert_eq!(lint.citation(), "RFC 5280 §4.1.2.2");
        assert_eq!(lint.spec_section_id(), Some("rfc5280-4.1.2.2"));
        assert_eq!(
            lint.spec_url(),
            Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.1.2.2")
        );
    }

    // -----------------------------------------------------------------------
    // Rfc5280BasicConstraintsCaLeafLint
    //
    // Oracle: `openssl x509 -text` reports cA flag for each fixture
    // (verified 2026-05-12):
    //
    // | fixture                              | BC.cA   | cert role |
    // |--------------------------------------|---------|-----------|
    // | leaf-p256-365d-san-eku.der           | FALSE   | leaf      |
    // | webpki-self-signed-365d.der          | TRUE    | self-CA   |
    // | smime-self-signed-365d.der           | TRUE    | self-CA   |
    // | codesign-self-signed-365d.der        | TRUE    | self-CA   |
    //
    // The CA-flagged fixtures are intentionally self-signed CA certs; we
    // exercise the lint by passing them with `SubjectKind::Leaf` to test
    // the lint's negative path, since no all-extensions-set leaf-with-CA
    // fixture exists. The lint's `check_cert` does not consult `kind`
    // beyond what the runner's `applies_to` filter does, so this is a
    // faithful test of the lint's logic.
    // -----------------------------------------------------------------------

    #[test]
    fn bc_ca_leaf_lint_accepts_normal_leaf() {
        let lint = Rfc5280BasicConstraintsCaLeafLint;
        let cert = load_cert("leaf-p256-365d-san-eku.der");
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Leaf, 0),
            LintResult::Pass
        );
    }

    #[test]
    fn bc_ca_leaf_lint_rejects_cert_with_ca_true() {
        let lint = Rfc5280BasicConstraintsCaLeafLint;
        // webpki-self-signed-365d.der has cA=TRUE; passed as Leaf, this
        // must trigger the lint's error path.
        let cert = load_cert("webpki-self-signed-365d.der");
        match lint.check_cert(&cert, SubjectKind::Leaf, 0) {
            LintResult::Error(detail) => {
                assert!(
                    detail.contains("cA=TRUE"),
                    "error detail must mention cA=TRUE; got: {detail}"
                );
            }
            other => panic!("expected Error, got: {other:?}"),
        }
    }

    #[test]
    fn bc_ca_leaf_lint_metadata_matches_rfc_section() {
        let lint = Rfc5280BasicConstraintsCaLeafLint;
        assert_eq!(lint.id(), "rfc5280.cert.bc.ca_false_for_leaf");
        assert_eq!(lint.citation(), "RFC 5280 §4.2.1.9");
        assert_eq!(lint.severity(), Severity::Error);
        assert_eq!(lint.scope(), Scope::Certificate);
        assert_eq!(lint.applies_to(), SubjectKind::Leaf);
        assert_eq!(lint.spec_section_id(), Some("rfc5280-4.2.1.9"));
        assert_eq!(
            lint.spec_url(),
            Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.9")
        );
    }

    // -----------------------------------------------------------------------
    // Rfc5280EkuServerAuthLint
    //
    // Oracle: `openssl x509 -text` reports EKU for each fixture
    // (verified 2026-05-12):
    //
    // | fixture                              | EKU                      |
    // |--------------------------------------|--------------------------|
    // | leaf-p256-365d-san-eku.der           | TLS Web Server Auth      |
    // | leaf-p256-365d-no-eku.der            | (absent)                 |
    // | leaf-p256-365d-wrong-eku.der         | E-mail Protection        |
    // -----------------------------------------------------------------------

    #[test]
    fn eku_server_auth_lint_accepts_server_auth_eku() {
        let lint = Rfc5280EkuServerAuthLint;
        let cert = load_cert("leaf-p256-365d-san-eku.der");
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Leaf, 0),
            LintResult::Pass
        );
    }

    #[test]
    fn eku_server_auth_lint_rejects_missing_eku() {
        let lint = Rfc5280EkuServerAuthLint;
        let cert = load_cert("leaf-p256-365d-no-eku.der");
        match lint.check_cert(&cert, SubjectKind::Leaf, 0) {
            LintResult::Error(detail) => {
                assert!(
                    detail.contains("ExtendedKeyUsage extension absent"),
                    "error detail must mention missing EKU; got: {detail}"
                );
            }
            other => panic!("expected Error, got: {other:?}"),
        }
    }

    #[test]
    fn eku_server_auth_lint_rejects_wrong_eku() {
        let lint = Rfc5280EkuServerAuthLint;
        // leaf-p256-365d-wrong-eku.der has E-mail Protection but not Server Auth.
        let cert = load_cert("leaf-p256-365d-wrong-eku.der");
        match lint.check_cert(&cert, SubjectKind::Leaf, 0) {
            LintResult::Error(detail) => {
                assert!(
                    detail.contains("id-kp-serverAuth"),
                    "error detail must mention id-kp-serverAuth; got: {detail}"
                );
            }
            other => panic!("expected Error, got: {other:?}"),
        }
    }

    #[test]
    fn eku_server_auth_lint_metadata_matches_rfc_section() {
        let lint = Rfc5280EkuServerAuthLint;
        assert_eq!(lint.id(), "rfc5280.cert.eku.server_auth_required");
        assert_eq!(lint.citation(), "RFC 5280 §4.2.1.12");
        assert_eq!(lint.severity(), Severity::Error);
        assert_eq!(lint.scope(), Scope::Certificate);
        assert_eq!(lint.applies_to(), SubjectKind::Leaf);
        assert_eq!(lint.spec_section_id(), Some("rfc5280-4.2.1.12"));
        assert_eq!(
            lint.spec_url(),
            Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12")
        );
    }

    // -----------------------------------------------------------------------
    // Rfc5280SanRequiredWhenSubjectEmptyLint
    //
    // Oracle: `openssl x509 -text` reads Subject and SAN criticality for
    // each fixture (verified 2026-05-12):
    //
    // | fixture                              | Subject empty? | SAN ext? | SAN critical? |
    // |--------------------------------------|----------------|----------|---------------|
    // | leaf-p256-365d-san-eku.der           | no             | yes      | no            |
    // | leaf-p256-365d-no-san.der            | no             | no       | n/a           |
    // | smime-self-signed-365d.der           | no             | yes      | no            |
    //
    // No empty-subject fixture currently exists in the workspace. The
    // positive paths verified here are: (a) non-empty Subject + SAN
    // present → Pass (lint does not apply), (b) non-empty Subject + SAN
    // absent → Pass (still does not apply). The negative path (empty
    // Subject + missing-or-non-critical SAN → Error) requires fixture
    // generation (pyca/cryptography `Name([])` + matching SAN
    // configuration) and is filed as out-of-scope per the bead.
    // -----------------------------------------------------------------------

    #[test]
    fn san_required_when_subject_empty_passes_when_subject_present() {
        let lint = Rfc5280SanRequiredWhenSubjectEmptyLint;
        let cert = load_cert("leaf-p256-365d-san-eku.der");
        assert!(
            !cert.tbs_certificate.subject.is_empty(),
            "fixture must have a non-empty Subject for this test"
        );
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Leaf, 0),
            LintResult::Pass
        );
    }

    #[test]
    fn san_required_when_subject_empty_passes_with_no_san_when_subject_present() {
        // Lint must not fire on certs that have a non-empty Subject and
        // no SAN — those are out of scope for this RFC 5280 §4.2.1.6
        // clause (which is conditional on an empty Subject).
        let lint = Rfc5280SanRequiredWhenSubjectEmptyLint;
        let cert = load_cert("leaf-p256-365d-no-san.der");
        assert!(!cert.tbs_certificate.subject.is_empty());
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Leaf, 0),
            LintResult::Pass
        );
    }

    #[test]
    fn san_required_when_subject_empty_metadata_matches_rfc_section() {
        let lint = Rfc5280SanRequiredWhenSubjectEmptyLint;
        assert_eq!(lint.id(), "rfc5280.cert.san.required_when_subject_empty");
        assert_eq!(lint.citation(), "RFC 5280 §4.2.1.6");
        assert_eq!(lint.severity(), Severity::Error);
        assert_eq!(lint.scope(), Scope::Certificate);
        assert_eq!(lint.applies_to(), SubjectKind::Any);
        assert_eq!(lint.spec_section_id(), Some("rfc5280-4.2.1.6"));
        assert_eq!(
            lint.spec_url(),
            Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.6")
        );
    }

    // -----------------------------------------------------------------------
    // Rfc5280SignatureAlgorithmMatchLint
    //
    // Positive-path oracle: `openssl asn1parse -i -in <fixture> -inform DER`
    // reads both the inner `tbsCertificate.signature` and outer
    // `signatureAlgorithm` SEQUENCEs. Well-formed certificates produced by
    // OpenSSL and pyca always have byte-identical outer/inner identifiers,
    // including the NULL parameters value for RSA-PKCS1 algorithms.
    // Verified 2026-05-12: all of the policy-checks/*.der fixtures pass the
    // lint.
    //
    // Negative-path oracle: x509-cert's `AlgorithmIdentifier` is
    // `AlgorithmIdentifier<Any>` with derived `PartialEq` (verified in
    // RustCrypto/formats spki/src/algorithm.rs at v0.7.x). The derived
    // impl compares the `oid` field and the `Option<Any>` parameters
    // field pairwise. `Option<T>: PartialEq where T: PartialEq` discriminates
    // `Some` from `None` at the discriminant level. `Any` itself derives
    // `PartialEq` over `(Tag, BytesOwned)`, so `Some(Any::null())` (NULL
    // tag, empty value) differs from `Some(Any { tag: OctetString, ... })`.
    // The negative tests below construct mismatched-AlgorithmIdentifier
    // certs in-memory and via DER round-trip, exercising the three
    // distinguishable mismatch cases — and lock both the lint's `Error`
    // behavior and x509-cert's decode preservation of the `Option`
    // distinction.
    // -----------------------------------------------------------------------

    #[test]
    fn signature_algorithm_match_passes_on_well_formed_rsa_fixture() {
        let lint = Rfc5280SignatureAlgorithmMatchLint;
        let cert = load_cert("leaf-rsa2048-365d-san-eku.der");
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Any, 0),
            LintResult::Pass
        );
    }

    #[test]
    fn signature_algorithm_match_passes_on_well_formed_ecdsa_fixture() {
        let lint = Rfc5280SignatureAlgorithmMatchLint;
        let cert = load_cert("leaf-p256-365d-san-eku.der");
        assert_eq!(
            lint.check_cert(&cert, SubjectKind::Any, 0),
            LintResult::Pass
        );
    }

    /// Negative path: outer `signatureAlgorithm` has `parameters: Some(NULL)`
    /// (the canonical RSA-PKCS1 shape), inner `tbsCertificate.signature` has
    /// `parameters: None`. The lint must report `Error`.
    ///
    /// Exercises the `Option` discriminant arm of the derived
    /// `AlgorithmIdentifier` `PartialEq`.
    #[test]
    fn signature_algorithm_match_rejects_outer_null_inner_absent() {
        use der::asn1::Any;

        let lint = Rfc5280SignatureAlgorithmMatchLint;
        let mut cert = load_cert("leaf-rsa2048-365d-san-eku.der");

        // Fixture invariant: well-formed RSA cert has matching Some(NULL)
        // on both sides; mutate only the inner to None.
        assert!(cert.signature_algorithm.parameters.is_some());
        assert_eq!(
            cert.signature_algorithm, cert.tbs_certificate.signature,
            "fixture must start with matching outer/inner identifiers"
        );

        cert.tbs_certificate.signature.parameters = None;
        // Outer still has Some(NULL); confirm the mutation produced the
        // intended mismatch shape before invoking the lint.
        assert_eq!(
            cert.signature_algorithm.parameters.as_ref().unwrap(),
            &Any::null()
        );
        assert!(cert.tbs_certificate.signature.parameters.is_none());

        match lint.check_cert(&cert, SubjectKind::Any, 0) {
            LintResult::Error(detail) => {
                assert!(
                    detail.contains("does not match"),
                    "error detail must report mismatch; got: {detail}"
                );
            }
            other => panic!("expected Error on outer-NULL/inner-absent mismatch, got: {other:?}"),
        }
    }

    /// Negative path: outer has `parameters: None`, inner has `Some(NULL)`.
    /// Symmetric to the preceding test.
    #[test]
    fn signature_algorithm_match_rejects_outer_absent_inner_null() {
        let lint = Rfc5280SignatureAlgorithmMatchLint;
        let mut cert = load_cert("leaf-rsa2048-365d-san-eku.der");

        cert.signature_algorithm.parameters = None;
        // Inner unchanged — still Some(NULL).
        assert!(cert.tbs_certificate.signature.parameters.is_some());

        match lint.check_cert(&cert, SubjectKind::Any, 0) {
            LintResult::Error(detail) => {
                assert!(
                    detail.contains("does not match"),
                    "error detail must report mismatch; got: {detail}"
                );
            }
            other => panic!("expected Error on outer-absent/inner-NULL mismatch, got: {other:?}"),
        }
    }

    /// Negative path: outer and inner carry different algorithm OIDs.
    /// Exercises the OID-mismatch arm of the comparison.
    #[test]
    fn signature_algorithm_match_rejects_oid_mismatch() {
        use der::oid::ObjectIdentifier;

        let lint = Rfc5280SignatureAlgorithmMatchLint;
        let mut cert = load_cert("leaf-rsa2048-365d-san-eku.der");

        // ecdsa-with-SHA256 OID (different from RSA fixture's sha256WithRSAEncryption).
        let ecdsa_with_sha256: ObjectIdentifier = "1.2.840.10045.4.3.2".parse().unwrap();
        cert.tbs_certificate.signature.oid = ecdsa_with_sha256;

        match lint.check_cert(&cert, SubjectKind::Any, 0) {
            LintResult::Error(detail) => {
                assert!(
                    detail.contains("1.2.840.10045.4.3.2"),
                    "error detail must name the mutated inner OID; got: {detail}"
                );
            }
            other => panic!("expected Error on OID mismatch, got: {other:?}"),
        }
    }

    /// DER round-trip oracle: encode a Certificate whose
    /// `signature_algorithm.parameters` is `None`, decode it back, and
    /// confirm the decoded value preserves `None` rather than normalizing
    /// to `Some(NULL)`. Locks x509-cert's decode behavior — the worry the
    /// review bead raised — directly.
    #[test]
    fn algorithm_identifier_decode_preserves_option_discriminant() {
        use der::{Decode, Encode};

        let mut cert = load_cert("leaf-rsa2048-365d-san-eku.der");
        cert.signature_algorithm.parameters = None;

        // Re-encode (signature bytes are arbitrary from the lint's POV;
        // the lint only inspects the algorithm-identifier SEQUENCEs).
        let der_bytes = cert.to_der().expect("encode mutated cert");
        let decoded = x509_cert::Certificate::from_der(&der_bytes).expect("decode mutated cert");

        assert!(
            decoded.signature_algorithm.parameters.is_none(),
            "x509-cert decode normalized None to Some(NULL); the lint's premise is invalidated"
        );

        // The lint must fire Error against the round-tripped cert — the
        // inner (unchanged, Some(NULL)) and outer (now None) differ.
        let lint = Rfc5280SignatureAlgorithmMatchLint;
        match lint.check_cert(&decoded, SubjectKind::Any, 0) {
            LintResult::Error(_) => {}
            other => panic!("expected Error after DER round-trip, got: {other:?}"),
        }
    }

    #[test]
    fn signature_algorithm_match_metadata_matches_rfc_section() {
        let lint = Rfc5280SignatureAlgorithmMatchLint;
        assert_eq!(lint.id(), "rfc5280.cert.signature_algorithm_match");
        assert_eq!(lint.citation(), "RFC 5280 §4.1.1.2");
        assert_eq!(lint.severity(), Severity::Error);
        assert_eq!(lint.scope(), Scope::Certificate);
        assert_eq!(lint.applies_to(), SubjectKind::Any);
        assert_eq!(lint.spec_section_id(), Some("rfc5280-4.1.1.2"));
        assert_eq!(
            lint.spec_url(),
            Some("https://www.rfc-editor.org/rfc/rfc5280#section-4.1.1.2")
        );
    }
}