use std::collections::HashMap;
pub struct SecretString {
inner: Vec<u8>,
}
impl SecretString {
pub fn new(s: impl Into<String>) -> Self {
Self {
inner: s.into().into_bytes(),
}
}
pub fn expose_secret(&self) -> &str {
std::str::from_utf8(&self.inner).unwrap_or("")
}
pub fn len(&self) -> usize {
self.inner.len()
}
pub fn is_empty(&self) -> bool {
self.inner.is_empty()
}
}
impl Drop for SecretString {
fn drop(&mut self) {
for b in self.inner.iter_mut() {
unsafe {
std::ptr::write_volatile(b, 0u8);
}
}
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
impl std::fmt::Display for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("[REDACTED]")
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum CredentialError {
NotFound(String),
PermissionDenied(String),
IoError(std::io::Error),
ParseError(String),
}
impl std::fmt::Display for CredentialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CredentialError::NotFound(k) => write!(f, "credential not found: {}", k),
CredentialError::PermissionDenied(p) => write!(f, "permission denied: {}", p),
CredentialError::IoError(e) => write!(f, "credential IO error: {}", e),
CredentialError::ParseError(s) => write!(f, "credential parse error: {}", s),
}
}
}
impl std::error::Error for CredentialError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CredentialError::IoError(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for CredentialError {
fn from(e: std::io::Error) -> Self {
CredentialError::IoError(e)
}
}
pub trait CredentialProvider: Send + Sync {
fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError>;
fn available_keys(&self) -> Vec<String>;
}
pub struct EnvVarCredentialProvider {
prefix: String,
}
impl EnvVarCredentialProvider {
pub fn new(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into().to_uppercase(),
}
}
fn env_key(&self, key: &str) -> String {
if self.prefix.is_empty() {
key.to_uppercase()
} else {
format!("{}_{}", self.prefix, key.to_uppercase())
}
}
}
impl CredentialProvider for EnvVarCredentialProvider {
fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
let env_key = self.env_key(key);
std::env::var(&env_key)
.map(SecretString::new)
.map_err(|_| CredentialError::NotFound(env_key))
}
fn available_keys(&self) -> Vec<String> {
vec![]
}
}
pub struct FileCredentialProvider {
path: std::path::PathBuf,
}
impl FileCredentialProvider {
pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
Self { path: path.into() }
}
fn load(&self) -> Result<HashMap<String, String>, CredentialError> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(&self.path).map_err(CredentialError::IoError)?;
if meta.mode() & 0o077 != 0 {
return Err(CredentialError::PermissionDenied(format!(
"credentials file {:?} must have mode 0600",
self.path
)));
}
}
let content = std::fs::read_to_string(&self.path).map_err(CredentialError::IoError)?;
serde_json::from_str::<HashMap<String, String>>(&content)
.map_err(|e| CredentialError::ParseError(e.to_string()))
}
}
impl CredentialProvider for FileCredentialProvider {
fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
let map = self.load()?;
map.get(key)
.map(|v| SecretString::new(v.clone()))
.ok_or_else(|| CredentialError::NotFound(key.to_string()))
}
fn available_keys(&self) -> Vec<String> {
self.load()
.map(|m| m.into_keys().collect())
.unwrap_or_default()
}
}
pub struct CompositeCredentialProvider {
providers: Vec<Box<dyn CredentialProvider>>,
}
impl CompositeCredentialProvider {
pub fn new() -> Self {
Self { providers: vec![] }
}
pub fn with_provider(mut self, p: impl CredentialProvider + 'static) -> Self {
self.providers.push(Box::new(p));
self
}
}
impl Default for CompositeCredentialProvider {
fn default() -> Self {
Self::new()
}
}
impl CredentialProvider for CompositeCredentialProvider {
fn get_credential(&self, key: &str) -> Result<SecretString, CredentialError> {
for p in &self.providers {
if let Ok(s) = p.get_credential(key) {
return Ok(s);
}
}
Err(CredentialError::NotFound(key.to_string()))
}
fn available_keys(&self) -> Vec<String> {
self.providers
.iter()
.flat_map(|p| p.available_keys())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::io::Write;
#[test]
fn test_secret_string_expose() {
let secret = SecretString::new("my-api-key");
assert_eq!(secret.expose_secret(), "my-api-key");
}
#[test]
fn test_secret_string_debug_redacted() {
let secret = SecretString::new("super-secret");
assert_eq!(format!("{:?}", secret), "[REDACTED]");
assert_eq!(format!("{}", secret), "[REDACTED]");
}
#[test]
fn test_secret_string_len() {
let secret = SecretString::new("hello");
assert_eq!(secret.len(), 5);
assert!(!secret.is_empty());
}
#[test]
fn test_secret_string_empty() {
let secret = SecretString::new("");
assert!(secret.is_empty());
assert_eq!(secret.len(), 0);
}
#[test]
fn test_env_var_provider_found() {
let key = format!("QUANTRS_TEST_KEY_{}", fastrand::u64(..));
env::set_var(&key, "test-token");
let provider = EnvVarCredentialProvider::new("");
let secret = provider.get_credential(&key).expect("should find env var");
assert_eq!(secret.expose_secret(), "test-token");
env::remove_var(&key);
}
#[test]
fn test_env_var_provider_with_prefix() {
let suffix = fastrand::u64(..);
let env_var = format!("QUANTRS_TEST_{}", suffix);
env::set_var(&env_var, "prefixed-value");
let provider = EnvVarCredentialProvider::new("QUANTRS");
let secret = provider
.get_credential(&format!("TEST_{}", suffix))
.expect("should find prefixed env var");
assert_eq!(secret.expose_secret(), "prefixed-value");
env::remove_var(&env_var);
}
#[test]
fn test_env_var_provider_not_found() {
let provider = EnvVarCredentialProvider::new("QUANTRS");
let result = provider.get_credential("DEFINITELY_NONEXISTENT_KEY_12345");
assert!(matches!(result, Err(CredentialError::NotFound(_))));
}
#[test]
fn test_env_var_provider_available_keys_empty() {
let provider = EnvVarCredentialProvider::new("QUANTRS");
assert!(provider.available_keys().is_empty());
}
#[test]
fn test_file_credential_provider() {
let dir = env::temp_dir();
let path = dir.join(format!("quantrs_creds_{}.json", fastrand::u64(..)));
let mut file = std::fs::File::create(&path).expect("create file");
write!(
file,
r#"{{"IBM_TOKEN":"ibm-secret","AWS_KEY":"aws-secret"}}"#
)
.expect("write credentials");
drop(file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms).expect("set permissions");
}
let provider = FileCredentialProvider::new(&path);
let secret = provider
.get_credential("IBM_TOKEN")
.expect("should find IBM_TOKEN");
assert_eq!(secret.expose_secret(), "ibm-secret");
let keys = provider.available_keys();
assert!(keys.contains(&"IBM_TOKEN".to_string()) || keys.contains(&"AWS_KEY".to_string()));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_file_credential_provider_not_found() {
let dir = env::temp_dir();
let path = dir.join(format!("quantrs_creds_nf_{}.json", fastrand::u64(..)));
let mut file = std::fs::File::create(&path).expect("create file");
write!(file, r#"{{"IBM_TOKEN":"ibm-secret"}}"#).expect("write credentials");
drop(file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms).expect("set permissions");
}
let provider = FileCredentialProvider::new(&path);
let result = provider.get_credential("NONEXISTENT");
assert!(matches!(result, Err(CredentialError::NotFound(_))));
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_composite_provider_fallthrough() {
let suffix = fastrand::u64(..);
let env_var = format!("QUANTRS_COMPOSITE_{}", suffix);
env::set_var(&env_var, "composite-value");
let missing_provider = EnvVarCredentialProvider::new("WRONG_PREFIX");
let found_provider = EnvVarCredentialProvider::new("");
let composite = CompositeCredentialProvider::new()
.with_provider(missing_provider)
.with_provider(found_provider);
let secret = composite
.get_credential(&env_var)
.expect("composite should find via second provider");
assert_eq!(secret.expose_secret(), "composite-value");
env::remove_var(&env_var);
}
#[test]
fn test_composite_provider_all_fail() {
let composite = CompositeCredentialProvider::new().with_provider(
EnvVarCredentialProvider::new("DEFINITELY_MISSING_PREFIX_XYZ"),
);
let result = composite.get_credential("NONEXISTENT");
assert!(matches!(result, Err(CredentialError::NotFound(_))));
}
#[test]
fn test_composite_provider_empty() {
let composite = CompositeCredentialProvider::new();
let result = composite.get_credential("any_key");
assert!(matches!(result, Err(CredentialError::NotFound(_))));
assert!(composite.available_keys().is_empty());
}
#[test]
fn test_credential_error_display() {
let e = CredentialError::NotFound("MY_KEY".to_string());
assert!(e.to_string().contains("MY_KEY"));
let e = CredentialError::PermissionDenied("/path/to/file".to_string());
assert!(e.to_string().contains("permission denied"));
let e = CredentialError::ParseError("invalid json".to_string());
assert!(e.to_string().contains("parse error"));
}
}