use std::path::Path;
#[derive(Debug, Clone)]
pub struct SecretValue {
pub value: String,
pub provider: &'static str,
}
pub trait SecretProvider {
fn resolve(&self, key: &str) -> Result<Option<SecretValue>, String>;
fn name(&self) -> &'static str;
}
pub struct EnvProvider;
impl SecretProvider for EnvProvider {
fn resolve(&self, key: &str) -> Result<Option<SecretValue>, String> {
let env_key = key.to_uppercase().replace('-', "_");
match std::env::var(&env_key) {
Ok(val) => Ok(Some(SecretValue {
value: val,
provider: "env",
})),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(e) => Err(format!("env var '{env_key}': {e}")),
}
}
fn name(&self) -> &'static str {
"env"
}
}
pub struct FileProvider {
dir: std::path::PathBuf,
}
impl FileProvider {
pub fn new(dir: &Path) -> Self {
Self {
dir: dir.to_path_buf(),
}
}
}
impl SecretProvider for FileProvider {
fn resolve(&self, key: &str) -> Result<Option<SecretValue>, String> {
if key.contains("..") || key.contains('/') || key.contains('\\') {
return Err(format!(
"invalid secret key '{}': must not contain path separators or '..'",
key
));
}
let path = self.dir.join(key);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("read secret {}: {e}", path.display()))?;
Ok(Some(SecretValue {
value: content.trim().to_string(),
provider: "file",
}))
}
fn name(&self) -> &'static str {
"file"
}
}
pub struct ExecProvider {
command: String,
}
impl ExecProvider {
pub fn new(command: &str) -> Self {
Self {
command: command.to_string(),
}
}
}
impl SecretProvider for ExecProvider {
fn resolve(&self, key: &str) -> Result<Option<SecretValue>, String> {
let output = std::process::Command::new("sh")
.args(["-c", &format!("{} \"$1\"", self.command), "--", key])
.output()
.map_err(|e| format!("exec secret provider: {e}"))?;
if !output.status.success() {
return Ok(None);
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
return Ok(None);
}
Ok(Some(SecretValue {
value,
provider: "exec",
}))
}
fn name(&self) -> &'static str {
"exec"
}
}
pub struct ProviderChain {
providers: Vec<Box<dyn SecretProvider>>,
}
impl ProviderChain {
pub fn new() -> Self {
Self {
providers: Vec::new(),
}
}
pub fn with(mut self, provider: Box<dyn SecretProvider>) -> Self {
self.providers.push(provider);
self
}
pub fn resolve(&self, key: &str) -> Result<Option<SecretValue>, String> {
for provider in &self.providers {
if let Some(val) = provider.resolve(key)? {
return Ok(Some(val));
}
}
Ok(None)
}
}
impl Default for ProviderChain {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn env_provider_missing_key() {
let provider = EnvProvider;
let result = provider.resolve("FORJAR_NONEXISTENT_KEY_999").unwrap();
assert!(result.is_none());
}
#[test]
fn env_provider_uses_path_var() {
let provider = EnvProvider;
let result = provider.resolve("PATH").unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().provider, "env");
}
#[test]
fn file_provider_resolves() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("db-password"), "s3cret\n").unwrap();
let provider = FileProvider::new(dir.path());
let result = provider.resolve("db-password").unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().value, "s3cret");
}
#[test]
fn file_provider_missing() {
let dir = TempDir::new().unwrap();
let provider = FileProvider::new(dir.path());
let result = provider.resolve("nonexistent").unwrap();
assert!(result.is_none());
}
#[test]
fn provider_chain_first_match() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("api-key"), "from-file").unwrap();
let chain = ProviderChain::new()
.with(Box::new(EnvProvider))
.with(Box::new(FileProvider::new(dir.path())));
let result = chain.resolve("api-key").unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().provider, "file");
}
#[test]
fn provider_chain_empty() {
let chain = ProviderChain::new();
let result = chain.resolve("anything").unwrap();
assert!(result.is_none());
}
#[test]
fn provider_chain_default() {
let chain = ProviderChain::default();
assert!(chain.resolve("test").unwrap().is_none());
}
#[test]
fn provider_names() {
assert_eq!(EnvProvider.name(), "env");
let dir = TempDir::new().unwrap();
assert_eq!(FileProvider::new(dir.path()).name(), "file");
assert_eq!(ExecProvider::new("echo").name(), "exec");
}
#[test]
fn exec_provider_resolves() {
let provider = ExecProvider::new("echo");
let result = provider.resolve("hello").unwrap();
assert!(result.is_some());
assert!(result.unwrap().value.contains("hello"));
}
#[test]
fn exec_provider_failure() {
let provider = ExecProvider::new("false");
let result = provider.resolve("key").unwrap();
assert!(result.is_none());
}
}