use std::sync::Once;
use keyring_core::Entry;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use super::Provider;
use super::ProviderUrl;
use crate::MonosecretError;
use crate::Result;
static SET_CREDENTIAL_STORE: Once = Once::new();
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct KeyringConfig {
pub folder_prefix: Option<String>,
}
impl TryFrom<&ProviderUrl> for KeyringConfig {
type Error = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "keyring" {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for keyring provider",
url.scheme()
)));
}
let mut config = Self::default();
if let Some(host) = url.host() {
let path = url.path();
config.folder_prefix = Some(format!("{host}{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://monosecret/shared/{profile}/{key}"],
}
impl KeyringProvider {
pub fn new(config: KeyringConfig) -> Self {
Self { config }
}
fn entry(service: &str, username: &str) -> Result<Entry> {
SET_CREDENTIAL_STORE.call_once(set_credential_store);
Entry::new(service, username).map_err(Into::into)
}
fn format_service(&self, project: &str, profile: &str, key: &str) -> String {
let format_string = self
.config
.folder_prefix
.as_deref()
.unwrap_or("monosecret/{project}/{profile}/{key}");
format_string
.replace("{project}", project)
.replace("{profile}", profile)
.replace("{key}", key)
}
}
impl Provider for KeyringProvider {
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, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
let service = self.format_service(project, profile, key);
let username = whoami::username()
.map_err(|e| MonosecretError::ProviderOperationFailed(e.to_string()))?;
let entry = Self::entry(&service, &username)?;
match entry.get_password() {
Ok(password) => Ok(Some(SecretString::new(password.into()))),
Err(keyring_core::Error::NoEntry) => Ok(None),
Err(e) => Err(e.into()),
}
}
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
let service = self.format_service(project, profile, key);
let username = whoami::username()
.map_err(|e| MonosecretError::ProviderOperationFailed(e.to_string()))?;
let entry = Self::entry(&service, &username)?;
entry.set_password(value.expose_secret())?;
Ok(())
}
}
fn set_credential_store() {
#[cfg(target_os = "macos")]
{
if let Ok(store) = apple_native_keyring_store::keychain::Store::new() {
keyring_core::set_default_store(store);
}
}
#[cfg(target_os = "windows")]
{
if let Ok(store) = windows_native_keyring_store::Store::new() {
keyring_core::set_default_store(store);
}
}
#[cfg(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
))]
{
if let Ok(store) = zbus_secret_service_keyring_store::Store::new() {
keyring_core::set_default_store(store);
}
}
}
#[cfg(test)]
mod tests {
use url::Url;
use super::*;
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"),
"monosecret/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://monosecret/shared/{profile}/{key}"))
.unwrap();
assert_eq!(
config.folder_prefix.as_deref(),
Some("monosecret/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}");
}
}