use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::secret::provider::{ProbeResult, SecretProvider};
use crate::secret::secret_string::SecretString;
use crate::{DodotError, Result};
#[derive(Default, Clone)]
pub struct SecretRegistry {
providers: HashMap<String, Arc<dyn SecretProvider>>,
cache: Arc<Mutex<HashMap<String, Arc<SecretString>>>>,
}
impl SecretRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, provider: Arc<dyn SecretProvider>) {
let scheme = provider.scheme().to_string();
if self.providers.contains_key(&scheme) {
tracing::debug!(scheme = %scheme, "secret provider replaced");
}
self.providers.insert(scheme, provider);
}
pub fn has(&self, scheme: &str) -> bool {
self.providers.contains_key(scheme)
}
pub fn schemes(&self) -> impl Iterator<Item = &str> {
self.providers.keys().map(String::as_str)
}
pub fn get(&self, scheme: &str) -> Option<&Arc<dyn SecretProvider>> {
self.providers.get(scheme)
}
pub fn resolve(&self, full_reference: &str) -> Result<SecretString> {
let (scheme, suffix) = split_scheme(full_reference)?;
let provider = self.get(scheme).ok_or_else(|| {
let config_key = scheme_to_config_key(scheme);
DodotError::Other(format!(
"no secret provider registered for scheme `{scheme}`. \
Configured schemes: [{}]. \
Add a `[secret.providers.{config_key}] enabled = true` block \
to your config, or check the reference for typos.",
self.sorted_schemes_for_display()
))
})?;
provider.resolve(suffix)
}
pub fn cache_get(&self, full_reference: &str) -> Option<Arc<SecretString>> {
self.cache.lock().unwrap().get(full_reference).cloned()
}
pub fn cache_put(&self, full_reference: &str, value: Arc<SecretString>) {
self.cache
.lock()
.unwrap()
.insert(full_reference.to_string(), value);
}
#[cfg(test)]
pub fn cache_len(&self) -> usize {
self.cache.lock().unwrap().len()
}
#[cfg(test)]
pub fn clear_cache(&self) {
self.cache.lock().unwrap().clear();
}
pub fn probe_all(&self) -> Vec<(String, ProbeResult)> {
let mut out: Vec<(String, ProbeResult)> = self
.providers
.iter()
.map(|(scheme, p)| (scheme.clone(), p.probe()))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn sorted_schemes_for_display(&self) -> String {
let mut s: Vec<&str> = self.providers.keys().map(String::as_str).collect();
s.sort_unstable();
s.join(", ")
}
}
pub fn scheme_to_config_key(scheme: &str) -> &str {
match scheme {
"secret-tool" => "secret_tool",
other => other,
}
}
pub fn split_scheme(reference: &str) -> Result<(&str, &str)> {
let (scheme, suffix) = reference.split_once(':').ok_or_else(|| {
DodotError::Other(format!(
"secret reference `{reference}` is missing a scheme prefix. \
Expected `<scheme>:<provider-specific-reference>` — for example \
`op://Vault/Item/Field` or `pass:path/to/secret`."
))
})?;
if scheme.is_empty() {
return Err(DodotError::Other(format!(
"secret reference `{reference}` has an empty scheme prefix. \
Expected `<scheme>:<provider-specific-reference>`."
)));
}
Ok((scheme, suffix))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::secret::test_support::MockSecretProvider;
#[test]
fn scheme_to_config_key_maps_only_secret_tool() {
assert_eq!(scheme_to_config_key("secret-tool"), "secret_tool");
assert_eq!(scheme_to_config_key("pass"), "pass");
assert_eq!(scheme_to_config_key("op"), "op");
assert_eq!(scheme_to_config_key("bw"), "bw");
assert_eq!(scheme_to_config_key("sops"), "sops");
assert_eq!(scheme_to_config_key("keychain"), "keychain");
}
#[test]
fn missing_provider_error_uses_underscore_key_for_secret_tool() {
let mut reg = SecretRegistry::new();
reg.register(Arc::new(MockSecretProvider::new("pass")));
let err = reg.resolve("secret-tool:GitHub").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no secret provider registered for scheme `secret-tool`"));
assert!(
msg.contains("[secret.providers.secret_tool]"),
"expected underscore form, got: {msg}"
);
assert!(
!msg.contains("[secret.providers.secret-tool]"),
"hyphen form must not leak through: {msg}"
);
}
#[test]
fn split_scheme_handles_op_uri_form() {
let (scheme, suffix) = split_scheme("op://Vault/Item/Field").unwrap();
assert_eq!(scheme, "op");
assert_eq!(suffix, "//Vault/Item/Field");
}
#[test]
fn split_scheme_handles_pass_single_colon() {
let (scheme, suffix) = split_scheme("pass:path/to/secret").unwrap();
assert_eq!(scheme, "pass");
assert_eq!(suffix, "path/to/secret");
}
#[test]
fn split_scheme_handles_sops_with_fragment() {
let (scheme, suffix) = split_scheme("sops:secrets.yaml#database.password").unwrap();
assert_eq!(scheme, "sops");
assert_eq!(suffix, "secrets.yaml#database.password");
}
#[test]
fn split_scheme_rejects_no_colon_with_actionable_message() {
let err = split_scheme("plain-string").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("missing a scheme prefix"));
assert!(msg.contains("`<scheme>:<provider-specific-reference>`"));
assert!(msg.contains("op://"));
assert!(msg.contains("pass:"));
}
#[test]
fn split_scheme_rejects_empty_scheme() {
let err = split_scheme(":nothing").unwrap_err();
assert!(err.to_string().contains("empty scheme prefix"));
}
#[test]
fn registry_dispatches_to_correct_provider() {
let mut reg = SecretRegistry::new();
reg.register(Arc::new(
MockSecretProvider::new("pass")
.with("path/to/db", "hunter2")
.with("path/to/api", "tok-abc"),
));
reg.register(Arc::new(
MockSecretProvider::new("op").with("//V/Item/password", "op-value"),
));
let v = reg.resolve("pass:path/to/db").unwrap();
assert_eq!(v.expose().unwrap(), "hunter2");
let v = reg.resolve("op://V/Item/password").unwrap();
assert_eq!(v.expose().unwrap(), "op-value");
}
#[test]
fn registry_unknown_scheme_lists_configured_schemes_in_error() {
let mut reg = SecretRegistry::new();
reg.register(Arc::new(MockSecretProvider::new("pass")));
reg.register(Arc::new(MockSecretProvider::new("op")));
let err = reg.resolve("sops:foo.yaml#x").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no secret provider registered for scheme `sops`"));
assert!(msg.contains("op, pass"));
}
#[test]
fn registry_register_replaces_same_scheme() {
let mut reg = SecretRegistry::new();
reg.register(Arc::new(MockSecretProvider::new("pass").with("k", "first")));
reg.register(Arc::new(
MockSecretProvider::new("pass").with("k", "second"),
));
assert_eq!(reg.resolve("pass:k").unwrap().expose().unwrap(), "second");
}
fn put(reg: &SecretRegistry, reference: &str, value: &str) {
reg.cache_put(reference, Arc::new(SecretString::new(value.to_string())));
}
#[test]
fn cache_get_returns_none_until_cache_put_populates_it() {
let reg = SecretRegistry::new();
assert!(reg.cache_get("op://V/I/F").is_none());
put(®, "op://V/I/F", "secret-value");
let hit = reg.cache_get("op://V/I/F").unwrap();
assert_eq!(hit.expose().unwrap(), "secret-value");
}
#[test]
fn cache_is_shared_between_clones_of_the_same_registry() {
let reg = SecretRegistry::new();
let clone = reg.clone();
put(&clone, "pass:k", "v");
let hit = reg.cache_get("pass:k").unwrap();
assert_eq!(hit.expose().unwrap(), "v");
}
#[test]
fn cache_is_independent_between_separate_registry_constructions() {
let a = SecretRegistry::new();
let b = SecretRegistry::new();
put(&a, "pass:k", "from-a");
assert!(b.cache_get("pass:k").is_none());
}
#[test]
fn registry_resolve_does_not_consult_or_populate_cache() {
let mut reg = SecretRegistry::new();
reg.register(Arc::new(MockSecretProvider::new("pass").with("k", "v")));
let _ = reg.resolve("pass:k").unwrap();
assert_eq!(reg.cache_len(), 0, "resolve() must not populate the cache");
assert!(
reg.cache_get("pass:k").is_none(),
"cache_get must miss when only resolve() ran"
);
}
#[test]
fn registry_has_and_schemes_reflect_registered_providers() {
let mut reg = SecretRegistry::new();
assert!(!reg.has("pass"));
reg.register(Arc::new(MockSecretProvider::new("pass")));
reg.register(Arc::new(MockSecretProvider::new("op")));
assert!(reg.has("pass"));
assert!(reg.has("op"));
assert!(!reg.has("sops"));
let mut schemes: Vec<&str> = reg.schemes().collect();
schemes.sort_unstable();
assert_eq!(schemes, vec!["op", "pass"]);
}
}