#![forbid(unsafe_code)]
#![warn(missing_docs, rust_2018_idioms)]
#![cfg_attr(docsrs, feature(doc_cfg))]
use std::borrow::Cow;
use x509_cert::Certificate;
pub use pkix_path::{Profile, ValidatedPath, ValidationPolicy};
fn cert_sha256_of(cert: &Certificate) -> Option<[u8; 32]> {
use der::Encode as _;
use sha2::Digest as _;
let der = cert.to_der().ok()?;
let mut hasher = sha2::Sha256::new();
hasher.update(&der);
Some(hasher.finalize().into())
}
#[cfg(feature = "serde")]
mod serde_helpers {
pub(crate) mod cert_sha256_hex {
use serde::{Deserialize as _, Deserializer, Serializer};
pub(crate) fn serialize<S: Serializer>(
v: &Option<[u8; 32]>,
s: S,
) -> Result<S::Ok, S::Error> {
match v {
Some(bytes) => {
let mut hex = String::with_capacity(64);
for b in bytes {
hex.push(char::from_digit(u32::from(b >> 4), 16).expect("hex high nibble"));
hex.push(
char::from_digit(u32::from(b & 0x0f), 16).expect("hex low nibble"),
);
}
s.serialize_some(&hex)
}
None => s.serialize_none(),
}
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
d: D,
) -> Result<Option<[u8; 32]>, D::Error> {
let opt: Option<String> = Option::deserialize(d)?;
let Some(hex) = opt else {
return Ok(None);
};
if hex.len() != 64 {
return Err(serde::de::Error::invalid_length(
hex.len(),
&"64 lowercase hex chars (32-byte SHA-256)",
));
}
let bytes = hex.as_bytes();
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let hi = nibble(bytes[i * 2])
.ok_or_else(|| serde::de::Error::custom("non-hex char in cert_sha256"))?;
let lo = nibble(bytes[i * 2 + 1])
.ok_or_else(|| serde::de::Error::custom("non-hex char in cert_sha256"))?;
*byte = (hi << 4) | lo;
}
Ok(Some(out))
}
fn nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
}
}
#[cfg(feature = "serde")]
pub(crate) fn de_cow_static<'de, D>(deserializer: D) -> Result<Cow<'static, str>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize as _;
let s = String::deserialize(deserializer)?;
Ok(Cow::Owned(s))
}
pub(crate) const DETAIL_MAX_BYTES: usize = 256;
pub(crate) fn truncate_for_detail(s: &str) -> Cow<'_, str> {
if s.len() <= DETAIL_MAX_BYTES {
return Cow::Borrowed(s);
}
let mut cut = DETAIL_MAX_BYTES;
while !s.is_char_boundary(cut) {
cut -= 1;
}
Cow::Owned(format!(
"{}... (truncated, {} bytes total)",
&s[..cut],
s.len()
))
}
pub mod deviation;
#[cfg(feature = "oscal")]
#[cfg_attr(docsrs, doc(cfg(feature = "oscal")))]
pub mod oscal;
pub mod report;
pub mod rfc5280;
pub mod rfc6125;
pub mod rfc8398;
pub mod rfc8551;
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Severity {
Info,
Notice,
Warn,
Error,
Fatal,
}
impl Severity {
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Info => 10,
Self::Notice => 20,
Self::Warn => 30,
Self::Error => 40,
Self::Fatal => 50,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Scope {
Certificate,
Path,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SubjectKind {
Leaf,
IntermediateCa,
AnchorIssued,
Any,
}
impl SubjectKind {
#[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,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct LintParameter {
pub id: Cow<'static, str>,
pub label: Cow<'static, str>,
pub default_value: Cow<'static, str>,
}
impl LintParameter {
#[must_use]
pub fn new(
id: impl Into<Cow<'static, str>>,
label: impl Into<Cow<'static, str>>,
default_value: impl Into<Cow<'static, str>>,
) -> Self {
Self {
id: id.into(),
label: label.into(),
default_value: default_value.into(),
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ParameterError {
UnknownParameter(String),
InvalidValue {
id: String,
reason: String,
},
}
impl core::fmt::Display for ParameterError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::UnknownParameter(id) => write!(f, "unknown lint parameter '{id}'"),
Self::InvalidValue { id, reason } => {
write!(f, "invalid value for lint parameter '{id}': {reason}")
}
}
}
}
impl std::error::Error for ParameterError {}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum LintResult {
Pass,
NotApplicable,
Warn(Cow<'static, str>),
Error(Cow<'static, str>),
Fatal(Cow<'static, str>),
}
impl LintResult {
#[must_use]
pub const fn is_pass(&self) -> bool {
matches!(self, Self::Pass)
}
#[must_use]
pub const fn is_finding(&self) -> bool {
matches!(self, Self::Warn(_) | Self::Error(_) | Self::Fatal(_))
}
#[must_use]
pub const fn is_fatal(&self) -> bool {
matches!(self, Self::Fatal(_))
}
#[must_use]
pub fn detail(&self) -> Option<&str> {
match self {
Self::Warn(d) | Self::Error(d) | Self::Fatal(d) => Some(d.as_ref()),
_ => None,
}
}
#[must_use]
pub fn warn(detail: impl Into<Cow<'static, str>>) -> Self {
Self::Warn(detail.into())
}
#[must_use]
pub fn error(detail: impl Into<Cow<'static, str>>) -> Self {
Self::Error(detail.into())
}
#[must_use]
pub fn fatal(detail: impl Into<Cow<'static, str>>) -> Self {
Self::Fatal(detail.into())
}
}
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::Notice => f.write_str("notice"),
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 {
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),
}
}
}
pub trait LintClone {
fn clone_box(&self) -> Box<dyn Lint>;
}
impl<T> LintClone for T
where
T: Lint + Clone + 'static,
{
fn clone_box(&self) -> Box<dyn Lint> {
Box::new(self.clone())
}
}
impl Clone for Box<dyn Lint> {
fn clone(&self) -> Self {
self.clone_box()
}
}
pub trait Lint: LintClone + Send + Sync {
fn id(&self) -> &'static str;
fn citation(&self) -> &'static str;
fn severity(&self) -> Severity;
fn scope(&self) -> Scope;
fn applies_to(&self) -> SubjectKind;
fn title(&self) -> &str {
self.id()
}
fn description(&self) -> Option<&str> {
None
}
fn spec_section_id(&self) -> Option<&str> {
#[allow(deprecated)]
self.rfc_section_id()
}
#[deprecated(
since = "0.6.0",
note = "renamed to `spec_section_id` because the slot accepts non-RFC ids (CA/B Forum, ITU-T, NIST); override `spec_section_id` instead. Scheduled removal target: 0.10.0."
)]
fn rfc_section_id(&self) -> Option<&str> {
None
}
fn spec_url(&self) -> Option<&str> {
#[allow(deprecated)]
self.rfc_url()
}
#[deprecated(
since = "0.6.0",
note = "renamed to `spec_url`; override `spec_url` instead. Scheduled removal target: 0.10.0."
)]
fn rfc_url(&self) -> Option<&str> {
None
}
fn parameters(&self) -> &[LintParameter] {
&[]
}
#[allow(unused_variables)]
fn set_parameter(&mut self, id: &str, value: &str) -> Result<(), ParameterError> {
Err(ParameterError::UnknownParameter(id.to_owned()))
}
#[allow(unused_variables)]
fn check_cert(&self, cert: &Certificate, kind: SubjectKind, now_unix: u64) -> LintResult {
LintResult::NotApplicable
}
#[allow(unused_variables)]
fn check_path(&self, chain: &[Certificate], path: &ValidatedPath, now_unix: u64) -> LintResult {
LintResult::NotApplicable
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct Finding {
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub lint_id: Cow<'static, str>,
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub citation: Cow<'static, str>,
#[cfg_attr(feature = "serde", serde(deserialize_with = "de_cow_static"))]
pub rule_bundle_version: Cow<'static, str>,
pub result: LintResult,
pub cert_index: Option<usize>,
pub evaluated_at_unix: u64,
#[cfg_attr(
feature = "serde",
serde(default, with = "serde_helpers::cert_sha256_hex")
)]
pub cert_sha256: Option<[u8; 32]>,
}
impl Finding {
#[must_use]
pub const fn is_finding(&self) -> bool {
self.result.is_finding()
}
#[must_use]
pub fn new(
lint_id: impl Into<Cow<'static, str>>,
citation: impl Into<Cow<'static, str>>,
rule_bundle_version: impl Into<Cow<'static, str>>,
result: LintResult,
evaluated_at_unix: u64,
) -> Self {
Self {
lint_id: lint_id.into(),
citation: citation.into(),
rule_bundle_version: rule_bundle_version.into(),
result,
cert_index: None,
evaluated_at_unix,
cert_sha256: None,
}
}
#[must_use]
pub fn with_cert_index(mut self, cert_index: usize) -> Self {
self.cert_index = Some(cert_index);
self
}
#[must_use]
pub fn with_cert_sha256(mut self, cert_sha256: [u8; 32]) -> Self {
self.cert_sha256 = Some(cert_sha256);
self
}
}
pub struct LintRunner {
lints: Vec<Box<dyn Lint>>,
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 {
#[must_use]
pub fn new(lints: Vec<Box<dyn Lint>>) -> Self {
Self::check_unique_ids(&lints);
Self {
lints,
bundle_version: std::borrow::Cow::Borrowed(""),
}
}
fn check_unique_ids(lints: &[Box<dyn Lint>]) {
let mut ids: Vec<&str> = lints.iter().map(|l| l.id()).collect();
let original_len = ids.len();
ids.sort_unstable();
ids.dedup();
if ids.len() != original_len {
let mut seen: std::collections::HashSet<&str> =
std::collections::HashSet::with_capacity(lints.len());
for l in lints {
let id = l.id();
if !seen.insert(id) {
panic!(
"LintRunner constructed with duplicate lint id {id:?}; \
duplicate IDs interact incorrectly with deviation matching \
and silently produce ambiguous audit trails. Deduplicate \
the lint set before constructing the runner."
);
}
}
unreachable!("duplicate detected by dedup but not by HashSet walk");
}
}
#[must_use]
pub fn with_bundle_version(
lints: Vec<Box<dyn Lint>>,
version: impl Into<std::borrow::Cow<'static, str>>,
) -> Self {
Self::check_unique_ids(&lints);
Self {
lints,
bundle_version: version.into(),
}
}
#[must_use]
pub fn lints(&self) -> &[Box<dyn Lint>] {
&self.lints
}
#[must_use]
pub fn bundle_version(&self) -> &str {
&self.bundle_version
}
#[cfg(feature = "oscal")]
#[cfg_attr(docsrs, doc(cfg(feature = "oscal")))]
pub fn filter_to_ids(
self,
ids: &[String],
) -> Result<LintRunner, crate::oscal::parse::ParseError> {
let mut by_id: std::collections::HashMap<&'static str, Option<Box<dyn Lint>>> =
std::collections::HashMap::with_capacity(self.lints.len());
for lint in self.lints {
by_id.insert(lint.id(), Some(lint));
}
let mut filtered: Vec<Box<dyn Lint>> = Vec::with_capacity(ids.len());
for id in ids {
let key = by_id
.keys()
.copied()
.find(|k| *k == id.as_str())
.ok_or_else(|| crate::oscal::parse::ParseError::UnknownLintId { id: id.clone() })?;
if let Some(lint) = by_id.get_mut(key).and_then(|slot| slot.take()) {
filtered.push(lint);
}
}
Ok(LintRunner {
lints: filtered,
bundle_version: self.bundle_version,
})
}
#[cfg(feature = "oscal")]
#[cfg_attr(docsrs, doc(cfg(feature = "oscal")))]
pub fn apply_parameter_overrides(
&mut self,
overrides: &[crate::oscal::profile::ParameterOverride],
) -> Result<(), crate::oscal::parse::ParseError> {
let mut resolved: Vec<(usize, &str, &crate::oscal::profile::ParameterOverride)> =
Vec::with_capacity(overrides.len());
for over in overrides {
let (lint_index, param_id) = self
.lints
.iter()
.enumerate()
.filter_map(|(i, l)| {
let lint_id = l.id();
over.param_id
.strip_prefix(lint_id)
.and_then(|rest| rest.strip_prefix('.'))
.map(|param_id| (i, lint_id.len(), param_id))
})
.max_by_key(|(_, prefix_len, _)| *prefix_len)
.map(|(i, _, param_id)| (i, param_id))
.ok_or_else(|| crate::oscal::parse::ParseError::UnknownParameterOverride {
param_id: over.param_id.clone(),
})?;
resolved.push((lint_index, param_id, over));
}
use std::collections::BTreeMap;
let mut clones: BTreeMap<usize, Box<dyn Lint>> = BTreeMap::new();
for (lint_index, param_id, over) in resolved {
let clone = clones
.entry(lint_index)
.or_insert_with(|| self.lints[lint_index].clone_box());
clone.set_parameter(param_id, &over.value).map_err(
|source| crate::oscal::parse::ParseError::InvalidParameterOverride {
param_id: over.param_id.clone(),
source,
},
)?;
}
for (lint_index, clone) in clones {
self.lints[lint_index] = clone;
}
Ok(())
}
#[must_use]
pub fn run_cert(
&self,
cert: &Certificate,
kind: SubjectKind,
cert_index: usize,
now_unix: u64,
) -> Vec<Finding> {
let cert_sha256 = cert_sha256_of(cert);
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,
cert_sha256,
});
if is_fatal {
break;
}
}
findings
}
#[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)
}
#[must_use]
pub fn run_chain(
&self,
chain: &[Certificate],
kinds: &[SubjectKind],
now_unix: u64,
) -> Vec<Finding> {
assert_eq!(
kinds.len(),
chain.len(),
"LintRunner::run_chain requires kinds.len() == chain.len() \
(got kinds={}, chain={}); see PKIX-7f92.9. A shorter `kinds` \
slice is treated as a caller bug — provide an explicit \
SubjectKind for every certificate.",
kinds.len(),
chain.len(),
);
let mut all = Vec::new();
for (i, cert) in chain.iter().enumerate() {
let kind = kinds[i];
all.extend(self.run_cert(cert, kind, i, now_unix));
}
all
}
#[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,
cert_sha256: None,
});
if is_fatal {
break;
}
}
findings
}
}
pub trait LintProfile: Profile {
fn lints(&self) -> &[Box<dyn Lint>];
#[must_use]
fn lint_runner(&self) -> LintRunner;
}
#[must_use = "the returned Result reports whether the cert passed all Error/Fatal lints"]
pub fn check_shape(
cert: &Certificate,
kind: SubjectKind,
now_unix: u64,
profile: &dyn LintProfile,
) -> Result<(), Vec<Finding>> {
let runner = profile.lint_runner();
let findings = runner.run_cert(cert, kind, 0, now_unix);
let failed = findings
.iter()
.any(|f| matches!(f.result, LintResult::Error(_) | LintResult::Fatal(_)));
if failed {
Err(findings)
} else {
Ok(())
}
}
const _: fn() = || {
fn _assert_send_sync<T: Send + Sync>() {}
_assert_send_sync::<Finding>();
_assert_send_sync::<LintRunner>();
_assert_send_sync::<LintResult>();
_assert_send_sync::<LintParameter>();
_assert_send_sync::<ParameterError>();
_assert_send_sync::<crate::deviation::Deviation>();
_assert_send_sync::<crate::deviation::DeviationScope>();
_assert_send_sync::<crate::deviation::DeviationStore>();
_assert_send_sync::<crate::deviation::DeviationAddError>();
_assert_send_sync::<crate::deviation::DeviationRunResult>();
_assert_send_sync::<crate::deviation::DeviationRunner>();
_assert_send_sync::<crate::deviation::ScopePropValue>();
_assert_send_sync::<crate::deviation::DeviatedFinding>();
_assert_send_sync::<crate::report::EvaluationReport>();
#[cfg(feature = "oscal")]
_assert_send_sync::<crate::oscal::parse::ParseError>();
#[cfg(feature = "oscal")]
_assert_send_sync::<crate::oscal::profile::ResolvedProfile>();
#[cfg(feature = "oscal")]
_assert_send_sync::<crate::oscal::emit::AssessmentResultsOptions>();
};
#[cfg(test)]
mod tests {
use super::*;
#[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() {
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));
}
#[test]
fn truncate_for_detail_passes_short_strings_through() {
let s = "small string";
let out = super::truncate_for_detail(s);
assert_eq!(out, "small string");
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn truncate_for_detail_passes_exact_cap_unchanged() {
let s: String = "a".repeat(super::DETAIL_MAX_BYTES);
let out = super::truncate_for_detail(&s);
assert_eq!(out.len(), super::DETAIL_MAX_BYTES);
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn truncate_for_detail_truncates_over_cap() {
let s: String = "a".repeat(super::DETAIL_MAX_BYTES + 100);
let out = super::truncate_for_detail(&s);
assert!(out.starts_with(&"a".repeat(super::DETAIL_MAX_BYTES)));
assert!(out.contains("... (truncated, "));
assert!(out.contains(&format!("{} bytes total)", s.len())));
assert!(matches!(out, std::borrow::Cow::Owned(_)));
}
#[test]
fn truncate_for_detail_bounds_worst_case_attacker_input() {
let mb_100: String = "X".repeat(100 * 1024 * 1024);
let out = super::truncate_for_detail(&mb_100);
assert!(
out.len() < super::DETAIL_MAX_BYTES + 100,
"truncated output must be bounded near the cap; got len={}",
out.len()
);
assert!(out.contains("(truncated"));
}
#[test]
fn truncate_for_detail_respects_utf8_char_boundaries() {
let mut s = String::with_capacity(super::DETAIL_MAX_BYTES * 2);
s.push_str(&"a".repeat(super::DETAIL_MAX_BYTES - 1));
s.push('ü'); for _ in 0..100 {
s.push('ü');
}
let out = super::truncate_for_detail(&s);
assert!(out.len() < s.len(), "must have truncated");
assert!(out.starts_with(&"a".repeat(super::DETAIL_MAX_BYTES - 1)));
}
#[test]
fn severity_rank_values_are_pinned() {
assert_eq!(Severity::Info.rank(), 10);
assert_eq!(Severity::Notice.rank(), 20);
assert_eq!(Severity::Warn.rank(), 30);
assert_eq!(Severity::Error.rank(), 40);
assert_eq!(Severity::Fatal.rank(), 50);
}
#[test]
fn severity_rank_ordering_is_info_notice_warn_error_fatal() {
assert!(Severity::Info.rank() < Severity::Notice.rank());
assert!(Severity::Notice.rank() < Severity::Warn.rank());
assert!(Severity::Warn.rank() < Severity::Error.rank());
assert!(Severity::Error.rank() < Severity::Fatal.rank());
}
#[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"));
}
#[derive(Clone)]
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
}
}
#[derive(Clone)]
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")
}
}
#[derive(Clone)]
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")
}
}
#[derive(Clone)]
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")
}
}
#[derive(Clone)]
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
}
}
}
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() {
let cert = load_fixture_cert();
let runner = LintRunner::new(vec![
Box::new(AlwaysFatal),
Box::new(AlwaysWarn), ]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
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() {
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());
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() {
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");
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);
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 spec_section_id_default_delegates_to_deprecated_rfc_section_id_override() {
#[derive(Clone)]
struct PreV06Lint;
#[allow(deprecated)]
impl Lint for PreV06Lint {
fn id(&self) -> &'static str { "test.pre-v06" }
fn citation(&self) -> &'static str { "RFC 5280 §X.Y" }
fn severity(&self) -> Severity { Severity::Warn }
fn scope(&self) -> Scope { Scope::Certificate }
fn applies_to(&self) -> SubjectKind { SubjectKind::Any }
fn title(&self) -> &str { "Pre-v06 lint" }
fn rfc_section_id(&self) -> Option<&str> { Some("rfc5280-x.y") }
fn rfc_url(&self) -> Option<&str> { Some("https://example/x.y") }
fn check_cert(&self, _: &Certificate, _: SubjectKind, _: u64) -> LintResult {
LintResult::Pass
}
}
let l = PreV06Lint;
assert_eq!(l.spec_section_id(), Some("rfc5280-x.y"));
assert_eq!(l.spec_url(), Some("https://example/x.y"));
}
#[test]
fn deprecated_rfc_section_id_returns_none_for_v06_impl_overriding_canonical() {
#[derive(Clone)]
struct V06Lint;
impl Lint for V06Lint {
fn id(&self) -> &'static str { "test.v06" }
fn citation(&self) -> &'static str { "RFC 5280 §X.Y" }
fn severity(&self) -> Severity { Severity::Warn }
fn scope(&self) -> Scope { Scope::Certificate }
fn applies_to(&self) -> SubjectKind { SubjectKind::Any }
fn title(&self) -> &str { "v06 lint" }
fn spec_section_id(&self) -> Option<&str> { Some("rfc5280-x.y") }
fn spec_url(&self) -> Option<&str> { Some("https://example/x.y") }
fn check_cert(&self, _: &Certificate, _: SubjectKind, _: u64) -> LintResult {
LintResult::Pass
}
}
let l = V06Lint;
assert_eq!(l.spec_section_id(), Some("rfc5280-x.y"));
#[allow(deprecated)]
let deprecated_value = l.rfc_section_id();
assert_eq!(deprecated_value, None);
#[allow(deprecated)]
let deprecated_url = l.rfc_url();
assert_eq!(deprecated_url, None);
}
#[test]
#[should_panic(expected = "LintRunner::run_chain requires kinds.len() == chain.len()")]
fn runner_run_chain_panics_on_kinds_shorter_than_chain() {
let cert = load_fixture_cert();
let chain = vec![cert.clone(), cert.clone(), cert];
let kinds = vec![SubjectKind::Leaf]; let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
let _ = runner.run_chain(&chain, &kinds, 0);
}
#[test]
#[should_panic(expected = "LintRunner::run_chain requires kinds.len() == chain.len()")]
fn runner_run_chain_panics_on_kinds_longer_than_chain() {
let cert = load_fixture_cert();
let chain = vec![cert.clone()];
let kinds = vec![SubjectKind::Leaf, SubjectKind::IntermediateCa];
let runner = LintRunner::new(vec![Box::new(AlwaysPass)]);
let _ = runner.run_chain(&chain, &kinds, 0);
}
#[test]
fn run_cert_stamps_cert_sha256_on_findings() {
use der::Encode as _;
use sha2::Digest as _;
let cert = load_fixture_cert();
let der = cert.to_der().expect("encode fixture cert");
let expected: [u8; 32] = sha2::Sha256::digest(&der).into();
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].cert_sha256,
Some(expected),
"run_cert must stamp SHA-256(DER) on every finding"
);
}
#[test]
fn run_chain_stamps_per_cert_sha256_on_each_finding() {
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);
assert_eq!(findings.len(), 3);
assert!(
findings[0].cert_sha256.is_some(),
"leaf finding must carry cert_sha256"
);
assert_eq!(
findings[0].cert_sha256, findings[1].cert_sha256,
"same cert at index 0 and 1 must produce the same hash"
);
assert_eq!(
findings[1].cert_sha256, findings[2].cert_sha256,
"same cert at index 1 and 2 must produce the same hash"
);
}
#[test]
fn run_path_leaves_cert_sha256_none_on_path_findings() {
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].cert_sha256, None,
"path-scope findings must have cert_sha256 = None"
);
}
#[test]
#[cfg(feature = "serde")]
fn finding_cert_sha256_serde_round_trip_some() {
use sha2::Digest as _;
let bytes: [u8; 32] = sha2::Sha256::digest(b"fixture bytes for sha256").into();
let f = Finding {
lint_id: std::borrow::Cow::Borrowed("x"),
citation: std::borrow::Cow::Borrowed("c"),
rule_bundle_version: std::borrow::Cow::Borrowed(""),
result: LintResult::Pass,
cert_index: Some(0),
evaluated_at_unix: 0,
cert_sha256: Some(bytes),
};
let json = serde_json::to_string(&f).expect("serialize");
let mut expected_hex = String::with_capacity(64);
for b in &bytes {
expected_hex.push(char::from_digit(u32::from(b >> 4), 16).unwrap());
expected_hex.push(char::from_digit(u32::from(b & 0x0f), 16).unwrap());
}
assert!(
json.contains(&expected_hex),
"JSON must contain the lowercase hex hash; got: {json}"
);
let f2: Finding = serde_json::from_str(&json).expect("deserialize");
assert_eq!(f2.cert_sha256, Some(bytes));
}
#[test]
#[cfg(feature = "serde")]
fn finding_cert_sha256_serde_round_trip_none() {
let f = Finding {
lint_id: std::borrow::Cow::Borrowed("x"),
citation: std::borrow::Cow::Borrowed("c"),
rule_bundle_version: std::borrow::Cow::Borrowed(""),
result: LintResult::Pass,
cert_index: None,
evaluated_at_unix: 0,
cert_sha256: None,
};
let json = serde_json::to_string(&f).expect("serialize");
assert!(
json.contains("\"cert_sha256\":null"),
"None must serialize as JSON null; got: {json}"
);
let f2: Finding = serde_json::from_str(&json).expect("deserialize");
assert_eq!(f2.cert_sha256, None);
}
#[test]
#[cfg(feature = "serde")]
fn finding_cert_sha256_rejects_non_hex_string() {
let bad = r#"{"lint_id":"x","citation":"c","rule_bundle_version":"","result":"Pass","cert_index":null,"evaluated_at_unix":0,"cert_sha256":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"}"#;
let err = serde_json::from_str::<Finding>(bad).expect_err("must reject non-hex chars");
assert!(
err.to_string().to_lowercase().contains("hex")
|| err.to_string().to_lowercase().contains("cert_sha256"),
"error must mention hex / cert_sha256; got: {err}"
);
}
#[test]
#[cfg(feature = "serde")]
fn finding_cert_sha256_rejects_non_ascii_multibyte_utf8() {
let payload: String = "ü".repeat(32);
assert_eq!(payload.len(), 64, "test oracle: payload is 64 bytes");
assert_eq!(payload.chars().count(), 32, "test oracle: payload has 32 chars");
let json = format!(
r#"{{"lint_id":"x","citation":"c","rule_bundle_version":"","result":"Pass","cert_index":null,"evaluated_at_unix":0,"cert_sha256":"{payload}"}}"#
);
let err = serde_json::from_str::<Finding>(&json)
.expect_err("must reject multi-byte UTF-8 hex string");
assert!(
err.to_string().to_lowercase().contains("hex")
|| err.to_string().to_lowercase().contains("cert_sha256"),
"error must mention hex / cert_sha256; got: {err}"
);
}
#[test]
#[cfg(feature = "serde")]
fn finding_cert_sha256_rejects_wrong_length() {
let bad = r#"{"lint_id":"x","citation":"c","rule_bundle_version":"","result":"Pass","cert_index":null,"evaluated_at_unix":0,"cert_sha256":"abc"}"#;
let err = serde_json::from_str::<Finding>(bad).expect_err("must reject short hex string");
assert!(
err.to_string().to_lowercase().contains("length")
|| err.to_string().to_lowercase().contains("64"),
"error must mention length; got: {err}"
);
}
#[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,
cert_sha256: None,
};
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,
cert_sha256: None,
};
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);
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();
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/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/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(), "");
}
#[derive(Clone)]
struct AlwaysError;
impl Lint for AlwaysError {
fn id(&self) -> &'static str {
"test.always_error"
}
fn citation(&self) -> &'static str {
"test"
}
fn severity(&self) -> Severity {
Severity::Error
}
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::error("always errors")
}
}
struct TestLintProfile {
lints: Vec<Box<dyn Lint>>,
build_runner: fn() -> LintRunner,
}
impl pkix_path::Profile for TestLintProfile {
fn id(&self) -> &'static str {
"test.profile"
}
fn version(&self) -> &'static str {
"0.0.0"
}
fn policy(&self, now_unix: u64) -> ValidationPolicy {
ValidationPolicy::new(now_unix)
}
fn policy_oids(&self) -> &[der::asn1::ObjectIdentifier] {
&[]
}
}
impl LintProfile for TestLintProfile {
fn lints(&self) -> &[Box<dyn Lint>] {
&self.lints
}
fn lint_runner(&self) -> LintRunner {
(self.build_runner)()
}
}
impl TestLintProfile {
fn new(lints: Vec<Box<dyn Lint>>, build_runner: fn() -> LintRunner) -> Self {
Self {
lints,
build_runner,
}
}
}
fn build_always_pass_runner() -> LintRunner {
LintRunner::new(vec![Box::new(AlwaysPass)])
}
fn build_always_warn_runner() -> LintRunner {
LintRunner::new(vec![Box::new(AlwaysWarn)])
}
fn build_always_error_runner() -> LintRunner {
LintRunner::new(vec![Box::new(AlwaysError)])
}
fn build_always_fatal_runner() -> LintRunner {
LintRunner::new(vec![Box::new(AlwaysFatal)])
}
fn build_pass_plus_warn_runner() -> LintRunner {
LintRunner::new(vec![Box::new(AlwaysPass), Box::new(AlwaysWarn)])
}
fn build_pass_plus_error_runner() -> LintRunner {
LintRunner::new(vec![Box::new(AlwaysPass), Box::new(AlwaysError)])
}
#[test]
fn check_shape_ok_when_all_lints_pass() {
let cert = load_fixture_cert();
let profile = TestLintProfile::new(vec![Box::new(AlwaysPass)], build_always_pass_runner);
assert!(check_shape(&cert, SubjectKind::Leaf, 0, &profile).is_ok());
}
#[test]
fn check_shape_ok_when_only_warn_findings() {
let cert = load_fixture_cert();
let profile = TestLintProfile::new(vec![Box::new(AlwaysWarn)], build_always_warn_runner);
assert!(check_shape(&cert, SubjectKind::Leaf, 0, &profile).is_ok());
}
#[test]
fn check_shape_err_on_error_finding() {
let cert = load_fixture_cert();
let profile = TestLintProfile::new(vec![Box::new(AlwaysError)], build_always_error_runner);
let result = check_shape(&cert, SubjectKind::Leaf, 0, &profile);
let findings = result.expect_err("AlwaysError must produce Err");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].lint_id, "test.always_error");
assert!(matches!(findings[0].result, LintResult::Error(_)));
}
#[test]
fn check_shape_err_on_fatal_finding() {
let cert = load_fixture_cert();
let profile = TestLintProfile::new(vec![Box::new(AlwaysFatal)], build_always_fatal_runner);
let result = check_shape(&cert, SubjectKind::Leaf, 0, &profile);
let findings = result.expect_err("AlwaysFatal must produce Err");
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].lint_id, "test.always_fatal");
assert!(findings[0].result.is_fatal());
}
#[test]
fn check_shape_err_carries_all_findings_including_pass() {
let cert = load_fixture_cert();
let profile = TestLintProfile::new(
vec![Box::new(AlwaysPass), Box::new(AlwaysError)],
build_pass_plus_error_runner,
);
let result = check_shape(&cert, SubjectKind::Leaf, 0, &profile);
let findings = result.expect_err("any Error must produce Err");
assert_eq!(findings.len(), 2, "Err carries the full Vec<Finding>");
assert!(findings.iter().any(|f| f.lint_id == "test.always_pass"));
assert!(findings.iter().any(|f| f.lint_id == "test.always_error"));
}
#[test]
fn check_shape_ok_when_pass_plus_warn_no_error() {
let cert = load_fixture_cert();
let profile = TestLintProfile::new(
vec![Box::new(AlwaysPass), Box::new(AlwaysWarn)],
build_pass_plus_warn_runner,
);
assert!(check_shape(&cert, SubjectKind::Leaf, 0, &profile).is_ok());
}
#[test]
fn use_case_mutual_exclusion_tls_cert_fails_smime_lints() {
use der::Decode as _;
let cert = Certificate::from_der(include_bytes!(
"../../pkix-path/tests/fixtures/policy-checks/webpki-self-signed-365d.der"
))
.expect("fixture is valid DER");
let runner = LintRunner::new(vec![
Box::new(crate::rfc5280::Rfc5280EkuServerAuthLint),
Box::new(crate::rfc6125::Rfc6125TlsServerSanLint),
Box::new(crate::rfc8398::Rfc8398SmimeSanLint),
Box::new(crate::rfc8551::Rfc8551EkuEmailProtectionLint),
]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
let errors_by_id: Vec<&str> = findings
.iter()
.filter(|f| matches!(f.result, LintResult::Error(_)))
.map(|f| f.lint_id.as_ref())
.collect();
assert!(
errors_by_id
.iter()
.any(|id| id.starts_with("rfc8398.") || id.starts_with("rfc8551.")),
"TLS cert must produce Error findings from the S/MIME lints: \
found error ids {errors_by_id:?}"
);
assert!(
!errors_by_id
.iter()
.any(|id| id.starts_with("rfc5280.cert.eku.") || id.starts_with("rfc6125.")),
"TLS cert must NOT produce Error findings from the TLS lints: \
found error ids {errors_by_id:?}"
);
}
#[test]
fn use_case_mutual_exclusion_smime_cert_fails_tls_lints() {
use der::Decode as _;
let cert = Certificate::from_der(include_bytes!(
"../../pkix-path/tests/fixtures/policy-checks/smime-self-signed-365d.der"
))
.expect("fixture is valid DER");
let runner = LintRunner::new(vec![
Box::new(crate::rfc5280::Rfc5280EkuServerAuthLint),
Box::new(crate::rfc6125::Rfc6125TlsServerSanLint),
Box::new(crate::rfc8398::Rfc8398SmimeSanLint),
Box::new(crate::rfc8551::Rfc8551EkuEmailProtectionLint),
]);
let findings = runner.run_cert(&cert, SubjectKind::Leaf, 0, 0);
let errors_by_id: Vec<&str> = findings
.iter()
.filter(|f| matches!(f.result, LintResult::Error(_)))
.map(|f| f.lint_id.as_ref())
.collect();
assert!(
errors_by_id
.iter()
.any(|id| id.starts_with("rfc5280.cert.eku.") || id.starts_with("rfc6125.")),
"S/MIME cert must produce Error findings from the TLS lints: \
found error ids {errors_by_id:?}"
);
assert!(
!errors_by_id
.iter()
.any(|id| id.starts_with("rfc8398.") || id.starts_with("rfc8551.")),
"S/MIME cert must NOT produce Error findings from the S/MIME lints: \
found error ids {errors_by_id:?}"
);
}
#[test]
#[should_panic(expected = "duplicate lint id")]
fn lint_runner_new_panics_on_duplicate_lint_ids() {
let lints: Vec<Box<dyn Lint>> = vec![Box::new(AlwaysPass), Box::new(AlwaysPass)];
let _ = LintRunner::new(lints);
}
#[test]
#[should_panic(expected = "duplicate lint id")]
fn lint_runner_with_bundle_version_panics_on_duplicate_lint_ids() {
let lints: Vec<Box<dyn Lint>> = vec![Box::new(AlwaysPass), Box::new(AlwaysPass)];
let _ = LintRunner::with_bundle_version(lints, "x");
}
#[test]
fn lint_runner_new_accepts_distinct_ids() {
let lints: Vec<Box<dyn Lint>> = vec![Box::new(AlwaysPass), Box::new(AlwaysWarn)];
let _runner = LintRunner::new(lints);
}
}