use serde::Serialize;
use smbcloud_ascapi_core::Client;
use smbcloud_ascapi_signing::prelude::*;
use std::process::Command;
use crate::certificates::CertificateSummary;
use crate::time::now_iso8601;
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct DoctorCertificate {
#[serde(flatten)]
pub certificate: CertificateSummary,
pub private_key_present: bool,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct DoctorReport {
pub certificates: Vec<DoctorCertificate>,
pub local_only_serials: Vec<String>,
pub usable_count: usize,
}
fn normalise_serial(serial: &str) -> String {
let hex: String = serial
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect::<String>()
.to_uppercase();
let trimmed = hex.trim_start_matches('0');
if trimmed.is_empty() {
"0".to_string()
} else {
trimmed.to_string()
}
}
fn local_keychain_serials() -> Result<Vec<String>, String> {
let output = Command::new("security")
.args(["find-certificate", "-a", "-p"])
.output()
.map_err(|error| format!("running `security find-certificate`: {error}"))?;
if !output.status.success() {
return Err(format!(
"`security find-certificate` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let pem = String::from_utf8_lossy(&output.stdout);
let mut serials = Vec::new();
for block in split_pem_certificates(&pem) {
if let Some(serial) = openssl_serial(&block) {
let normalised = normalise_serial(&serial);
if !normalised.is_empty() && !serials.contains(&normalised) {
serials.push(normalised);
}
}
}
Ok(serials)
}
fn split_pem_certificates(pem: &str) -> Vec<String> {
const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
const END: &str = "-----END CERTIFICATE-----";
let mut blocks = Vec::new();
let mut rest = pem;
while let Some(start) = rest.find(BEGIN) {
let after_start = &rest[start..];
if let Some(end_rel) = after_start.find(END) {
let end = end_rel + END.len();
blocks.push(after_start[..end].to_string());
rest = &after_start[end..];
} else {
break;
}
}
blocks
}
fn openssl_serial(pem: &str) -> Option<String> {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new("openssl")
.args(["x509", "-noout", "-serial"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(pem.as_bytes()).ok()?;
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
text.split('=').nth(1).map(|s| s.trim().to_string())
}
pub async fn run_doctor(client: &Client) -> Result<DoctorReport, String> {
let certificates = client
.list_certificates(None)
.await
.map_err(|error| error.to_string())?;
let local_serials = local_keychain_serials()?;
let now = now_iso8601();
let mut doctor_certificates = Vec::with_capacity(certificates.len());
let mut matched_local = std::collections::HashSet::new();
let mut usable_count = 0;
for certificate in &certificates {
let summary = CertificateSummary::from_resource(certificate, &now);
let normalised = summary
.serial_number
.as_deref()
.map(normalise_serial)
.filter(|s| !s.is_empty());
let private_key_present = match &normalised {
Some(serial) => local_serials.contains(serial),
None => false,
};
if private_key_present {
if let Some(serial) = &normalised {
matched_local.insert(serial.clone());
}
if summary.expired != Some(true) {
usable_count += 1;
}
}
doctor_certificates.push(DoctorCertificate {
certificate: summary,
private_key_present,
});
}
let local_only_serials = local_serials
.into_iter()
.filter(|serial| !matched_local.contains(serial))
.collect();
Ok(DoctorReport {
certificates: doctor_certificates,
local_only_serials,
usable_count,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalise_serial_folds_casing_separators_and_leading_zeros() {
assert_eq!(normalise_serial("1a2b3c"), "1A2B3C");
assert_eq!(normalise_serial("1A:2B:3C"), "1A2B3C");
assert_eq!(normalise_serial("0x1a2b3c"), "1A2B3C");
assert_eq!(normalise_serial("001A2B3C"), "1A2B3C");
}
#[test]
fn normalise_serial_keeps_a_single_zero() {
assert_eq!(normalise_serial("00"), "0");
assert_eq!(normalise_serial("0"), "0");
}
#[test]
fn split_pem_separates_concatenated_certificates() {
let pem = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n\
-----BEGIN CERTIFICATE-----\nBBBB\n-----END CERTIFICATE-----\n";
let blocks = split_pem_certificates(pem);
assert_eq!(blocks.len(), 2);
assert!(blocks[0].contains("AAAA"));
assert!(blocks[1].contains("BBBB"));
}
#[test]
fn split_pem_ignores_an_unterminated_trailing_block() {
let pem = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n\
-----BEGIN CERTIFICATE-----\nBBBB\n";
let blocks = split_pem_certificates(pem);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].contains("AAAA"));
}
#[test]
fn doctor_report_never_serializes_certificate_material() {
let report = DoctorReport {
certificates: vec![DoctorCertificate {
certificate: CertificateSummary {
id: "ABC123".to_string(),
name: Some("Apple Distribution: Example".to_string()),
certificate_type: Some("DISTRIBUTION".to_string()),
serial_number: Some("1A2B3C".to_string()),
expiration_date: Some("2027-08-06T00:00:00".to_string()),
expired: Some(false),
},
private_key_present: true,
}],
local_only_serials: vec!["DEADBEEF".to_string()],
usable_count: 1,
};
let json = serde_json::to_string(&report).expect("serializes");
assert!(!json.contains("BEGIN CERTIFICATE"));
assert!(!json.contains("BEGIN PRIVATE KEY"));
assert!(json.contains("1A2B3C"));
assert!(json.contains("private_key_present"));
}
}