#![deny(rustdoc::broken_intra_doc_links)]
#![deny(rustdoc::private_intra_doc_links)]
#![deny(rustdoc::invalid_html_tags)]
use devboy_core::{Error, Result};
use keyring::Entry;
use secrecy::{ExposeSecret, SecretString};
use tracing::{debug, warn};
pub mod cache;
pub use cache::CachedStore;
const SERVICE_NAME: &str = "devboy-tools";
pub trait CredentialStore: Send + Sync {
fn store(&self, key: &str, value: &SecretString) -> Result<()>;
fn get(&self, key: &str) -> Result<Option<SecretString>>;
fn delete(&self, key: &str) -> Result<()>;
fn exists(&self, key: &str) -> bool {
matches!(self.get(key), Ok(Some(_)))
}
fn is_available(&self) -> bool {
true
}
fn is_writable(&self) -> bool {
true
}
}
#[derive(Debug)]
pub struct KeychainStore {
service_name: String,
}
impl KeychainStore {
pub fn new() -> Self {
Self {
service_name: SERVICE_NAME.to_string(),
}
}
pub fn with_service_name(service_name: impl Into<String>) -> Self {
Self {
service_name: service_name.into(),
}
}
fn make_entry(&self, key: &str) -> std::result::Result<Entry, keyring::Error> {
Entry::new(&self.service_name, key)
}
}
impl Default for KeychainStore {
fn default() -> Self {
Self::new()
}
}
impl CredentialStore for KeychainStore {
fn store(&self, key: &str, value: &SecretString) -> Result<()> {
debug!(key = key, "Storing credential in keychain");
let entry = self.make_entry(key).map_err(|e| {
Error::Storage(format!(
"Failed to create keychain entry for '{}': {}",
key, e
))
})?;
entry
.set_password(value.expose_secret())
.map_err(|e| Error::Storage(format!("Failed to store credential '{}': {}", key, e)))?;
Ok(())
}
fn get(&self, key: &str) -> Result<Option<SecretString>> {
debug!(key = key, "Retrieving credential from keychain");
let entry = self.make_entry(key).map_err(|e| {
Error::Storage(format!(
"Failed to create keychain entry for '{}': {}",
key, e
))
})?;
match entry.get_password() {
Ok(password) => Ok(Some(SecretString::from(password))),
Err(keyring::Error::NoEntry) => {
debug!(key = key, "Credential not found");
Ok(None)
}
Err(e) => {
warn!(key = key, error = %e, "Failed to retrieve credential");
Err(Error::Storage(format!(
"Failed to retrieve credential '{}': {}",
key, e
)))
}
}
}
fn delete(&self, key: &str) -> Result<()> {
debug!(key = key, "Deleting credential from keychain");
let entry = self.make_entry(key).map_err(|e| {
Error::Storage(format!(
"Failed to create keychain entry for '{}': {}",
key, e
))
})?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => {
debug!(key = key, "Credential was already deleted");
Ok(())
}
Err(e) => Err(Error::Storage(format!(
"Failed to delete credential '{}': {}",
key, e
))),
}
}
fn is_available(&self) -> bool {
match self.make_entry("__devboy_availability_check__") {
Ok(_) => true,
Err(e) => {
debug!(error = %e, "Keychain not available");
false
}
}
}
}
#[derive(Default)]
pub struct MemoryStore {
credentials: std::sync::RwLock<std::collections::HashMap<String, SecretString>>,
}
impl std::fmt::Debug for MemoryStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let creds = self.credentials.read();
let (count, keys) = match &creds {
Ok(map) => (map.len(), map.keys().cloned().collect::<Vec<_>>()),
Err(_) => (0, vec!["<lock-poisoned>".to_string()]),
};
f.debug_struct("MemoryStore")
.field("credentials", &format!("<{count} redacted secret(s)>"))
.field("keys", &keys)
.finish()
}
}
impl MemoryStore {
pub fn new() -> Self {
Self::default()
}
pub fn with_credentials(credentials: impl IntoIterator<Item = (String, String)>) -> Self {
let store = Self::new();
{
let mut creds = store.credentials.write().unwrap();
for (k, v) in credentials {
creds.insert(k, SecretString::from(v));
}
}
store
}
}
impl CredentialStore for MemoryStore {
fn store(&self, key: &str, value: &SecretString) -> Result<()> {
let mut creds = self
.credentials
.write()
.map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
creds.insert(key.to_string(), value.clone());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<SecretString>> {
let creds = self
.credentials
.read()
.map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
Ok(creds.get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
let mut creds = self
.credentials
.write()
.map_err(|e| Error::Storage(format!("Lock poisoned: {}", e)))?;
creds.remove(key);
Ok(())
}
}
const DEFAULT_ENV_PREFIX: &str = "DEVBOY";
type EnvReader = fn(&str) -> std::result::Result<String, std::env::VarError>;
fn read_env_var(key: &str) -> std::result::Result<String, std::env::VarError> {
std::env::var(key)
}
pub struct EnvVarStore {
prefix: String,
fallback_without_prefix: bool,
env_reader: EnvReader,
}
impl EnvVarStore {
pub fn new() -> Self {
Self {
prefix: DEFAULT_ENV_PREFIX.to_string(),
fallback_without_prefix: true,
env_reader: read_env_var,
}
}
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
fallback_without_prefix: true,
env_reader: read_env_var,
}
}
pub fn without_fallback(mut self) -> Self {
self.fallback_without_prefix = false;
self
}
#[cfg(test)]
fn with_env_reader(mut self, reader: EnvReader) -> Self {
self.env_reader = reader;
self
}
fn key_to_env_name(&self, key: &str) -> String {
key.to_uppercase().replace(['.', '/', '-'], "_")
}
fn prefixed_env_name(&self, key: &str) -> String {
format!("{}_{}", self.prefix, self.key_to_env_name(key))
}
fn unprefixed_env_name(&self, key: &str) -> String {
self.key_to_env_name(key)
}
}
impl std::fmt::Debug for EnvVarStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EnvVarStore")
.field("prefix", &self.prefix)
.field("fallback_without_prefix", &self.fallback_without_prefix)
.finish()
}
}
impl Default for EnvVarStore {
fn default() -> Self {
Self::new()
}
}
impl CredentialStore for EnvVarStore {
fn store(&self, _key: &str, _value: &SecretString) -> Result<()> {
Err(Error::Storage(
"EnvVarStore is read-only. Use OS keychain or set environment variables directly."
.to_string(),
))
}
fn get(&self, key: &str) -> Result<Option<SecretString>> {
let prefixed = self.prefixed_env_name(key);
if let Ok(value) = (self.env_reader)(&prefixed) {
debug!(key = key, env_var = %prefixed, "Found credential in environment variable");
return Ok(Some(SecretString::from(value)));
}
if self.fallback_without_prefix {
let unprefixed = self.unprefixed_env_name(key);
if let Ok(value) = (self.env_reader)(&unprefixed) {
debug!(key = key, env_var = %unprefixed, "Found credential in environment variable (unprefixed)");
return Ok(Some(SecretString::from(value)));
}
}
debug!(key = key, "Credential not found in environment variables");
Ok(None)
}
fn delete(&self, _key: &str) -> Result<()> {
Err(Error::Storage(
"EnvVarStore is read-only. Environment variables cannot be deleted.".to_string(),
))
}
fn is_writable(&self) -> bool {
false
}
}
pub struct ChainStore {
stores: Vec<Box<dyn CredentialStore>>,
}
impl ChainStore {
pub fn new(stores: Vec<Box<dyn CredentialStore>>) -> Self {
Self { stores }
}
pub fn default_chain() -> Self {
Self::new(vec![
Box::new(EnvVarStore::new()),
Box::new(KeychainStore::new()),
])
}
pub fn ci_chain() -> Self {
Self::new(vec![
Box::new(EnvVarStore::new()),
Box::new(MemoryStore::new()),
])
}
pub fn len(&self) -> usize {
self.stores.len()
}
pub fn is_empty(&self) -> bool {
self.stores.is_empty()
}
}
impl std::fmt::Debug for ChainStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChainStore")
.field("stores_count", &self.stores.len())
.finish()
}
}
impl CredentialStore for ChainStore {
fn store(&self, key: &str, value: &SecretString) -> Result<()> {
let mut last_error: Option<Error> = None;
for store in &self.stores {
if store.is_writable() && store.is_available() {
match store.store(key, value) {
Ok(()) => return Ok(()),
Err(e) => {
debug!(key = key, error = %e, "Store write failed, trying next");
last_error = Some(e);
}
}
}
}
Err(last_error.unwrap_or_else(|| {
Error::Storage("No writable credential store available in chain".to_string())
}))
}
fn get(&self, key: &str) -> Result<Option<SecretString>> {
let mut last_error: Option<Error> = None;
for store in &self.stores {
match store.get(key) {
Ok(Some(value)) => return Ok(Some(value)),
Ok(None) => continue,
Err(e) => {
debug!(key = key, error = %e, "Store returned error, trying next");
last_error = Some(e);
}
}
}
if let Some(e) = last_error {
Err(e)
} else {
Ok(None)
}
}
fn delete(&self, key: &str) -> Result<()> {
let mut deleted_any = false;
let mut last_error: Option<Error> = None;
for store in &self.stores {
if store.is_writable() {
match store.delete(key) {
Ok(()) => deleted_any = true,
Err(e) => last_error = Some(e),
}
}
}
if deleted_any {
Ok(())
} else if let Some(e) = last_error {
Err(e)
} else {
Ok(())
}
}
fn is_available(&self) -> bool {
self.stores.iter().any(|s| s.is_available())
}
fn is_writable(&self) -> bool {
self.stores.iter().any(|s| s.is_writable())
}
}
pub fn token_key(provider: &str) -> String {
format!("{}/token", provider)
}
pub fn build_default_store(cache_ttl_secs: u64) -> Box<dyn CredentialStore> {
let chain = ChainStore::default_chain();
if cache_ttl_secs == 0 {
Box::new(chain)
} else {
Box::new(CachedStore::new(
chain,
std::time::Duration::from_secs(cache_ttl_secs),
))
}
}
pub fn wrap_with_cache<S: CredentialStore + 'static>(
inner: S,
cache_ttl_secs: u64,
) -> Box<dyn CredentialStore> {
if cache_ttl_secs == 0 {
Box::new(inner)
} else {
Box::new(CachedStore::new(
inner,
std::time::Duration::from_secs(cache_ttl_secs),
))
}
}
pub fn email_key(provider: &str) -> String {
format!("{}/email", provider)
}
#[cfg(test)]
mod tests {
use super::*;
fn secret(s: &str) -> SecretString {
SecretString::from(s.to_string())
}
fn exposed(s: &Option<SecretString>) -> Option<&str> {
s.as_ref().map(|v| v.expose_secret())
}
#[test]
fn test_memory_store_basic() {
let store = MemoryStore::new();
store.store("test/key", &secret("test-value")).unwrap();
let value = store.get("test/key").unwrap();
assert_eq!(exposed(&value), Some("test-value"));
assert!(store.exists("test/key"));
assert!(!store.exists("nonexistent"));
store.delete("test/key").unwrap();
let value = store.get("test/key").unwrap();
assert!(value.is_none());
store.delete("nonexistent").unwrap();
}
#[test]
fn test_memory_store_with_credentials() {
let store = MemoryStore::with_credentials([
("gitlab/token".to_string(), "glpat-xxx".to_string()),
("github/token".to_string(), "ghp-yyy".to_string()),
]);
assert_eq!(
exposed(&store.get("gitlab/token").unwrap()),
Some("glpat-xxx")
);
assert_eq!(
exposed(&store.get("github/token").unwrap()),
Some("ghp-yyy")
);
}
#[test]
fn test_token_key() {
assert_eq!(token_key("gitlab"), "gitlab/token");
assert_eq!(token_key("github"), "github/token");
}
#[test]
fn test_email_key() {
assert_eq!(email_key("jira"), "jira/email");
}
#[test]
fn test_memory_store_delete_nonexistent() {
let store = MemoryStore::new();
store.delete("nonexistent/key").unwrap();
assert!(store.get("nonexistent/key").unwrap().is_none());
}
#[test]
fn test_memory_store_exists() {
let store = MemoryStore::new();
assert!(!store.exists("test/key"));
store.store("test/key", &secret("value")).unwrap();
assert!(store.exists("test/key"));
store.delete("test/key").unwrap();
assert!(!store.exists("test/key"));
}
#[test]
fn test_memory_store_overwrite() {
let store = MemoryStore::new();
store.store("test/key", &secret("value1")).unwrap();
assert_eq!(exposed(&store.get("test/key").unwrap()), Some("value1"));
store.store("test/key", &secret("value2")).unwrap();
assert_eq!(exposed(&store.get("test/key").unwrap()), Some("value2"));
}
#[test]
fn test_credential_store_exists_default_impl() {
let store = MemoryStore::new();
store.store("key1", &secret("val1")).unwrap();
assert!(CredentialStore::exists(&store, "key1"));
assert!(!CredentialStore::exists(&store, "key2"));
}
#[test]
fn test_keychain_store_new() {
let store = KeychainStore::new();
assert_eq!(store.service_name, "devboy-tools");
}
#[test]
fn test_keychain_store_with_service_name() {
let store = KeychainStore::with_service_name("test-service");
assert_eq!(store.service_name, "test-service");
}
#[test]
fn test_keychain_store_default() {
let store = KeychainStore::default();
assert_eq!(store.service_name, "devboy-tools");
}
#[test]
fn test_env_var_store_new() {
let store = EnvVarStore::new();
assert_eq!(store.prefix, "DEVBOY");
assert!(store.fallback_without_prefix);
}
#[test]
fn test_env_var_store_with_prefix() {
let store = EnvVarStore::with_prefix("CUSTOM");
assert_eq!(store.prefix, "CUSTOM");
assert!(store.fallback_without_prefix);
}
#[test]
fn test_env_var_store_without_fallback() {
let store = EnvVarStore::new().without_fallback();
assert!(!store.fallback_without_prefix);
}
#[test]
fn test_env_var_store_key_to_env_name() {
let store = EnvVarStore::new();
assert_eq!(store.key_to_env_name("github.token"), "GITHUB_TOKEN");
assert_eq!(store.key_to_env_name("gitlab/token"), "GITLAB_TOKEN");
assert_eq!(
store.key_to_env_name("contexts.dashboard.github.token"),
"CONTEXTS_DASHBOARD_GITHUB_TOKEN"
);
assert_eq!(
store.key_to_env_name("devboy-cloud.token"),
"DEVBOY_CLOUD_TOKEN"
);
}
#[test]
fn test_env_var_store_prefixed_env_name() {
let store = EnvVarStore::new();
assert_eq!(
store.prefixed_env_name("github.token"),
"DEVBOY_GITHUB_TOKEN"
);
let custom = EnvVarStore::with_prefix("MYAPP");
assert_eq!(
custom.prefixed_env_name("github.token"),
"MYAPP_GITHUB_TOKEN"
);
}
fn mock_env_reader(key: &str) -> std::result::Result<String, std::env::VarError> {
match key {
"DEVBOY_TEST_TOKEN" => Ok("prefixed-value".into()),
"TEST_FALLBACK_TOKEN" => Ok("unprefixed-value".into()),
"DEVBOY_TEST_PRIORITY_TOKEN" => Ok("prefixed".into()),
"TEST_PRIORITY_TOKEN" => Ok("unprefixed".into()),
"TEST_NO_FALLBACK_TOKEN" => Ok("unprefixed-value".into()),
"DEVBOY_CHAIN_TEST_TOKEN" => Ok("from-env".into()),
_ => Err(std::env::VarError::NotPresent),
}
}
#[test]
fn test_env_var_store_get_prefixed() {
let store = EnvVarStore::new().with_env_reader(mock_env_reader);
let result = store.get("test.token").unwrap();
assert_eq!(exposed(&result), Some("prefixed-value"));
}
#[test]
fn test_env_var_store_get_unprefixed_fallback() {
let store = EnvVarStore::new().with_env_reader(mock_env_reader);
let result = store.get("test.fallback.token").unwrap();
assert_eq!(exposed(&result), Some("unprefixed-value"));
}
#[test]
fn test_env_var_store_prefixed_takes_priority() {
let store = EnvVarStore::new().with_env_reader(mock_env_reader);
let result = store.get("test.priority.token").unwrap();
assert_eq!(exposed(&result), Some("prefixed"));
}
#[test]
fn test_env_var_store_no_fallback() {
let store = EnvVarStore::new()
.without_fallback()
.with_env_reader(mock_env_reader);
let result = store.get("test.no.fallback.token").unwrap();
assert!(result.is_none());
}
#[test]
fn test_env_var_store_not_found() {
let store = EnvVarStore::new().with_env_reader(mock_env_reader);
let result = store.get("nonexistent.key.that.does.not.exist").unwrap();
assert!(result.is_none());
}
#[test]
fn test_env_var_store_is_read_only() {
let store = EnvVarStore::new();
assert!(!store.is_writable());
let store_result = store.store("test.key", &secret("value"));
assert!(store_result.is_err());
let delete_result = store.delete("test.key");
assert!(delete_result.is_err());
}
#[test]
fn test_env_var_store_default() {
let store = EnvVarStore::default();
assert_eq!(store.prefix, "DEVBOY");
}
#[test]
fn test_chain_store_new() {
let store = ChainStore::new(vec![]);
assert!(store.is_empty());
assert_eq!(store.len(), 0);
}
#[test]
fn test_chain_store_default_chain() {
let store = ChainStore::default_chain();
assert_eq!(store.len(), 2); assert!(!store.is_empty());
}
#[test]
fn test_chain_store_ci_chain() {
let store = ChainStore::ci_chain();
assert_eq!(store.len(), 2); }
#[test]
fn test_chain_store_get_first_match_wins() {
let store1 = MemoryStore::with_credentials([("key1".to_string(), "value1".to_string())]);
let store2 = MemoryStore::with_credentials([
("key1".to_string(), "value2".to_string()),
("key2".to_string(), "value2".to_string()),
]);
let chain = ChainStore::new(vec![Box::new(store1), Box::new(store2)]);
assert_eq!(exposed(&chain.get("key1").unwrap()), Some("value1"));
assert_eq!(exposed(&chain.get("key2").unwrap()), Some("value2"));
assert!(chain.get("key3").unwrap().is_none());
}
#[test]
fn test_chain_store_store_to_first_writable() {
let chain = ChainStore::new(vec![
Box::new(EnvVarStore::new()),
Box::new(MemoryStore::new()),
]);
chain.store("test.key", &secret("test-value")).unwrap();
assert_eq!(exposed(&chain.get("test.key").unwrap()), Some("test-value"));
}
#[test]
fn test_chain_store_no_writable_store_error() {
let chain = ChainStore::new(vec![Box::new(EnvVarStore::new())]);
let result = chain.store("test.key", &secret("value"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("No writable"));
}
#[test]
fn test_chain_store_delete_from_all_writable() {
let store1 = MemoryStore::new();
let store2 = MemoryStore::new();
store1.store("key", &secret("val1")).unwrap();
store2.store("key", &secret("val2")).unwrap();
let chain = ChainStore::new(vec![Box::new(store1), Box::new(store2)]);
chain.delete("key").unwrap();
assert!(chain.get("key").unwrap().is_none());
}
#[test]
fn test_chain_store_is_available() {
let empty = ChainStore::new(vec![]);
assert!(!empty.is_available());
let with_memory = ChainStore::new(vec![Box::new(MemoryStore::new())]);
assert!(with_memory.is_available());
}
#[test]
fn test_chain_store_is_writable() {
let read_only = ChainStore::new(vec![Box::new(EnvVarStore::new())]);
assert!(!read_only.is_writable());
let writable = ChainStore::new(vec![Box::new(MemoryStore::new())]);
assert!(writable.is_writable());
}
#[test]
fn test_chain_store_env_var_priority() {
let env_store = EnvVarStore::new().with_env_reader(mock_env_reader);
let memory = MemoryStore::with_credentials([(
"chain.test.token".to_string(),
"from-memory".to_string(),
)]);
let chain = ChainStore::new(vec![Box::new(env_store), Box::new(memory)]);
assert_eq!(
exposed(&chain.get("chain.test.token").unwrap()),
Some("from-env")
);
}
#[test]
fn test_chain_store_fallback_to_memory_when_env_empty() {
let memory = MemoryStore::with_credentials([(
"fallback.test.token".to_string(),
"from-memory".to_string(),
)]);
let chain = ChainStore::new(vec![Box::new(EnvVarStore::new()), Box::new(memory)]);
assert_eq!(
exposed(&chain.get("fallback.test.token").unwrap()),
Some("from-memory")
);
}
#[test]
fn test_chain_store_debug_impl() {
let chain = ChainStore::default_chain();
let debug_str = format!("{:?}", chain);
assert!(debug_str.contains("ChainStore"));
assert!(debug_str.contains("stores_count"));
}
#[test]
fn test_build_default_store_zero_ttl_returns_writable_chain() {
let store = build_default_store(0);
assert!(store.is_writable());
}
#[test]
fn test_build_default_store_positive_ttl_delegates_writable() {
let store = build_default_store(60);
assert!(store.is_writable());
}
#[test]
fn test_wrap_with_cache_zero_ttl_is_passthrough() {
let inner = MemoryStore::with_credentials([("k".to_string(), "v".to_string())]);
let store = wrap_with_cache(inner, 0);
assert_eq!(exposed(&store.get("k").unwrap()), Some("v"));
}
#[test]
fn test_wrap_with_cache_populated_ttl_caches_lookups() {
let inner = MemoryStore::with_credentials([("k".to_string(), "v1".to_string())]);
let store = wrap_with_cache(inner, 60);
assert_eq!(exposed(&store.get("k").unwrap()), Some("v1"));
assert_eq!(exposed(&store.get("k").unwrap()), Some("v1"));
}
}