use thiserror::Error;
#[derive(Debug, Error)]
pub enum SecretError {
#[error("secret not found: {0}")]
NotFound(String),
#[error("access denied: {0}")]
AccessDenied(String),
#[error("backend unavailable: {0}")]
Unavailable(String),
}
#[derive(Clone)]
pub struct SecretString {
inner: String,
}
impl SecretString {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self {
inner: value.into(),
}
}
#[must_use]
pub fn expose(&self) -> &str {
&self.inner
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
impl std::fmt::Display for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
#[cfg(feature = "secure")]
impl Drop for SecretString {
fn drop(&mut self) {
use zeroize::Zeroize;
self.inner.zeroize();
}
}
pub trait SecretProvider: Send + Sync {
fn get_secret(&self, key: &str) -> Result<SecretString, SecretError>;
fn has_secret(&self, key: &str) -> bool {
self.get_secret(key).is_ok()
}
}
#[derive(Debug, Default, Clone)]
pub struct EnvSecretProvider;
impl SecretProvider for EnvSecretProvider {
fn get_secret(&self, key: &str) -> Result<SecretString, SecretError> {
std::env::var(key)
.map(SecretString::new)
.map_err(|_| SecretError::NotFound(key.to_string()))
}
}
#[derive(Clone)]
pub struct StaticSecretProvider {
value: SecretString,
}
impl StaticSecretProvider {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self {
value: SecretString::new(value),
}
}
}
impl std::fmt::Debug for StaticSecretProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StaticSecretProvider")
.field("value", &"[REDACTED]")
.finish()
}
}
impl SecretProvider for StaticSecretProvider {
fn get_secret(&self, _key: &str) -> Result<SecretString, SecretError> {
Ok(self.value.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_string_debug_is_redacted() {
let s = SecretString::new("super-secret-key");
assert_eq!(format!("{s:?}"), "[REDACTED]");
}
#[test]
fn secret_string_display_is_redacted() {
let s = SecretString::new("super-secret-key");
assert_eq!(format!("{s}"), "[REDACTED]");
}
#[test]
fn secret_string_expose_returns_value() {
let s = SecretString::new("my-key-123");
assert_eq!(s.expose(), "my-key-123");
}
#[test]
fn env_provider_returns_not_found_for_missing_var() {
let provider = EnvSecretProvider;
let result = provider.get_secret("CONVERGE_TEST_NONEXISTENT_KEY_12345");
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), SecretError::NotFound(k) if k == "CONVERGE_TEST_NONEXISTENT_KEY_12345")
);
}
#[test]
fn static_provider_returns_value_for_any_key() {
let provider = StaticSecretProvider::new("test-secret");
let s1 = provider.get_secret("ANY_KEY").unwrap();
let s2 = provider.get_secret("OTHER_KEY").unwrap();
assert_eq!(s1.expose(), "test-secret");
assert_eq!(s2.expose(), "test-secret");
}
#[test]
fn static_provider_debug_is_redacted() {
let provider = StaticSecretProvider::new("secret");
let debug = format!("{provider:?}");
assert!(!debug.contains("secret"));
assert!(debug.contains("REDACTED"));
}
#[test]
fn has_secret_delegates_to_get_secret() {
let provider = StaticSecretProvider::new("val");
assert!(provider.has_secret("anything"));
let env_provider = EnvSecretProvider;
assert!(!env_provider.has_secret("CONVERGE_TEST_NONEXISTENT_KEY_12345"));
}
}