use std::collections::HashMap;
use std::fmt::Write as _;
use std::hash::BuildHasher;
use chrono::{Datelike, Local, Offset, Timelike};
use crate::config::{CertField, CertFieldSource, SigAuto, SigField, SigFieldValue};
use crate::pki::CertInfo;
const MONTH_ABBR: [&str; 13] = [
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
#[must_use]
pub fn format_utc_offset(offset_seconds: i32) -> String {
let total_minutes = offset_seconds / 60;
let hours = total_minutes / 60;
let minutes = (total_minutes % 60).abs();
if hours == 0 && minutes == 0 {
return "UTC".to_owned();
}
let sign = if offset_seconds < 0 { '-' } else { '+' };
let mut out = format!("UTC{sign}{}", hours.abs());
if minutes != 0 {
let _ = write!(out, ":{minutes:02}");
}
out
}
#[must_use]
pub fn make_date_str() -> String {
let now = Local::now();
let offset_seconds = now.offset().fix().local_minus_utc();
let month = MONTH_ABBR[now.month() as usize];
format!(
"{} {} {}, {:02}:{:02}:{:02} {}",
now.day(),
month,
now.year(),
now.hour(),
now.minute(),
now.second(),
format_utc_offset(offset_seconds),
)
}
fn source_value(source: CertFieldSource, signer: &CertInfo) -> Option<&str> {
match source {
CertFieldSource::Name => signer.name.as_deref(),
CertFieldSource::Dn => signer.dn.as_deref(),
CertFieldSource::Organization => signer.organization.as_deref(),
CertFieldSource::Email => signer.email.as_deref(),
}
}
#[must_use]
pub fn extract_cert_fields(
cert_fields: &[CertField],
signer: &CertInfo,
) -> HashMap<String, String> {
let mut result = HashMap::new();
for field in cert_fields {
let raw = source_value(field.source, signer).unwrap_or("");
if raw.is_empty() {
continue;
}
match &field.regex {
Some(pattern) => match regex::Regex::new(pattern) {
Ok(re) => {
if let Some(group1) = re.captures(raw).and_then(|c| c.get(1)) {
if !group1.as_str().is_empty() {
result.insert(field.id.clone(), group1.as_str().to_owned());
}
}
}
Err(e) => {
log::warn!("Invalid regex {pattern:?} in field {:?}: {e}", field.id);
}
},
None => {
result.insert(field.id.clone(), raw.to_owned());
}
}
}
result
}
#[must_use]
pub fn extract_display_fields<S: BuildHasher>(
sig_fields: &[SigField],
cert_values: &HashMap<String, String, S>,
) -> Vec<String> {
let mut result = Vec::new();
for field in sig_fields {
match &field.value {
SigFieldValue::Auto(SigAuto::Date) => {
let date_str = make_date_str();
result.push(labeled(field.label.as_deref(), &date_str, "Date"));
}
SigFieldValue::Cert(cert_field) => {
if let Some(raw) = cert_values.get(cert_field) {
if !raw.is_empty() {
result.push(labeled(field.label.as_deref(), raw, ""));
}
}
}
}
}
result
}
fn labeled(label: Option<&str>, value: &str, default_label: &str) -> String {
match label {
Some(l) if !l.is_empty() => format!("{l}: {value}"),
_ if !default_label.is_empty() => format!("{default_label}: {value}"),
_ => value.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utc_offset_formatting() {
assert_eq!(format_utc_offset(0), "UTC");
assert_eq!(format_utc_offset(4 * 3600), "UTC+4");
assert_eq!(format_utc_offset(5 * 3600 + 30 * 60), "UTC+5:30");
assert_eq!(format_utc_offset(-(5 * 3600 + 30 * 60)), "UTC-5:30");
assert_eq!(format_utc_offset(-4 * 3600), "UTC-4");
assert_eq!(format_utc_offset(30 * 60), "UTC+0:30");
}
#[test]
fn date_str_has_expected_shape() {
let s = make_date_str();
assert!(s.contains("UTC"), "{s}");
assert!(s.contains(", "), "{s}");
let month_ok = MONTH_ABBR[1..].iter().any(|m| s.contains(m));
assert!(month_ok, "no month in {s}");
}
fn signer() -> CertInfo {
CertInfo {
name: Some("John Doe".to_owned()),
email: Some("john@example.com".to_owned()),
organization: Some("ACME".to_owned()),
dn: Some("SERIALNUMBER=1234567890, CN=John Doe".to_owned()),
not_before: None,
not_after: None,
}
}
#[test]
fn extracts_plain_and_regex_fields() {
let fields = vec![
CertField {
id: "name".to_owned(),
label: "Name".to_owned(),
source: CertFieldSource::Name,
regex: None,
},
CertField {
id: "ssn".to_owned(),
label: "SSN".to_owned(),
source: CertFieldSource::Dn,
regex: Some(r"SERIALNUMBER=(\d+)".to_owned()),
},
];
let values = extract_cert_fields(&fields, &signer());
assert_eq!(values.get("name").map(String::as_str), Some("John Doe"));
assert_eq!(values.get("ssn").map(String::as_str), Some("1234567890"));
}
#[test]
fn skips_empty_and_non_matching() {
let empty_signer = CertInfo {
name: None,
email: None,
organization: None,
dn: Some("CN=x".to_owned()),
not_before: None,
not_after: None,
};
let fields = vec![
CertField {
id: "name".to_owned(),
label: "Name".to_owned(),
source: CertFieldSource::Name,
regex: None,
},
CertField {
id: "ssn".to_owned(),
label: "SSN".to_owned(),
source: CertFieldSource::Dn,
regex: Some(r"SERIALNUMBER=(\d+)".to_owned()),
},
];
let values = extract_cert_fields(&fields, &empty_signer);
assert!(values.is_empty());
}
#[test]
fn builds_display_fields_with_labels() {
let mut cert_values = HashMap::new();
cert_values.insert("name".to_owned(), "John Doe".to_owned());
cert_values.insert("ssn".to_owned(), "1234567890".to_owned());
let sig_fields = vec![
SigField {
value: SigFieldValue::Cert("name".to_owned()),
label: None,
},
SigField {
value: SigFieldValue::Cert("ssn".to_owned()),
label: Some("SSN".to_owned()),
},
SigField {
value: SigFieldValue::Auto(SigAuto::Date),
label: None,
},
];
let display = extract_display_fields(&sig_fields, &cert_values);
assert_eq!(display.len(), 3);
assert_eq!(display[0], "John Doe");
assert_eq!(display[1], "SSN: 1234567890");
assert!(display[2].starts_with("Date: "), "{}", display[2]);
}
#[test]
fn display_field_skips_missing_cert_value() {
let cert_values = HashMap::new();
let sig_fields = vec![SigField {
value: SigFieldValue::Cert("missing".to_owned()),
label: None,
}];
assert!(extract_display_fields(&sig_fields, &cert_values).is_empty());
}
}