use crate::{Result, SecretSpecError};
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, percent_encode};
use secrecy::SecretString;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::sync::OnceLock;
use url::Url;
pub(crate) const URI_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'<')
.add(b'>')
.add(b'[')
.add(b']')
.add(b'|')
.add(b'^')
.add(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()
}
pub fn port(&self) -> Option<u16> {
self.0.port()
}
pub fn query_pairs(&self) -> url::form_urlencoded::Parse<'_> {
self.0.query_pairs()
}
pub fn encode(value: &str) -> String {
percent_encode(value.as_bytes(), URI_ENCODE_SET).to_string()
}
}
#[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 = "awssm")]
pub mod awssm;
#[cfg(feature = "bws")]
pub mod bws;
pub mod dotenv;
pub mod env;
#[cfg(feature = "gcsm")]
pub mod gcsm;
#[cfg(feature = "keyring")]
pub mod keyring;
pub mod lastpass;
pub mod onepassword;
pub mod pass;
pub mod protonpass;
#[cfg(feature = "vault")]
pub mod vault;
#[macro_use]
pub mod macros;
#[cfg(test)]
pub(crate) mod tests;
#[derive(Debug, Clone)]
pub struct ProviderInfo {
pub name: &'static str,
pub description: &'static str,
pub examples: &'static [&'static str],
}
impl ProviderInfo {
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(", ")
)
}
}
}
pub use macros::{PROVIDER_REGISTRY, ProviderRegistration};
pub fn providers() -> Vec<ProviderInfo> {
PROVIDER_REGISTRY
.iter()
.map(|reg| reg.info.clone())
.collect()
}
pub trait Provider: Send + Sync {
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>>;
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()>;
fn allows_set(&self) -> bool {
true
}
fn name(&self) -> &'static str;
fn uri(&self) -> String;
fn set_reason(&self, _reason: Option<String>) {}
fn reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
Err(SecretSpecError::ProviderOperationFailed(format!(
"Provider '{}' does not support reflection",
self.name()
)))
}
fn get_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
let mut results = HashMap::new();
for key in keys {
if let Some(value) = self.get(project, key, profile)? {
results.insert((*key).to_string(), value);
}
}
Ok(results)
}
}
impl<T: Provider> Provider for std::sync::Arc<T> {
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
(**self).get(project, key, profile)
}
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
(**self).set(project, key, value, profile)
}
fn allows_set(&self) -> bool {
(**self).allows_set()
}
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_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
(**self).get_batch(project, keys, profile)
}
}
pub(crate) struct ProviderWithPreflight {
pub provider: Box<dyn Provider>,
pub preflight: Option<Box<dyn Fn() -> Result<()> + Send + Sync>>,
}
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 result = self.result.get_or_init(|| {
if let Some(f) = &self.preflight {
f().map_err(|e| e.to_string())
} else {
Ok(())
}
});
match result {
Ok(()) => Ok(()),
Err(msg) => Err(SecretSpecError::ProviderOperationFailed(msg.clone())),
}
}
}
impl Provider for PreflightGuard {
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
self.check()?;
self.inner.get(project, key, profile)
}
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
self.check()?;
self.inner.set(project, key, value, profile)
}
fn allows_set(&self) -> bool {
self.inner.allows_set()
}
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 reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
self.check()?;
self.inner.reflect()
}
fn get_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
self.check()?;
self.inner.get_batch(project, keys, profile)
}
}
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> {
let (scheme, rest) = if let Some(pos) = s.find(':') {
let scheme = &s[..pos];
let rest = &s[pos + 1..];
(scheme, rest)
} else {
(s, "")
};
if scheme == "1password" {
return Err(SecretSpecError::ProviderOperationFailed(
"Invalid scheme '1password'. Use 'onepassword' instead (e.g., onepassword://vault/path)".to_string()
));
}
let is_valid_scheme = PROVIDER_REGISTRY
.iter()
.any(|reg| reg.schemes.contains(&scheme));
if !is_valid_scheme {
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 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 url_string = {
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))
}
}
impl TryFrom<&Url> for Box<dyn Provider> {
type Error = SecretSpecError;
fn try_from(url: &Url) -> Result<Self> {
provider_from_url(&ProviderUrl::new(url.clone()))
}
}
fn provider_from_url(url: &ProviderUrl) -> Result<Box<dyn Provider>> {
let scheme = url.scheme();
let registration = PROVIDER_REGISTRY
.iter()
.find(|reg| reg.schemes.contains(&scheme))
.ok_or_else(|| SecretSpecError::ProviderNotFound(scheme.to_string()))?;
let pwp = (registration.factory)(url)?;
if pwp.preflight.is_some() {
Ok(Box::new(PreflightGuard::new(pwp)))
} else {
Ok(pwp.provider)
}
}
#[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 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 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"
);
}
}