use std::collections::HashSet;
use std::sync::Arc;
use std::time::SystemTime;
use crate::{
authentication::credentials::Credential,
errors::{AuthError, Result},
};
#[derive(Debug, Clone)]
pub struct ClientCertConfig {
pub trusted_ca_ders: Vec<Vec<u8>>,
pub subject_allowlist: Vec<String>,
pub issuer_allowlist: Vec<String>,
pub require_san: bool,
pub token_lifetime_secs: u64,
}
impl Default for ClientCertConfig {
fn default() -> Self {
Self {
trusted_ca_ders: Vec::new(),
subject_allowlist: Vec::new(),
issuer_allowlist: Vec::new(),
require_san: false,
token_lifetime_secs: 3600,
}
}
}
impl ClientCertConfig {
pub fn new() -> Self {
Self::default()
}
pub fn trust_ca(mut self, ca_der: Vec<u8>) -> Self {
self.trusted_ca_ders.push(ca_der);
self
}
pub fn allow_subject(mut self, pattern: impl Into<String>) -> Self {
self.subject_allowlist.push(pattern.into());
self
}
pub fn allow_issuer(mut self, pattern: impl Into<String>) -> Self {
self.issuer_allowlist.push(pattern.into());
self
}
pub fn with_require_san(mut self) -> Self {
self.require_san = true;
self
}
}
#[derive(Debug, Clone)]
pub struct CertIdentity {
pub subject_dn: String,
pub common_name: Option<String>,
pub sans: Vec<String>,
pub issuer_dn: String,
}
pub struct ClientCertAuthMethod {
config: ClientCertConfig,
}
impl ClientCertAuthMethod {
pub fn new(config: ClientCertConfig) -> Self {
Self { config }
}
pub fn authenticate(&self, credential: &Credential) -> Result<CertIdentity> {
let cert_der = match credential {
Credential::Certificate {
certificate,
private_key,
..
} => {
if !private_key.is_empty() {
tracing::warn!(
"ClientCertAuthMethod received a non-empty private_key — \
it will be ignored. For mTLS flows use \
`Credential::client_cert_from_tls(der_bytes)`."
);
}
certificate.as_slice()
}
other => {
return Err(AuthError::InvalidCredential {
credential_type: other.credential_type().to_string(),
message: "ClientCertAuthMethod requires a Credential::Certificate. \
Use Credential::client_cert_from_tls(der_bytes) for mTLS flows."
.to_string(),
});
}
};
self.validate_der(cert_der)
}
fn validate_der(&self, cert_der: &[u8]) -> Result<CertIdentity> {
use x509_parser::prelude::*;
if cert_der.is_empty() {
return Err(AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: "Certificate DER bytes are empty".to_string(),
});
}
let (_, cert) =
X509Certificate::from_der(cert_der).map_err(|_| AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: "Failed to parse X.509 DER certificate — verify that the bytes \
are DER-encoded (not PEM) and are not truncated."
.to_string(),
})?;
self.check_validity(&cert)?;
self.check_subject_allowlist(&cert)?;
self.check_issuer_allowlist(&cert)?;
self.check_san_required(&cert)?;
self.check_trust_chain(cert_der)?;
self.extract_identity(&cert)
}
fn check_validity(&self, cert: &x509_parser::certificate::X509Certificate<'_>) -> Result<()> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let not_before = cert.validity().not_before.timestamp();
let not_after = cert.validity().not_after.timestamp();
if now < not_before {
return Err(AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: format!(
"Certificate is not yet valid (valid from Unix timestamp {})",
not_before
),
});
}
if now > not_after {
return Err(AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: "Certificate has expired".to_string(),
});
}
Ok(())
}
fn check_subject_allowlist(
&self,
cert: &x509_parser::certificate::X509Certificate<'_>,
) -> Result<()> {
if self.config.subject_allowlist.is_empty() {
return Ok(());
}
let subject = cert.subject().to_string();
if !self
.config
.subject_allowlist
.iter()
.any(|p| subject.contains(p.as_str()))
{
return Err(AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: format!("Subject DN '{}' is not in the subject allowlist", subject),
});
}
Ok(())
}
fn check_issuer_allowlist(
&self,
cert: &x509_parser::certificate::X509Certificate<'_>,
) -> Result<()> {
if self.config.issuer_allowlist.is_empty() {
return Ok(());
}
let issuer = cert.issuer().to_string();
if !self
.config
.issuer_allowlist
.iter()
.any(|p| issuer.contains(p.as_str()))
{
return Err(AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: format!("Issuer DN '{}' is not in the issuer allowlist", issuer),
});
}
Ok(())
}
fn check_san_required(
&self,
cert: &x509_parser::certificate::X509Certificate<'_>,
) -> Result<()> {
if !self.config.require_san {
return Ok(());
}
let has_san = cert
.extensions()
.iter()
.any(|ext| ext.oid.to_id_string() == "2.5.29.17");
if !has_san {
return Err(AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: "Certificate does not contain a Subject Alternative Name (SAN) \
extension, but require_san is enabled in the configuration."
.to_string(),
});
}
Ok(())
}
fn check_trust_chain(&self, cert_der: &[u8]) -> Result<()> {
if self.config.trusted_ca_ders.is_empty() {
return Ok(());
}
use x509_parser::prelude::*;
let (_, cert) =
X509Certificate::from_der(cert_der).map_err(|_| AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: "Failed to re-parse certificate for chain check".to_string(),
})?;
let issuer_dn = cert.issuer().to_string();
let found = self.config.trusted_ca_ders.iter().any(|ca_der| {
if let Ok((_, ca_cert)) = X509Certificate::from_der(ca_der) {
ca_cert.subject().to_string() == issuer_dn
} else {
false
}
});
if !found {
return Err(AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: format!(
"No trusted CA found for issuer '{}'. \
Add the issuing CA's DER bytes to ClientCertConfig::trusted_ca_ders.",
issuer_dn
),
});
}
Ok(())
}
fn extract_identity(
&self,
cert: &x509_parser::certificate::X509Certificate<'_>,
) -> Result<CertIdentity> {
let subject_dn = cert.subject().to_string();
let issuer_dn = cert.issuer().to_string();
let common_name = cert
.subject()
.iter_common_name()
.next()
.and_then(|attr| attr.as_str().ok())
.map(str::to_string);
let mut sans: Vec<String> = Vec::new();
for ext in cert.extensions() {
if ext.oid.to_id_string() == "2.5.29.17"
&& let x509_parser::extensions::ParsedExtension::SubjectAlternativeName(san) =
ext.parsed_extension()
{
for gn in &san.general_names {
let entry = match gn {
x509_parser::extensions::GeneralName::DNSName(s) => {
format!("dns:{s}")
}
x509_parser::extensions::GeneralName::RFC822Name(s) => {
format!("email:{s}")
}
x509_parser::extensions::GeneralName::IPAddress(ip) => {
format!("ip:{}", fmt_ip(ip))
}
_ => continue,
};
sans.push(entry);
}
}
}
Ok(CertIdentity {
subject_dn,
common_name,
sans,
issuer_dn,
})
}
}
fn fmt_ip(bytes: &[u8]) -> String {
match bytes.len() {
4 => format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]),
16 => {
let parts: Vec<String> = bytes
.chunks(2)
.map(|c| format!("{:02x}{:02x}", c[0], c[1]))
.collect();
parts.join(":")
}
_ => format!("{:?}", bytes),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CertPin {
pub sha256_hex: String,
}
impl CertPin {
pub fn from_der(cert_der: &[u8]) -> Self {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(cert_der);
Self {
sha256_hex: hex::encode(digest),
}
}
pub fn from_hex(hex_fingerprint: impl Into<String>) -> Self {
Self {
sha256_hex: hex_fingerprint.into().to_lowercase(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CertPinStore {
pins: Arc<std::sync::RwLock<HashSet<String>>>,
}
impl CertPinStore {
pub fn new() -> Self {
Self::default()
}
pub fn add(&self, pin: &CertPin) {
self.pins.write().unwrap().insert(pin.sha256_hex.clone());
}
pub fn remove(&self, pin: &CertPin) -> bool {
self.pins.write().unwrap().remove(&pin.sha256_hex)
}
pub fn is_pinned(&self, cert_der: &[u8]) -> bool {
let pin = CertPin::from_der(cert_der);
self.pins.read().unwrap().contains(&pin.sha256_hex)
}
pub fn count(&self) -> usize {
self.pins.read().unwrap().len()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RevocationStatus {
Good,
Revoked {
reason: Option<String>,
},
Unknown,
}
#[derive(Debug, Clone, Default)]
pub struct CrlStore {
revoked: Arc<std::sync::RwLock<std::collections::HashMap<String, HashSet<String>>>>,
}
impl CrlStore {
pub fn new() -> Self {
Self::default()
}
pub fn add_revoked(&self, issuer_dn: &str, serial_hex: &str) {
self.revoked
.write()
.unwrap()
.entry(issuer_dn.to_string())
.or_default()
.insert(serial_hex.to_lowercase());
}
pub fn check(&self, issuer_dn: &str, serial_hex: &str) -> RevocationStatus {
let store = self.revoked.read().unwrap();
if let Some(serials) = store.get(issuer_dn) {
if serials.contains(&serial_hex.to_lowercase()) {
return RevocationStatus::Revoked { reason: None };
}
}
RevocationStatus::Good
}
pub fn check_der(&self, cert_der: &[u8]) -> RevocationStatus {
use x509_parser::prelude::*;
let Ok((_, cert)) = X509Certificate::from_der(cert_der) else {
return RevocationStatus::Unknown;
};
let issuer = cert.issuer().to_string();
let serial = cert.raw_serial_as_string().to_lowercase();
self.check(&issuer, &serial)
}
pub fn revoked_count(&self) -> usize {
self.revoked
.read()
.unwrap()
.values()
.map(|s| s.len())
.sum()
}
pub fn clear_issuer(&self, issuer_dn: &str) {
self.revoked.write().unwrap().remove(issuer_dn);
}
}
pub fn cert_thumbprint_s256(cert_der: &[u8]) -> String {
use base64::Engine;
use sha2::{Digest, Sha256};
let digest = Sha256::digest(cert_der);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}
pub fn verify_cert_binding(cert_der: &[u8], expected_thumbprint: &str) -> Result<()> {
let actual = cert_thumbprint_s256(cert_der);
if actual == expected_thumbprint {
Ok(())
} else {
Err(AuthError::InvalidCredential {
credential_type: "certificate".to_string(),
message: format!(
"Certificate thumbprint mismatch: token bound to '{}', presented cert has '{}'",
expected_thumbprint, actual
),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let cfg = ClientCertConfig::default();
assert!(cfg.trusted_ca_ders.is_empty());
assert!(cfg.subject_allowlist.is_empty());
assert!(cfg.issuer_allowlist.is_empty());
assert!(!cfg.require_san);
assert_eq!(cfg.token_lifetime_secs, 3600);
}
#[test]
fn test_config_builder_chain() {
let cfg = ClientCertConfig::new()
.allow_subject("alice")
.allow_issuer("MyCA")
.with_require_san();
assert_eq!(cfg.subject_allowlist, ["alice"]);
assert_eq!(cfg.issuer_allowlist, ["MyCA"]);
assert!(cfg.require_san);
}
#[test]
fn test_wrong_credential_type_rejected() {
let method = ClientCertAuthMethod::new(ClientCertConfig::new());
let cred = Credential::Password {
username: "u".into(),
password: "p".into(),
};
let err = method.authenticate(&cred).unwrap_err();
assert!(
format!("{err}").contains("Certificate"),
"unexpected: {err}"
);
}
#[test]
fn test_empty_der_rejected() {
let method = ClientCertAuthMethod::new(ClientCertConfig::new());
let cred = Credential::Certificate {
certificate: vec![],
private_key: vec![],
passphrase: None,
};
let err = method.authenticate(&cred).unwrap_err();
assert!(format!("{err}").contains("empty"), "unexpected: {err}");
}
#[test]
fn test_garbage_der_rejected() {
let method = ClientCertAuthMethod::new(ClientCertConfig::new());
let cred = Credential::Certificate {
certificate: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04],
private_key: vec![],
passphrase: None,
};
assert!(method.authenticate(&cred).is_err());
}
fn build_cert_der(
cn: &str,
not_before_utc: &[u8; 13], not_after_utc: &[u8; 13],
) -> Vec<u8> {
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
let rng = SystemRandom::new();
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
let kp = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let pub_key = kp.public_key().as_ref();
let tlv = |tag: u8, content: &[u8]| -> Vec<u8> {
assert!(
content.len() < 128,
"content too large for short-form TLV: {} bytes",
content.len()
);
let mut v = vec![tag, content.len() as u8];
v.extend_from_slice(content);
v
};
let long_tlv = |tag: u8, content: &[u8]| -> Vec<u8> {
let len = content.len();
let mut v = vec![tag];
if len < 128 {
v.push(len as u8);
} else {
v.push(0x81);
v.push(len as u8);
}
v.extend_from_slice(content);
v
};
let alg_id = tlv(0x30, &[0x06, 0x03, 0x2B, 0x65, 0x70]);
let cn_bytes = cn.as_bytes();
let utf8_cn = tlv(0x0C, cn_bytes);
let oid_cn = [0x06u8, 0x03, 0x55, 0x04, 0x03];
let seq_atv = [oid_cn.as_slice(), utf8_cn.as_slice()].concat();
let name = tlv(0x30, &tlv(0x31, &tlv(0x30, &seq_atv)));
let nb_der = tlv(0x17, not_before_utc);
let na_der = tlv(0x17, not_after_utc);
let validity = tlv(0x30, &[nb_der.as_slice(), na_der.as_slice()].concat());
let mut bit_content = vec![0x00u8];
bit_content.extend_from_slice(pub_key);
let bit_str = tlv(0x03, &bit_content);
let spki = tlv(0x30, &[alg_id.as_slice(), bit_str.as_slice()].concat());
let version = [0xA0u8, 0x03, 0x02, 0x01, 0x02]; let serial = tlv(0x02, &[0x01]);
let tbs_body: Vec<u8> = [
version.as_slice(),
serial.as_slice(),
alg_id.as_slice(), name.as_slice(), validity.as_slice(),
name.as_slice(), spki.as_slice(),
]
.concat();
let tbs = long_tlv(0x30, &tbs_body);
let sig = kp.sign(&tbs);
let mut sig_content = vec![0x00u8]; sig_content.extend_from_slice(sig.as_ref()); let sig_bit_str = tlv(0x03, &sig_content);
let cert_body: Vec<u8> =
[tbs.as_slice(), alg_id.as_slice(), sig_bit_str.as_slice()].concat();
long_tlv(0x30, &cert_body)
}
fn valid_cert(cn: &str) -> Vec<u8> {
build_cert_der(cn, b"250101000000Z", b"270101000000Z")
}
fn expired_cert(cn: &str) -> Vec<u8> {
build_cert_der(cn, b"200101000000Z", b"210101000000Z")
}
fn future_cert(cn: &str) -> Vec<u8> {
build_cert_der(cn, b"280101000000Z", b"300101000000Z")
}
fn cert_cred(der: Vec<u8>) -> Credential {
Credential::Certificate {
certificate: der,
private_key: vec![],
passphrase: None,
}
}
#[test]
fn test_valid_cert_accepted() {
let method = ClientCertAuthMethod::new(ClientCertConfig::new());
let id = method
.authenticate(&cert_cred(valid_cert("alice")))
.expect("valid cert should be accepted");
assert!(
id.subject_dn.contains("alice"),
"subject should contain CN: {}",
id.subject_dn
);
assert_eq!(id.common_name.as_deref(), Some("alice"));
}
#[test]
fn test_expired_cert_rejected() {
let method = ClientCertAuthMethod::new(ClientCertConfig::new());
let err = method
.authenticate(&cert_cred(expired_cert("bob")))
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("expired"), "expected 'expired' in: {msg}");
}
#[test]
fn test_future_cert_rejected() {
let method = ClientCertAuthMethod::new(ClientCertConfig::new());
let err = method
.authenticate(&cert_cred(future_cert("carol")))
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("valid"), "expected 'valid' in: {msg}");
}
#[test]
fn test_subject_allowlist_permits_matching_cn() {
let cfg = ClientCertConfig::new().allow_subject("alice");
assert!(
ClientCertAuthMethod::new(cfg)
.authenticate(&cert_cred(valid_cert("alice")))
.is_ok()
);
}
#[test]
fn test_subject_allowlist_blocks_non_matching_cn() {
let cfg = ClientCertConfig::new().allow_subject("alice");
let err = ClientCertAuthMethod::new(cfg)
.authenticate(&cert_cred(valid_cert("mallory")))
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("allowlist"), "expected 'allowlist' in: {msg}");
}
#[test]
fn test_issuer_allowlist_permits_self_signed_when_matches() {
let cfg = ClientCertConfig::new().allow_issuer("alice");
assert!(
ClientCertAuthMethod::new(cfg)
.authenticate(&cert_cred(valid_cert("alice")))
.is_ok()
);
}
#[test]
fn test_issuer_allowlist_blocks_unmatched_issuer() {
let cfg = ClientCertConfig::new().allow_issuer("TrustedCorp");
let err = ClientCertAuthMethod::new(cfg)
.authenticate(&cert_cred(valid_cert("alice")))
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("allowlist"), "expected 'allowlist' in: {msg}");
}
#[test]
fn test_require_san_rejects_cert_without_san() {
let cfg = ClientCertConfig::new().with_require_san();
let err = ClientCertAuthMethod::new(cfg)
.authenticate(&cert_cred(valid_cert("alice")))
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Subject Alternative Name") || msg.contains("SAN"),
"expected SAN mention in: {msg}"
);
}
#[test]
fn test_trusted_ca_accepts_when_issuer_dn_matches() {
let der = valid_cert("alice");
let cfg = ClientCertConfig::new().trust_ca(der.clone());
assert!(
ClientCertAuthMethod::new(cfg)
.authenticate(&cert_cred(der))
.is_ok()
);
}
#[test]
fn test_trusted_ca_rejects_when_no_ca_matches() {
let untrusted_cert = valid_cert("alice");
let different_ca = valid_cert("OtherCA");
let cfg = ClientCertConfig::new().trust_ca(different_ca);
let err = ClientCertAuthMethod::new(cfg)
.authenticate(&cert_cred(untrusted_cert))
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("trusted CA") || msg.contains("issuer"),
"expected CA/issuer mention in: {msg}"
);
}
#[test]
fn test_client_cert_from_tls_constructor() {
let der = valid_cert("sys");
let cred = Credential::client_cert_from_tls(der.clone());
match &cred {
Credential::Certificate {
certificate,
private_key,
passphrase,
} => {
assert_eq!(certificate, &der);
assert!(private_key.is_empty(), "private_key should be empty");
assert!(passphrase.is_none());
}
_ => panic!("Expected Credential::Certificate"),
}
let method = ClientCertAuthMethod::new(ClientCertConfig::new());
assert!(method.authenticate(&cred).is_ok());
}
#[test]
fn test_issuer_dn_populated_in_identity() {
let method = ClientCertAuthMethod::new(ClientCertConfig::new());
let id = method
.authenticate(&cert_cred(valid_cert("charlie")))
.unwrap();
assert_eq!(id.issuer_dn, id.subject_dn);
}
#[test]
fn test_cert_pin_from_der() {
let der = valid_cert("pin-test");
let pin = CertPin::from_der(&der);
assert_eq!(pin.sha256_hex.len(), 64); }
#[test]
fn test_cert_pin_deterministic() {
let der = vec![0x30, 0x82, 0x01, 0x00];
let p1 = CertPin::from_der(&der);
let p2 = CertPin::from_der(&der);
assert_eq!(p1, p2);
}
#[test]
fn test_cert_pin_from_hex() {
let pin = CertPin::from_hex("AABB");
assert_eq!(pin.sha256_hex, "aabb"); }
#[test]
fn test_cert_pin_store_add_and_check() {
let store = CertPinStore::new();
let der = valid_cert("pinned");
let pin = CertPin::from_der(&der);
store.add(&pin);
assert_eq!(store.count(), 1);
assert!(store.is_pinned(&der));
assert!(!store.is_pinned(&valid_cert("not-pinned")));
}
#[test]
fn test_cert_pin_store_remove() {
let store = CertPinStore::new();
let der = valid_cert("removable");
let pin = CertPin::from_der(&der);
store.add(&pin);
assert!(store.remove(&pin));
assert!(!store.is_pinned(&der));
assert_eq!(store.count(), 0);
}
#[test]
fn test_crl_store_add_and_check() {
let store = CrlStore::new();
store.add_revoked("CN=TestCA", "0a1b2c");
assert_eq!(
store.check("CN=TestCA", "0a1b2c"),
RevocationStatus::Revoked { reason: None }
);
assert_eq!(store.check("CN=TestCA", "ffffff"), RevocationStatus::Good);
assert_eq!(store.check("CN=OtherCA", "0a1b2c"), RevocationStatus::Good);
}
#[test]
fn test_crl_store_case_insensitive_serial() {
let store = CrlStore::new();
store.add_revoked("CN=CA", "aAbBcC");
assert_eq!(
store.check("CN=CA", "AABBCC"),
RevocationStatus::Revoked { reason: None }
);
}
#[test]
fn test_crl_store_check_der() {
let store = CrlStore::new();
let der = valid_cert("crl-test");
assert_eq!(store.check_der(&der), RevocationStatus::Good);
}
#[test]
fn test_crl_store_revoked_count() {
let store = CrlStore::new();
store.add_revoked("CN=CA1", "01");
store.add_revoked("CN=CA1", "02");
store.add_revoked("CN=CA2", "01");
assert_eq!(store.revoked_count(), 3);
}
#[test]
fn test_crl_store_clear_issuer() {
let store = CrlStore::new();
store.add_revoked("CN=CA", "01");
store.add_revoked("CN=CA", "02");
store.clear_issuer("CN=CA");
assert_eq!(store.revoked_count(), 0);
}
#[test]
fn test_cert_thumbprint_s256() {
let der = valid_cert("rfc8705");
let thumbprint = cert_thumbprint_s256(&der);
assert_eq!(thumbprint.len(), 43);
}
#[test]
fn test_cert_thumbprint_deterministic() {
let der = vec![0x30, 0x82, 0x00, 0x01];
let t1 = cert_thumbprint_s256(&der);
let t2 = cert_thumbprint_s256(&der);
assert_eq!(t1, t2);
}
#[test]
fn test_verify_cert_binding_success() {
let der = valid_cert("bound");
let thumbprint = cert_thumbprint_s256(&der);
assert!(verify_cert_binding(&der, &thumbprint).is_ok());
}
#[test]
fn test_verify_cert_binding_mismatch() {
let der = valid_cert("bound");
let err = verify_cert_binding(&der, "wrong-thumbprint").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("mismatch"), "expected 'mismatch' in: {msg}");
}
}