use std::time::Duration;
use async_trait::async_trait;
use bitflags::bitflags;
use secrecy::SecretString;
use thiserror::Error;
use crate::SecretPath;
bitflags! {
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
pub struct Capabilities: u32 {
const READ = 0b0000_0001;
const LIST = 0b0000_0010;
const VALIDATE = 0b0000_0100;
const WRITE = 0b0000_1000;
const ROTATE = 0b0001_0000;
const BIOMETRIC_PROMPT = 0b0010_0000;
const AUDIT_LOGGED = 0b0100_0000;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialRef {
Path(SecretPath),
Sentinel(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceStatus {
Available,
Locked,
NotInstalled,
Error(String),
}
impl SourceStatus {
pub fn is_available(&self) -> bool {
matches!(self, SourceStatus::Available)
}
}
#[derive(Debug)]
pub struct GetOutcome {
pub value: SecretString,
pub lease_duration: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteRef {
pub reference: String,
pub display: Option<String>,
}
#[derive(Debug, Error)]
pub enum SourceError {
#[error("source `{name}` is not available: {reason}")]
Unavailable {
name: String,
reason: String,
},
#[error("source `{name}` does not support {capability:?}")]
UnsupportedCapability {
name: String,
capability: Capabilities,
},
#[error("source `{name}` rejected reference `{reference}`: {reason}")]
BadReference {
name: String,
reference: String,
reason: String,
},
#[error("source `{name}` upstream error: {message}")]
Upstream {
name: String,
message: String,
},
#[error("source `{name}` requires unlock")]
Locked {
name: String,
},
#[error("I/O error talking to source `{name}`")]
Io {
name: String,
#[source]
source: std::io::Error,
},
}
#[async_trait]
pub trait SecretSource: Send + Sync {
fn name(&self) -> &str;
fn capabilities(&self) -> Capabilities;
fn requires_credential(&self) -> Option<CredentialRef> {
None
}
async fn is_available(&self) -> SourceStatus;
async fn get(&self, reference: &str) -> Result<Option<GetOutcome>, SourceError>;
async fn list(&self) -> Result<Vec<RemoteRef>, SourceError> {
Err(SourceError::UnsupportedCapability {
name: self.name().to_owned(),
capability: Capabilities::LIST,
})
}
async fn validate(&self, reference: &str) -> Result<(), SourceError> {
let _ = reference;
Err(SourceError::UnsupportedCapability {
name: self.name().to_owned(),
capability: Capabilities::VALIDATE,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
#[test]
fn capabilities_bit_values_match_adr_021() {
assert_eq!(Capabilities::READ.bits(), 0b0000_0001);
assert_eq!(Capabilities::LIST.bits(), 0b0000_0010);
assert_eq!(Capabilities::VALIDATE.bits(), 0b0000_0100);
assert_eq!(Capabilities::WRITE.bits(), 0b0000_1000);
assert_eq!(Capabilities::ROTATE.bits(), 0b0001_0000);
assert_eq!(Capabilities::BIOMETRIC_PROMPT.bits(), 0b0010_0000);
assert_eq!(Capabilities::AUDIT_LOGGED.bits(), 0b0100_0000);
}
#[test]
fn capabilities_compose_via_bitor() {
let read_only = Capabilities::READ | Capabilities::LIST | Capabilities::VALIDATE;
assert!(read_only.contains(Capabilities::READ));
assert!(read_only.contains(Capabilities::LIST));
assert!(read_only.contains(Capabilities::VALIDATE));
assert!(!read_only.contains(Capabilities::WRITE));
assert!(!read_only.contains(Capabilities::ROTATE));
}
#[test]
fn capabilities_default_is_empty() {
let empty: Capabilities = Capabilities::default();
assert_eq!(empty.bits(), 0);
assert!(empty.is_empty());
}
#[test]
fn source_status_is_available_only_for_available_variant() {
assert!(SourceStatus::Available.is_available());
assert!(!SourceStatus::Locked.is_available());
assert!(!SourceStatus::NotInstalled.is_available());
assert!(!SourceStatus::Error("disk full".into()).is_available());
}
#[test]
fn credential_ref_can_be_a_sources_path() {
let path = SecretPath::parse_internal("__sources/vault-team/deploy").unwrap();
let cr = CredentialRef::Path(path.clone());
match cr {
CredentialRef::Path(p) => assert_eq!(p, path),
CredentialRef::Sentinel(_) => panic!("wrong variant"),
}
}
#[test]
fn credential_ref_can_be_a_sentinel() {
let cr = CredentialRef::Sentinel("biometric".into());
match cr {
CredentialRef::Sentinel(s) => assert_eq!(s, "biometric"),
CredentialRef::Path(_) => panic!("wrong variant"),
}
}
struct ReadOnlySource;
#[async_trait]
impl SecretSource for ReadOnlySource {
fn name(&self) -> &str {
"read-only-fixture"
}
fn capabilities(&self) -> Capabilities {
Capabilities::READ
}
async fn is_available(&self) -> SourceStatus {
SourceStatus::Available
}
async fn get(&self, _reference: &str) -> Result<Option<GetOutcome>, SourceError> {
Ok(Some(GetOutcome {
value: SecretString::from("hunter2"),
lease_duration: None,
}))
}
}
#[tokio::test]
async fn default_list_reports_unsupported_capability() {
let s = ReadOnlySource;
let err = s.list().await.unwrap_err();
match err {
SourceError::UnsupportedCapability { name, capability } => {
assert_eq!(name, "read-only-fixture");
assert_eq!(capability, Capabilities::LIST);
}
other => panic!("expected UnsupportedCapability, got {other:?}"),
}
}
#[tokio::test]
async fn default_validate_reports_unsupported_capability() {
let s = ReadOnlySource;
let err = s.validate("anything").await.unwrap_err();
match err {
SourceError::UnsupportedCapability { name, capability } => {
assert_eq!(name, "read-only-fixture");
assert_eq!(capability, Capabilities::VALIDATE);
}
other => panic!("expected UnsupportedCapability, got {other:?}"),
}
}
#[tokio::test]
async fn default_requires_credential_returns_none() {
let s = ReadOnlySource;
assert!(s.requires_credential().is_none());
}
#[tokio::test]
async fn source_works_through_dyn_box() {
let s: Box<dyn SecretSource> = Box::new(ReadOnlySource);
assert_eq!(s.name(), "read-only-fixture");
assert_eq!(s.capabilities(), Capabilities::READ);
let status = s.is_available().await;
assert!(status.is_available());
let out = s.get("ignored").await.unwrap().unwrap();
assert_eq!(out.value.expose_secret(), "hunter2");
assert!(out.lease_duration.is_none());
}
#[test]
fn source_error_display_includes_name_and_context() {
let e = SourceError::UnsupportedCapability {
name: "vault-team".into(),
capability: Capabilities::WRITE,
};
let s = format!("{e}");
assert!(s.contains("vault-team"));
assert!(s.contains("WRITE"));
}
#[test]
fn source_error_io_preserves_underlying() {
let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "ECONNREFUSED");
let e = SourceError::Io {
name: "plugin".into(),
source: io,
};
assert!(format!("{e}").contains("plugin"));
let chained = std::error::Error::source(&e).unwrap().to_string();
assert!(chained.contains("ECONNREFUSED"));
}
}