use crate::Result;
use crate::provider::{Address, Provider};
use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap;
use std::convert::TryFrom;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[cfg(test)]
use tempfile::TempDir;
pub struct MockProvider {
storage: Arc<Mutex<HashMap<String, String>>>,
}
impl MockProvider {
pub fn new() -> Self {
Self {
storage: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl Provider for MockProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: format!("{}/{}/{}", project, profile, key),
..Default::default()
})
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let full_key = super::flat_item(self, addr)?;
let storage = self.storage.lock().unwrap();
Ok(storage
.get(&*full_key)
.map(|v| SecretString::new(v.clone().into())))
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
let full_key = super::flat_item(self, addr)?.into_owned();
let mut storage = self.storage.lock().unwrap();
storage.insert(full_key, value.expose_secret().to_string());
Ok(())
}
fn delete(&self, addr: Address<'_>) -> Result<bool> {
let item = super::flat_item(self, addr)?.into_owned();
Ok(self.storage.lock().unwrap().remove(&item).is_some())
}
fn name(&self) -> &'static str {
"mock"
}
fn uri(&self) -> String {
"mock://".to_string()
}
}
struct CountingProvider {
values: HashMap<String, String>,
gets: Arc<Mutex<HashMap<String, usize>>>,
}
impl CountingProvider {
fn new(values: &[(&str, &str)]) -> Self {
Self {
values: values
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
gets: Arc::new(Mutex::new(HashMap::new())),
}
}
fn get_count(&self, item: &str) -> usize {
self.gets.lock().unwrap().get(item).copied().unwrap_or(0)
}
}
impl Provider for CountingProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: format!("{}/{}/{}", project, profile, key),
..Default::default()
})
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let item = super::flat_item(self, addr)?.into_owned();
*self.gets.lock().unwrap().entry(item.clone()).or_insert(0) += 1;
Ok(self
.values
.get(&item)
.map(|v| SecretString::new(v.clone().into())))
}
fn set(&self, _addr: Address<'_>, _value: &SecretString) -> Result<()> {
Ok(())
}
fn name(&self) -> &'static str {
"counting"
}
fn uri(&self) -> String {
"counting://".to_string()
}
}
static MEM_STORE: std::sync::LazyLock<Mutex<HashMap<String, String>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
pub(crate) struct MemTestProvider;
pub(crate) struct MemTestConfig;
impl TryFrom<&super::ProviderUrl> for MemTestConfig {
type Error = crate::SecretSpecError;
fn try_from(_url: &super::ProviderUrl) -> Result<Self> {
Ok(Self)
}
}
impl MemTestProvider {
fn new(_config: MemTestConfig) -> Self {
Self
}
}
crate::register_provider! {
struct: MemTestProvider,
config: MemTestConfig,
name: "memtest",
description: "In-memory provider for tests",
schemes: ["memtest"],
examples: ["memtest://"],
credential_names: ["test_token"],
deletes: true,
}
impl Provider for MemTestProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: format!("{}/{}/{}", project, profile, key),
..Default::default()
})
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let item = super::flat_item(self, addr)?.into_owned();
Ok(MEM_STORE
.lock()
.unwrap()
.get(&item)
.map(|v| SecretString::new(v.clone().into())))
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
let item = super::flat_item(self, addr)?.into_owned();
MEM_STORE
.lock()
.unwrap()
.insert(item, value.expose_secret().to_string());
Ok(())
}
fn delete(&self, addr: Address<'_>) -> Result<bool> {
let item = super::flat_item(self, addr)?.into_owned();
Ok(MEM_STORE.lock().unwrap().remove(&item).is_some())
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
"memtest://".to_string()
}
}
pub(crate) struct FailWriteProvider;
pub(crate) struct FailWriteConfig;
impl TryFrom<&super::ProviderUrl> for FailWriteConfig {
type Error = crate::SecretSpecError;
fn try_from(_url: &super::ProviderUrl) -> Result<Self> {
Ok(Self)
}
}
impl FailWriteProvider {
fn new(_config: FailWriteConfig) -> Self {
Self
}
}
crate::register_provider! {
struct: FailWriteProvider,
config: FailWriteConfig,
name: "failwrite",
description: "In-memory provider for tests whose writes always fail",
schemes: ["failwrite"],
examples: ["failwrite://"],
deletes: true,
}
impl Provider for FailWriteProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: format!("{}/{}/{}", project, profile, key),
..Default::default()
})
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
MemTestProvider.get(addr)
}
fn set(&self, _addr: Address<'_>, _value: &SecretString) -> Result<()> {
Err(crate::SecretSpecError::ProviderOperationFailed(
"failwrite always fails to write".to_string(),
))
}
fn delete(&self, addr: Address<'_>) -> Result<bool> {
MemTestProvider.delete(addr)
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
"failwrite://".to_string()
}
}
pub(crate) struct FailDeleteProvider;
pub(crate) struct FailDeleteConfig;
impl TryFrom<&super::ProviderUrl> for FailDeleteConfig {
type Error = crate::SecretSpecError;
fn try_from(_url: &super::ProviderUrl) -> Result<Self> {
Ok(Self)
}
}
impl FailDeleteProvider {
fn new(_config: FailDeleteConfig) -> Self {
Self
}
}
crate::register_provider! {
struct: FailDeleteProvider,
config: FailDeleteConfig,
name: "faildelete",
description: "In-memory provider for tests whose deletes always fail",
schemes: ["faildelete"],
examples: ["faildelete://"],
deletes: true,
}
impl Provider for FailDeleteProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
MemTestProvider.convention_address(project, profile, key)
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
MemTestProvider.get(addr)
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
MemTestProvider.set(addr, value)
}
fn delete(&self, _addr: Address<'_>) -> Result<bool> {
Err(crate::SecretSpecError::ProviderOperationFailed(
"faildelete always fails to delete".to_string(),
))
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
"faildelete://".to_string()
}
}
static EXPIRING_TTLS: std::sync::LazyLock<Mutex<HashMap<String, std::time::Duration>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
pub(crate) fn recorded_expiry(item: &str) -> Option<std::time::Duration> {
EXPIRING_TTLS.lock().unwrap().get(item).copied()
}
pub(crate) struct ExpiringProvider;
pub(crate) struct ExpiringConfig;
impl TryFrom<&super::ProviderUrl> for ExpiringConfig {
type Error = crate::SecretSpecError;
fn try_from(_url: &super::ProviderUrl) -> Result<Self> {
Ok(Self)
}
}
impl ExpiringProvider {
fn new(_config: ExpiringConfig) -> Self {
Self
}
}
crate::register_provider! {
struct: ExpiringProvider,
config: ExpiringConfig,
name: "expiring",
description: "In-memory provider for tests that records requested expiries",
schemes: ["expiring"],
examples: ["expiring://"],
deletes: true,
}
impl Provider for ExpiringProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
MemTestProvider.convention_address(project, profile, key)
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
MemTestProvider.get(addr)
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
MemTestProvider.set(addr, value)
}
fn set_expiring(
&self,
addr: Address<'_>,
value: &SecretString,
max_age: std::time::Duration,
) -> Result<()> {
let item = super::flat_item(self, addr)?.into_owned();
EXPIRING_TTLS.lock().unwrap().insert(item, max_age);
self.set(addr, value)
}
fn delete(&self, addr: Address<'_>) -> Result<bool> {
MemTestProvider.delete(addr)
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
"expiring://".to_string()
}
}
#[test]
fn get_each_dedupes_one_address_across_names() {
let p = CountingProvider::new(&[("svc", "val")]);
let coords = crate::config::NativeAddress {
item: "svc".into(),
..Default::default()
};
let addr = Address::Native(&coords);
let out = super::get_each(&p, &[("FIRST", addr), ("SECOND", addr)]).unwrap();
assert_eq!(out["FIRST"].expose_secret(), "val");
assert_eq!(out["SECOND"].expose_secret(), "val");
assert_eq!(p.get_count("svc"), 1, "one address must be fetched once");
}
#[test]
fn get_each_fetches_distinct_addresses_and_omits_missing() {
let p = CountingProvider::new(&[("one", "v1"), ("two", "v2")]);
let a1 = crate::config::NativeAddress {
item: "one".into(),
..Default::default()
};
let a2 = crate::config::NativeAddress {
item: "two".into(),
..Default::default()
};
let a3 = crate::config::NativeAddress {
item: "absent".into(),
..Default::default()
};
let out = super::get_each(
&p,
&[
("A", Address::Native(&a1)),
("B", Address::Native(&a2)),
("C", Address::Native(&a3)),
],
)
.unwrap();
assert_eq!(out["A"].expose_secret(), "v1");
assert_eq!(out["B"].expose_secret(), "v2");
assert!(!out.contains_key("C"), "a missing secret is omitted");
assert_eq!(p.get_count("one"), 1);
assert_eq!(p.get_count("two"), 1);
assert_eq!(p.get_count("absent"), 1);
}
struct PeakConcurrencyProvider {
delay: Duration,
current: AtomicUsize,
peak: AtomicUsize,
}
impl PeakConcurrencyProvider {
fn new(delay: Duration) -> Self {
Self {
delay,
current: AtomicUsize::new(0),
peak: AtomicUsize::new(0),
}
}
fn peak(&self) -> usize {
self.peak.load(Ordering::SeqCst)
}
}
impl Provider for PeakConcurrencyProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: format!("{project}/{profile}/{key}"),
..Default::default()
})
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let item = super::flat_item(self, addr)?.into_owned();
let now = self.current.fetch_add(1, Ordering::SeqCst) + 1;
let mut peak = self.peak.load(Ordering::SeqCst);
while now > peak {
match self
.peak
.compare_exchange(peak, now, Ordering::SeqCst, Ordering::SeqCst)
{
Ok(_) => break,
Err(observed) => peak = observed,
}
}
std::thread::sleep(self.delay);
self.current.fetch_sub(1, Ordering::SeqCst);
Ok(Some(SecretString::new(item.into())))
}
fn set(&self, _addr: Address<'_>, _value: &SecretString) -> Result<()> {
Ok(())
}
fn name(&self) -> &'static str {
"peak"
}
fn uri(&self) -> String {
"peak://".to_string()
}
}
#[test]
fn get_each_concurrency_defaults_and_parses_env() {
let _lock = crate::tests::scrub_resolution_env();
let _clear = crate::tests::EnvVarGuard::remove(super::GET_EACH_CONCURRENCY_ENV);
assert_eq!(super::get_each_concurrency(), 8);
let _set = crate::tests::EnvVarGuard::set(super::GET_EACH_CONCURRENCY_ENV, "4");
assert_eq!(super::get_each_concurrency(), 4);
drop(_set);
let _zero = crate::tests::EnvVarGuard::set(super::GET_EACH_CONCURRENCY_ENV, "0");
assert_eq!(
super::get_each_concurrency(),
8,
"zero is invalid and must fall back"
);
drop(_zero);
let _bad = crate::tests::EnvVarGuard::set(super::GET_EACH_CONCURRENCY_ENV, "nope");
assert_eq!(super::get_each_concurrency(), 8);
}
#[test]
fn get_each_respects_concurrency_cap() {
let _lock = crate::tests::scrub_resolution_env();
let _env = crate::tests::EnvVarGuard::set(super::GET_EACH_CONCURRENCY_ENV, "3");
let p = PeakConcurrencyProvider::new(Duration::from_millis(80));
let coords: Vec<crate::config::NativeAddress> = (0..10)
.map(|i| crate::config::NativeAddress {
item: format!("item-{i}"),
..Default::default()
})
.collect();
let requests: Vec<(&str, Address<'_>)> = coords
.iter()
.enumerate()
.map(|(i, c)| {
(
["n0", "n1", "n2", "n3", "n4", "n5", "n6", "n7", "n8", "n9"][i],
Address::Native(c),
)
})
.collect();
let out = super::get_each(&p, &requests).unwrap();
assert_eq!(out.len(), 10);
assert!(
p.peak() <= 3,
"peak in-flight gets {} exceeded concurrency cap 3",
p.peak()
);
assert!(
p.peak() >= 2,
"expected some concurrency, peak={}",
p.peak()
);
}
#[test]
fn test_create_from_string_with_full_uris() {
let provider = Box::<dyn Provider>::try_from("onepassword://Private").unwrap();
assert_eq!(provider.name(), "onepassword");
let provider = Box::<dyn Provider>::try_from("onepassword://work@Production").unwrap();
assert_eq!(provider.name(), "onepassword");
let provider =
Box::<dyn Provider>::try_from("onepassword+token://:ops_abc123@Private").unwrap();
assert_eq!(provider.name(), "onepassword");
}
#[test]
fn uri_never_echoes_a_userinfo_password() {
const SECRET: &str = "leaked_pw_DO_NOT_ECHO";
for reg in super::PROVIDER_REGISTRY {
for &scheme in reg.schemes {
let source = format!("{scheme}://attribution:{SECRET}@host/path");
let Ok(provider) = Box::<dyn Provider>::try_from(source.as_str()) else {
continue;
};
let uri = provider.uri();
assert!(
!uri.contains(SECRET),
"provider scheme {scheme:?} echoed a URL password into uri(): {uri:?}"
);
}
}
}
#[test]
fn test_create_from_string_with_plain_names() {
let provider = Box::<dyn Provider>::try_from("env").unwrap();
assert_eq!(provider.name(), "env");
let provider = Box::<dyn Provider>::try_from("systemd-credential").unwrap();
assert_eq!(provider.name(), "systemd-credential");
let provider = Box::<dyn Provider>::try_from("keyring").unwrap();
assert_eq!(provider.name(), "keyring");
let provider = Box::<dyn Provider>::try_from("dotenv").unwrap();
assert_eq!(provider.name(), "dotenv");
match Box::<dyn Provider>::try_from("onepassword") {
Ok(provider) => assert_eq!(provider.name(), "onepassword"),
Err(e) => panic!("Failed to create onepassword provider: {}", e),
}
let provider = Box::<dyn Provider>::try_from("lastpass").unwrap();
assert_eq!(provider.name(), "lastpass");
let provider = Box::<dyn Provider>::try_from("gopass").unwrap();
assert_eq!(provider.name(), "gopass");
let provider = Box::<dyn Provider>::try_from("pass").unwrap();
assert_eq!(provider.name(), "pass");
let provider = Box::<dyn Provider>::try_from("protonpass").unwrap();
assert_eq!(provider.name(), "protonpass");
}
#[test]
fn test_create_from_string_with_colon() {
let provider = Box::<dyn Provider>::try_from("env:").unwrap();
assert_eq!(provider.name(), "env");
let provider = Box::<dyn Provider>::try_from("keyring:").unwrap();
assert_eq!(provider.name(), "keyring");
}
#[test]
fn test_invalid_onepassword_scheme() {
let result = Box::<dyn Provider>::try_from("1password");
match result {
Err(err) => assert!(err.to_string().contains("Use 'onepassword' instead")),
Ok(_) => panic!("Expected error for '1password' scheme"),
}
let result = Box::<dyn Provider>::try_from("1password:");
match result {
Err(err) => assert!(err.to_string().contains("Use 'onepassword' instead")),
Ok(_) => panic!("Expected error for '1password:' scheme"),
}
let result = Box::<dyn Provider>::try_from("1password://Private");
match result {
Err(err) => assert!(err.to_string().contains("Use 'onepassword' instead")),
Ok(_) => panic!("Expected error for '1password://' scheme"),
}
}
#[test]
fn test_dotenv_with_custom_path() {
let provider = Box::<dyn Provider>::try_from("dotenv://custom/path/to/.env").unwrap();
assert_eq!(provider.name(), "dotenv");
let provider = Box::<dyn Provider>::try_from("dotenv:///custom/path/.env").unwrap();
assert_eq!(provider.name(), "dotenv");
}
#[test]
fn test_unknown_provider() {
let result = Box::<dyn Provider>::try_from("unknown");
assert!(result.is_err());
match result {
Err(crate::SecretSpecError::ProviderNotFound(scheme)) => {
assert_eq!(scheme, "unknown");
}
_ => panic!("Expected ProviderNotFound error"),
}
}
#[test]
fn test_dotenv_shorthand_from_docs() {
let provider = Box::<dyn Provider>::try_from("dotenv:.env.production").unwrap();
assert_eq!(provider.name(), "dotenv");
}
#[test]
fn test_documentation_examples() {
let provider = Box::<dyn Provider>::try_from("onepassword://work@Production").unwrap();
assert_eq!(provider.name(), "onepassword");
let provider = Box::<dyn Provider>::try_from("dotenv:/path/to/.env").unwrap();
assert_eq!(provider.name(), "dotenv");
let provider = Box::<dyn Provider>::try_from("lastpass://folder").unwrap();
assert_eq!(provider.name(), "lastpass");
let provider = Box::<dyn Provider>::try_from("dotenv://path").unwrap();
assert_eq!(provider.name(), "dotenv");
let provider = Box::<dyn Provider>::try_from("pass://").unwrap();
assert_eq!(provider.name(), "pass");
}
#[test]
fn test_edge_cases_and_normalization() {
let provider = Box::<dyn Provider>::try_from("keyring:").unwrap();
assert_eq!(provider.name(), "keyring");
let provider = Box::<dyn Provider>::try_from("dotenv:/absolute/path").unwrap();
assert_eq!(provider.name(), "dotenv");
let Err(err) = Box::<dyn Provider>::try_from("env://localhost") else {
panic!("env authority must be rejected");
};
assert!(err.to_string().contains("ref = { item ="), "{err}");
}
#[test]
fn test_onepassword_uri_forms() {
let provider = Box::<dyn Provider>::try_from("onepassword://Production").unwrap();
assert_eq!(provider.name(), "onepassword");
let Err(err) = Box::<dyn Provider>::try_from("op://Production/db/password") else {
panic!("op:// provider spec must be rejected");
};
assert!(
err.to_string()
.contains("ref = { vault = \"Production\", item = \"db\", field = \"password\" }"),
"{err}"
);
assert!(Box::<dyn Provider>::try_from("onepassword://vault/Production").is_err());
}
#[test]
fn test_url_parsing_behavior() {
use url::Url;
let url = "onepassword://vault/Production".parse::<Url>().unwrap();
assert_eq!(url.scheme(), "onepassword");
assert_eq!(url.host_str(), Some("vault"));
assert_eq!(url.path(), "/Production");
let url = "dotenv://path/to/.env".parse::<Url>().unwrap();
assert_eq!(url.scheme(), "dotenv");
assert_eq!(url.host_str(), Some("path"));
assert_eq!(url.path(), "/to/.env");
}
#[test]
fn test_onepassword_vault_name_with_spaces() {
let provider = Box::<dyn Provider>::try_from("onepassword://Home%20Lab").unwrap();
assert_eq!(provider.name(), "onepassword");
assert_eq!(provider.uri(), "onepassword://Home%20Lab");
let provider = Box::<dyn Provider>::try_from("onepassword://Home Lab").unwrap();
assert_eq!(provider.name(), "onepassword");
assert_eq!(provider.uri(), "onepassword://Home%20Lab");
let provider = Box::<dyn Provider>::try_from("onepassword://work@Home Lab").unwrap();
assert_eq!(provider.name(), "onepassword");
assert_eq!(provider.uri(), "onepassword://work@Home%20Lab");
}
#[test]
fn test_provider_names_with_special_characters() {
let provider = Box::<dyn Provider>::try_from("pass://My Secrets/app").unwrap();
assert_eq!(provider.name(), "pass");
let provider = Box::<dyn Provider>::try_from("keyring://My App/{profile}/{key}").unwrap();
assert_eq!(provider.name(), "keyring");
let provider = Box::<dyn Provider>::try_from("lastpass://Shared Items/dev").unwrap();
assert_eq!(provider.name(), "lastpass");
let provider = Box::<dyn Provider>::try_from("pass://My%20Secrets/app").unwrap();
assert_eq!(provider.name(), "pass");
}
#[cfg(test)]
mod integration_tests {
use super::*;
fn generate_test_project_name() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_micros();
let suffix = timestamp % 100000;
format!("secretspec_test_{}", suffix)
}
fn get_test_providers() -> Vec<String> {
std::env::var("SECRETSPEC_TEST_PROVIDERS")
.unwrap_or_else(|_| String::new())
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
.collect()
}
fn create_provider_with_temp_path(provider_name: &str) -> (Box<dyn Provider>, Option<TempDir>) {
match provider_name {
"dotenv" => {
let temp_dir = TempDir::new().expect("Create temp directory");
let dotenv_path = temp_dir.path().join(".env");
let provider_spec = format!("dotenv:{}", dotenv_path.to_str().unwrap());
let provider = Box::<dyn Provider>::try_from(provider_spec.as_str())
.expect("Should create dotenv provider with path");
(provider, Some(temp_dir))
}
"pass" => {
let provider =
Box::<dyn Provider>::try_from("pass").expect("Should create pass provider");
(provider, None)
}
#[cfg(feature = "vault")]
"vault" | "vault-kv1" => {
let provider_spec = if provider_name == "vault-kv1" {
"vault://127.0.0.1:8200/kv1?tls=false&kv=1"
} else {
"vault://127.0.0.1:8200?tls=false"
};
let provider = Box::<dyn Provider>::try_from(provider_spec)
.expect("Should create vault provider");
(provider, None)
}
#[cfg(feature = "openbao")]
"openbao" | "openbao-kv1" => {
let provider_spec = if provider_name == "openbao-kv1" {
"openbao://127.0.0.1:8200/kv1?tls=false&kv=1"
} else {
"openbao://127.0.0.1:8200?tls=false"
};
let provider = Box::<dyn Provider>::try_from(provider_spec)
.expect("Should create openbao provider");
(provider, None)
}
#[cfg(feature = "infisical")]
"infisical" => {
let project = std::env::var("INFISICAL_TEST_PROJECT").expect(
"Testing the infisical provider requires a real project: set INFISICAL_TEST_PROJECT to a project UUID (and authenticate via INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET or INFISICAL_TOKEN).",
);
let host = std::env::var("INFISICAL_TEST_HOST")
.unwrap_or_else(|_| "app.infisical.com".to_string());
let env = std::env::var("INFISICAL_TEST_ENV").unwrap_or_else(|_| "dev".to_string());
let tls = if host.starts_with("localhost") || host.starts_with("127.0.0.1") {
"&tls=false"
} else {
""
};
let provider_spec = format!("infisical://{host}/{project}?env={env}{tls}");
let provider = Box::<dyn Provider>::try_from(provider_spec.as_str())
.expect("Should create infisical provider");
(provider, None)
}
#[cfg(feature = "akv")]
"akv" => {
let vault = std::env::var("AKV_TEST_VAULT").expect(
"Testing the akv provider requires a real Key Vault: set AKV_TEST_VAULT to a vault name (and authenticate via AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET or `az login`).",
);
let provider_spec = format!("akv://{vault}");
let provider = Box::<dyn Provider>::try_from(provider_spec.as_str())
.expect("Should create akv provider");
(provider, None)
}
_ => {
let provider = Box::<dyn Provider>::try_from(provider_name)
.unwrap_or_else(|_| panic!("{} provider should exist", provider_name));
(provider, None)
}
}
}
fn test_provider_basic_workflow(provider: &dyn Provider, provider_name: &str) {
let project_name = generate_test_project_name();
let result = provider.get(Address::convention(
&project_name,
"default",
"TEST_PASSWORD",
));
match result {
Ok(None) => {
}
Ok(Some(_)) => {
panic!("[{}] Should not find non-existent secret", provider_name);
}
Err(_) => {
}
}
let test_value = SecretString::new(format!("test_password_{}", provider_name).into());
let writable = provider
.check_writable(Address::convention("proj", "default", "KEY"))
.is_ok();
if writable {
provider
.set(
Address::convention(&project_name, "default", "TEST_PASSWORD"),
&test_value,
)
.unwrap_or_else(|_| {
panic!(
"[{}] Provider claims to support set but failed",
provider_name
)
});
let retrieved = provider
.get(Address::convention(
&project_name,
"default",
"TEST_PASSWORD",
))
.unwrap_or_else(|_| {
panic!(
"[{}] Should not error when getting after set",
provider_name
)
});
match retrieved {
Some(value) => {
assert_eq!(
value.expose_secret(),
test_value.expose_secret(),
"[{}] Retrieved value should match set value",
provider_name
);
}
None => {
panic!("[{}] Should find secret after setting it", provider_name);
}
}
} else {
match provider.set(
Address::convention(&project_name, "default", "TEST_PASSWORD"),
&test_value,
) {
Ok(_) => {
panic!(
"[{}] Read-only provider should not allow set operations",
provider_name
);
}
Err(_) => {
println!(
"[{}] Read-only provider correctly rejected set",
provider_name
);
}
}
}
let addr = Address::convention(&project_name, "default", "TEST_PASSWORD");
match provider.delete(addr) {
Ok(removed) => {
assert_eq!(
removed, writable,
"[{provider_name}] delete must report whether an entry was removed"
);
assert!(
provider.get(addr).unwrap_or(None).is_none(),
"[{provider_name}] the secret must be gone after delete"
);
assert!(
!provider.delete(addr).unwrap_or_else(|error| panic!(
"[{provider_name}] deleting an absent entry must not fail: {error}"
)),
"[{provider_name}] deleting an absent entry removes nothing"
);
}
Err(error) => assert!(
error.to_string().contains("does not support deleting"),
"[{provider_name}] unexpected delete failure: {error}"
),
}
}
#[test]
fn test_all_providers_basic_workflow() {
println!("Testing MockProvider");
let mock = MockProvider::new();
test_provider_basic_workflow(&mock, "mock");
let providers = get_test_providers();
for provider_name in providers {
println!("Testing provider: {}", provider_name);
let (provider, _temp_dir) = create_provider_with_temp_path(&provider_name);
test_provider_basic_workflow(provider.as_ref(), &provider_name);
}
}
#[cfg(feature = "infisical")]
#[test]
fn test_infisical_refuses_a_withheld_value() {
let (Ok(client_id), Ok(client_secret)) = (
std::env::var("INFISICAL_TEST_NOREAD_CLIENT_ID"),
std::env::var("INFISICAL_TEST_NOREAD_CLIENT_SECRET"),
) else {
eprintln!(
"skipping: set INFISICAL_TEST_NOREAD_CLIENT_ID/SECRET to an identity that may \
see a secret exists but not read it (needs an Infisical custom role)"
);
return;
};
if !get_test_providers().iter().any(|p| p == "infisical") {
eprintln!("skipping: SECRETSPEC_TEST_PROVIDERS does not name infisical");
return;
}
if std::env::var("INFISICAL_TOKEN").is_ok() {
eprintln!(
"skipping: INFISICAL_TOKEN outranks the restricted identity's credentials. \
Unset it and authenticate with INFISICAL_CLIENT_ID/INFISICAL_CLIENT_SECRET \
to exercise a withheld value."
);
return;
}
let project_name = generate_test_project_name();
let (writer, _t) = create_provider_with_temp_path("infisical");
writer
.set(
Address::convention(&project_name, "default", "HIDDEN_KEY"),
&SecretString::new("plaintext".into()),
)
.expect("the writing identity should store a secret");
let (mut restricted, _t) = create_provider_with_temp_path("infisical");
let mut credentials = crate::provider::ProviderCredentials::new();
credentials.insert("client_id".to_string(), SecretString::new(client_id.into()));
credentials.insert(
"client_secret".to_string(),
SecretString::new(client_secret.into()),
);
restricted.with_credentials(credentials);
let err = restricted
.get(Address::convention(&project_name, "default", "HIDDEN_KEY"))
.expect_err("a withheld value must not read as a secret");
assert!(
err.to_string().contains("withheld"),
"the refusal should say the value was withheld, got: {err}"
);
assert!(
!err.to_string().contains("plaintext"),
"the error must not carry the value"
);
}
#[test]
fn test_all_providers_isolate_profiles() {
let mock = MockProvider::new();
test_provider_profile_isolation(&mock, "mock");
for provider_name in get_test_providers() {
println!("Testing provider: {}", provider_name);
let (provider, _temp_dir) = create_provider_with_temp_path(&provider_name);
test_provider_profile_isolation(provider.as_ref(), &provider_name);
}
}
const FLAT_PROVIDERS: &[&str] = &["dotenv", "env"];
fn test_provider_profile_isolation(provider: &dyn Provider, provider_name: &str) {
if FLAT_PROVIDERS.contains(&provider_name)
|| provider
.check_writable(Address::convention("proj", "default", "KEY"))
.is_err()
{
return;
}
let project_name = generate_test_project_name();
let profiles = ["dev", "staging", "prod"];
for profile in profiles {
let value = SecretString::new(format!("value_for_{profile}").into());
provider
.set(
Address::convention(&project_name, profile, "API_KEY"),
&value,
)
.unwrap_or_else(|e| panic!("[{provider_name}] set under '{profile}': {e}"));
}
for profile in profiles {
let found = provider
.get(Address::convention(&project_name, profile, "API_KEY"))
.unwrap_or_else(|e| panic!("[{provider_name}] get under '{profile}': {e}"))
.unwrap_or_else(|| panic!("[{provider_name}] '{profile}' lost its secret"));
assert_eq!(
found.expose_secret(),
format!("value_for_{profile}"),
"[{provider_name}] profile '{profile}' reads another profile's value"
);
}
}
#[test]
fn test_provider_special_characters() {
let test_cases = vec![
("SPACED_VALUE", "value with spaces"),
("NEWLINE_VALUE", "value\nwith\nnewlines"),
("SPECIAL_CHARS", "!@#%^&*()_+-=[]{}|;',./<>?"),
("UNICODE_VALUE", "🔐 Secret with émojis and ñ"),
];
let provider = MockProvider::new();
let project_name = generate_test_project_name();
for (key, value) in &test_cases {
let secret_value = SecretString::new(value.to_string().into());
provider
.set(
Address::convention(&project_name, "default", key),
&secret_value,
)
.expect("Mock provider should handle all characters");
let result = provider
.get(Address::convention(&project_name, "default", key))
.expect("Should not error when getting");
assert_eq!(
result.map(|s| s.expose_secret().to_string()),
Some(value.to_string()),
"Special characters should be preserved"
);
}
}
#[test]
fn test_provider_profile_support() {
let provider = MockProvider::new();
let project_name = generate_test_project_name();
let profiles = vec!["dev", "staging", "prod"];
let test_key = "API_KEY";
for profile in &profiles {
let value = SecretString::new(format!("key_for_{}", profile).into());
provider
.set(
Address::convention(&project_name, profile, test_key),
&value,
)
.expect("Should set with profile");
let result = provider
.get(Address::convention(&project_name, profile, test_key))
.expect("Should get with profile");
assert_eq!(
result.map(|s| s.expose_secret().to_string()),
Some(value.expose_secret().to_string()),
"Profile-specific value should match"
);
}
for profile in profiles {
let result = provider
.get(Address::convention(&project_name, profile, test_key))
.expect("Should not error");
let expected_value = format!("key_for_{}", profile);
assert_eq!(
result.map(|s| s.expose_secret().to_string()),
Some(expected_value),
"Should find profile-specific value"
);
}
}
#[test]
fn test_default_reflect_returns_error() {
let provider = MockProvider::new();
let result = provider.reflect();
assert!(
result.is_err(),
"Default reflect implementation should return an error"
);
let error = result.unwrap_err();
let error_msg = error.to_string();
assert!(
error_msg.contains("does not support reflection"),
"Error message should indicate reflection is not supported"
);
}
#[test]
fn test_pass_provider_creation() {
let provider = Box::<dyn Provider>::try_from("pass").unwrap();
assert_eq!(provider.name(), "pass");
assert_eq!(provider.uri(), "pass");
let provider = Box::<dyn Provider>::try_from("pass://").unwrap();
assert_eq!(provider.name(), "pass");
assert_eq!(provider.uri(), "pass");
}
#[test]
fn test_keyring_with_folder_prefix() {
let provider =
Box::<dyn Provider>::try_from("keyring://secretspec/shared/{profile}/{key}").unwrap();
assert_eq!(provider.name(), "keyring");
assert_eq!(
provider.uri(),
"keyring://secretspec/shared/{profile}/{key}"
);
let provider = Box::<dyn Provider>::try_from("keyring://").unwrap();
assert_eq!(provider.name(), "keyring");
assert_eq!(provider.uri(), "keyring");
}
#[test]
fn test_pass_with_folder_prefix() {
let provider =
Box::<dyn Provider>::try_from("pass://secretspec/shared/{profile}/{key}").unwrap();
assert_eq!(provider.name(), "pass");
assert_eq!(provider.uri(), "pass://secretspec/shared/{profile}/{key}");
let provider = Box::<dyn Provider>::try_from("pass://").unwrap();
assert_eq!(provider.name(), "pass");
assert_eq!(provider.uri(), "pass");
}
#[test]
fn test_pass_provider_is_writable() {
let provider = Box::<dyn Provider>::try_from("pass").unwrap();
assert!(
provider
.check_writable(Address::convention("proj", "default", "KEY"))
.is_ok(),
"Pass provider should support write operations"
);
}
#[test]
fn test_protonpass_provider_creation() {
let provider = Box::<dyn Provider>::try_from("protonpass").unwrap();
assert_eq!(provider.name(), "protonpass");
assert_eq!(provider.uri(), "protonpass");
let provider = Box::<dyn Provider>::try_from("protonpass://").unwrap();
assert_eq!(provider.name(), "protonpass");
assert_eq!(provider.uri(), "protonpass");
let provider = Box::<dyn Provider>::try_from("protonpass://Work").unwrap();
assert_eq!(provider.name(), "protonpass");
assert_eq!(provider.uri(), "protonpass://Work");
let provider =
Box::<dyn Provider>::try_from("protonpass://Work/{project}/{profile}/{key}").unwrap();
assert_eq!(provider.name(), "protonpass");
assert_eq!(
provider.uri(),
"protonpass://Work/{project}/{profile}/{key}"
);
}
#[test]
fn test_protonpass_provider_is_writable() {
let provider = Box::<dyn Provider>::try_from("protonpass").unwrap();
assert!(
provider
.check_writable(Address::convention("proj", "default", "KEY"))
.is_ok(),
"ProtonPass provider should support write operations"
);
}
#[cfg(feature = "awssm")]
#[test]
fn test_awssm_batch_get() {
let providers = get_test_providers();
if !providers.contains(&"awssm".to_string()) {
return;
}
let (provider, _temp_dir) = create_provider_with_temp_path("awssm");
let project_name = generate_test_project_name();
let profile = "default";
let test_secrets = vec![
("BATCH_TEST_1", "value1"),
("BATCH_TEST_2", "value2"),
("BATCH_TEST_3", "value3"),
];
for (key, value) in &test_secrets {
provider
.set(
Address::convention(&project_name, profile, key),
&SecretString::new(value.to_string().into()),
)
.unwrap();
}
let keys = [
"BATCH_TEST_1",
"BATCH_TEST_2",
"BATCH_TEST_3",
"NONEXISTENT",
];
let requests: Vec<(&str, Address<'_>)> = keys
.iter()
.map(|key| (*key, Address::convention(&project_name, profile, key)))
.collect();
let result = provider.get_many(&requests).unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result["BATCH_TEST_1"].expose_secret(), "value1");
assert_eq!(result["BATCH_TEST_2"].expose_secret(), "value2");
assert_eq!(result["BATCH_TEST_3"].expose_secret(), "value3");
assert!(!result.contains_key("NONEXISTENT"));
}
#[cfg(feature = "awssm")]
#[test]
fn test_awssm_provider_creation() {
let provider = Box::<dyn Provider>::try_from("awssm://us-east-1").unwrap();
assert_eq!(provider.name(), "awssm");
assert_eq!(provider.uri(), "awssm://us-east-1");
}
#[cfg(feature = "awssm")]
#[test]
fn test_awssm_provider_creation_without_region() {
let provider = Box::<dyn Provider>::try_from("awssm://").unwrap();
assert_eq!(provider.name(), "awssm");
assert_eq!(provider.uri(), "awssm");
let provider = Box::<dyn Provider>::try_from("awssm").unwrap();
assert_eq!(provider.name(), "awssm");
assert_eq!(provider.uri(), "awssm");
}
#[cfg(feature = "awssm")]
#[test]
fn test_awssm_provider_with_aws_profile() {
let provider = Box::<dyn Provider>::try_from("awssm://production@us-east-1").unwrap();
assert_eq!(provider.name(), "awssm");
assert_eq!(provider.uri(), "awssm://production@us-east-1");
let provider = Box::<dyn Provider>::try_from("awssm://staging@eu-west-1").unwrap();
assert_eq!(provider.name(), "awssm");
assert_eq!(provider.uri(), "awssm://staging@eu-west-1");
}
#[cfg(feature = "awssm")]
#[test]
fn test_awssm_provider_with_prefix() {
let provider = Box::<dyn Provider>::try_from("awssm://us-east-1?prefix=myteam").unwrap();
assert_eq!(provider.name(), "awssm");
assert_eq!(provider.uri(), "awssm://us-east-1?prefix=myteam");
}
#[cfg(feature = "awssm")]
#[test]
fn test_awssm_provider_with_prefix_and_profile() {
let provider =
Box::<dyn Provider>::try_from("awssm://production@us-east-1?prefix=myteam").unwrap();
assert_eq!(provider.name(), "awssm");
assert_eq!(provider.uri(), "awssm://production@us-east-1?prefix=myteam");
}
#[cfg(feature = "awssm")]
#[test]
fn test_awssm_provider_with_prefix_no_region() {
let provider = Box::<dyn Provider>::try_from("awssm://?prefix=myteam").unwrap();
assert_eq!(provider.name(), "awssm");
assert_eq!(provider.uri(), "awssm://?prefix=myteam");
}
#[cfg(feature = "vault")]
#[test]
fn test_vault_provider_creation() {
let provider =
Box::<dyn Provider>::try_from("vault://vault.example.com:8200/secret").unwrap();
assert_eq!(provider.name(), "vault");
}
#[cfg(feature = "vault")]
#[test]
fn test_vault_provider_default_mount() {
let provider = Box::<dyn Provider>::try_from("vault://vault.example.com:8200").unwrap();
assert_eq!(provider.name(), "vault");
}
#[cfg(feature = "vault")]
#[test]
fn test_vault_provider_custom_mount() {
let provider =
Box::<dyn Provider>::try_from("vault://vault.example.com:8200/custom-kv").unwrap();
assert_eq!(provider.name(), "vault");
}
#[cfg(feature = "vault")]
#[test]
fn test_vault_provider_kv_v1() {
let provider =
Box::<dyn Provider>::try_from("vault://vault.example.com:8200/secret?kv=1").unwrap();
assert_eq!(provider.name(), "vault");
}
#[cfg(feature = "vault")]
#[test]
fn test_vault_provider_with_namespace() {
let provider =
Box::<dyn Provider>::try_from("vault://ns1@vault.example.com:8200/secret").unwrap();
assert_eq!(provider.name(), "vault");
}
#[cfg(feature = "vault")]
#[test]
fn test_vault_provider_tls_false() {
let provider =
Box::<dyn Provider>::try_from("vault://127.0.0.1:8200/secret?tls=false").unwrap();
assert_eq!(provider.name(), "vault");
}
#[cfg(feature = "openbao")]
#[test]
fn test_openbao_provider_creation() {
let provider = Box::<dyn Provider>::try_from("openbao://bao.internal:8200/secret").unwrap();
assert_eq!(provider.name(), "openbao");
assert_eq!(provider.uri(), "openbao://bao.internal:8200/secret");
}
#[cfg(feature = "vault")]
#[test]
fn test_vault_provider_requires_address() {
let had_vault_addr = std::env::var("VAULT_ADDR").ok();
unsafe {
std::env::remove_var("VAULT_ADDR");
}
let result = Box::<dyn Provider>::try_from("vault://");
assert!(result.is_err(), "Vault provider should require an address");
if let Some(addr) = had_vault_addr {
unsafe {
std::env::set_var("VAULT_ADDR", addr);
}
}
}
#[cfg(feature = "gcsm")]
#[test]
fn test_gcsm_provider_creation() {
let provider = Box::<dyn Provider>::try_from("gcsm://my-project").unwrap();
assert_eq!(provider.name(), "gcsm");
assert_eq!(provider.uri(), "gcsm://my-project");
}
#[cfg(feature = "gcsm")]
#[test]
fn test_gcsm_provider_requires_project_id() {
let result = Box::<dyn Provider>::try_from("gcsm://");
assert!(result.is_err(), "GCSM provider should require project ID");
let result = Box::<dyn Provider>::try_from("gcsm");
assert!(result.is_err(), "GCSM provider should require project ID");
}
#[cfg(feature = "bws")]
#[test]
fn test_bws_provider_creation() {
let provider =
Box::<dyn Provider>::try_from("bws://a9230ec4-5507-4870-b8b5-b3f500587e4c").unwrap();
assert_eq!(provider.name(), "bws");
assert_eq!(provider.uri(), "bws://a9230ec4-5507-4870-b8b5-b3f500587e4c");
}
#[cfg(feature = "bws")]
#[test]
fn test_bws_provider_requires_project_id() {
let result = Box::<dyn Provider>::try_from("bws://");
assert!(result.is_err());
let result = Box::<dyn Provider>::try_from("bws");
assert!(result.is_err());
}
#[cfg(feature = "bws")]
#[test]
fn test_bws_provider_validates_uuid_format() {
let result = Box::<dyn Provider>::try_from("bws://not-a-uuid");
assert!(result.is_err());
let result = Box::<dyn Provider>::try_from("bws://12345");
assert!(result.is_err());
}
#[cfg(feature = "gcsm")]
#[test]
fn test_gcsm_provider_validates_project_id_format() {
let result = Box::<dyn Provider>::try_from("gcsm://short");
assert!(result.is_err(), "Should reject project ID < 6 chars");
let result = Box::<dyn Provider>::try_from("gcsm://123456");
assert!(
result.is_err(),
"Should reject project ID starting with number"
);
let result = Box::<dyn Provider>::try_from("gcsm://My-Project-123");
assert!(result.is_err(), "Should reject project ID with uppercase");
let result = Box::<dyn Provider>::try_from("gcsm://my-project-");
assert!(
result.is_err(),
"Should reject project ID ending with hyphen"
);
let result = Box::<dyn Provider>::try_from("gcsm://my_project");
assert!(result.is_err(), "Should reject project ID with underscore");
let provider = Box::<dyn Provider>::try_from("gcsm://my-project-123").unwrap();
assert_eq!(provider.name(), "gcsm");
let provider = Box::<dyn Provider>::try_from("gcsm://project123").unwrap();
assert_eq!(provider.name(), "gcsm");
}
#[test]
fn credentials_reach_preflight_wrapped_provider() {
use crate::provider::{ProviderCredentials, ProviderUrl, provider_from_url};
use url::Url;
let _lock = crate::tests::scrub_resolution_env();
let _env = crate::tests::EnvVarGuard::remove("OP_SERVICE_ACCOUNT_TOKEN");
let scope_with = |token: Option<&str>| {
let mut credentials = ProviderCredentials::new();
if let Some(token) = token {
credentials.insert(
"service_account_token".to_string(),
SecretString::new(token.into()),
);
}
let url = ProviderUrl::new(Url::parse("onepassword://Private").unwrap());
provider_from_url(&url, credentials)
.unwrap()
.auth_scope_key()
.expect("onepassword advertises an auth scope")
};
let without_token = scope_with(None);
let with_token = scope_with(Some("tok-xyz"));
assert_ne!(
with_token, without_token,
"provider credential should be injected before Arc-wrapping"
);
assert_eq!(with_token, scope_with(Some("tok-xyz")));
assert_ne!(with_token, scope_with(Some("tok-other")));
assert!(
!with_token.contains("tok-xyz"),
"auth scope key must not embed the plaintext token: {with_token}"
);
}
}
#[cfg(test)]
const HOSTILE_ITEMS: &[&str] = &[
"CACHIX_SIGNING_KEY_cache-a",
"with space",
"1LEADING_DIGIT",
"dotted.name",
"sla/sh",
"_VALID_UNDERSCORE",
"PLAIN_VALID_1",
];
#[cfg(test)]
fn assert_write_read_symmetry(provider: &dyn Provider) {
use secrecy::ExposeSecret;
provider
.set(
Address::convention("proj", "default", "KEEP"),
&SecretString::new("kept".into()),
)
.unwrap();
for item in HOSTILE_ITEMS {
let addr = crate::config::NativeAddress {
item: (*item).to_string(),
..Default::default()
};
let wrote = provider
.set(Address::Native(&addr), &SecretString::new("v".into()))
.is_ok();
if wrote {
let got = provider.get(Address::Native(&addr)).unwrap();
assert_eq!(
got.map(|s| s.expose_secret().to_string()),
Some("v".to_string()),
"provider `{}` accepted a write of `{item}` it cannot read back",
provider.name(),
);
}
let kept = provider
.get(Address::convention("proj", "default", "KEEP"))
.unwrap();
assert_eq!(
kept.map(|s| s.expose_secret().to_string()),
Some("kept".to_string()),
"provider `{}`: a write of `{item}` corrupted other secrets",
provider.name(),
);
}
}
#[test]
fn dotenv_write_read_symmetry() {
use super::dotenv::{DotEnvConfig, DotEnvProvider};
let dir = TempDir::new().unwrap();
let provider = DotEnvProvider::new(DotEnvConfig {
path: dir.path().join(".env"),
});
assert_write_read_symmetry(&provider);
}
#[test]
fn mock_provider_write_read_symmetry() {
assert_write_read_symmetry(&MockProvider::new());
}