use std::collections::HashMap;
use mailrs_dkim::key_list::{SkipReason, parse_key_list};
use crate::dkim_sign::DkimDomainKey;
pub fn extra_keys_from_env() -> HashMap<String, DkimDomainKey> {
match std::env::var("MAILRS_DKIM_KEYS") {
Ok(raw) => parse_extra_keys(&raw, |path| std::fs::read_to_string(path)),
Err(_) => HashMap::new(),
}
}
fn parse_extra_keys<F>(raw: &str, mut read_key: F) -> HashMap<String, DkimDomainKey>
where
F: FnMut(&str) -> std::io::Result<String>,
{
let mut out = HashMap::new();
for item in parse_key_list(raw) {
let entry = match item {
Ok(entry) => entry,
Err(skipped) => {
let why = match skipped.reason {
SkipReason::NotThreeFields => "expected domain:selector:path",
SkipReason::EmptyField => "empty field",
};
tracing::warn!(
event = "config_error",
entry = skipped.entry,
"MAILRS_DKIM_KEYS entry skipped ({why})"
);
continue;
}
};
let pem = match read_key(entry.key_path) {
Ok(pem) => pem,
Err(e) => {
tracing::warn!(
event = "config_error",
domain = %entry.domain,
path = entry.key_path,
error = %e,
"MAILRS_DKIM_KEYS entry skipped (key file unreadable)"
);
continue;
}
};
out.insert(
entry.domain,
DkimDomainKey {
selector: entry.selector.to_string(),
private_key_pem: pem,
parsed_key: Default::default(),
},
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn always(pem: &'static str) -> impl FnMut(&str) -> std::io::Result<String> {
move |_| Ok(pem.to_string())
}
#[test]
fn parses_a_single_entry() {
let keys = parse_extra_keys("golia.ai:mail:/dkim/golia.jp.key", always("PEM"));
assert_eq!(keys.len(), 1);
let k = keys.get("golia.ai").expect("entry present");
assert_eq!(k.selector, "mail");
assert_eq!(k.private_key_pem, "PEM");
}
#[test]
fn parses_every_hosted_domain() {
let raw = "golia.ai:mail:/k,dadaya.jp:mail:/k,bitreits.com:mail:/k,\
doracawl.com:mail:/k,madcawl.com:mail:/k,marspot.com:mail:/k";
let keys = parse_extra_keys(raw, always("PEM"));
assert_eq!(keys.len(), 6);
for d in [
"golia.ai",
"dadaya.jp",
"bitreits.com",
"doracawl.com",
"madcawl.com",
"marspot.com",
] {
assert!(keys.contains_key(d), "{d} must be present");
}
}
#[test]
fn tolerates_whitespace_and_trailing_separators() {
let keys = parse_extra_keys(
" golia.ai : mail : /k , , dadaya.jp:mail:/k ,",
always("PEM"),
);
assert_eq!(keys.len(), 2);
assert!(keys.contains_key("golia.ai"));
assert!(keys.contains_key("dadaya.jp"));
}
#[test]
fn lowercases_the_domain_key() {
let keys = parse_extra_keys("GOLIA.AI:mail:/k", always("PEM"));
assert!(keys.contains_key("golia.ai"));
assert!(!keys.contains_key("GOLIA.AI"));
}
#[test]
fn keeps_a_path_containing_colons() {
let keys = parse_extra_keys("golia.ai:mail:/a:b/c.key", |path| {
assert_eq!(path, "/a:b/c.key");
Ok("PEM".into())
});
assert_eq!(keys.len(), 1);
}
#[test]
fn skips_a_malformed_entry_but_keeps_the_rest() {
let keys = parse_extra_keys(
"golia.ai:mail:/k,broken-entry,dadaya.jp:mail:/k",
always("PEM"),
);
assert_eq!(keys.len(), 2, "one bad entry must not lose the good ones");
assert!(keys.contains_key("golia.ai"));
assert!(keys.contains_key("dadaya.jp"));
}
#[test]
fn skips_an_entry_with_an_empty_field() {
let keys = parse_extra_keys("golia.ai::/k,:mail:/k,dadaya.jp:mail:", always("PEM"));
assert!(keys.is_empty());
}
#[test]
fn skips_an_entry_whose_key_file_is_unreadable() {
let keys = parse_extra_keys("golia.ai:mail:/missing,dadaya.jp:mail:/ok", |path| {
if path == "/missing" {
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "nope"));
}
Ok("PEM".into())
});
assert_eq!(keys.len(), 1);
assert!(keys.contains_key("dadaya.jp"));
}
#[test]
fn empty_input_is_single_domain_mode() {
assert!(parse_extra_keys("", always("PEM")).is_empty());
assert!(parse_extra_keys(" ", always("PEM")).is_empty());
}
}