use std::env;
use std::error::Error;
use std::fmt;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Clone, Eq, PartialEq)]
pub struct SecretString(String);
impl SecretString {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose_secret(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretString([REDACTED])")
}
}
impl fmt::Display for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "from", rename_all = "camelCase", deny_unknown_fields)]
pub enum SecretRef {
Env { name: String },
KubernetesSecret { name: String, key: String },
Vault { path: String, key: String },
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ResolveOptions {
pub allow_literal_secrets: bool,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SecretResolutionError {
#[error("unsupported secret reference provider `{provider}`")]
UnsupportedReference { provider: &'static str },
#[error("failed to resolve environment variable `{name}`: {source}")]
EnvVar {
name: String,
#[source]
source: env::VarError,
},
#[error("secret provider failed: {message}")]
Provider {
message: String,
#[source]
source: Option<Box<dyn Error + Send + Sync>>,
},
}
#[async_trait]
pub trait SecretResolver: Send + Sync {
async fn resolve(&self, secret_ref: &SecretRef) -> Result<SecretString, SecretResolutionError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct EnvSecretResolver;
#[async_trait]
impl SecretResolver for EnvSecretResolver {
async fn resolve(&self, secret_ref: &SecretRef) -> Result<SecretString, SecretResolutionError> {
match secret_ref {
SecretRef::Env { name } => env::var(name).map(SecretString::new).map_err(|source| {
SecretResolutionError::EnvVar {
name: name.clone(),
source,
}
}),
SecretRef::KubernetesSecret { .. } => {
Err(SecretResolutionError::UnsupportedReference {
provider: "kubernetesSecret",
})
}
SecretRef::Vault { .. } => {
Err(SecretResolutionError::UnsupportedReference { provider: "vault" })
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_string_redacts_debug_and_display() {
let secret = SecretString::new("super-secret");
assert_eq!(format!("{secret:?}"), "SecretString([REDACTED])");
assert_eq!(secret.to_string(), "[REDACTED]");
assert_eq!(secret.expose_secret(), "super-secret");
}
#[test]
fn secret_ref_parses_env_contract() {
let value = serde_json::json!({
"from": "env",
"name": "POSTGRES_PASSWORD"
});
let parsed: SecretRef = serde_json::from_value(value).unwrap();
assert_eq!(
parsed,
SecretRef::Env {
name: "POSTGRES_PASSWORD".into()
}
);
}
#[test]
fn secret_ref_rejects_unknown_fields() {
let value = serde_json::json!({
"from": "env",
"name": "POSTGRES_PASSWORD",
"extra": true
});
let err = serde_json::from_value::<SecretRef>(value).unwrap_err();
assert!(err.to_string().contains("unknown field"));
}
#[tokio::test]
async fn env_resolver_reports_missing_env_as_provider_error() {
let resolver = EnvSecretResolver;
let err = resolver
.resolve(&SecretRef::Env {
name: "CRABKA_CONNECT_TEST_MISSING_SECRET".into(),
})
.await
.unwrap_err();
assert!(matches!(
err,
SecretResolutionError::EnvVar {
name,
source: env::VarError::NotPresent,
} if name == "CRABKA_CONNECT_TEST_MISSING_SECRET"
));
}
#[tokio::test]
async fn env_resolver_reports_valid_non_env_refs_as_unsupported() {
let resolver = EnvSecretResolver;
let err = resolver
.resolve(&SecretRef::Vault {
path: "secret/data/connect/pg".into(),
key: "password".into(),
})
.await
.unwrap_err();
assert!(matches!(
err,
SecretResolutionError::UnsupportedReference { provider: "vault" }
));
}
#[test]
fn provider_error_carries_message_and_optional_source() {
let err = SecretResolutionError::Provider {
message: "vault token expired".into(),
source: Some(Box::new(env::VarError::NotPresent)),
};
assert_eq!(
err.to_string(),
"secret provider failed: vault token expired"
);
assert!(std::error::Error::source(&err).is_some());
}
}