use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue, WritableSecretStore};
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(),
));
}
if parsed.param("field").is_some() {
return Err(SecretError::InvalidUri(
"keyring does not support ?field= (keyring values are opaque strings, not JSON \
objects); remove ?field= or use a backend that supports JSON field extraction \
(e.g. aws-sm)"
.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 service = self.service.clone();
let account = self.account.clone();
tokio::task::spawn_blocking(move || {
let entry =
keyring::Entry::new(&service, &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(),
}),
}
})
.await
.map_err(|e| SecretError::Backend {
backend: "keyring",
source: e.into(),
})?
}
async fn refresh(&self) -> Result<SecretValue, SecretError> {
self.get().await
}
}
#[async_trait::async_trait]
impl WritableSecretStore for KeyringBackend {
async fn put(&self, value: SecretValue) -> Result<(), SecretError> {
let s = std::str::from_utf8(value.as_bytes())
.map_err(|_| {
SecretError::DecodeFailed("keyring backend requires UTF-8 secret values".into())
})?
.to_owned();
let service = self.service.clone();
let account = self.account.clone();
tokio::task::spawn_blocking(move || {
let entry =
keyring::Entry::new(&service, &account).map_err(|e| SecretError::Backend {
backend: "keyring",
source: e.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(),
},
})
})
.await
.map_err(|e| SecretError::Backend {
backend: "keyring",
source: e.into(),
})?
}
}
inventory::submit!(secretx_core::BackendRegistration {
name: "keyring",
factory: |uri: &str| {
KeyringBackend::from_uri(uri)
.map(|b| std::sync::Arc::new(b) as std::sync::Arc<dyn secretx_core::SecretStore>)
},
});
inventory::submit!(secretx_core::WritableBackendRegistration {
name: "keyring",
factory: |uri: &str| {
KeyringBackend::from_uri(uri).map(|b| {
std::sync::Arc::new(b) as std::sync::Arc<dyn secretx_core::WritableSecretStore>
})
},
});
#[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(_))
));
}
#[test]
fn from_uri_field_selector_rejected() {
let result = KeyringBackend::from_uri("secretx:keyring:my-app/api-key?field=token");
match result {
Err(SecretError::InvalidUri(msg)) => {
assert!(
msg.contains("keyring does not support ?field="),
"error must mention the limitation, got: {msg}"
);
}
Err(e) => panic!("expected InvalidUri, got: {e}"),
Ok(_) => panic!("expected InvalidUri, got Ok"),
}
}
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"
);
}
}