use std::sync::OnceLock;
use serde::Deserialize;
use thiserror::Error;
static SERVICE_NAME: OnceLock<String> = OnceLock::new();
pub fn init_service_name(name: &str) {
SERVICE_NAME.set(name.to_string()).ok();
}
pub fn service_name() -> &'static str {
SERVICE_NAME
.get()
.map(|s| s.as_str())
.unwrap_or("agentmail")
}
#[derive(Debug, Error)]
pub enum SecretError {
#[error(
"no default keychain configured for this user (errSecNoDefaultKeychain / -25307). \
Fix: run `security default-keychain -s login.keychain-db`, \
or set AGENTMAIL_PASSWORD_<ACCOUNT> as a fallback."
)]
NoDefaultKeychain,
#[error(
"keychain not accessible in this context (errSecInteractionNotAllowed / -25308). \
You're likely running headless (launchd, SSH, CI) where the keychain can't prompt. \
Set AGENTMAIL_PASSWORD_<ACCOUNT> as a fallback."
)]
InteractionNotAllowed,
#[error(
"keychain entry needs an entitlement this binary doesn't have (errSecMissingEntitlement / -34018). \
The data-protection keychain requires a signed binary with a stable team identifier. \
Use a file-based keyring entry or set AGENTMAIL_PASSWORD_<ACCOUNT>."
)]
MissingEntitlement,
#[error(
"no default keyring store has been installed in this process \
(neither data-protection nor file-based backend could be opened)"
)]
NoDefaultStore,
#[error("keyring backend error: {0}")]
Backend(String),
#[error("setting a command-based secret is not supported")]
CommandNotWritable,
#[error("command failed ({status}): {stderr}")]
CommandFailed { status: String, stderr: String },
#[error("command I/O error: {0}")]
CommandIo(String),
#[error("internal task error: {0}")]
Internal(String),
}
pub(crate) fn map_keyring_error(err: keyring_core::error::Error) -> SecretError {
use keyring_core::error::Error as KErr;
match err {
KErr::NoDefaultStore => SecretError::NoDefaultStore,
KErr::PlatformFailure(ref inner) | KErr::NoStorageAccess(ref inner) => {
classify_platform_message(&inner.to_string())
.unwrap_or_else(|| SecretError::Backend(err.to_string()))
}
other => SecretError::Backend(other.to_string()),
}
}
pub(crate) fn classify_platform_message(msg: &str) -> Option<SecretError> {
let lower = msg.to_lowercase();
if msg.contains("-25307") || lower.contains("no default keychain") {
Some(SecretError::NoDefaultKeychain)
} else if msg.contains("-25308") || lower.contains("interaction is not allowed") {
Some(SecretError::InteractionNotAllowed)
} else if msg.contains("-34018") || lower.contains("missing entitlement") {
Some(SecretError::MissingEntitlement)
} else {
None
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Secret {
Raw(String),
Keyring(String),
#[serde(alias = "cmd")]
Command(String),
}
impl Secret {
pub fn new_raw(value: impl Into<String>) -> Self {
Self::Raw(value.into())
}
pub fn new_keyring(key: impl Into<String>) -> Self {
Self::Keyring(key.into())
}
pub async fn get(&self) -> Result<String, SecretError> {
match self {
Secret::Raw(v) => Ok(v.clone()),
Secret::Keyring(key) => {
let service = service_name().to_string();
let key = key.clone();
tokio::task::spawn_blocking(move || {
let entry =
keyring_core::Entry::new(&service, &key).map_err(map_keyring_error)?;
entry.get_password().map_err(map_keyring_error)
})
.await
.map_err(|e| SecretError::Internal(e.to_string()))?
}
Secret::Command(cmd) => {
let output = tokio::process::Command::new("sh")
.args(["-c", cmd])
.output()
.await
.map_err(|e| SecretError::CommandIo(e.to_string()))?;
if !output.status.success() {
return Err(SecretError::CommandFailed {
status: output.status.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
}
}
pub async fn set(&mut self, value: &str) -> Result<(), SecretError> {
match self {
Secret::Raw(v) => {
*v = value.to_string();
Ok(())
}
Secret::Keyring(key) => {
let service = service_name().to_string();
let key = key.clone();
let value = value.to_string();
tokio::task::spawn_blocking(move || {
let entry =
keyring_core::Entry::new(&service, &key).map_err(map_keyring_error)?;
entry.set_password(&value).map_err(map_keyring_error)
})
.await
.map_err(|e| SecretError::Internal(e.to_string()))?
}
Secret::Command(_) => Err(SecretError::CommandNotWritable),
}
}
pub async fn delete(&mut self) -> Result<(), SecretError> {
match self {
Secret::Raw(v) => {
v.clear();
Ok(())
}
Secret::Keyring(key) => {
let service = service_name().to_string();
let key = key.clone();
tokio::task::spawn_blocking(move || {
if let Ok(entry) = keyring_core::Entry::new(&service, &key) {
let _ = entry.delete_credential();
}
Ok(())
})
.await
.map_err(|e| SecretError::Internal(e.to_string()))?
}
Secret::Command(_) => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn raw_get_returns_value() {
let s = Secret::new_raw("hunter2");
assert_eq!(s.get().await.unwrap(), "hunter2");
}
#[tokio::test]
async fn raw_set_updates_value() {
let mut s = Secret::new_raw("old");
s.set("new").await.unwrap();
assert_eq!(s.get().await.unwrap(), "new");
}
#[tokio::test]
async fn raw_delete_clears() {
let mut s = Secret::new_raw("hunter2");
s.delete().await.unwrap();
assert_eq!(s.get().await.unwrap(), "");
}
#[tokio::test]
async fn command_get_runs_shell() {
let s = Secret::Command("printf hunter2".to_string());
assert_eq!(s.get().await.unwrap(), "hunter2");
}
#[tokio::test]
async fn command_set_errors() {
let mut s = Secret::Command("echo".to_string());
let err = s.set("anything").await.unwrap_err();
assert!(matches!(err, SecretError::CommandNotWritable));
}
#[tokio::test]
async fn command_failure_surfaces_stderr() {
let s = Secret::Command("echo boom 1>&2; exit 1".to_string());
let err = s.get().await.unwrap_err();
match err {
SecretError::CommandFailed { stderr, .. } => assert!(stderr.contains("boom")),
other => panic!("expected CommandFailed, got {other:?}"),
}
}
#[test]
fn classify_no_default_keychain_by_code() {
let mapped = classify_platform_message("OSStatus error -25307");
assert!(matches!(mapped, Some(SecretError::NoDefaultKeychain)));
}
#[test]
fn classify_no_default_keychain_by_message() {
let mapped = classify_platform_message("No default keychain could be found.");
assert!(matches!(mapped, Some(SecretError::NoDefaultKeychain)));
}
#[test]
fn classify_interaction_not_allowed_by_code() {
let mapped = classify_platform_message("error code -25308");
assert!(matches!(mapped, Some(SecretError::InteractionNotAllowed)));
}
#[test]
fn classify_missing_entitlement_by_code() {
let mapped = classify_platform_message("OSStatus error -34018");
assert!(matches!(mapped, Some(SecretError::MissingEntitlement)));
}
#[test]
fn classify_unknown_returns_none() {
assert!(classify_platform_message("some unrelated error").is_none());
}
#[tokio::test]
async fn keyring_roundtrip_with_mock_store() {
keyring_core::set_default_store(keyring_core::mock::Store::new().unwrap());
let mut s = Secret::new_keyring("agentmail.test.roundtrip");
s.set("hunter2").await.unwrap();
assert_eq!(s.get().await.unwrap(), "hunter2");
s.delete().await.unwrap();
assert!(s.get().await.is_err());
}
}