use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue};
pub struct KeyringBackend {
service: String,
account: String,
}
impl KeyringBackend {
pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
let parsed = SecretUri::parse(uri)?;
if parsed.backend() != "keyring" {
return Err(SecretError::InvalidUri(format!(
"expected backend `keyring`, got `{}`",
parsed.backend()
)));
}
let Some(sep) = parsed.path().find('/') else {
return Err(SecretError::InvalidUri(
"keyring URI requires `secretx://keyring/<service>/<account>`".into(),
));
};
let path = parsed.path();
let service = &path[..sep];
let account = &path[sep + 1..];
if service.is_empty() {
return Err(SecretError::InvalidUri(
"keyring URI: service name must not be empty".into(),
));
}
if account.is_empty() {
return Err(SecretError::InvalidUri(
"keyring URI: account name must not be empty".into(),
));
}
Ok(Self {
service: service.to_string(),
account: account.to_string(),
})
}
}
#[async_trait::async_trait]
impl SecretStore for KeyringBackend {
async fn get(&self) -> Result<SecretValue, SecretError> {
let entry = keyring::Entry::new(&self.service, &self.account).map_err(|e| {
SecretError::Backend {
backend: "keyring",
source: e.into(),
}
})?;
match entry.get_password() {
Ok(pw) => Ok(SecretValue::new(pw.into_bytes())),
Err(keyring::Error::NoEntry) => Err(SecretError::NotFound),
Err(keyring::Error::NoStorageAccess(e)) => Err(SecretError::Unavailable {
backend: "keyring",
source: e,
}),
Err(e) => Err(SecretError::Backend {
backend: "keyring",
source: e.into(),
}),
}
}
async fn put(&self, value: SecretValue) -> Result<(), SecretError> {
let entry = keyring::Entry::new(&self.service, &self.account).map_err(|e| {
SecretError::Backend {
backend: "keyring",
source: e.into(),
}
})?;
let s = std::str::from_utf8(value.as_bytes()).map_err(|_| SecretError::Backend {
backend: "keyring",
source: "keyring backend requires UTF-8 secret values".into(),
})?;
entry.set_password(s).map_err(|e| match e {
keyring::Error::NoStorageAccess(inner) => SecretError::Unavailable {
backend: "keyring",
source: inner,
},
other => SecretError::Backend {
backend: "keyring",
source: other.into(),
},
})
}
async fn refresh(&self) -> Result<SecretValue, SecretError> {
self.get().await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_uri_ok() {
let b = KeyringBackend::from_uri("secretx://keyring/my-app/api-key").unwrap();
assert_eq!(b.service, "my-app");
assert_eq!(b.account, "api-key");
}
#[test]
fn from_uri_ok_nested_account() {
let b = KeyringBackend::from_uri("secretx://keyring/svc/user/sub").unwrap();
assert_eq!(b.service, "svc");
assert_eq!(b.account, "user/sub");
}
#[test]
fn from_uri_wrong_backend() {
assert!(matches!(
KeyringBackend::from_uri("secretx://env/MY_VAR"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_missing_slash() {
assert!(matches!(
KeyringBackend::from_uri("secretx://keyring/onlyone"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_empty_account() {
assert!(matches!(
KeyringBackend::from_uri("secretx://keyring/svc/"),
Err(SecretError::InvalidUri(_))
));
}
#[test]
fn from_uri_empty_path() {
assert!(matches!(
KeyringBackend::from_uri("secretx://keyring"),
Err(SecretError::InvalidUri(_))
));
}
fn is_no_storage(e: &SecretError) -> bool {
matches!(e, SecretError::Unavailable { .. })
}
#[tokio::test]
async fn integration_roundtrip() {
if std::env::var("SECRETX_KEYRING_INTEGRATION_TESTS").as_deref() != Ok("1") {
return;
}
let svc = "secretx-test";
let acct = "roundtrip";
let uri = format!("secretx://keyring/{svc}/{acct}");
let backend = KeyringBackend::from_uri(&uri).unwrap();
if let Ok(entry) = keyring::Entry::new(svc, acct) {
let _ = entry.delete_credential();
}
let put_result = backend
.put(SecretValue::new(b"test-secret-value".to_vec()))
.await;
match put_result {
Ok(()) => {}
Err(ref e) if is_no_storage(e) => {
eprintln!("keyring: no storage available, skipping integration test");
return;
}
Err(e) => panic!("put failed: {e}"),
}
let got = backend.get().await.expect("get after put failed");
assert_eq!(got.as_bytes(), b"test-secret-value");
let refreshed = backend.refresh().await.expect("refresh failed");
assert_eq!(refreshed.as_bytes(), b"test-secret-value");
if let Ok(entry) = keyring::Entry::new(svc, acct) {
let _ = entry.delete_credential();
}
let after = backend.get().await;
assert!(
matches!(after, Err(SecretError::NotFound)),
"expected NotFound after delete"
);
}
}