mod batch;
mod fingerprint;
mod registry;
mod resolved;
pub mod resolvers;
mod salt;
mod types;
pub use batch::{BatchConfig, BatchResolver, resolve_batch};
pub use fingerprint::compute_secret_fingerprint;
pub use registry::SecretRegistry;
pub use resolved::ResolvedSecrets;
pub use salt::SaltConfig;
pub use types::{BatchSecrets, SecureSecret};
pub use resolvers::{EnvSecretResolver, ExecSecretResolver};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SecretError {
#[error("Secret '{name}' not found from source '{secret_source}'")]
NotFound {
name: String,
secret_source: String,
},
#[error("Secret '{name}' is too short ({len} chars, minimum 4) for cache key inclusion")]
TooShort {
name: String,
len: usize,
},
#[error("CUENV_SECRET_SALT required when secrets have cache_key: true")]
MissingSalt,
#[error("Failed to resolve secret '{name}': {message}")]
ResolutionFailed {
name: String,
message: String,
},
#[error("Unsupported secret resolver: {resolver}")]
UnsupportedResolver {
resolver: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SecretSpec {
pub source: String,
#[serde(default)]
pub cache_key: bool,
}
impl SecretSpec {
#[must_use]
pub fn new(source: impl Into<String>) -> Self {
Self {
source: source.into(),
cache_key: false,
}
}
#[must_use]
pub fn with_cache_key(source: impl Into<String>) -> Self {
Self {
source: source.into(),
cache_key: true,
}
}
}
#[async_trait]
pub trait SecretResolver: Send + Sync {
async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError>;
fn provider_name(&self) -> &'static str;
async fn resolve_secure(
&self,
name: &str,
spec: &SecretSpec,
) -> Result<SecureSecret, SecretError> {
let value = self.resolve(name, spec).await?;
Ok(SecureSecret::new(value))
}
async fn resolve_batch(
&self,
secrets: &HashMap<String, SecretSpec>,
) -> Result<HashMap<String, SecureSecret>, SecretError> {
use futures::future::try_join_all;
let futures: Vec<_> = secrets
.iter()
.map(|(name, spec)| {
let name = name.clone();
let spec = spec.clone();
async move {
let value = self.resolve_secure(&name, &spec).await?;
Ok::<_, SecretError>((name, value))
}
})
.collect();
let results = try_join_all(futures).await?;
Ok(results.into_iter().collect())
}
fn supports_native_batch(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_error_not_found() {
let err = SecretError::NotFound {
name: "API_KEY".to_string(),
secret_source: "env:API_KEY".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("API_KEY"));
assert!(msg.contains("env:API_KEY"));
}
#[test]
fn test_secret_error_too_short() {
let err = SecretError::TooShort {
name: "SHORT_SECRET".to_string(),
len: 2,
};
let msg = err.to_string();
assert!(msg.contains("SHORT_SECRET"));
assert!(msg.contains("2 chars"));
assert!(msg.contains("minimum 4"));
}
#[test]
fn test_secret_error_missing_salt() {
let err = SecretError::MissingSalt;
let msg = err.to_string();
assert!(msg.contains("CUENV_SECRET_SALT"));
assert!(msg.contains("cache_key: true"));
}
#[test]
fn test_secret_error_resolution_failed() {
let err = SecretError::ResolutionFailed {
name: "DATABASE_URL".to_string(),
message: "connection timeout".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("DATABASE_URL"));
assert!(msg.contains("connection timeout"));
}
#[test]
fn test_secret_error_unsupported_resolver() {
let err = SecretError::UnsupportedResolver {
resolver: "unknown".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("unknown"));
}
#[test]
fn test_secret_error_debug() {
let err = SecretError::MissingSalt;
let debug = format!("{err:?}");
assert!(debug.contains("MissingSalt"));
}
#[test]
fn test_secret_spec_new() {
let spec = SecretSpec::new("env:API_KEY");
assert_eq!(spec.source, "env:API_KEY");
assert!(!spec.cache_key);
}
#[test]
fn test_secret_spec_with_cache_key() {
let spec = SecretSpec::with_cache_key("env:CACHE_AFFECTING_SECRET");
assert_eq!(spec.source, "env:CACHE_AFFECTING_SECRET");
assert!(spec.cache_key);
}
#[test]
fn test_secret_spec_new_with_string() {
let spec = SecretSpec::new(String::from("vault://path/to/secret"));
assert_eq!(spec.source, "vault://path/to/secret");
}
#[test]
fn test_secret_spec_equality() {
let spec1 = SecretSpec::new("source1");
let spec2 = SecretSpec::new("source1");
let spec3 = SecretSpec::new("source2");
let spec4 = SecretSpec::with_cache_key("source1");
assert_eq!(spec1, spec2);
assert_ne!(spec1, spec3);
assert_ne!(spec1, spec4); }
#[test]
fn test_secret_spec_clone() {
let spec = SecretSpec::with_cache_key("important");
let cloned = spec.clone();
assert_eq!(spec, cloned);
}
#[test]
fn test_secret_spec_debug() {
let spec = SecretSpec::new("test-source");
let debug = format!("{spec:?}");
assert!(debug.contains("SecretSpec"));
assert!(debug.contains("test-source"));
}
#[test]
fn test_secret_spec_serialization() {
let spec = SecretSpec::with_cache_key("op://vault/item/field");
let json = serde_json::to_string(&spec).unwrap();
assert!(json.contains("op://vault/item/field"));
assert!(json.contains("cache_key"));
let parsed: SecretSpec = serde_json::from_str(&json).unwrap();
assert_eq!(spec, parsed);
}
#[test]
fn test_secret_spec_deserialization_default_cache_key() {
let json = r#"{"source": "test"}"#;
let spec: SecretSpec = serde_json::from_str(json).unwrap();
assert_eq!(spec.source, "test");
assert!(!spec.cache_key); }
}