use keyring::Entry;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DEFAULT_SERVICE: &str = "car";
pub fn resolve_env_or_keychain(env_var: &str) -> Option<String> {
if let Ok(v) = std::env::var(env_var) {
if !v.is_empty() {
return Some(v);
}
}
let store = SecretStore::new();
if !store.is_available() {
return None;
}
let secret_ref = SecretRef::new(DEFAULT_SERVICE, env_var);
match store.get(&secret_ref) {
Ok(v) if !v.is_empty() => {
tracing::debug!(env_var = %env_var, "resolved API key from OS keychain");
Some(v)
}
Ok(_) => None, Err(SecretError::NotFound { .. }) => None,
Err(e) => {
tracing::warn!(env_var = %env_var, error = %e, "keychain lookup failed");
None
}
}
}
#[derive(Debug, Error)]
pub enum SecretError {
#[error("secret store unavailable: {0}")]
Unavailable(String),
#[error("no entry for service={service:?} key={key:?}")]
NotFound { service: String, key: String },
#[error("secret store error: {0}")]
Backend(String),
#[error("stored value is not valid JSON: {0}")]
InvalidJson(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretStatus {
pub service: String,
pub key: String,
pub exists: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailabilityCheck {
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SecretRef {
pub service: String,
pub key: String,
}
impl SecretRef {
pub fn new(service: impl Into<String>, key: impl Into<String>) -> Self {
Self {
service: service.into(),
key: key.into(),
}
}
pub fn with_default_service(key: impl Into<String>) -> Self {
Self {
service: DEFAULT_SERVICE.to_string(),
key: key.into(),
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SecretStore;
impl SecretStore {
pub fn new() -> Self {
Self
}
pub fn put(&self, r: &SecretRef, value: &str) -> Result<(), SecretError> {
platform_put(self, r, value)
}
pub fn put_json<T: Serialize>(&self, r: &SecretRef, value: &T) -> Result<(), SecretError> {
let s = serde_json::to_string(value)
.map_err(|e| SecretError::Backend(format!("serialize: {}", e)))?;
self.put(r, &s)
}
pub fn get(&self, r: &SecretRef) -> Result<String, SecretError> {
let entry = self.entry(r)?;
match entry.get_password() {
Ok(v) => Ok(v),
Err(keyring::Error::NoEntry) => mac_fallback_get(r),
Err(other) => Err(classify(other, "get_password")),
}
}
pub fn get_json<T: for<'de> Deserialize<'de>>(&self, r: &SecretRef) -> Result<T, SecretError> {
let raw = self.get(r)?;
serde_json::from_str(&raw).map_err(|e| SecretError::InvalidJson(e.to_string()))
}
pub fn delete(&self, r: &SecretRef) -> Result<(), SecretError> {
let entry = self.entry(r)?;
match entry.delete_credential() {
Ok(_) => Ok(()),
Err(keyring::Error::NoEntry) => mac_delete_fallback(r),
Err(other) => match mac_delete_fallback(r) {
Ok(()) => Ok(()),
Err(_) => Err(classify(other, "delete_credential")),
},
}
}
pub fn status(&self, r: &SecretRef) -> Result<SecretStatus, SecretError> {
let entry = self.entry(r)?;
let exists = match entry.get_password() {
Ok(_) => true,
Err(keyring::Error::NoEntry) => mac_fallback_exists(r)?,
Err(other) => return Err(classify(other, "status")),
};
Ok(SecretStatus {
service: r.service.clone(),
key: r.key.clone(),
exists,
})
}
const PROBE_SERVICE: &'static str = "car-internal";
const PROBE_KEY: &'static str = "__availability_probe__";
pub fn is_available(&self) -> bool {
self.availability().available
}
pub fn availability(&self) -> AvailabilityCheck {
let probe = SecretRef::new(Self::PROBE_SERVICE, Self::PROBE_KEY);
match self.entry(&probe) {
Ok(entry) => match entry.get_password() {
Ok(_) | Err(keyring::Error::NoEntry) => AvailabilityCheck {
available: true,
reason: None,
},
Err(keyring::Error::PlatformFailure(e)) => AvailabilityCheck {
available: false,
reason: Some(format!("platform failure: {e}")),
},
Err(keyring::Error::NoStorageAccess(e)) => AvailabilityCheck {
available: false,
reason: Some(format!("no storage access: {e}")),
},
Err(_) => AvailabilityCheck {
available: true,
reason: None,
},
},
Err(SecretError::Unavailable(reason)) => AvailabilityCheck {
available: false,
reason: Some(reason),
},
Err(other) => AvailabilityCheck {
available: false,
reason: Some(other.to_string()),
},
}
}
fn entry(&self, r: &SecretRef) -> Result<Entry, SecretError> {
Entry::new(&r.service, &r.key).map_err(|e| classify(e, "entry"))
}
}
#[cfg(target_os = "macos")]
fn platform_put(_store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
mac_put_via_security_cli(&r.service, &r.key, value)
}
#[cfg(not(target_os = "macos"))]
fn platform_put(store: &SecretStore, r: &SecretRef, value: &str) -> Result<(), SecretError> {
let entry = store.entry(r)?;
entry
.set_password(value)
.map_err(|e| classify(e, "set_password"))
}
#[cfg(target_os = "macos")]
fn mac_delete_fallback(r: &SecretRef) -> Result<(), SecretError> {
use std::process::{Command, Stdio};
let output = Command::new("/usr/bin/security")
.arg("delete-generic-password")
.arg("-s")
.arg(&r.service)
.arg("-a")
.arg(&r.key)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| SecretError::Backend(format!("/usr/bin/security spawn: {}", e)))?;
if output.status.success() {
return Ok(());
}
if output.status.code() == Some(44) {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(SecretError::Backend(format!(
"/usr/bin/security delete-generic-password failed: code={} {}",
output.status.code().unwrap_or(-1),
stderr.trim()
)))
}
#[cfg(not(target_os = "macos"))]
fn mac_delete_fallback(_r: &SecretRef) -> Result<(), SecretError> {
Ok(())
}
#[cfg(target_os = "macos")]
fn mac_put_via_security_cli(service: &str, account: &str, value: &str) -> Result<(), SecretError> {
use std::process::{Command, Stdio};
let output = Command::new("/usr/bin/security")
.arg("add-generic-password")
.arg("-U") .arg("-A") .arg("-s")
.arg(service)
.arg("-a")
.arg(account)
.arg("-w")
.arg(value)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.map_err(|e| SecretError::Backend(format!("/usr/bin/security spawn: {}", e)))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(SecretError::Backend(format!(
"/usr/bin/security add-generic-password failed: code={} {}",
output.status.code().unwrap_or(-1),
stderr.trim()
)))
}
#[cfg(target_os = "macos")]
fn mac_fallback_get(r: &SecretRef) -> Result<String, SecretError> {
if let Some(value) = mac_try_secitem_get(r) {
return Ok(value);
}
if let Some(value) = mac_try_legacy_get(r) {
return Ok(value);
}
if let Some(value) = mac_try_cli_get(r) {
return Ok(value);
}
Err(SecretError::NotFound {
service: r.service.clone(),
key: r.key.clone(),
})
}
#[cfg(target_os = "macos")]
fn mac_try_secitem_get(r: &SecretRef) -> Option<String> {
use security_framework::passwords::get_generic_password;
match get_generic_password(&r.service, &r.key) {
Ok(bytes) => match std::str::from_utf8(&bytes) {
Ok(s) => Some(s.to_string()),
Err(e) => {
tracing::warn!(
target: "car_secrets",
error = %e,
"SecItemCopyMatching returned non-utf8 bytes; falling back"
);
None
}
},
Err(e) => {
tracing::debug!(
target: "car_secrets",
code = e.code(),
"SecItemCopyMatching miss; trying legacy SecKeychainFindGenericPassword(NULL)"
);
None
}
}
}
#[cfg(target_os = "macos")]
fn mac_try_legacy_get(r: &SecretRef) -> Option<String> {
use security_framework::os::macos::passwords::find_generic_password;
match find_generic_password(None, &r.service, &r.key) {
Ok((pw, _item)) => {
let bytes: &[u8] = &pw;
match std::str::from_utf8(bytes) {
Ok(s) => Some(s.to_string()),
Err(e) => {
tracing::warn!(
target: "car_secrets",
error = %e,
"legacy keychain returned non-utf8 bytes; falling back"
);
None
}
}
}
Err(e) => {
tracing::debug!(
target: "car_secrets",
code = e.code(),
"legacy keychain miss; trying /usr/bin/security shell-out"
);
None
}
}
}
#[cfg(target_os = "macos")]
fn mac_try_cli_get(r: &SecretRef) -> Option<String> {
use std::process::Command;
let output = match Command::new("/usr/bin/security")
.arg("find-generic-password")
.arg("-s")
.arg(&r.service)
.arg("-a")
.arg(&r.key)
.arg("-w")
.output()
{
Ok(out) => out,
Err(e) => {
tracing::warn!(
target: "car_secrets",
error = %e,
"/usr/bin/security spawn failed"
);
return None;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::debug!(
target: "car_secrets",
code = output.status.code().unwrap_or(-1),
stderr = %stderr.trim(),
"/usr/bin/security exit non-zero on value path"
);
return None;
}
let mut s = String::from_utf8(output.stdout)
.map_err(|e| {
tracing::warn!(
target: "car_secrets",
error = %e,
"/usr/bin/security stdout was not valid utf-8"
);
})
.ok()?;
if s.ends_with('\n') {
s.pop();
}
Some(s)
}
#[cfg(not(target_os = "macos"))]
fn mac_fallback_get(r: &SecretRef) -> Result<String, SecretError> {
Err(SecretError::NotFound {
service: r.service.clone(),
key: r.key.clone(),
})
}
#[cfg(target_os = "macos")]
fn mac_fallback_exists(r: &SecretRef) -> Result<bool, SecretError> {
if mac_try_secitem_exists(r) {
return Ok(true);
}
if mac_try_legacy_exists(r) {
return Ok(true);
}
if mac_try_cli_exists(r) {
return Ok(true);
}
Ok(false)
}
#[cfg(target_os = "macos")]
fn mac_try_secitem_exists(r: &SecretRef) -> bool {
use security_framework::passwords::get_generic_password;
match get_generic_password(&r.service, &r.key) {
Ok(_) => true,
Err(e) => {
tracing::debug!(
target: "car_secrets",
code = e.code(),
"SecItemCopyMatching exists-probe miss"
);
false
}
}
}
#[cfg(target_os = "macos")]
fn mac_try_legacy_exists(r: &SecretRef) -> bool {
use security_framework::os::macos::passwords::find_generic_password;
match find_generic_password(None, &r.service, &r.key) {
Ok(_) => true,
Err(e) => {
tracing::debug!(
target: "car_secrets",
code = e.code(),
"legacy SecKeychainFindGenericPassword(NULL) exists-probe miss"
);
false
}
}
}
#[cfg(target_os = "macos")]
fn mac_try_cli_exists(r: &SecretRef) -> bool {
use std::process::{Command, Stdio};
let output = Command::new("/usr/bin/security")
.arg("find-generic-password")
.arg("-s")
.arg(&r.service)
.arg("-a")
.arg(&r.key)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output();
match output {
Ok(out) if out.status.success() => true,
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::debug!(
target: "car_secrets",
code = out.status.code().unwrap_or(-1),
stderr = %stderr.trim(),
"/usr/bin/security exists-probe exit non-zero"
);
false
}
Err(e) => {
tracing::warn!(
target: "car_secrets",
error = %e,
"/usr/bin/security exists-probe spawn failed"
);
false
}
}
}
#[cfg(not(target_os = "macos"))]
fn mac_fallback_exists(_r: &SecretRef) -> Result<bool, SecretError> {
Ok(false)
}
fn classify(e: keyring::Error, op: &str) -> SecretError {
use keyring::Error as K;
match e {
K::NoEntry => SecretError::NotFound {
service: String::new(),
key: String::new(),
},
K::PlatformFailure(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
K::NoStorageAccess(inner) => SecretError::Unavailable(format!("{}: {}", op, inner)),
K::BadEncoding(_) => SecretError::Backend(format!("{}: value encoding", op)),
other => SecretError::Backend(format!("{}: {}", op, other)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
fn test_service() -> String {
format!(
"car-secrets-tests-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
)
}
fn skip_if_unavailable() -> bool {
!SecretStore::new().is_available()
}
#[test]
fn roundtrip_string() {
if skip_if_unavailable() {
eprintln!("skipping: no secret store backend available");
return;
}
let store = SecretStore::new();
let svc = test_service();
let r = SecretRef::new(&svc, "roundtrip");
store.put(&r, "hello world").unwrap();
assert_eq!(store.get(&r).unwrap(), "hello world");
assert!(store.status(&r).unwrap().exists);
store.delete(&r).unwrap();
assert!(!store.status(&r).unwrap().exists);
}
#[test]
fn get_missing_returns_not_found() {
if skip_if_unavailable() {
return;
}
let store = SecretStore::new();
let r = SecretRef::new(test_service(), "never_written");
match store.get(&r) {
Err(SecretError::NotFound { .. }) => (),
other => panic!("expected NotFound, got {:?}", other),
}
}
#[test]
fn delete_missing_is_idempotent() {
if skip_if_unavailable() {
return;
}
let store = SecretStore::new();
let r = SecretRef::new(test_service(), "missing");
store.delete(&r).unwrap();
store.delete(&r).unwrap();
}
#[test]
fn json_roundtrip() {
if skip_if_unavailable() {
return;
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct Session {
cookies: Vec<String>,
expires_at: i64,
}
let store = SecretStore::new();
let svc = test_service();
let r = SecretRef::new(&svc, "session");
let s = Session {
cookies: vec!["a=1".into(), "b=2".into()],
expires_at: 1_700_000_000,
};
store.put_json(&r, &s).unwrap();
let back: Session = store.get_json(&r).unwrap();
assert_eq!(back, s);
store.delete(&r).unwrap();
}
#[test]
fn status_no_leak() {
if skip_if_unavailable() {
return;
}
let store = SecretStore::new();
let r = SecretRef::new(test_service(), "status");
store.put(&r, "secret-payload").unwrap();
let st = store.status(&r).unwrap();
let encoded = serde_json::to_string(&st).unwrap();
assert!(!encoded.contains("secret-payload"));
store.delete(&r).unwrap();
}
#[cfg(target_os = "macos")]
#[test]
fn mac_fallback_get_returns_not_found_for_missing_key() {
let r = SecretRef::new(
"car-secrets-tests-issue142-missing",
"definitely-never-written",
);
match mac_fallback_get(&r) {
Err(SecretError::NotFound { service, key }) => {
assert_eq!(service, "car-secrets-tests-issue142-missing");
assert_eq!(key, "definitely-never-written");
}
other => panic!("expected NotFound, got {:?}", other),
}
assert!(matches!(mac_fallback_exists(&r), Ok(false)));
}
}