use crate::config::NativeAddress;
use crate::{Result, SecretSpecError};
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, percent_encode};
use secrecy::{ExposeSecret, SecretString};
use std::borrow::Cow;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::sync::{Arc, LazyLock, Mutex, OnceLock};
use url::Url;
pub(crate) type ProviderCredentials = HashMap<String, SecretString>;
pub(crate) fn credential_or_env(
credentials: &ProviderCredentials,
name: &str,
env_var: &str,
) -> Option<String> {
credential_or_envs(credentials, name, &[env_var])
}
pub(crate) fn credential_or_envs(
credentials: &ProviderCredentials,
name: &str,
env_vars: &[&str],
) -> Option<String> {
credentials
.get(name)
.map(|secret| secret.expose_secret().to_string())
.filter(|value| !value.is_empty())
.or_else(|| preferred_env(env_vars))
}
pub(crate) fn preferred_env(names: &[&str]) -> Option<String> {
for name in names {
if let Some(value) = std::env::var_os(name) {
return value.into_string().ok().filter(|value| !value.is_empty());
}
}
None
}
pub(crate) const URI_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'<')
.add(b'>')
.add(b'[')
.add(b']')
.add(b'|')
.add(b'^')
.add(b'\\');
const WINDOWS_PATH_ENCODE_SET: &AsciiSet = &URI_ENCODE_SET.add(b':');
const QUERY_ENCODE_SET: &AsciiSet = &URI_ENCODE_SET.add(b'%').add(b'#').add(b'&').add(b'+');
fn is_windows_abs_path(s: &str) -> bool {
let b = s.as_bytes();
b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
}
pub(crate) struct ProviderUrl(Url);
impl ProviderUrl {
pub fn new(url: Url) -> Self {
Self(url)
}
pub fn scheme(&self) -> &str {
self.0.scheme()
}
pub fn host(&self) -> Option<String> {
self.0
.host_str()
.map(|h| percent_decode_str(h).decode_utf8_lossy().into_owned())
}
pub fn username(&self) -> String {
percent_decode_str(self.0.username())
.decode_utf8_lossy()
.into_owned()
}
pub fn password(&self) -> Option<String> {
self.0
.password()
.map(|p| percent_decode_str(p).decode_utf8_lossy().into_owned())
}
pub fn path(&self) -> String {
percent_decode_str(self.0.path())
.decode_utf8_lossy()
.into_owned()
}
#[cfg(any(feature = "infisical", feature = "openbao", feature = "vault", test))]
pub fn port(&self) -> Option<u16> {
self.0.port()
}
#[cfg(any(
feature = "awssm",
feature = "infisical",
feature = "kdbx",
feature = "openbao",
feature = "sops",
feature = "vault",
test
))]
pub fn query_pairs(&self) -> url::form_urlencoded::Parse<'_> {
self.0.query_pairs()
}
pub fn query_value(&self, key: &str) -> Option<String> {
self.0
.query_pairs()
.find(|(k, _)| k == key)
.map(|(_, v)| v.into_owned())
.filter(|v| !v.is_empty())
}
pub(crate) fn has_query(&self) -> bool {
self.0.query().is_some()
}
pub fn encode(value: &str) -> String {
percent_encode(value.as_bytes(), URI_ENCODE_SET).to_string()
}
pub fn encode_query(value: &str) -> String {
percent_encode(value.as_bytes(), QUERY_ENCODE_SET).to_string()
}
}
impl std::fmt::Display for ProviderUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[allow(dead_code)]
pub(crate) fn block_on<F: std::future::Future>(future: F) -> F::Output {
match tokio::runtime::Handle::try_current() {
Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)),
Err(_) => tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Failed to create tokio runtime")
.block_on(future),
}
}
#[cfg(feature = "age")]
pub mod age;
#[cfg(feature = "akv")]
pub mod akv;
#[cfg(feature = "awssm")]
pub mod awssm;
#[cfg(feature = "bws")]
pub mod bws;
pub mod dotenv;
pub mod env;
#[cfg(feature = "gcsm")]
pub mod gcsm;
pub mod gopass;
#[cfg(feature = "infisical")]
pub mod infisical;
#[cfg(feature = "kdbx")]
pub mod kdbx;
#[cfg(feature = "keyring")]
pub mod keyring;
pub mod lastpass;
pub mod onepassword;
#[cfg(feature = "openbao")]
pub mod openbao;
pub mod pass;
pub mod protonpass;
#[cfg(feature = "scaleway")]
pub mod scaleway;
#[cfg(feature = "sops")]
pub mod sops;
pub mod systemd_credential;
#[cfg(feature = "vault")]
pub mod vault;
#[cfg(any(feature = "openbao", feature = "vault"))]
mod vault_common;
#[macro_use]
pub mod macros;
#[cfg(test)]
pub(crate) mod tests;
#[derive(Debug, Clone)]
pub struct ProviderInfo {
pub name: &'static str,
#[cfg_attr(not(any(feature = "cli", test)), allow(dead_code))]
pub description: &'static str,
#[cfg_attr(not(any(feature = "cli", test)), allow(dead_code))]
pub examples: &'static [&'static str],
}
impl ProviderInfo {
#[cfg(any(feature = "cli", test))]
pub fn display_with_examples(&self) -> String {
if self.examples.is_empty() {
format!("{}: {}", self.name, self.description)
} else {
format!(
"{}: {} (e.g., {})",
self.name,
self.description,
self.examples.join(", ")
)
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub enum Address<'a> {
Convention {
project: &'a str,
profile: &'a str,
key: &'a str,
},
Native(&'a NativeAddress),
}
impl<'a> Address<'a> {
pub fn convention(project: &'a str, profile: &'a str, key: &'a str) -> Self {
Address::Convention {
project,
profile,
key,
}
}
}
fn reject_unsupported_coords(
provider: &str,
addr: &NativeAddress,
supported: &[&str],
) -> Result<()> {
for (name, value) in addr.coordinates() {
if name == "item" || value.is_none() {
continue;
}
if !supported.contains(&name) {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"the {provider} provider does not support the `{name}` coordinate. \
Drop `{name}` from the ref for `{item}`.",
item = addr.item
)));
}
}
Ok(())
}
pub(crate) fn flat_item<'a, P: Provider + ?Sized>(
provider: &P,
addr: Address<'a>,
) -> Result<Cow<'a, str>> {
match provider.resolve_coords(addr)? {
Cow::Borrowed(native) => Ok(Cow::Borrowed(native.item.as_str())),
Cow::Owned(native) => Ok(Cow::Owned(native.item)),
}
}
pub use macros::{PROVIDER_REGISTRY, ProviderRegistration, declared_flag};
#[cfg(feature = "cli")]
pub fn providers() -> Vec<ProviderInfo> {
PROVIDER_REGISTRY
.iter()
.map(|reg| reg.info.clone())
.collect()
}
fn split_spec(spec: &str) -> (&str, &str) {
match spec.find(':') {
Some(pos) => (&spec[..pos], &spec[pos + 1..]),
None => (spec, ""),
}
}
fn registration_for_scheme(scheme: &str) -> Option<&'static ProviderRegistration> {
PROVIDER_REGISTRY
.iter()
.find(|reg| reg.schemes.contains(&scheme))
}
pub(crate) fn spec_names_known_provider(spec: &str) -> Result<bool> {
let (scheme, _) = split_spec(spec);
if scheme == "1password" {
return Err(SecretSpecError::ProviderOperationFailed(
"Invalid scheme '1password'. Use 'onepassword' instead (e.g., onepassword://vault)"
.to_string(),
));
}
Ok(registration_for_scheme(scheme).is_some())
}
pub(crate) fn credential_names_for_spec(spec: &str) -> &'static [&'static str] {
let (scheme, _) = split_spec(spec);
registration_for_scheme(scheme).map_or(&[], |reg| reg.credential_names)
}
pub(crate) fn spec_provider_deletes(spec: &str) -> bool {
let (scheme, _) = split_spec(spec);
registration_for_scheme(scheme).is_some_and(|reg| reg.deletes)
}
pub(crate) fn deleting_provider_names() -> Vec<&'static str> {
let mut names: Vec<&'static str> = PROVIDER_REGISTRY
.iter()
.filter(|reg| reg.deletes)
.map(|reg| reg.info.name)
.collect();
names.sort_unstable();
names
}
pub(crate) fn provider_display_name_for_spec(spec: &str) -> String {
let (scheme, _) = split_spec(spec);
registration_for_scheme(scheme)
.map(|reg| reg.info.name.to_string())
.unwrap_or_else(|| scheme.to_string())
}
pub trait Provider: Send + Sync {
fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result<NativeAddress>;
fn supported_coords(&self) -> &'static [&'static str] {
&[]
}
fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result<Cow<'a, NativeAddress>> {
let coords = match addr {
Address::Native(native) => Cow::Borrowed(native),
Address::Convention {
project,
profile,
key,
} => Cow::Owned(self.convention_address(project, profile, key)?),
};
reject_unsupported_coords(self.name(), &coords, self.supported_coords())?;
Ok(coords)
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>>;
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()>;
fn set_expiring(
&self,
addr: Address<'_>,
value: &SecretString,
max_age: std::time::Duration,
) -> Result<()> {
let _ = max_age;
self.set(addr, value)
}
fn delete(&self, addr: Address<'_>) -> Result<bool> {
let _ = addr;
Err(SecretSpecError::ProviderOperationFailed(format!(
"provider '{}' does not support deleting secrets",
self.name()
)))
}
fn check_writable(&self, addr: Address<'_>) -> Result<()> {
let _ = addr;
Ok(())
}
fn auth_scope_key(&self) -> Option<String> {
None
}
fn name(&self) -> &'static str;
fn uri(&self) -> String;
fn set_reason(&self, _reason: Option<String>) {}
fn with_base_dir(&mut self, _base_dir: &std::path::Path) {}
fn with_credentials(&mut self, _credentials: ProviderCredentials) {}
fn reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
Err(SecretSpecError::ProviderOperationFailed(format!(
"Provider '{}' does not support reflection",
self.name()
)))
}
fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretString>> {
get_each(self, requests)
}
}
const DEFAULT_GET_EACH_CONCURRENCY: usize = 8;
pub(crate) const GET_EACH_CONCURRENCY_ENV: &str = "SECRETSPEC_PROVIDER_CONCURRENCY";
pub(crate) fn get_each_concurrency() -> usize {
std::env::var(GET_EACH_CONCURRENCY_ENV)
.ok()
.and_then(|value| value.parse::<usize>().ok())
.filter(|&n| n >= 1)
.unwrap_or(DEFAULT_GET_EACH_CONCURRENCY)
}
pub(crate) fn get_each<P: Provider + ?Sized>(
provider: &P,
requests: &[(&str, Address<'_>)],
) -> Result<HashMap<String, SecretString>> {
let mut groups: HashMap<Address<'_>, Vec<&str>> = HashMap::new();
for (name, addr) in requests {
groups.entry(*addr).or_default().push(name);
}
let groups: Vec<(Address<'_>, Vec<&str>)> = groups.into_iter().collect();
let fetched: Vec<(Vec<&str>, Result<Option<SecretString>>)> = if groups.len() <= 1 {
groups
.into_iter()
.map(|(addr, names)| (names, provider.get(addr)))
.collect()
} else {
let concurrency = get_each_concurrency();
let mut fetched = Vec::with_capacity(groups.len());
for chunk in groups.chunks(concurrency) {
std::thread::scope(|scope| {
let handles: Vec<_> = chunk
.iter()
.map(|(addr, names)| {
let addr = *addr;
(names, scope.spawn(move || provider.get(addr)))
})
.collect();
for (names, handle) in handles {
fetched.push((
names.clone(),
handle.join().expect("get_many fetch thread panicked"),
));
}
});
}
fetched
};
let mut results = HashMap::new();
for (names, result) in fetched {
if let Some(value) = result? {
for name in names {
results.insert(name.to_string(), value.clone());
}
}
}
Ok(results)
}
impl<T: Provider> Provider for std::sync::Arc<T> {
fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result<NativeAddress> {
(**self).convention_address(project, profile, key)
}
fn supported_coords(&self) -> &'static [&'static str] {
(**self).supported_coords()
}
fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result<Cow<'a, NativeAddress>> {
(**self).resolve_coords(addr)
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
(**self).get(addr)
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
(**self).set(addr, value)
}
fn set_expiring(
&self,
addr: Address<'_>,
value: &SecretString,
max_age: std::time::Duration,
) -> Result<()> {
(**self).set_expiring(addr, value, max_age)
}
fn delete(&self, addr: Address<'_>) -> Result<bool> {
(**self).delete(addr)
}
fn check_writable(&self, addr: Address<'_>) -> Result<()> {
(**self).check_writable(addr)
}
fn auth_scope_key(&self) -> Option<String> {
(**self).auth_scope_key()
}
fn name(&self) -> &'static str {
(**self).name()
}
fn uri(&self) -> String {
(**self).uri()
}
fn set_reason(&self, reason: Option<String>) {
(**self).set_reason(reason);
}
fn reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
(**self).reflect()
}
fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretString>> {
(**self).get_many(requests)
}
}
pub(crate) struct ProviderWithPreflight {
pub provider: Box<dyn Provider>,
pub preflight: Option<Box<dyn Fn() -> Result<()> + Send + Sync>>,
}
type AuthCheckResult = std::result::Result<(), String>;
type AuthCheckCell = Arc<OnceLock<AuthCheckResult>>;
pub(crate) struct AuthCheckCache<K> {
cells: Mutex<HashMap<K, AuthCheckCell>>,
}
impl<K> Default for AuthCheckCache<K> {
fn default() -> Self {
Self {
cells: Mutex::new(HashMap::new()),
}
}
}
impl<K: std::hash::Hash + Eq + Clone> AuthCheckCache<K> {
pub(crate) fn check(
&self,
key: K,
probe: impl FnOnce() -> std::result::Result<(), String>,
) -> std::result::Result<(), String> {
let cell = self
.cells
.lock()
.unwrap()
.entry(key.clone())
.or_default()
.clone();
let result = cell.get_or_init(probe).clone();
if result.is_err() {
let mut cells = self.cells.lock().unwrap();
if let Some(existing) = cells.get(&key)
&& Arc::ptr_eq(existing, &cell)
{
cells.remove(&key);
}
}
result
}
}
static PREFLIGHT_AUTH_CACHE: LazyLock<AuthCheckCache<(&'static str, String)>> =
LazyLock::new(AuthCheckCache::default);
struct PreflightGuard {
inner: Box<dyn Provider>,
preflight: Option<Box<dyn Fn() -> Result<()> + Send + Sync>>,
result: OnceLock<std::result::Result<(), String>>,
}
impl PreflightGuard {
fn new(pwp: ProviderWithPreflight) -> Self {
Self {
inner: pwp.provider,
preflight: pwp.preflight,
result: OnceLock::new(),
}
}
fn check(&self) -> Result<()> {
let Some(f) = &self.preflight else {
return Ok(());
};
if let Some(scope) = self.inner.auth_scope_key() {
return PREFLIGHT_AUTH_CACHE
.check((self.inner.name(), scope), || {
f().map_err(|e| e.to_string())
})
.map_err(SecretSpecError::ProviderOperationFailed);
}
let result = self.result.get_or_init(|| f().map_err(|e| e.to_string()));
match result {
Ok(()) => Ok(()),
Err(msg) => Err(SecretSpecError::ProviderOperationFailed(msg.clone())),
}
}
}
impl Provider for PreflightGuard {
fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result<NativeAddress> {
self.inner.convention_address(project, profile, key)
}
fn supported_coords(&self) -> &'static [&'static str] {
self.inner.supported_coords()
}
fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result<Cow<'a, NativeAddress>> {
self.inner.resolve_coords(addr)
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
self.check()?;
self.inner.get(addr)
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
self.check()?;
self.inner.set(addr, value)
}
fn set_expiring(
&self,
addr: Address<'_>,
value: &SecretString,
max_age: std::time::Duration,
) -> Result<()> {
self.check()?;
self.inner.set_expiring(addr, value, max_age)
}
fn delete(&self, addr: Address<'_>) -> Result<bool> {
self.check()?;
self.inner.delete(addr)
}
fn check_writable(&self, addr: Address<'_>) -> Result<()> {
self.inner.check_writable(addr)
}
fn auth_scope_key(&self) -> Option<String> {
self.inner.auth_scope_key()
}
fn name(&self) -> &'static str {
self.inner.name()
}
fn uri(&self) -> String {
self.inner.uri()
}
fn set_reason(&self, reason: Option<String>) {
self.inner.set_reason(reason);
}
fn with_base_dir(&mut self, base_dir: &std::path::Path) {
self.inner.with_base_dir(base_dir);
}
fn with_credentials(&mut self, credentials: ProviderCredentials) {
self.inner.with_credentials(credentials);
}
fn reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
self.check()?;
self.inner.reflect()
}
fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result<HashMap<String, SecretString>> {
self.check()?;
self.inner.get_many(requests)
}
}
impl TryFrom<String> for Box<dyn Provider> {
type Error = SecretSpecError;
fn try_from(s: String) -> Result<Self> {
Self::try_from(&s as &str)
}
}
impl TryFrom<&str> for Box<dyn Provider> {
type Error = SecretSpecError;
fn try_from(s: &str) -> Result<Self> {
provider_from_spec(s, ProviderCredentials::new())
}
}
pub(crate) fn provider_from_spec(
s: &str,
credentials: ProviderCredentials,
) -> Result<Box<dyn Provider>> {
let (scheme, rest) = split_spec(s);
if !spec_names_known_provider(s)? {
if PROVIDER_REGISTRY.iter().any(|reg| reg.info.name == scheme) {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Provider '{}' exists but URI parsing failed",
scheme
)));
} else {
return Err(SecretSpecError::ProviderNotFound(scheme.to_string()));
}
}
let path_candidate = rest.trim_start_matches('/');
let url_string = if is_windows_abs_path(path_candidate) {
format!(
"{}://{}",
scheme,
percent_encode(path_candidate.as_bytes(), WINDOWS_PATH_ENCODE_SET)
)
} else {
let url_string = match rest {
"" | ":" => format!("{}://", scheme),
s if s.starts_with("//") => format!("{}:{}", scheme, s),
s if s.starts_with('/') => format!("{}://{}", scheme, s),
s => format!("{}://{}", scheme, s),
};
let scheme_end = url_string.find("://").unwrap() + 3;
let (prefix, rest) = url_string.split_at(scheme_end);
format!(
"{}{}",
prefix,
percent_encode(rest.as_bytes(), URI_ENCODE_SET)
)
};
let proper_url = Url::parse(&url_string).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Invalid provider specification '{}': {}",
s, e
))
})?;
provider_from_url(&ProviderUrl::new(proper_url), credentials)
}
impl TryFrom<&Url> for Box<dyn Provider> {
type Error = SecretSpecError;
fn try_from(url: &Url) -> Result<Self> {
provider_from_url(&ProviderUrl::new(url.clone()), ProviderCredentials::new())
}
}
pub(crate) fn provider_from_url(
url: &ProviderUrl,
credentials: ProviderCredentials,
) -> Result<Box<dyn Provider>> {
let scheme = url.scheme();
let registration = registration_for_scheme(scheme)
.ok_or_else(|| SecretSpecError::ProviderNotFound(scheme.to_string()))?;
let pwp = (registration.factory)(url, credentials)?;
if pwp.preflight.is_some() {
Ok(Box::new(PreflightGuard::new(pwp)))
} else {
Ok(pwp.provider)
}
}
#[cfg(test)]
mod auth_cache_tests {
use super::AuthCheckCache;
use std::cell::Cell;
#[test]
fn success_probes_once_per_key() {
let cache = AuthCheckCache::default();
let probes = Cell::new(0);
for _ in 0..3 {
let result = cache.check("key", || {
probes.set(probes.get() + 1);
Ok(())
});
assert_eq!(result, Ok(()));
}
assert_eq!(probes.get(), 1);
}
#[test]
fn failure_is_not_cached() {
let cache = AuthCheckCache::default();
assert_eq!(
cache.check("key", || Err("not signed in".to_string())),
Err("not signed in".to_string())
);
assert_eq!(cache.check("key", || Ok(())), Ok(()));
let probes = Cell::new(0);
assert_eq!(
cache.check("key", || {
probes.set(probes.get() + 1);
Ok(())
}),
Ok(())
);
assert_eq!(probes.get(), 0);
}
#[test]
fn keys_are_independent() {
let cache = AuthCheckCache::default();
assert_eq!(cache.check("a", || Ok(())), Ok(()));
assert_eq!(
cache.check("b", || Err("nope".to_string())),
Err("nope".to_string())
);
assert_eq!(cache.check("a", || Err("unused".to_string())), Ok(()));
}
}
#[cfg(test)]
mod url_tests {
use super::*;
use std::collections::HashMap;
use url::Url;
fn url(s: &str) -> ProviderUrl {
ProviderUrl::new(Url::parse(s).unwrap())
}
#[test]
fn host_and_path_are_percent_decoded() {
let u = url("keyring://Home%20Lab/My%20Path");
assert_eq!(u.host().as_deref(), Some("Home Lab"));
assert_eq!(u.path(), "/My Path");
}
#[test]
fn username_and_password_are_percent_decoded() {
let u = url("onepassword://work%40acct:tok%20en@Vault");
assert_eq!(u.username(), "work@acct");
assert_eq!(u.password().as_deref(), Some("tok en"));
assert_eq!(u.host().as_deref(), Some("Vault"));
}
#[test]
fn missing_password_and_port_are_none() {
let u = url("keyring://host");
assert_eq!(u.password(), None);
assert_eq!(u.port(), None);
assert_eq!(u.username(), "");
}
#[test]
fn port_is_parsed_when_present() {
assert_eq!(url("https://example.com:8200/").port(), Some(8200));
}
#[test]
fn detects_windows_absolute_paths() {
assert!(is_windows_abs_path(r"C:\Users\foo"));
assert!(is_windows_abs_path("C:/Users/foo"));
assert!(is_windows_abs_path(r"d:\x"));
assert!(!is_windows_abs_path("/tmp/foo"));
assert!(!is_windows_abs_path("relative/path"));
assert!(!is_windows_abs_path("C:"));
assert!(!is_windows_abs_path("vault"));
}
#[test]
fn windows_dotenv_path_parses_instead_of_failing_on_port() {
let provider = Box::<dyn Provider>::try_from(r"dotenv://C:\Users\foo\.env");
assert!(
provider.is_ok(),
"Windows dotenv path should parse, got {:?}",
provider.err()
);
}
#[test]
fn query_pairs_are_decoded() {
let u = url("keyring://h/p?prefix=a%20b&kv=v2");
let pairs: HashMap<String, String> = u
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
assert_eq!(pairs.get("prefix").map(String::as_str), Some("a b"));
assert_eq!(pairs.get("kv").map(String::as_str), Some("v2"));
}
#[test]
fn encode_escapes_spaces_but_keeps_plain() {
assert_eq!(ProviderUrl::encode("plain"), "plain");
assert_eq!(ProviderUrl::encode("Home Lab"), "Home%20Lab");
}
#[test]
fn windows_drive_paths_parse_as_provider_specs() {
for spec in [
r"dotenv://C:\Users\me\.env",
r"dotenv://C:/Users/me/.env",
r"dotenv:C:\Users\me\.env",
] {
assert!(
Box::<dyn Provider>::try_from(spec).is_ok(),
"should parse: {}",
spec
);
}
assert!(Box::<dyn Provider>::try_from("dotenv:///tmp/.env").is_ok());
assert!(Box::<dyn Provider>::try_from("dotenv://.env").is_ok());
}
#[test]
fn encode_query_escapes_query_significant_chars() {
assert_eq!(ProviderUrl::encode_query("/a/b"), "/a/b");
assert_eq!(ProviderUrl::encode_query("a&b"), "a%26b");
assert_eq!(ProviderUrl::encode_query("a+b"), "a%2Bb");
assert_eq!(ProviderUrl::encode_query("a#b"), "a%23b");
assert_eq!(ProviderUrl::encode_query("a%b"), "a%25b");
assert_eq!(ProviderUrl::encode_query("a b"), "a%20b");
let value = "/srv/a&b+c#d%e f";
let encoded = ProviderUrl::encode_query(value);
let u = url(&format!("keyring://?store_dir={encoded}"));
let decoded = u
.query_pairs()
.find(|(k, _)| k == "store_dir")
.map(|(_, v)| v.into_owned());
assert_eq!(decoded.as_deref(), Some(value));
}
#[test]
fn provider_info_display_with_and_without_examples() {
let with = ProviderInfo {
name: "onepassword",
description: "OnePassword",
examples: &["onepassword://vault", "onepassword://work@Production"],
};
assert_eq!(
with.display_with_examples(),
"onepassword: OnePassword (e.g., onepassword://vault, onepassword://work@Production)"
);
let without = ProviderInfo {
name: "env",
description: "Environment variables",
examples: &[],
};
assert_eq!(
without.display_with_examples(),
"env: Environment variables"
);
}
}
#[cfg(test)]
mod provider_credentials_tests {
use super::{ProviderCredentials, credential_or_env, preferred_env};
use crate::tests::EnvVarGuard;
use secrecy::SecretString;
fn credentials(name: &str, value: &str) -> ProviderCredentials {
let mut credentials = ProviderCredentials::new();
credentials.insert(name.to_string(), SecretString::new(value.into()));
credentials
}
#[test]
fn explicit_credential_wins_over_environment() {
let _lock = crate::tests::scrub_resolution_env();
const NAME: &str = "access_token";
const ENV_VAR: &str = "SECRETSPEC_TEST_PROVIDER_CREDENTIAL";
let _var = EnvVarGuard::set(ENV_VAR, "from-env");
assert_eq!(
credential_or_env(&credentials(NAME, "explicit"), NAME, ENV_VAR).as_deref(),
Some("explicit"),
);
}
#[test]
fn environment_is_a_fallback() {
let _lock = crate::tests::scrub_resolution_env();
const NAME: &str = "access_token";
const ENV_VAR: &str = "SECRETSPEC_TEST_PROVIDER_CREDENTIAL_FALLBACK";
let _var = EnvVarGuard::set(ENV_VAR, "from-env");
assert_eq!(
credential_or_env(&ProviderCredentials::new(), NAME, ENV_VAR).as_deref(),
Some("from-env"),
);
assert_eq!(
credential_or_env(&credentials(NAME, ""), NAME, ENV_VAR).as_deref(),
Some("from-env"),
);
}
#[test]
fn a_present_preferred_environment_variable_blocks_compatibility_fallback() {
let _lock = crate::tests::scrub_resolution_env();
const PREFERRED: &str = "SECRETSPEC_TEST_PREFERRED_ENV";
const FALLBACK: &str = "SECRETSPEC_TEST_COMPATIBILITY_ENV";
{
let _preferred = EnvVarGuard::set(PREFERRED, "");
let _fallback = EnvVarGuard::set(FALLBACK, "from-fallback");
assert_eq!(preferred_env(&[PREFERRED, FALLBACK]), None);
}
{
let _preferred = EnvVarGuard::remove(PREFERRED);
let _fallback = EnvVarGuard::set(FALLBACK, "from-fallback");
assert_eq!(
preferred_env(&[PREFERRED, FALLBACK]).as_deref(),
Some("from-fallback")
);
}
}
}