use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap;
#[derive(Clone)]
pub struct SecureSecret {
inner: SecretString,
}
impl SecureSecret {
#[must_use]
pub fn new(value: String) -> Self {
Self {
inner: SecretString::from(value),
}
}
#[must_use]
pub fn expose(&self) -> &str {
self.inner.expose_secret()
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.expose_secret().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.inner.expose_secret().is_empty()
}
}
impl std::fmt::Debug for SecureSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
impl std::fmt::Display for SecureSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
#[derive(Default)]
pub struct BatchSecrets {
secrets: HashMap<String, SecureSecret>,
fingerprints: HashMap<String, String>,
}
impl BatchSecrets {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
secrets: HashMap::with_capacity(capacity),
fingerprints: HashMap::with_capacity(capacity),
}
}
pub fn insert(&mut self, name: String, value: SecureSecret, fingerprint: Option<String>) {
if let Some(fp) = fingerprint {
self.fingerprints.insert(name.clone(), fp);
}
self.secrets.insert(name, value);
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&SecureSecret> {
self.secrets.get(name)
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.secrets.contains_key(name)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.secrets.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.secrets.len()
}
#[must_use]
pub const fn fingerprints(&self) -> &HashMap<String, String> {
&self.fingerprints
}
#[must_use]
pub fn fingerprint(&self, name: &str) -> Option<&str> {
self.fingerprints.get(name).map(String::as_str)
}
pub fn names(&self) -> impl Iterator<Item = &String> {
self.secrets.keys()
}
#[must_use]
pub fn into_env_map(self) -> HashMap<String, String> {
self.secrets
.into_iter()
.map(|(k, v)| (k, v.expose().to_string()))
.collect()
}
#[must_use]
pub fn into_resolved_secrets(self) -> crate::ResolvedSecrets {
let fingerprints = self.fingerprints;
let values = self
.secrets
.into_iter()
.map(|(k, v)| (k, v.expose().to_string()))
.collect();
crate::ResolvedSecrets {
values,
fingerprints,
}
}
pub fn merge(&mut self, other: Self) {
for (name, value) in other.secrets {
let fingerprint = other.fingerprints.get(&name).cloned();
self.insert(name, value, fingerprint);
}
}
}
impl std::fmt::Debug for BatchSecrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BatchSecrets")
.field("count", &self.secrets.len())
.field("names", &self.secrets.keys().collect::<Vec<_>>())
.field("fingerprints", &self.fingerprints.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secure_secret_debug_is_redacted() {
let secret = SecureSecret::new("my-super-secret-password".to_string());
let debug_output = format!("{secret:?}");
assert_eq!(debug_output, "[REDACTED]");
assert!(!debug_output.contains("password"));
}
#[test]
fn secure_secret_display_is_redacted() {
let secret = SecureSecret::new("my-super-secret-password".to_string());
let display_output = format!("{secret}");
assert_eq!(display_output, "[REDACTED]");
}
#[test]
fn secure_secret_expose_returns_value() {
let secret = SecureSecret::new("test-value".to_string());
assert_eq!(secret.expose(), "test-value");
}
#[test]
fn secure_secret_len_works() {
let secret = SecureSecret::new("12345".to_string());
assert_eq!(secret.len(), 5);
assert!(!secret.is_empty());
}
#[test]
fn batch_secrets_insert_and_get() {
let mut batch = BatchSecrets::new();
batch.insert(
"API_KEY".to_string(),
SecureSecret::new("secret123".to_string()),
Some("fingerprint123".to_string()),
);
assert!(batch.contains("API_KEY"));
assert!(!batch.contains("OTHER"));
assert_eq!(batch.len(), 1);
let secret = batch.get("API_KEY").unwrap();
assert_eq!(secret.expose(), "secret123");
assert_eq!(batch.fingerprint("API_KEY"), Some("fingerprint123"));
}
#[test]
fn batch_secrets_debug_hides_values() {
let mut batch = BatchSecrets::new();
batch.insert(
"SECRET".to_string(),
SecureSecret::new("password".to_string()),
None,
);
let debug_output = format!("{batch:?}");
assert!(!debug_output.contains("password"));
assert!(debug_output.contains("SECRET"));
assert!(debug_output.contains("count"));
}
#[test]
fn batch_secrets_into_env_map() {
let mut batch = BatchSecrets::new();
batch.insert(
"KEY1".to_string(),
SecureSecret::new("value1".to_string()),
None,
);
batch.insert(
"KEY2".to_string(),
SecureSecret::new("value2".to_string()),
None,
);
let env_map = batch.into_env_map();
assert_eq!(env_map.get("KEY1"), Some(&"value1".to_string()));
assert_eq!(env_map.get("KEY2"), Some(&"value2".to_string()));
}
#[test]
fn batch_secrets_merge() {
let mut batch1 = BatchSecrets::new();
batch1.insert(
"KEY1".to_string(),
SecureSecret::new("value1".to_string()),
None,
);
let mut batch2 = BatchSecrets::new();
batch2.insert(
"KEY2".to_string(),
SecureSecret::new("value2".to_string()),
Some("fp2".to_string()),
);
batch1.merge(batch2);
assert_eq!(batch1.len(), 2);
assert!(batch1.contains("KEY1"));
assert!(batch1.contains("KEY2"));
assert_eq!(batch1.fingerprint("KEY2"), Some("fp2"));
}
}