use std::collections::HashMap;
use std::sync::RwLock;
use async_trait::async_trait;
use crate::error::ShikumiError;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SecretError {
#[error("secret not found: {name}")]
NotFound { name: String },
#[error("unauthorized: {message}")]
Unauthorized { message: String },
#[error("{backend} does not support {operation}")]
Unsupported {
backend: &'static str,
operation: &'static str,
},
#[error("backend error: {0}")]
Backend(String),
#[error(transparent)]
Shikumi(#[from] ShikumiError),
}
impl SecretError {
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(self, Self::Backend(msg) if msg.contains("timeout") || msg.contains("5"))
}
#[must_use]
pub const fn unsupported(backend: &'static str, op: SecretOperation) -> Self {
Self::Unsupported {
backend,
operation: op.as_str(),
}
}
#[must_use]
pub const fn kind(&self) -> SecretErrorKind {
match self {
Self::NotFound { .. } => SecretErrorKind::NotFound,
Self::Unauthorized { .. } => SecretErrorKind::Unauthorized,
Self::Unsupported { .. } => SecretErrorKind::Unsupported,
Self::Backend(_) => SecretErrorKind::Backend,
Self::Shikumi(_) => SecretErrorKind::Shikumi,
}
}
#[must_use]
pub const fn as_shikumi(&self) -> Option<&ShikumiError> {
match self {
Self::Shikumi(inner) => Some(inner),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SecretErrorKind {
NotFound,
Unauthorized,
Unsupported,
Backend,
Shikumi,
}
impl SecretErrorKind {
pub const ALL: &'static [Self] = &[
Self::NotFound,
Self::Unauthorized,
Self::Unsupported,
Self::Backend,
Self::Shikumi,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NotFound => "not-found",
Self::Unauthorized => "unauthorized",
Self::Unsupported => "unsupported",
Self::Backend => "backend",
Self::Shikumi => "shikumi",
}
}
}
impl crate::ClosedAxis for SecretErrorKind {
const ALL: &'static [Self] = Self::ALL;
}
impl crate::ClosedAxisLabel for SecretErrorKind {
fn as_str(self) -> &'static str {
Self::as_str(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SecretOperation {
Get,
List,
Put,
Delete,
Rotate,
GetVersion,
}
impl SecretOperation {
pub const ALL: &'static [Self] = &[
Self::Get,
Self::List,
Self::Put,
Self::Delete,
Self::Rotate,
Self::GetVersion,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Get => "get",
Self::List => "list",
Self::Put => "put",
Self::Delete => "delete",
Self::Rotate => "rotate",
Self::GetVersion => "get_version",
}
}
#[must_use]
pub const fn is_supported_by(self, caps: Capabilities) -> bool {
match self {
Self::Get => caps.get,
Self::List => caps.list,
Self::Put => caps.put,
Self::Delete => caps.delete,
Self::Rotate => caps.rotate,
Self::GetVersion => caps.versions,
}
}
}
impl crate::ClosedAxis for SecretOperation {
const ALL: &'static [Self] = Self::ALL;
}
impl crate::ClosedAxisLabel for SecretOperation {
fn as_str(self) -> &'static str {
Self::as_str(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
pub get: bool,
pub list: bool,
pub put: bool,
pub delete: bool,
pub rotate: bool,
pub versions: bool,
}
impl Capabilities {
#[must_use]
pub const fn read_only() -> Self {
Self {
get: true,
list: false,
put: false,
delete: false,
rotate: false,
versions: false,
}
}
#[must_use]
pub const fn full() -> Self {
Self {
get: true,
list: true,
put: true,
delete: true,
rotate: true,
versions: true,
}
}
#[must_use]
pub const fn supports(self, op: SecretOperation) -> bool {
op.is_supported_by(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SecretClientKind {
Mem,
Command,
Akeyless,
AwsSecretsManager,
OpConnect,
Vault,
GcpSecretManager,
}
impl SecretClientKind {
pub const ALL: &'static [Self] = &[
Self::Mem,
Self::Command,
Self::Akeyless,
Self::AwsSecretsManager,
Self::OpConnect,
Self::Vault,
Self::GcpSecretManager,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Mem => "mem",
Self::Command => "command",
Self::Akeyless => "akeyless",
Self::AwsSecretsManager => "aws-secrets-manager",
Self::OpConnect => "op-connect",
Self::Vault => "vault",
Self::GcpSecretManager => "gcp-secret-manager",
}
}
}
impl crate::ClosedAxis for SecretClientKind {
const ALL: &'static [Self] = Self::ALL;
}
impl crate::ClosedAxisLabel for SecretClientKind {
fn as_str(self) -> &'static str {
Self::as_str(self)
}
}
#[derive(Debug, Clone, Default)]
pub struct SecretMetadata {
pub version: Option<String>,
pub updated_at: Option<String>,
pub tags: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct Secret {
pub value: String,
pub metadata: SecretMetadata,
}
#[async_trait]
pub trait SecretClient: Send + Sync {
fn backend_name(&self) -> &'static str;
fn capabilities(&self) -> Capabilities;
fn client_kind(&self) -> Option<SecretClientKind> {
<SecretClientKind as crate::ClosedAxisLabel>::from_canonical_str(self.backend_name())
}
async fn get(&self, name: &str) -> Result<String, SecretError>;
async fn get_with_metadata(&self, name: &str) -> Result<Secret, SecretError> {
let value = self.get(name).await?;
Ok(Secret {
value,
metadata: SecretMetadata::default(),
})
}
async fn list(&self, _prefix: Option<&str>) -> Result<Vec<String>, SecretError> {
Err(SecretError::unsupported(
self.backend_name(),
SecretOperation::List,
))
}
async fn put(&self, _name: &str, _value: &str) -> Result<(), SecretError> {
Err(SecretError::unsupported(
self.backend_name(),
SecretOperation::Put,
))
}
async fn delete(&self, _name: &str) -> Result<(), SecretError> {
Err(SecretError::unsupported(
self.backend_name(),
SecretOperation::Delete,
))
}
async fn rotate(&self, _name: &str) -> Result<(), SecretError> {
Err(SecretError::unsupported(
self.backend_name(),
SecretOperation::Rotate,
))
}
async fn get_version(&self, _name: &str, _version: &str) -> Result<String, SecretError> {
Err(SecretError::unsupported(
self.backend_name(),
SecretOperation::GetVersion,
))
}
}
pub struct MemClient {
store: RwLock<HashMap<String, Vec<String>>>,
}
impl MemClient {
#[must_use]
pub fn new() -> Self {
Self {
store: RwLock::new(HashMap::new()),
}
}
#[must_use]
pub fn with_seed<I, K, V>(iter: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let client = Self::new();
for (k, v) in iter {
client
.store
.write()
.expect("MemClient lock poisoned")
.insert(k.into(), vec![v.into()]);
}
client
}
}
impl Default for MemClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl SecretClient for MemClient {
fn backend_name(&self) -> &'static str {
"mem"
}
fn capabilities(&self) -> Capabilities {
Capabilities::full()
}
async fn get(&self, name: &str) -> Result<String, SecretError> {
let store = self.store.read().expect("MemClient lock poisoned");
store
.get(name)
.and_then(|versions| versions.last().cloned())
.ok_or_else(|| SecretError::NotFound {
name: name.to_owned(),
})
}
async fn get_with_metadata(&self, name: &str) -> Result<Secret, SecretError> {
let store = self.store.read().expect("MemClient lock poisoned");
let versions = store.get(name).ok_or_else(|| SecretError::NotFound {
name: name.to_owned(),
})?;
let value = versions
.last()
.cloned()
.ok_or_else(|| SecretError::NotFound {
name: name.to_owned(),
})?;
let metadata = SecretMetadata {
version: Some(versions.len().to_string()),
updated_at: None,
tags: HashMap::new(),
};
Ok(Secret { value, metadata })
}
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, SecretError> {
let store = self.store.read().expect("MemClient lock poisoned");
let mut names: Vec<String> = store
.keys()
.filter(|k| prefix.is_none_or(|p| k.starts_with(p)))
.cloned()
.collect();
names.sort();
Ok(names)
}
async fn put(&self, name: &str, value: &str) -> Result<(), SecretError> {
self.store
.write()
.expect("MemClient lock poisoned")
.entry(name.to_owned())
.or_default()
.push(value.to_owned());
Ok(())
}
async fn delete(&self, name: &str) -> Result<(), SecretError> {
let removed = self
.store
.write()
.expect("MemClient lock poisoned")
.remove(name);
if removed.is_some() {
Ok(())
} else {
Err(SecretError::NotFound {
name: name.to_owned(),
})
}
}
async fn rotate(&self, name: &str) -> Result<(), SecretError> {
let mut store = self.store.write().expect("MemClient lock poisoned");
let versions = store.get_mut(name).ok_or_else(|| SecretError::NotFound {
name: name.to_owned(),
})?;
let next = format!("rotated-v{}-{name}", versions.len() + 1);
versions.push(next);
Ok(())
}
async fn get_version(&self, name: &str, version: &str) -> Result<String, SecretError> {
let n: usize = version.parse().map_err(|_| {
SecretError::Backend(format!("mem version must be an integer, got {version:?}"))
})?;
if n == 0 {
return Err(SecretError::Backend(
"mem versions are 1-indexed; 0 is invalid".into(),
));
}
let store = self.store.read().expect("MemClient lock poisoned");
let versions = store.get(name).ok_or_else(|| SecretError::NotFound {
name: name.to_owned(),
})?;
versions.get(n - 1).cloned().ok_or_else(|| {
SecretError::Backend(format!(
"mem has {} versions for {name}, version {n} out of range",
versions.len()
))
})
}
}
pub struct CommandClient {
template: Option<String>,
name_map: HashMap<String, String>,
}
impl CommandClient {
#[must_use]
pub fn with_get_template(template: impl Into<String>) -> Self {
Self {
template: Some(template.into()),
name_map: HashMap::new(),
}
}
#[must_use]
pub fn with_name_map<I, K, V>(iter: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
Self {
template: None,
name_map: iter
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
}
}
}
#[async_trait]
impl SecretClient for CommandClient {
fn backend_name(&self) -> &'static str {
"command"
}
fn capabilities(&self) -> Capabilities {
Capabilities::read_only()
}
async fn get(&self, name: &str) -> Result<String, SecretError> {
let cmd: String = if let Some(explicit) = self.name_map.get(name) {
explicit.clone()
} else if let Some(template) = &self.template {
template.replace("{name}", name)
} else {
return Err(SecretError::NotFound {
name: name.to_owned(),
});
};
crate::secret::resolve_command(&cmd).map_err(SecretError::from)
}
}
#[cfg(feature = "akeyless-native")]
pub struct AkeylessClient {
auth: crate::secret::AkeylessAuth,
}
#[cfg(feature = "akeyless-native")]
impl AkeylessClient {
#[must_use]
pub fn new(auth: crate::secret::AkeylessAuth) -> Self {
Self { auth }
}
pub fn from_env() -> Result<Self, SecretError> {
let auth = crate::secret::AkeylessAuth::from_env()?;
Ok(Self::new(auth))
}
}
#[cfg(feature = "akeyless-native")]
#[async_trait]
impl SecretClient for AkeylessClient {
fn backend_name(&self) -> &'static str {
"akeyless"
}
fn capabilities(&self) -> Capabilities {
Capabilities::full()
}
async fn get(&self, name: &str) -> Result<String, SecretError> {
crate::secret::resolve_akeyless_native(&self.auth, name)
.await
.map_err(SecretError::from)
}
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, SecretError> {
let cfg = self.auth.configuration();
let request = akeyless_api::models::ListItems {
token: Some(self.auth.token.clone()),
path: prefix.map(str::to_owned),
auto_pagination: Some("enabled".into()),
..Default::default()
};
let response = akeyless_api::apis::v2_api::list_items(&cfg, request)
.await
.map_err(|e| SecretError::Backend(format!("akeyless list-items: {e}")))?;
let mut names: Vec<String> = response
.items
.unwrap_or_default()
.into_iter()
.filter_map(|item| item.item_name)
.collect();
names.sort();
Ok(names)
}
async fn put(&self, name: &str, value: &str) -> Result<(), SecretError> {
let cfg = self.auth.configuration();
let update = akeyless_api::models::UpdateSecretVal {
token: Some(self.auth.token.clone()),
name: name.to_owned(),
value: value.to_owned(),
..Default::default()
};
let update_result = akeyless_api::apis::v2_api::update_secret_val(&cfg, update).await;
match update_result {
Ok(_) => Ok(()),
Err(err) => {
let msg = format!("{err}");
if msg.contains("ItemNotExist")
|| msg.contains("not exist")
|| msg.contains("not found")
{
let create = akeyless_api::models::CreateSecret {
token: Some(self.auth.token.clone()),
name: name.to_owned(),
value: value.to_owned(),
..Default::default()
};
akeyless_api::apis::v2_api::create_secret(&cfg, create)
.await
.map_err(|e| {
SecretError::Backend(format!("akeyless create-secret({name}): {e}"))
})?;
Ok(())
} else {
Err(SecretError::Backend(format!(
"akeyless update-secret-val({name}): {msg}"
)))
}
}
}
}
async fn delete(&self, name: &str) -> Result<(), SecretError> {
let cfg = self.auth.configuration();
let request = akeyless_api::models::DeleteItem {
token: Some(self.auth.token.clone()),
name: name.to_owned(),
delete_immediately: Some(true),
..Default::default()
};
akeyless_api::apis::v2_api::delete_item(&cfg, request)
.await
.map_err(|e| SecretError::Backend(format!("akeyless delete-item({name}): {e}")))?;
Ok(())
}
async fn rotate(&self, name: &str) -> Result<(), SecretError> {
let cfg = self.auth.configuration();
let request = akeyless_api::models::RotateSecret {
token: Some(self.auth.token.clone()),
name: name.to_owned(),
..Default::default()
};
akeyless_api::apis::v2_api::rotate_secret(&cfg, request)
.await
.map_err(|e| SecretError::Backend(format!("akeyless rotate-secret({name}): {e}")))?;
Ok(())
}
async fn get_version(&self, name: &str, version: &str) -> Result<String, SecretError> {
let cfg = self.auth.configuration();
let version_num: i32 = version.parse().map_err(|_| {
SecretError::Backend(format!(
"akeyless version must be an integer, got {version:?}"
))
})?;
let request = akeyless_api::models::GetSecretValue {
names: vec![name.to_owned()],
token: Some(self.auth.token.clone()),
version: Some(version_num),
..Default::default()
};
let response = akeyless_api::apis::v2_api::get_secret_value(&cfg, request)
.await
.map_err(|e| {
SecretError::Backend(format!(
"akeyless get-secret-value({name}, v={version}): {e}"
))
})?;
let obj = response.as_object().ok_or_else(|| {
SecretError::Backend(format!(
"akeyless response for {name} v{version} was not an object"
))
})?;
obj.get(name)
.and_then(|v| v.as_str())
.map(str::to_owned)
.ok_or_else(|| {
SecretError::Backend(format!(
"akeyless response missing value for {name} v{version}"
))
})
}
}
#[cfg(feature = "aws-native")]
pub struct AwsClient {
client: aws_sdk_secretsmanager::Client,
}
#[cfg(feature = "aws-native")]
impl AwsClient {
#[must_use]
pub fn new(client: aws_sdk_secretsmanager::Client) -> Self {
Self { client }
}
pub async fn from_env() -> Self {
let client = crate::secret::aws_secretsmanager_client().await;
Self::new(client)
}
}
#[cfg(feature = "aws-native")]
#[async_trait]
impl SecretClient for AwsClient {
fn backend_name(&self) -> &'static str {
"aws-secrets-manager"
}
fn capabilities(&self) -> Capabilities {
Capabilities::full()
}
async fn get(&self, name: &str) -> Result<String, SecretError> {
crate::secret::resolve_aws_secret_native(&self.client, name)
.await
.map_err(SecretError::from)
}
async fn get_with_metadata(&self, name: &str) -> Result<Secret, SecretError> {
let response = self
.client
.get_secret_value()
.secret_id(name)
.send()
.await
.map_err(|e| SecretError::Backend(format!("aws get-secret-value({name}): {e}")))?;
let value = response.secret_string().map(str::to_owned).ok_or_else(|| {
SecretError::Backend(format!(
"aws secret {name} has no SecretString (binary-only)"
))
})?;
let mut metadata = SecretMetadata::default();
if let Some(version) = response.version_id() {
metadata.version = Some(version.to_owned());
}
if let Some(created) = response.created_date() {
metadata.updated_at = Some(format!("{}", created.secs()));
}
if !response.version_stages().is_empty() {
metadata
.tags
.insert("stages".into(), response.version_stages().join(","));
}
Ok(Secret { value, metadata })
}
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, SecretError> {
let mut names = Vec::new();
let mut next_token: Option<String> = None;
loop {
let mut req = self.client.list_secrets();
if let Some(t) = &next_token {
req = req.next_token(t);
}
let resp = req
.send()
.await
.map_err(|e| SecretError::Backend(format!("aws list-secrets: {e}")))?;
for entry in resp.secret_list() {
if let Some(n) = entry.name() {
if prefix.is_none_or(|p| n.starts_with(p)) {
names.push(n.to_owned());
}
}
}
next_token = resp.next_token().map(str::to_owned);
if next_token.is_none() {
break;
}
}
names.sort();
Ok(names)
}
async fn put(&self, name: &str, value: &str) -> Result<(), SecretError> {
let update_result = self
.client
.put_secret_value()
.secret_id(name)
.secret_string(value)
.send()
.await;
match update_result {
Ok(_) => Ok(()),
Err(err) => {
let err_str = format!("{err}");
if err_str.contains("ResourceNotFoundException") || err_str.contains("not found") {
self.client
.create_secret()
.name(name)
.secret_string(value)
.send()
.await
.map_err(|e| {
SecretError::Backend(format!("aws create-secret({name}): {e}"))
})?;
Ok(())
} else {
Err(SecretError::Backend(format!(
"aws put-secret-value({name}): {err_str}"
)))
}
}
}
}
async fn delete(&self, name: &str) -> Result<(), SecretError> {
self.client
.delete_secret()
.secret_id(name)
.force_delete_without_recovery(true)
.send()
.await
.map_err(|e| SecretError::Backend(format!("aws delete-secret({name}): {e}")))?;
Ok(())
}
async fn rotate(&self, name: &str) -> Result<(), SecretError> {
self.client
.rotate_secret()
.secret_id(name)
.send()
.await
.map_err(|e| SecretError::Backend(format!("aws rotate-secret({name}): {e}")))?;
Ok(())
}
async fn get_version(&self, name: &str, version: &str) -> Result<String, SecretError> {
let response = self
.client
.get_secret_value()
.secret_id(name)
.version_id(version)
.send()
.await
.map_err(|e| {
SecretError::Backend(format!("aws get-secret-value({name}, v={version}): {e}"))
})?;
response.secret_string().map(str::to_owned).ok_or_else(|| {
SecretError::Backend(format!("aws secret {name} v{version} has no SecretString"))
})
}
}
#[cfg(feature = "op-native")]
pub struct OpConnectClient {
http: reqwest::Client,
base_url: String,
token: String,
vault_id: String,
}
#[cfg(feature = "op-native")]
#[derive(Debug, Clone)]
pub struct OpConnectConfig {
pub base_url: String,
pub token: String,
pub vault_id: String,
}
#[cfg(feature = "op-native")]
impl OpConnectClient {
#[must_use]
pub fn new(config: OpConnectConfig) -> Self {
Self {
http: reqwest::Client::new(),
base_url: config.base_url.trim_end_matches('/').to_owned(),
token: config.token,
vault_id: config.vault_id,
}
}
pub fn from_env() -> Result<Self, SecretError> {
let read = |var: &str| {
std::env::var(var).map_err(|_| SecretError::Unauthorized {
message: format!("{var} not set"),
})
};
Ok(Self::new(OpConnectConfig {
base_url: read("OP_CONNECT_HOST")?,
token: read("OP_CONNECT_TOKEN")?,
vault_id: read("OP_CONNECT_VAULT")?,
}))
}
fn auth_header(&self) -> String {
format!("Bearer {}", self.token)
}
async fn resolve_item_id(&self, name: &str) -> Result<String, SecretError> {
let url = format!(
"{}/v1/vaults/{}/items?filter=title+eq+%22{}%22",
self.base_url,
self.vault_id,
urlencode(name)
);
let response = self
.http
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("op list items: {e}")))?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED
|| response.status() == reqwest::StatusCode::FORBIDDEN
{
return Err(SecretError::Unauthorized {
message: format!("op list items: {}", response.status()),
});
}
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"op list items: HTTP {}",
response.status()
)));
}
let items: Vec<serde_json::Value> = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("op list items parse: {e}")))?;
items
.into_iter()
.find_map(|item| item.get("id").and_then(|v| v.as_str()).map(str::to_owned))
.ok_or_else(|| SecretError::NotFound {
name: name.to_owned(),
})
}
async fn fetch_item_value(&self, item_id: &str, name: &str) -> Result<String, SecretError> {
let url = format!(
"{}/v1/vaults/{}/items/{}",
self.base_url, self.vault_id, item_id
);
let response = self
.http
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("op get item({name}): {e}")))?;
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"op get item({name}): HTTP {}",
response.status()
)));
}
let item: serde_json::Value = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("op get item({name}) parse: {e}")))?;
let fields = item
.get("fields")
.and_then(|v| v.as_array())
.ok_or_else(|| SecretError::Backend(format!("op item {name} has no fields array")))?;
fields
.iter()
.find_map(|f| {
let purpose = f.get("purpose").and_then(|v| v.as_str()).unwrap_or("");
let kind = f.get("type").and_then(|v| v.as_str()).unwrap_or("");
if purpose == "PASSWORD" || kind == "CONCEALED" {
f.get("value").and_then(|v| v.as_str()).map(str::to_owned)
} else {
None
}
})
.ok_or_else(|| {
SecretError::Backend(format!(
"op item {name} has no PASSWORD/CONCEALED field with a value"
))
})
}
}
#[cfg(feature = "op-native")]
#[async_trait]
impl SecretClient for OpConnectClient {
fn backend_name(&self) -> &'static str {
"op-connect"
}
fn capabilities(&self) -> Capabilities {
Capabilities {
get: true,
list: true,
put: true,
delete: true,
rotate: false,
versions: false,
}
}
async fn get(&self, name: &str) -> Result<String, SecretError> {
let id = self.resolve_item_id(name).await?;
self.fetch_item_value(&id, name).await
}
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, SecretError> {
let url = format!("{}/v1/vaults/{}/items", self.base_url, self.vault_id);
let response = self
.http
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("op list items: {e}")))?;
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"op list items: HTTP {}",
response.status()
)));
}
let items: Vec<serde_json::Value> = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("op list items parse: {e}")))?;
let mut names: Vec<String> = items
.into_iter()
.filter_map(|item| {
item.get("title")
.and_then(|v| v.as_str())
.map(str::to_owned)
})
.filter(|n| prefix.is_none_or(|p| n.starts_with(p)))
.collect();
names.sort();
Ok(names)
}
async fn put(&self, name: &str, value: &str) -> Result<(), SecretError> {
let existing = self.resolve_item_id(name).await;
let body = serde_json::json!({
"vault": { "id": self.vault_id },
"title": name,
"category": "API_CREDENTIAL",
"fields": [{
"id": "credential",
"label": "credential",
"type": "CONCEALED",
"purpose": "PASSWORD",
"value": value,
}]
});
let response = match existing {
Ok(id) => {
let url = format!("{}/v1/vaults/{}/items/{}", self.base_url, self.vault_id, id);
self.http
.put(&url)
.header("Authorization", self.auth_header())
.json(&body)
.send()
.await
}
Err(SecretError::NotFound { .. }) => {
let url = format!("{}/v1/vaults/{}/items", self.base_url, self.vault_id);
self.http
.post(&url)
.header("Authorization", self.auth_header())
.json(&body)
.send()
.await
}
Err(e) => return Err(e),
}
.map_err(|e| SecretError::Backend(format!("op put item({name}): {e}")))?;
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"op put item({name}): HTTP {}",
response.status()
)));
}
Ok(())
}
async fn delete(&self, name: &str) -> Result<(), SecretError> {
let id = self.resolve_item_id(name).await?;
let url = format!("{}/v1/vaults/{}/items/{}", self.base_url, self.vault_id, id);
let response = self
.http
.delete(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("op delete item({name}): {e}")))?;
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"op delete item({name}): HTTP {}",
response.status()
)));
}
Ok(())
}
}
#[cfg(feature = "op-native")]
fn urlencode(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
c.to_string()
} else {
format!("%{:02X}", c as u32)
}
})
.collect()
}
#[cfg(feature = "vault-native")]
pub struct VaultClient {
http: reqwest::Client,
base_url: String,
token: String,
mount: String,
namespace: Option<String>,
}
#[cfg(feature = "vault-native")]
#[derive(Debug, Clone)]
pub struct VaultConfig {
pub base_url: String,
pub token: String,
pub mount: String,
pub namespace: Option<String>,
}
#[cfg(feature = "vault-native")]
impl VaultClient {
#[must_use]
pub fn new(config: VaultConfig) -> Self {
Self {
http: reqwest::Client::new(),
base_url: config.base_url.trim_end_matches('/').to_owned(),
token: config.token,
mount: config.mount.trim_matches('/').to_owned(),
namespace: config.namespace,
}
}
pub fn from_env() -> Result<Self, SecretError> {
let base_url = std::env::var("VAULT_ADDR").map_err(|_| SecretError::Unauthorized {
message: "VAULT_ADDR not set".into(),
})?;
let token = std::env::var("VAULT_TOKEN").map_err(|_| SecretError::Unauthorized {
message: "VAULT_TOKEN not set".into(),
})?;
let mount = std::env::var("VAULT_KV_MOUNT").unwrap_or_else(|_| "secret".into());
let namespace = std::env::var("VAULT_NAMESPACE").ok();
Ok(Self::new(VaultConfig {
base_url,
token,
mount,
namespace,
}))
}
fn apply_headers(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let mut req = req.header("X-Vault-Token", &self.token);
if let Some(ns) = &self.namespace {
req = req.header("X-Vault-Namespace", ns);
}
req
}
fn data_url(&self, path: &str) -> String {
format!(
"{}/v1/{}/data/{}",
self.base_url,
self.mount,
path.trim_start_matches('/')
)
}
fn metadata_url(&self, path: &str) -> String {
format!(
"{}/v1/{}/metadata/{}",
self.base_url,
self.mount,
path.trim_start_matches('/')
)
}
fn extract_value(body: &serde_json::Value, name: &str) -> Result<String, SecretError> {
let data = body
.get("data")
.and_then(|v| v.get("data"))
.ok_or_else(|| {
SecretError::Backend(format!("vault response for {name} missing data.data"))
})?;
if let Some(obj) = data.as_object() {
if obj.len() == 1 {
if let Some(v) = obj.values().next().and_then(|v| v.as_str()) {
return Ok(v.to_owned());
}
}
}
Ok(data.to_string())
}
}
#[cfg(feature = "vault-native")]
#[async_trait]
impl SecretClient for VaultClient {
fn backend_name(&self) -> &'static str {
"vault"
}
fn capabilities(&self) -> Capabilities {
Capabilities {
get: true,
list: true,
put: true,
delete: true,
rotate: false,
versions: true,
}
}
async fn get(&self, name: &str) -> Result<String, SecretError> {
let response = self
.apply_headers(self.http.get(self.data_url(name)))
.send()
.await
.map_err(|e| SecretError::Backend(format!("vault get({name}): {e}")))?;
match response.status() {
reqwest::StatusCode::NOT_FOUND => Err(SecretError::NotFound {
name: name.to_owned(),
}),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
Err(SecretError::Unauthorized {
message: format!("vault get({name}): {}", response.status()),
})
}
status if !status.is_success() => Err(SecretError::Backend(format!(
"vault get({name}): HTTP {status}"
))),
_ => {
let body: serde_json::Value = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("vault get({name}) parse: {e}")))?;
Self::extract_value(&body, name)
}
}
}
async fn get_with_metadata(&self, name: &str) -> Result<Secret, SecretError> {
let response = self
.apply_headers(self.http.get(self.data_url(name)))
.send()
.await
.map_err(|e| SecretError::Backend(format!("vault get({name}): {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(SecretError::NotFound {
name: name.to_owned(),
});
}
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"vault get({name}): HTTP {}",
response.status()
)));
}
let body: serde_json::Value = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("vault get({name}) parse: {e}")))?;
let value = Self::extract_value(&body, name)?;
let mut metadata = SecretMetadata::default();
if let Some(v) = body
.get("data")
.and_then(|v| v.get("metadata"))
.and_then(|m| m.get("version"))
{
metadata.version = Some(v.to_string());
}
if let Some(t) = body
.get("data")
.and_then(|v| v.get("metadata"))
.and_then(|m| m.get("created_time"))
.and_then(|v| v.as_str())
{
metadata.updated_at = Some(t.to_owned());
}
Ok(Secret { value, metadata })
}
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, SecretError> {
let path = prefix.unwrap_or("").trim_start_matches('/');
let url = self.metadata_url(path);
let response = self
.apply_headers(
self.http
.request(reqwest::Method::from_bytes(b"LIST").unwrap(), &url),
)
.send()
.await
.map_err(|e| SecretError::Backend(format!("vault list: {e}")))?;
match response.status() {
reqwest::StatusCode::NOT_FOUND => Ok(Vec::new()),
s if !s.is_success() => Err(SecretError::Backend(format!("vault list: HTTP {s}"))),
_ => {
let body: serde_json::Value = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("vault list parse: {e}")))?;
let keys = body
.get("data")
.and_then(|v| v.get("keys"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut names: Vec<String> = keys
.into_iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.map(|k| {
if path.is_empty() {
k
} else {
format!("{}/{}", path.trim_end_matches('/'), k)
}
})
.collect();
names.sort();
Ok(names)
}
}
}
async fn put(&self, name: &str, value: &str) -> Result<(), SecretError> {
let body = serde_json::json!({ "data": { "value": value } });
let response = self
.apply_headers(self.http.post(self.data_url(name)))
.json(&body)
.send()
.await
.map_err(|e| SecretError::Backend(format!("vault put({name}): {e}")))?;
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"vault put({name}): HTTP {}",
response.status()
)));
}
Ok(())
}
async fn delete(&self, name: &str) -> Result<(), SecretError> {
let response = self
.apply_headers(self.http.delete(self.metadata_url(name)))
.send()
.await
.map_err(|e| SecretError::Backend(format!("vault delete({name}): {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(SecretError::NotFound {
name: name.to_owned(),
});
}
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"vault delete({name}): HTTP {}",
response.status()
)));
}
Ok(())
}
async fn get_version(&self, name: &str, version: &str) -> Result<String, SecretError> {
let url = format!("{}?version={}", self.data_url(name), version);
let response = self
.apply_headers(self.http.get(&url))
.send()
.await
.map_err(|e| SecretError::Backend(format!("vault get({name}, v={version}): {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(SecretError::NotFound {
name: name.to_owned(),
});
}
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"vault get({name}, v={version}): HTTP {}",
response.status()
)));
}
let body: serde_json::Value = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("vault get parse: {e}")))?;
Self::extract_value(&body, name)
}
}
#[cfg(feature = "gcp-native")]
pub struct GcpSecretClient {
http: reqwest::Client,
project: String,
base_url: String,
token: std::sync::RwLock<String>,
}
#[cfg(feature = "gcp-native")]
#[derive(Debug, Clone)]
pub struct GcpSecretConfig {
pub project: String,
pub token: String,
pub base_url: Option<String>,
}
#[cfg(feature = "gcp-native")]
impl GcpSecretClient {
#[must_use]
pub fn new(config: GcpSecretConfig) -> Self {
Self {
http: reqwest::Client::new(),
project: config.project,
base_url: config
.base_url
.unwrap_or_else(|| "https://secretmanager.googleapis.com".into()),
token: std::sync::RwLock::new(config.token),
}
}
pub fn from_env() -> Result<Self, SecretError> {
let project = std::env::var("GCP_PROJECT").map_err(|_| SecretError::Unauthorized {
message: "GCP_PROJECT not set".into(),
})?;
let token =
std::env::var("GCLOUD_ACCESS_TOKEN").map_err(|_| SecretError::Unauthorized {
message: "GCLOUD_ACCESS_TOKEN not set (run `gcloud auth print-access-token`)"
.into(),
})?;
Ok(Self::new(GcpSecretConfig {
project,
token,
base_url: None,
}))
}
pub fn set_token(&self, token: impl Into<String>) {
*self
.token
.write()
.expect("GcpSecretClient token lock poisoned") = token.into();
}
fn auth_header(&self) -> String {
let guard = self
.token
.read()
.expect("GcpSecretClient token lock poisoned");
format!("Bearer {}", *guard)
}
fn secret_url(&self, name: &str) -> String {
format!(
"{}/v1/projects/{}/secrets/{}",
self.base_url, self.project, name
)
}
fn access_url(&self, name: &str, version: &str) -> String {
format!(
"{}/v1/projects/{}/secrets/{}/versions/{}:access",
self.base_url, self.project, name, version
)
}
fn decode_payload(body: &serde_json::Value, name: &str) -> Result<String, SecretError> {
let data_b64 = body
.get("payload")
.and_then(|p| p.get("data"))
.and_then(|d| d.as_str())
.ok_or_else(|| {
SecretError::Backend(format!("gcp {name}: response missing payload.data"))
})?;
let bytes = base64_decode(data_b64)
.map_err(|e| SecretError::Backend(format!("gcp {name}: base64 decode: {e}")))?;
String::from_utf8(bytes)
.map_err(|e| SecretError::Backend(format!("gcp {name}: non-UTF8 payload: {e}")))
}
}
#[cfg(feature = "gcp-native")]
#[async_trait]
impl SecretClient for GcpSecretClient {
fn backend_name(&self) -> &'static str {
"gcp-secret-manager"
}
fn capabilities(&self) -> Capabilities {
Capabilities {
get: true,
list: true,
put: true,
delete: true,
rotate: false,
versions: true,
}
}
async fn get(&self, name: &str) -> Result<String, SecretError> {
let response = self
.http
.get(self.access_url(name, "latest"))
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("gcp get({name}): {e}")))?;
match response.status() {
reqwest::StatusCode::NOT_FOUND => Err(SecretError::NotFound {
name: name.to_owned(),
}),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
Err(SecretError::Unauthorized {
message: format!("gcp get({name}): {}", response.status()),
})
}
s if !s.is_success() => Err(SecretError::Backend(format!("gcp get({name}): HTTP {s}"))),
_ => {
let body: serde_json::Value = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("gcp get({name}) parse: {e}")))?;
Self::decode_payload(&body, name)
}
}
}
async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, SecretError> {
let mut names = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut url = format!(
"{}/v1/projects/{}/secrets?pageSize=500",
self.base_url, self.project
);
if let Some(tok) = &page_token {
url.push_str(&format!("&pageToken={tok}"));
}
let response = self
.http
.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("gcp list-secrets: {e}")))?;
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"gcp list-secrets: HTTP {}",
response.status()
)));
}
let body: serde_json::Value = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("gcp list-secrets parse: {e}")))?;
if let Some(secrets) = body.get("secrets").and_then(|v| v.as_array()) {
for secret in secrets {
if let Some(resource_name) = secret.get("name").and_then(|v| v.as_str()) {
if let Some(short) =
resource_name.rsplit_once('/').map(|(_, n)| n.to_owned())
{
if prefix.is_none_or(|p| short.starts_with(p)) {
names.push(short);
}
}
}
}
}
page_token = body
.get("nextPageToken")
.and_then(|v| v.as_str())
.map(str::to_owned);
if page_token.is_none() || page_token.as_deref() == Some("") {
break;
}
}
names.sort();
Ok(names)
}
async fn put(&self, name: &str, value: &str) -> Result<(), SecretError> {
let container_url = self.secret_url(name);
let get_response = self
.http
.get(&container_url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("gcp get-secret({name}): {e}")))?;
if get_response.status() == reqwest::StatusCode::NOT_FOUND {
let create_url = format!(
"{}/v1/projects/{}/secrets?secretId={}",
self.base_url, self.project, name
);
let create_body = serde_json::json!({
"replication": { "automatic": {} }
});
let create_response = self
.http
.post(&create_url)
.header("Authorization", self.auth_header())
.json(&create_body)
.send()
.await
.map_err(|e| SecretError::Backend(format!("gcp create-secret({name}): {e}")))?;
if !create_response.status().is_success() {
return Err(SecretError::Backend(format!(
"gcp create-secret({name}): HTTP {}",
create_response.status()
)));
}
} else if !get_response.status().is_success() {
return Err(SecretError::Backend(format!(
"gcp get-secret({name}): HTTP {}",
get_response.status()
)));
}
let add_url = format!("{container_url}:addVersion");
let payload_b64 = base64_encode(value.as_bytes());
let add_body = serde_json::json!({
"payload": { "data": payload_b64 }
});
let add_response = self
.http
.post(&add_url)
.header("Authorization", self.auth_header())
.json(&add_body)
.send()
.await
.map_err(|e| SecretError::Backend(format!("gcp add-version({name}): {e}")))?;
if !add_response.status().is_success() {
return Err(SecretError::Backend(format!(
"gcp add-version({name}): HTTP {}",
add_response.status()
)));
}
Ok(())
}
async fn delete(&self, name: &str) -> Result<(), SecretError> {
let response = self
.http
.delete(self.secret_url(name))
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("gcp delete-secret({name}): {e}")))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(SecretError::NotFound {
name: name.to_owned(),
});
}
if !response.status().is_success() {
return Err(SecretError::Backend(format!(
"gcp delete-secret({name}): HTTP {}",
response.status()
)));
}
Ok(())
}
async fn get_version(&self, name: &str, version: &str) -> Result<String, SecretError> {
let response = self
.http
.get(self.access_url(name, version))
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| SecretError::Backend(format!("gcp get({name}, v={version}): {e}")))?;
match response.status() {
reqwest::StatusCode::NOT_FOUND => Err(SecretError::NotFound {
name: name.to_owned(),
}),
s if !s.is_success() => Err(SecretError::Backend(format!(
"gcp get({name}, v={version}): HTTP {s}"
))),
_ => {
let body: serde_json::Value = response
.json()
.await
.map_err(|e| SecretError::Backend(format!("gcp get parse: {e}")))?;
Self::decode_payload(&body, name)
}
}
}
}
#[cfg(feature = "gcp-native")]
fn base64_encode(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
let mut chunks = bytes.chunks_exact(3);
for chunk in &mut chunks {
let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
out.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
out.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
out.push(ALPHABET[(n & 0x3F) as usize] as char);
}
let rem = chunks.remainder();
match rem.len() {
0 => {}
1 => {
let n = u32::from(rem[0]) << 16;
out.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
out.push('=');
out.push('=');
}
2 => {
let n = (u32::from(rem[0]) << 16) | (u32::from(rem[1]) << 8);
out.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
out.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
out.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
out.push('=');
}
_ => unreachable!(),
}
out
}
#[cfg(feature = "gcp-native")]
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
let mut buf = Vec::with_capacity(s.len() * 3 / 4);
let mut accum: u32 = 0;
let mut bits: u32 = 0;
let mut pad = 0usize;
for c in s.chars() {
if c.is_ascii_whitespace() {
continue;
}
let v = match c {
'A'..='Z' => (c as u32) - ('A' as u32),
'a'..='z' => (c as u32) - ('a' as u32) + 26,
'0'..='9' => (c as u32) - ('0' as u32) + 52,
'+' => 62,
'/' => 63,
'=' => {
pad += 1;
continue;
}
_ => return Err(format!("invalid base64 char: {c:?}")),
};
if pad > 0 {
return Err("data after padding".into());
}
accum = (accum << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
let byte = u8::try_from((accum >> bits) & 0xFF).unwrap_or_default();
buf.push(byte);
accum &= (1 << bits) - 1;
}
}
if bits != 0 && accum != 0 {
return Err(format!("trailing bits: {bits}"));
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn mem_client_get_missing_returns_not_found() {
let client = MemClient::new();
match client.get("nonexistent").await {
Err(SecretError::NotFound { name }) => assert_eq!(name, "nonexistent"),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn mem_client_put_get_roundtrip() {
let client = MemClient::new();
client.put("key", "value").await.unwrap();
assert_eq!(client.get("key").await.unwrap(), "value");
}
#[tokio::test]
async fn mem_client_put_overwrites() {
let client = MemClient::new();
client.put("key", "v1").await.unwrap();
client.put("key", "v2").await.unwrap();
assert_eq!(client.get("key").await.unwrap(), "v2");
}
#[tokio::test]
async fn mem_client_list_all() {
let client = MemClient::with_seed([("a", "1"), ("b", "2"), ("c", "3")]);
let names = client.list(None).await.unwrap();
assert_eq!(names, vec!["a", "b", "c"]);
}
#[tokio::test]
async fn mem_client_list_with_prefix() {
let client = MemClient::with_seed([("prod/jwt", "1"), ("prod/api", "2"), ("dev/jwt", "3")]);
let mut names = client.list(Some("prod/")).await.unwrap();
names.sort();
assert_eq!(names, vec!["prod/api", "prod/jwt"]);
}
#[tokio::test]
async fn mem_client_delete_removes() {
let client = MemClient::with_seed([("gone", "soon")]);
client.delete("gone").await.unwrap();
assert!(matches!(
client.get("gone").await,
Err(SecretError::NotFound { .. })
));
}
#[tokio::test]
async fn mem_client_delete_missing_errors() {
let client = MemClient::new();
assert!(matches!(
client.delete("nope").await,
Err(SecretError::NotFound { .. })
));
}
#[tokio::test]
async fn mem_client_rotate_missing_key_errors() {
let client = MemClient::new();
match client.rotate("anything").await {
Err(SecretError::NotFound { name }) => assert_eq!(name, "anything"),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn mem_client_rotate_appends_version() {
let client = MemClient::with_seed([("key", "v1")]);
client.rotate("key").await.unwrap();
let v1 = client.get_version("key", "1").await.unwrap();
let v2 = client.get_version("key", "2").await.unwrap();
assert_eq!(v1, "v1");
assert!(v2.starts_with("rotated-v2-"));
assert_eq!(client.get("key").await.unwrap(), v2);
}
#[tokio::test]
async fn mem_client_versions_track_puts() {
let client = MemClient::new();
client.put("key", "v1").await.unwrap();
client.put("key", "v2").await.unwrap();
client.put("key", "v3").await.unwrap();
assert_eq!(client.get_version("key", "1").await.unwrap(), "v1");
assert_eq!(client.get_version("key", "2").await.unwrap(), "v2");
assert_eq!(client.get_version("key", "3").await.unwrap(), "v3");
let secret = client.get_with_metadata("key").await.unwrap();
assert_eq!(secret.value, "v3");
assert_eq!(secret.metadata.version.as_deref(), Some("3"));
}
#[tokio::test]
async fn mem_client_get_version_out_of_range_errors() {
let client = MemClient::with_seed([("key", "v1")]);
assert!(matches!(
client.get_version("key", "99").await,
Err(SecretError::Backend(_))
));
}
#[tokio::test]
async fn mem_client_capabilities_advertised_full() {
let caps = MemClient::new().capabilities();
assert!(caps.get && caps.list && caps.put && caps.delete);
assert!(caps.rotate && caps.versions);
}
#[tokio::test]
async fn mem_client_get_with_metadata_exposes_version() {
let client = MemClient::with_seed([("key", "value")]);
let secret = client.get_with_metadata("key").await.unwrap();
assert_eq!(secret.value, "value");
assert_eq!(secret.metadata.version.as_deref(), Some("1"));
assert!(secret.metadata.tags.is_empty());
}
#[tokio::test]
async fn command_client_template_substitution() {
let client = CommandClient::with_get_template("echo resolved-{name}");
let value = client.get("test").await.unwrap();
assert_eq!(value, "resolved-test");
}
#[tokio::test]
async fn command_client_name_map() {
let client =
CommandClient::with_name_map([("jwt", "echo from-map"), ("api", "echo api-value")]);
assert_eq!(client.get("jwt").await.unwrap(), "from-map");
assert_eq!(client.get("api").await.unwrap(), "api-value");
}
#[tokio::test]
async fn command_client_missing_key_errors() {
let client = CommandClient::with_name_map([("only", "echo x")]);
assert!(matches!(
client.get("missing").await,
Err(SecretError::NotFound { .. })
));
}
#[tokio::test]
async fn command_client_write_ops_unsupported() {
let client = CommandClient::with_get_template("echo {name}");
assert!(matches!(
client.put("k", "v").await,
Err(SecretError::Unsupported {
operation: "put",
..
})
));
assert!(matches!(
client.delete("k").await,
Err(SecretError::Unsupported {
operation: "delete",
..
})
));
assert!(matches!(
client.list(None).await,
Err(SecretError::Unsupported {
operation: "list",
..
})
));
}
#[tokio::test]
async fn command_client_capabilities_read_only() {
let caps = CommandClient::with_get_template("x").capabilities();
assert!(caps.get);
assert!(!caps.put && !caps.delete && !caps.list && !caps.rotate);
}
#[tokio::test]
async fn trait_object_dispatch_works() {
let client: std::sync::Arc<dyn SecretClient> =
std::sync::Arc::new(MemClient::with_seed([("key", "value")]));
assert_eq!(client.get("key").await.unwrap(), "value");
assert_eq!(client.backend_name(), "mem");
}
#[test]
fn capabilities_read_only_shape() {
let caps = Capabilities::read_only();
assert!(caps.get);
assert!(!caps.list && !caps.put && !caps.delete && !caps.rotate && !caps.versions);
}
#[test]
fn capabilities_full_shape() {
let caps = Capabilities::full();
assert!(caps.get && caps.list && caps.put && caps.delete && caps.rotate && caps.versions);
}
#[test]
fn secret_error_not_retryable_by_default() {
let err = SecretError::NotFound { name: "x".into() };
assert!(!err.is_retryable());
}
#[test]
fn secret_error_display_shapes() {
let unauth = SecretError::Unauthorized {
message: "no token".into(),
};
assert!(unauth.to_string().contains("no token"));
let unsupported = SecretError::Unsupported {
backend: "sops",
operation: "rotate",
};
assert!(unsupported.to_string().contains("sops"));
assert!(unsupported.to_string().contains("rotate"));
}
#[test]
fn secret_operation_all_covers_every_variant() {
let mut seen: std::collections::HashSet<SecretOperation> = std::collections::HashSet::new();
for op in SecretOperation::ALL.iter().copied() {
assert!(seen.insert(op), "duplicate in ALL: {op:?}");
}
assert_eq!(seen.len(), 6);
assert!(seen.contains(&SecretOperation::Get));
assert!(seen.contains(&SecretOperation::List));
assert!(seen.contains(&SecretOperation::Put));
assert!(seen.contains(&SecretOperation::Delete));
assert!(seen.contains(&SecretOperation::Rotate));
assert!(seen.contains(&SecretOperation::GetVersion));
}
#[test]
fn secret_operation_all_has_no_duplicates() {
let mut sorted: Vec<&'static str> =
SecretOperation::ALL.iter().map(|o| o.as_str()).collect();
sorted.sort_unstable();
let original_len = sorted.len();
sorted.dedup();
assert_eq!(
sorted.len(),
original_len,
"SecretOperation::ALL must not list any variant twice",
);
}
#[test]
fn secret_operation_is_static_copy_hashable() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn assert_send_sync<T: Send + Sync + 'static>() {}
fn assert_copy<T: Copy>() {}
fn assert_eq_hash<T: Eq + std::hash::Hash>() {}
assert_send_sync::<SecretOperation>();
assert_copy::<SecretOperation>();
assert_eq_hash::<SecretOperation>();
let op = SecretOperation::GetVersion;
let mut h1 = DefaultHasher::new();
op.hash(&mut h1);
let mut h2 = DefaultHasher::new();
op.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn secret_operation_as_str_yields_canonical_snake_case_names() {
assert_eq!(SecretOperation::Get.as_str(), "get");
assert_eq!(SecretOperation::List.as_str(), "list");
assert_eq!(SecretOperation::Put.as_str(), "put");
assert_eq!(SecretOperation::Delete.as_str(), "delete");
assert_eq!(SecretOperation::Rotate.as_str(), "rotate");
assert_eq!(SecretOperation::GetVersion.as_str(), "get_version");
}
#[test]
fn capabilities_supports_matches_field_pointwise() {
let caps = Capabilities {
get: true,
list: true,
put: true,
delete: true,
rotate: true,
versions: true,
};
assert_eq!(caps.supports(SecretOperation::Get), caps.get);
assert_eq!(caps.supports(SecretOperation::List), caps.list);
assert_eq!(caps.supports(SecretOperation::Put), caps.put);
assert_eq!(caps.supports(SecretOperation::Delete), caps.delete);
assert_eq!(caps.supports(SecretOperation::Rotate), caps.rotate);
assert_eq!(caps.supports(SecretOperation::GetVersion), caps.versions);
let none = Capabilities {
get: false,
list: false,
put: false,
delete: false,
rotate: false,
versions: false,
};
for op in SecretOperation::ALL.iter().copied() {
assert!(!none.supports(op), "no-cap caps must reject {op:?}");
}
let mut probe = none;
probe.put = true;
assert!(probe.supports(SecretOperation::Put));
for op in SecretOperation::ALL.iter().copied() {
assert_eq!(
probe.supports(op),
op == SecretOperation::Put,
"after flipping only `put`, supports({op:?}) must be (op == Put)",
);
}
}
#[test]
fn secret_operation_is_supported_by_dual_agrees_with_capabilities_supports() {
for caps in [
Capabilities::read_only(),
Capabilities::full(),
Capabilities {
get: true,
list: false,
put: true,
delete: false,
rotate: true,
versions: false,
},
] {
for op in SecretOperation::ALL.iter().copied() {
assert_eq!(
caps.supports(op),
op.is_supported_by(caps),
"supports/is_supported_by must agree on {op:?} / {caps:?}",
);
}
}
}
#[test]
fn capabilities_read_only_supports_only_get() {
let caps = Capabilities::read_only();
for op in SecretOperation::ALL.iter().copied() {
assert_eq!(
caps.supports(op),
op == SecretOperation::Get,
"read_only must support exactly Get; got mismatch on {op:?}",
);
}
}
#[test]
fn capabilities_full_supports_every_operation() {
let caps = Capabilities::full();
for op in SecretOperation::ALL.iter().copied() {
assert!(caps.supports(op), "full caps must support {op:?}");
}
}
#[test]
fn secret_error_unsupported_uses_canonical_str_pointwise() {
for op in SecretOperation::ALL.iter().copied() {
let err = SecretError::unsupported("test-backend", op);
match err {
SecretError::Unsupported { backend, operation } => {
assert_eq!(backend, "test-backend");
assert_eq!(
operation,
op.as_str(),
"constructor must use op.as_str() pointwise on {op:?}",
);
}
other => panic!("expected Unsupported, got {other:?}"),
}
}
}
#[tokio::test]
async fn secret_client_default_unsupported_arms_use_secret_operation_labels() {
fn assert_unsupported_with_op(
result: Result<(), SecretError>,
backend_expected: &'static str,
op: SecretOperation,
) {
match result {
Err(SecretError::Unsupported { backend, operation }) => {
assert_eq!(backend, backend_expected);
assert_eq!(
operation,
op.as_str(),
"default impl for {op:?} must emit op.as_str() as the operation tag",
);
}
other => panic!("expected Unsupported({op:?}), got {other:?}"),
}
}
let client = CommandClient::with_get_template("echo {name}");
let backend = client.backend_name();
assert_unsupported_with_op(
client.list(None).await.map(|_| ()),
backend,
SecretOperation::List,
);
assert_unsupported_with_op(client.put("k", "v").await, backend, SecretOperation::Put);
assert_unsupported_with_op(client.delete("k").await, backend, SecretOperation::Delete);
assert_unsupported_with_op(client.rotate("k").await, backend, SecretOperation::Rotate);
assert_unsupported_with_op(
client.get_version("k", "1").await.map(|_| ()),
backend,
SecretOperation::GetVersion,
);
}
fn one_per_secret_error_kind() -> [(SecretError, SecretErrorKind); 5] {
[
(
SecretError::NotFound { name: "x".into() },
SecretErrorKind::NotFound,
),
(
SecretError::Unauthorized {
message: "no token".into(),
},
SecretErrorKind::Unauthorized,
),
(
SecretError::Unsupported {
backend: "sops",
operation: "rotate",
},
SecretErrorKind::Unsupported,
),
(
SecretError::Backend("connection refused".into()),
SecretErrorKind::Backend,
),
(
SecretError::Shikumi(ShikumiError::NotFound { tried: Vec::new() }),
SecretErrorKind::Shikumi,
),
]
}
#[test]
fn secret_error_kind_all_covers_every_variant() {
let mut seen: std::collections::HashSet<SecretErrorKind> = std::collections::HashSet::new();
for kind in SecretErrorKind::ALL.iter().copied() {
assert!(seen.insert(kind), "duplicate in ALL: {kind:?}");
}
assert_eq!(seen.len(), 5);
for (_, expected) in one_per_secret_error_kind() {
assert!(
seen.contains(&expected),
"construction-table kind {expected:?} missing from SecretErrorKind::ALL",
);
}
}
#[test]
fn secret_error_kind_all_has_no_duplicates() {
let mut sorted: Vec<&'static str> =
SecretErrorKind::ALL.iter().map(|k| k.as_str()).collect();
sorted.sort_unstable();
let original_len = sorted.len();
sorted.dedup();
assert_eq!(
sorted.len(),
original_len,
"SecretErrorKind::ALL must not list any variant twice",
);
}
#[test]
fn secret_error_kind_is_static_copy_hashable() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn assert_send_sync<T: Send + Sync + 'static>() {}
fn assert_copy<T: Copy>() {}
fn assert_eq_hash<T: Eq + std::hash::Hash>() {}
assert_send_sync::<SecretErrorKind>();
assert_copy::<SecretErrorKind>();
assert_eq_hash::<SecretErrorKind>();
let kind = SecretErrorKind::Backend;
let mut h1 = DefaultHasher::new();
kind.hash(&mut h1);
let mut h2 = DefaultHasher::new();
kind.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn secret_error_kind_as_str_yields_canonical_lowercase_names() {
assert_eq!(SecretErrorKind::NotFound.as_str(), "not-found");
assert_eq!(SecretErrorKind::Unauthorized.as_str(), "unauthorized");
assert_eq!(SecretErrorKind::Unsupported.as_str(), "unsupported");
assert_eq!(SecretErrorKind::Backend.as_str(), "backend");
assert_eq!(SecretErrorKind::Shikumi.as_str(), "shikumi");
}
#[test]
fn secret_error_kind_pins_every_variant_pointwise() {
for (err, expected_kind) in one_per_secret_error_kind() {
assert_eq!(
err.kind(),
expected_kind,
"SecretError::kind on {err:?} must yield {expected_kind:?}",
);
}
}
#[test]
fn secret_error_kind_image_lies_in_secret_error_kind_all() {
for (err, _) in one_per_secret_error_kind() {
assert!(
SecretErrorKind::ALL.contains(&err.kind()),
"SecretError::kind({err:?}) must lie in SecretErrorKind::ALL",
);
}
}
#[test]
fn secret_error_kind_pins_unsupported_payload_independence() {
for op in SecretOperation::ALL.iter().copied() {
let err = SecretError::unsupported("any-backend", op);
assert_eq!(
err.kind(),
SecretErrorKind::Unsupported,
"unsupported({op:?}) must classify as SecretErrorKind::Unsupported",
);
}
}
#[test]
fn secret_error_as_shikumi_agrees_with_kind_pointwise() {
for (err, expected_kind) in one_per_secret_error_kind() {
assert_eq!(
err.as_shikumi().is_some(),
expected_kind == SecretErrorKind::Shikumi,
"as_shikumi().is_some() must match (kind == Shikumi) on {err:?}",
);
}
}
#[test]
fn secret_error_as_shikumi_recovers_inner_pointwise() {
for shikumi_kind in crate::ShikumiErrorKind::ALL.iter().copied() {
let inner = match shikumi_kind {
crate::ShikumiErrorKind::NotFound => ShikumiError::NotFound { tried: Vec::new() },
_ => continue,
};
let err = SecretError::Shikumi(inner);
let recovered = err.as_shikumi().expect("Self::Shikumi must yield Some");
assert_eq!(
recovered.kind(),
shikumi_kind,
"as_shikumi must preserve inner ShikumiError::kind ({shikumi_kind:?})",
);
assert_eq!(err.kind(), SecretErrorKind::Shikumi);
}
}
#[cfg(feature = "op-native")]
#[test]
fn op_urlencode_handles_spaces_and_reserved_chars() {
assert_eq!(urlencode("simple"), "simple");
assert_eq!(urlencode("with space"), "with%20space");
assert_eq!(urlencode("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e");
assert_eq!(urlencode("a-b_c.d~e"), "a-b_c.d~e");
}
#[cfg(feature = "op-native")]
#[test]
fn op_connect_client_constructs_from_config() {
let client = OpConnectClient::new(OpConnectConfig {
base_url: "https://connect.example.com/".into(),
token: "bearer-tok".into(),
vault_id: "VAULT_UUID".into(),
});
assert_eq!(client.backend_name(), "op-connect");
let caps = client.capabilities();
assert!(caps.get && caps.list && caps.put && caps.delete);
assert!(!caps.rotate && !caps.versions);
assert_eq!(client.base_url, "https://connect.example.com");
}
#[cfg(feature = "vault-native")]
#[test]
fn vault_client_constructs_from_config() {
let client = VaultClient::new(VaultConfig {
base_url: "https://vault.example.com:8200/".into(),
token: "vault-tok".into(),
mount: "/secret/".into(),
namespace: Some("admin/team-a".into()),
});
assert_eq!(client.backend_name(), "vault");
let caps = client.capabilities();
assert!(caps.get && caps.list && caps.put && caps.delete && caps.versions);
assert!(!caps.rotate);
assert_eq!(client.base_url, "https://vault.example.com:8200");
assert_eq!(client.mount, "secret");
}
#[cfg(feature = "vault-native")]
#[test]
fn vault_url_construction() {
let client = VaultClient::new(VaultConfig {
base_url: "https://vault.example.com:8200".into(),
token: "t".into(),
mount: "secret".into(),
namespace: None,
});
assert_eq!(
client.data_url("foo/bar"),
"https://vault.example.com:8200/v1/secret/data/foo/bar"
);
assert_eq!(
client.metadata_url("foo/bar"),
"https://vault.example.com:8200/v1/secret/metadata/foo/bar"
);
assert_eq!(
client.data_url("/foo"),
"https://vault.example.com:8200/v1/secret/data/foo"
);
}
#[cfg(feature = "vault-native")]
#[test]
fn vault_extract_value_single_field() {
let body = serde_json::json!({
"data": { "data": { "value": "hello" } }
});
assert_eq!(VaultClient::extract_value(&body, "x").unwrap(), "hello");
}
#[cfg(feature = "vault-native")]
#[test]
fn vault_extract_value_multi_field_returns_json_string() {
let body = serde_json::json!({
"data": { "data": { "username": "u", "password": "p" } }
});
let v = VaultClient::extract_value(&body, "x").unwrap();
assert!(v.contains("\"username\":\"u\""));
assert!(v.contains("\"password\":\"p\""));
}
#[cfg(feature = "vault-native")]
#[test]
fn vault_extract_value_missing_errors() {
let body = serde_json::json!({ "data": {} });
assert!(matches!(
VaultClient::extract_value(&body, "x"),
Err(SecretError::Backend(_))
));
}
#[cfg(feature = "gcp-native")]
#[test]
fn gcp_base64_roundtrip() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_decode("").unwrap(), b"");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_decode("Zg==").unwrap(), b"f");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
assert_eq!(base64_encode(b"hello world"), "aGVsbG8gd29ybGQ=");
assert_eq!(base64_decode("aGVsbG8gd29ybGQ=").unwrap(), b"hello world");
let bin: Vec<u8> = (0..=255).collect();
assert_eq!(base64_decode(&base64_encode(&bin)).unwrap(), bin);
}
#[cfg(feature = "gcp-native")]
#[test]
fn gcp_base64_tolerates_whitespace() {
let wrapped = "aGVs\nbG8g\nd29y\nbGQ=";
assert_eq!(base64_decode(wrapped).unwrap(), b"hello world");
}
#[cfg(feature = "gcp-native")]
#[test]
fn gcp_base64_rejects_invalid_chars() {
assert!(base64_decode("not*valid").is_err());
assert!(base64_decode("Zg==extra").is_err()); }
#[cfg(feature = "gcp-native")]
#[test]
fn gcp_client_constructs_from_config() {
let client = GcpSecretClient::new(GcpSecretConfig {
project: "my-project".into(),
token: "ya29.abc123".into(),
base_url: None,
});
assert_eq!(client.backend_name(), "gcp-secret-manager");
let caps = client.capabilities();
assert!(caps.get && caps.list && caps.put && caps.delete && caps.versions);
assert!(!caps.rotate);
assert_eq!(client.project, "my-project");
assert!(client.base_url.starts_with("https://"));
}
#[cfg(feature = "gcp-native")]
#[test]
fn gcp_url_construction() {
let client = GcpSecretClient::new(GcpSecretConfig {
project: "p".into(),
token: "t".into(),
base_url: Some("https://test.googleapis.com".into()),
});
assert_eq!(
client.secret_url("db-password"),
"https://test.googleapis.com/v1/projects/p/secrets/db-password"
);
assert_eq!(
client.access_url("db-password", "latest"),
"https://test.googleapis.com/v1/projects/p/secrets/db-password/versions/latest:access"
);
assert_eq!(
client.access_url("db-password", "7"),
"https://test.googleapis.com/v1/projects/p/secrets/db-password/versions/7:access"
);
}
#[cfg(feature = "gcp-native")]
#[test]
fn gcp_token_rotation() {
let client = GcpSecretClient::new(GcpSecretConfig {
project: "p".into(),
token: "old".into(),
base_url: None,
});
assert_eq!(client.auth_header(), "Bearer old");
client.set_token("new");
assert_eq!(client.auth_header(), "Bearer new");
}
#[cfg(feature = "gcp-native")]
#[test]
fn gcp_decode_payload_happy_path() {
let body = serde_json::json!({
"name": "projects/p/secrets/s/versions/1",
"payload": { "data": "aGVsbG8=" }
});
assert_eq!(
GcpSecretClient::decode_payload(&body, "s").unwrap(),
"hello"
);
}
#[cfg(feature = "gcp-native")]
#[test]
fn gcp_decode_payload_missing_data_errors() {
let body = serde_json::json!({ "name": "x", "payload": {} });
assert!(matches!(
GcpSecretClient::decode_payload(&body, "x"),
Err(SecretError::Backend(_))
));
}
#[test]
fn secret_client_kind_all_covers_every_variant() {
let mut seen: std::collections::HashSet<SecretClientKind> =
std::collections::HashSet::new();
for kind in SecretClientKind::ALL.iter().copied() {
assert!(seen.insert(kind), "duplicate in ALL: {kind:?}");
}
assert_eq!(seen.len(), 7);
assert!(seen.contains(&SecretClientKind::Mem));
assert!(seen.contains(&SecretClientKind::Command));
assert!(seen.contains(&SecretClientKind::Akeyless));
assert!(seen.contains(&SecretClientKind::AwsSecretsManager));
assert!(seen.contains(&SecretClientKind::OpConnect));
assert!(seen.contains(&SecretClientKind::Vault));
assert!(seen.contains(&SecretClientKind::GcpSecretManager));
}
#[test]
fn secret_client_kind_all_has_no_duplicates() {
let mut sorted: Vec<&'static str> =
SecretClientKind::ALL.iter().map(|k| k.as_str()).collect();
sorted.sort_unstable();
let original_len = sorted.len();
sorted.dedup();
assert_eq!(
sorted.len(),
original_len,
"SecretClientKind::ALL must not list any variant twice",
);
}
#[test]
fn secret_client_kind_is_static_copy_hashable() {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
fn assert_send_sync<T: Send + Sync + 'static>() {}
fn assert_copy<T: Copy>() {}
fn assert_eq_hash<T: Eq + std::hash::Hash>() {}
assert_send_sync::<SecretClientKind>();
assert_copy::<SecretClientKind>();
assert_eq_hash::<SecretClientKind>();
let k = SecretClientKind::AwsSecretsManager;
let mut h1 = DefaultHasher::new();
k.hash(&mut h1);
let mut h2 = DefaultHasher::new();
k.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn secret_client_kind_as_str_yields_canonical_names() {
assert_eq!(SecretClientKind::Mem.as_str(), "mem");
assert_eq!(SecretClientKind::Command.as_str(), "command");
assert_eq!(SecretClientKind::Akeyless.as_str(), "akeyless");
assert_eq!(
SecretClientKind::AwsSecretsManager.as_str(),
"aws-secrets-manager",
);
assert_eq!(SecretClientKind::OpConnect.as_str(), "op-connect");
assert_eq!(SecretClientKind::Vault.as_str(), "vault");
assert_eq!(
SecretClientKind::GcpSecretManager.as_str(),
"gcp-secret-manager",
);
}
#[test]
fn secret_client_kind_as_str_pins_mem_client_backend_name() {
let client = MemClient::new();
assert_eq!(client.backend_name(), SecretClientKind::Mem.as_str());
}
#[test]
fn secret_client_kind_as_str_pins_command_client_backend_name() {
let client = CommandClient::with_get_template("echo x");
assert_eq!(client.backend_name(), SecretClientKind::Command.as_str());
}
#[test]
fn secret_client_default_client_kind_recovers_mem_kind() {
let client = MemClient::new();
assert_eq!(client.client_kind(), Some(SecretClientKind::Mem));
}
#[test]
fn secret_client_default_client_kind_recovers_command_kind() {
let client = CommandClient::with_get_template("echo x");
assert_eq!(client.client_kind(), Some(SecretClientKind::Command));
}
#[test]
fn secret_client_default_client_kind_recovers_backend_name_pointwise() {
let mem = MemClient::new();
assert_eq!(
mem.client_kind().map(SecretClientKind::as_str),
Some(mem.backend_name()),
);
let cmd = CommandClient::with_get_template("x");
assert_eq!(
cmd.client_kind().map(SecretClientKind::as_str),
Some(cmd.backend_name()),
);
}
#[cfg(feature = "op-native")]
#[test]
fn secret_client_kind_recovers_op_connect_backend_name() {
let client = OpConnectClient::new(OpConnectConfig {
base_url: "https://connect.example.com/".into(),
token: "t".into(),
vault_id: "v".into(),
});
assert_eq!(client.client_kind(), Some(SecretClientKind::OpConnect));
assert_eq!(
client.client_kind().map(SecretClientKind::as_str),
Some(client.backend_name()),
);
}
#[cfg(feature = "vault-native")]
#[test]
fn secret_client_kind_recovers_vault_backend_name() {
let client = VaultClient::new(VaultConfig {
base_url: "https://vault.example.com:8200/".into(),
token: "t".into(),
mount: "/secret/".into(),
namespace: None,
});
assert_eq!(client.client_kind(), Some(SecretClientKind::Vault));
assert_eq!(
client.client_kind().map(SecretClientKind::as_str),
Some(client.backend_name()),
);
}
#[test]
fn secret_client_kind_image_lies_in_secret_client_kind_all() {
for kind in SecretClientKind::ALL.iter().copied() {
let parsed =
<SecretClientKind as crate::ClosedAxisLabel>::from_canonical_str(kind.as_str());
assert_eq!(parsed, Some(kind), "round-trip failed for {kind:?}");
}
}
}