use anyhow::Result;
pub const SERVICE: &str = "smtp-test-tool";
pub trait Keystore: Send + Sync {
fn save(&self, user: &str, secret: &str) -> Result<()>;
fn load(&self, user: &str) -> Result<Option<String>>;
fn forget(&self, user: &str) -> Result<()>;
}
#[cfg(feature = "keychain")]
mod os_impl {
use super::{Keystore, SERVICE};
use anyhow::{Context, Result};
#[derive(Default, Debug, Clone, Copy)]
pub struct OsKeystore;
impl Keystore for OsKeystore {
fn save(&self, user: &str, secret: &str) -> Result<()> {
let entry =
keyring::Entry::new(SERVICE, user).context("opening OS keychain entry for save")?;
entry
.set_password(secret)
.context("writing secret to OS keychain")
}
fn load(&self, user: &str) -> Result<Option<String>> {
let entry =
keyring::Entry::new(SERVICE, user).context("opening OS keychain entry for load")?;
match entry.get_password() {
Ok(s) => Ok(Some(s)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e).context("reading secret from OS keychain"),
}
}
fn forget(&self, user: &str) -> Result<()> {
let entry = keyring::Entry::new(SERVICE, user)
.context("opening OS keychain entry for forget")?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e).context("deleting OS keychain entry"),
}
}
}
}
#[cfg(feature = "keychain")]
pub use os_impl::OsKeystore;
#[derive(Default, Debug, Clone, Copy)]
pub struct NullKeystore;
impl Keystore for NullKeystore {
fn save(&self, _user: &str, _secret: &str) -> Result<()> {
anyhow::bail!("keychain support is not compiled in - rebuild with `--features keychain`")
}
fn load(&self, _user: &str) -> Result<Option<String>> {
Ok(None)
}
fn forget(&self, _user: &str) -> Result<()> {
Ok(())
}
}
pub fn default_keystore() -> Box<dyn Keystore> {
#[cfg(feature = "keychain")]
{
Box::new(OsKeystore)
}
#[cfg(not(feature = "keychain"))]
{
Box::new(NullKeystore)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
struct MockKeystore {
map: Mutex<HashMap<String, String>>,
}
impl Keystore for MockKeystore {
fn save(&self, user: &str, secret: &str) -> Result<()> {
self.map
.lock()
.expect("mock keystore mutex poisoned")
.insert(user.into(), secret.into());
Ok(())
}
fn load(&self, user: &str) -> Result<Option<String>> {
Ok(self
.map
.lock()
.expect("mock keystore mutex poisoned")
.get(user)
.cloned())
}
fn forget(&self, user: &str) -> Result<()> {
self.map
.lock()
.expect("mock keystore mutex poisoned")
.remove(user);
Ok(())
}
}
#[test]
fn mock_load_returns_none_for_missing_entry() {
let ks = MockKeystore::default();
assert_eq!(ks.load("alice@example.com").unwrap(), None);
}
#[test]
fn mock_save_then_load_round_trips() {
let ks = MockKeystore::default();
ks.save("alice@example.com", "s3cret").unwrap();
assert_eq!(
ks.load("alice@example.com").unwrap().as_deref(),
Some("s3cret")
);
}
#[test]
fn mock_forget_is_idempotent() {
let ks = MockKeystore::default();
ks.forget("never-was-here").unwrap();
ks.save("user@example.com", "x").unwrap();
ks.forget("user@example.com").unwrap();
ks.forget("user@example.com").unwrap();
assert_eq!(ks.load("user@example.com").unwrap(), None);
}
#[test]
fn mock_overwrites_existing_secret() {
let ks = MockKeystore::default();
ks.save("u", "a").unwrap();
ks.save("u", "b").unwrap();
assert_eq!(ks.load("u").unwrap().as_deref(), Some("b"));
}
#[test]
fn null_keystore_load_is_none() {
let ks = NullKeystore;
assert_eq!(ks.load("any").unwrap(), None);
}
#[test]
fn null_keystore_save_errors_clearly() {
let ks = NullKeystore;
let err = ks.save("u", "p").unwrap_err();
let s = err.to_string();
assert!(
s.contains("--features keychain"),
"error must hint at the cargo feature, got: {s}"
);
}
}