use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, percent_encode};
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(super) 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'+');
pub(super) 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(super) fn as_url(&self) -> &Url {
&self.0
}
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 = "aac",
feature = "infisical",
feature = "openbao",
feature = "vault",
test
))]
pub fn port(&self) -> Option<u16> {
self.0.port()
}
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(crate) fn has_fragment(&self) -> bool {
self.0.fragment().is_some()
}
pub(crate) fn has_port(&self) -> bool {
self.0.port().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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::Provider;
use std::collections::HashMap;
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 windows_file_path_uses_a_standard_file_url() {
let provider = Box::<dyn Provider>::try_from(r"file://C:\Users\foo\secrets").unwrap();
assert_eq!(provider.name(), "file");
assert_eq!(provider.uri(), "file:///C:/Users/foo/secrets");
}
#[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));
}
}
#[cfg(test)]
mod encoding_properties {
use super::*;
use proptest::prelude::*;
fn query_value_of(uri: &str, key: &str) -> Option<String> {
let url = ProviderUrl::new(Url::parse(uri).ok()?);
url.query_pairs()
.find(|(k, _)| k == key)
.map(|(_, v)| v.into_owned())
}
proptest! {
#[test]
fn encode_query_round_trips(value in ".*") {
let uri = format!("keyring://?v={}", ProviderUrl::encode_query(&value));
let decoded = query_value_of(&uri, "v");
prop_assert_eq!(
decoded.as_deref(),
Some(value.as_str()),
"value {:?} did not survive the round-trip through {:?}",
value,
uri,
);
}
#[test]
fn encode_query_is_deterministic(value in ".*") {
prop_assert_eq!(
ProviderUrl::encode_query(&value),
ProviderUrl::encode_query(&value),
);
}
#[test]
fn encoded_values_are_query_safe(value in ".*") {
let encoded = ProviderUrl::encode_query(&value);
prop_assert!(
!encoded.contains('&') && !encoded.contains('#') && !encoded.contains('+'),
"encoded {encoded:?} still carries a query-structural character",
);
}
}
}