This repository has been archived on 2026-08-20. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
el-retired/ui/vessels/el-secrets/src/resolver.rs
T

283 lines
8.2 KiB
Rust

/// SecretsResolver — loads all required secrets at startup.
///
/// The resolver validates that all required secrets are present before the
/// application starts. If any are missing, startup fails with a clear error
/// listing what's missing — not a runtime panic deep in the app.
use std::collections::HashMap;
use crate::error::SecretsError;
use crate::secret::Secret;
use crate::source::SecretsSource;
/// A resolved, validated set of secrets.
///
/// Created by SecretsResolver at startup. Once resolved, all secrets are
/// available and guaranteed to have been present at startup time.
pub struct ResolvedSecrets {
values: HashMap<String, Secret<String>>,
}
impl ResolvedSecrets {
/// Get a secret by key.
///
/// Returns None if the key wasn't declared as required.
/// In normal use you will always have the keys you declared.
pub fn get(&self, key: &str) -> Option<&Secret<String>> {
self.values.get(key)
}
/// Get a secret or panic with a clear message.
///
/// Use this for secrets that are truly required and were declared in
/// the resolver — if the resolver passed, this will always succeed.
pub fn require(&self, key: &str) -> &Secret<String> {
self.values.get(key).unwrap_or_else(|| {
panic!(
"Secret '{}' was not declared as required in SecretsResolver. \
Declare all required secrets before calling resolve().",
key
)
})
}
/// The number of resolved secrets.
pub fn len(&self) -> usize {
self.values.len()
}
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
/// Loads and validates all required secrets at startup.
///
/// Usage:
/// ```ignore
/// let secrets = SecretsResolver::new()
/// .source(EnvVarSource::new())
/// .require("jwt.secret_key")
/// .require("database.password")
/// .resolve()?;
///
/// let jwt_key = secrets.require("jwt.secret_key");
/// ```
pub struct SecretsResolver {
sources: Vec<Box<dyn SecretsSource>>,
required: Vec<String>,
}
impl SecretsResolver {
pub fn new() -> Self {
Self {
sources: Vec::new(),
required: Vec::new(),
}
}
/// Add a secret source. Sources are tried in order; first success wins.
pub fn source(mut self, source: Box<dyn SecretsSource>) -> Self {
self.sources.push(source);
self
}
/// Declare a required secret key.
///
/// All required keys must be present in at least one source.
/// resolve() fails if any are missing.
pub fn require(mut self, key: impl Into<String>) -> Self {
self.required.push(key.into());
self
}
/// Declare multiple required secret keys.
pub fn require_all(mut self, keys: &[&str]) -> Self {
for key in keys {
self.required.push(key.to_string());
}
self
}
/// Load all required secrets and validate they are all present.
///
/// Returns an error listing ALL missing secrets — not just the first one —
/// so you can fix them all in one go.
pub fn resolve(self) -> Result<ResolvedSecrets, SecretsError> {
let mut values = HashMap::new();
let mut missing = Vec::new();
for key in &self.required {
match self.fetch_from_sources(key) {
Ok(secret) => {
values.insert(key.clone(), secret);
}
Err(_) => {
missing.push(key.clone());
}
}
}
if !missing.is_empty() {
return Err(SecretsError::MissingRequired { keys: missing });
}
Ok(ResolvedSecrets { values })
}
/// Attempt to fetch a key from sources in order.
fn fetch_from_sources(&self, key: &str) -> Result<Secret<String>, SecretsError> {
for source in &self.sources {
if let Ok(secret) = source.get(key) {
return Ok(secret);
}
}
Err(SecretsError::NotFoundInAnySource {
key: key.to_string(),
})
}
/// Resolve without requiring all keys — useful when you want
/// to load whatever is available.
pub fn resolve_optional(self) -> ResolvedSecrets {
let mut values = HashMap::new();
for key in &self.required {
if let Ok(secret) = self.fetch_from_sources(key) {
values.insert(key.clone(), secret);
}
}
ResolvedSecrets { values }
}
}
impl Default for SecretsResolver {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::source::InMemorySource;
fn make_source(pairs: &[(&str, &str)]) -> InMemorySource {
let mut src = InMemorySource::new();
for (k, v) in pairs {
src.insert(*k, *v);
}
src
}
#[test]
fn resolve_all_present() {
let src = make_source(&[("jwt.key", "secret"), ("db.password", "pass123")]);
let secrets = SecretsResolver::new()
.source(Box::new(src))
.require("jwt.key")
.require("db.password")
.resolve()
.unwrap();
assert_eq!(secrets.require("jwt.key").expose(), "secret");
assert_eq!(secrets.require("db.password").expose(), "pass123");
}
#[test]
fn resolve_missing_fails_with_list() {
let src = make_source(&[("jwt.key", "secret")]);
let result = SecretsResolver::new()
.source(Box::new(src))
.require("jwt.key")
.require("db.password") // missing
.require("stripe.key") // also missing
.resolve();
match result {
Err(SecretsError::MissingRequired { keys }) => {
assert!(keys.contains(&"db.password".to_string()));
assert!(keys.contains(&"stripe.key".to_string()));
assert_eq!(keys.len(), 2);
}
_ => panic!("expected MissingRequired error"),
}
}
#[test]
fn resolve_optional_skips_missing() {
let src = make_source(&[("jwt.key", "secret")]);
let secrets = SecretsResolver::new()
.source(Box::new(src))
.require("jwt.key")
.require("missing.key")
.resolve_optional();
assert!(secrets.get("jwt.key").is_some());
assert!(secrets.get("missing.key").is_none());
}
#[test]
fn multiple_sources_fallback() {
let mut primary = InMemorySource::new();
primary.insert("jwt.key", "from-primary");
let mut secondary = InMemorySource::new();
secondary.insert("db.password", "from-secondary");
let secrets = SecretsResolver::new()
.source(Box::new(primary))
.source(Box::new(secondary))
.require("jwt.key")
.require("db.password")
.resolve()
.unwrap();
assert_eq!(secrets.require("jwt.key").expose(), "from-primary");
assert_eq!(secrets.require("db.password").expose(), "from-secondary");
}
#[test]
fn first_source_wins() {
let mut s1 = InMemorySource::new();
s1.insert("key", "value-1");
let mut s2 = InMemorySource::new();
s2.insert("key", "value-2");
let secrets = SecretsResolver::new()
.source(Box::new(s1))
.source(Box::new(s2))
.require("key")
.resolve()
.unwrap();
assert_eq!(secrets.require("key").expose(), "value-1");
}
#[test]
fn require_all() {
let src = make_source(&[("a", "1"), ("b", "2"), ("c", "3")]);
let secrets = SecretsResolver::new()
.source(Box::new(src))
.require_all(&["a", "b", "c"])
.resolve()
.unwrap();
assert_eq!(secrets.len(), 3);
}
#[test]
fn resolved_len() {
let src = make_source(&[("k", "v")]);
let secrets = SecretsResolver::new()
.source(Box::new(src))
.require("k")
.resolve()
.unwrap();
assert_eq!(secrets.len(), 1);
}
#[test]
fn resolved_is_empty() {
let resolved = ResolvedSecrets { values: HashMap::new() };
assert!(resolved.is_empty());
}
}