use super::{Address, Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use keyring::Entry;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct KeyringConfig {
pub folder_prefix: Option<String>,
}
impl TryFrom<&ProviderUrl> for KeyringConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "keyring" {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for keyring provider",
url.scheme()
)));
}
let mut config = Self::default();
if let Some(host) = url.host() {
config.folder_prefix = Some(format!("{}{}", host, url.path()));
}
Ok(config)
}
}
pub struct KeyringProvider {
config: KeyringConfig,
}
crate::register_provider! {
struct: KeyringProvider,
config: KeyringConfig,
name: "keyring",
description: "Uses system keychain (Recommended)",
schemes: ["keyring"],
examples: ["keyring://", "keyring://secretspec/shared/{profile}/{key}"],
}
impl KeyringProvider {
pub fn new(config: KeyringConfig) -> Self {
Self { config }
}
fn format_service(&self, project: &str, profile: &str, key: &str) -> String {
let format_string = self
.config
.folder_prefix
.as_deref()
.unwrap_or("secretspec/{project}/{profile}/{key}");
format_string
.replace("{project}", project)
.replace("{profile}", profile)
.replace("{key}", key)
}
fn entry_target(&self, addr: Address<'_>) -> Result<(String, String)> {
let coords = self.resolve_coords(addr)?;
let account = match &coords.field {
Some(account) => account.clone(),
None => Self::current_username()?,
};
Ok((coords.item.clone(), account))
}
fn current_username() -> Result<String> {
whoami::username().map_err(|e| SecretSpecError::ProviderOperationFailed(e.to_string()))
}
}
impl Provider for KeyringProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: self.format_service(project, profile, key),
..Default::default()
})
}
fn supported_coords(&self) -> &'static [&'static str] {
&["field"]
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
if let Some(ref prefix) = self.config.folder_prefix {
format!("keyring://{}", ProviderUrl::encode(prefix))
} else {
"keyring".to_string()
}
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let (service, username) = self.entry_target(addr)?;
let entry = Entry::new(&service, &username)?;
match entry.get_password() {
Ok(password) => Ok(Some(SecretString::new(password.into()))),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
let (service, username) = self.entry_target(addr)?;
let entry = Entry::new(&service, &username)?;
entry.set_password(value.expose_secret())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use url::Url;
fn provider_url(s: &str) -> ProviderUrl {
ProviderUrl::new(Url::parse(s).unwrap())
}
#[test]
fn format_service_default_pattern() {
let provider = KeyringProvider::new(KeyringConfig::default());
assert_eq!(
provider.format_service("myproj", "prod", "API_KEY"),
"secretspec/myproj/prod/API_KEY"
);
}
#[test]
fn format_service_custom_prefix() {
let provider = KeyringProvider::new(KeyringConfig {
folder_prefix: Some("vault/{profile}/{key}".to_string()),
});
assert_eq!(
provider.format_service("myproj", "prod", "API_KEY"),
"vault/prod/API_KEY"
);
}
#[test]
fn try_from_sets_folder_prefix_from_host_and_path() {
let config =
KeyringConfig::try_from(&provider_url("keyring://secretspec/shared/{profile}/{key}"))
.unwrap();
assert_eq!(
config.folder_prefix.as_deref(),
Some("secretspec/shared/{profile}/{key}")
);
}
#[test]
fn try_from_without_host_has_no_prefix() {
let config = KeyringConfig::try_from(&provider_url("keyring://")).unwrap();
assert_eq!(config.folder_prefix, None);
}
#[test]
fn try_from_rejects_wrong_scheme() {
let err = KeyringConfig::try_from(&provider_url("pass://x")).unwrap_err();
assert!(err.to_string().contains("Invalid scheme"));
}
#[test]
fn uri_round_trips_default_and_prefix() {
assert_eq!(
KeyringProvider::new(KeyringConfig::default()).uri(),
"keyring"
);
let provider = KeyringProvider::new(KeyringConfig {
folder_prefix: Some("my vault/{key}".to_string()),
});
assert_eq!(provider.uri(), "keyring://my%20vault/{key}");
}
#[test]
fn native_address_maps_item_and_field_to_service_and_account() {
let p = KeyringProvider::new(KeyringConfig::default());
let addr = crate::config::NativeAddress {
item: "com.example.app".into(),
field: Some("alice".into()),
..Default::default()
};
assert_eq!(
p.entry_target(Address::Native(&addr)).unwrap(),
("com.example.app".to_string(), "alice".to_string())
);
}
#[test]
fn native_address_account_defaults_to_current_username() {
let p = KeyringProvider::new(KeyringConfig::default());
let addr = crate::config::NativeAddress {
item: "com.example.app".into(),
..Default::default()
};
let (service, account) = p.entry_target(Address::Native(&addr)).unwrap();
assert_eq!(service, "com.example.app");
assert_eq!(account, whoami::username().unwrap());
}
#[test]
fn native_address_rejects_version() {
let p = KeyringProvider::new(KeyringConfig::default());
let addr = crate::config::NativeAddress {
item: "com.example.app".into(),
version: Some("3".into()),
..Default::default()
};
let err = p.entry_target(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`version`"), "{err}");
}
}