use keyring::Entry;
use polyoxide_venue::{Class, Classify, Secret};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum KeychainError {
#[error("Keychain entry not found: service={service}, key={key}")]
NotFound {
service: String,
key: String,
},
#[error("Keychain error: {0}")]
Backend(#[from] keyring::Error),
#[error("Invalid {field} for {service}: {reason}")]
Invalid {
service: String,
field: String,
reason: String,
},
}
impl KeychainError {
pub fn invalid(service: &str, field: &str, reason: impl Into<String>) -> Self {
Self::Invalid {
service: service.to_owned(),
field: field.to_owned(),
reason: reason.into(),
}
}
}
impl Classify for KeychainError {
fn class(&self) -> Class {
match self {
Self::NotFound { .. } | Self::Backend(_) | Self::Invalid { .. } => {
Class::InvalidRequest
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Field {
pub name: &'static str,
pub secret: bool,
}
impl Field {
pub const fn secret(name: &'static str) -> Self {
Self { name, secret: true }
}
pub const fn plain(name: &'static str) -> Self {
Self {
name,
secret: false,
}
}
}
pub trait StoredCredential {
const SERVICE: &'static str;
const FIELDS: &'static [Field];
fn validate(values: &[(&str, Secret<String>)]) -> Result<(), KeychainError>;
}
pub fn given<'a>(values: &'a [(&str, Secret<String>)], name: &str) -> Option<&'a Secret<String>> {
values
.iter()
.find(|(given, _)| *given == name)
.map(|(_, value)| value)
}
pub fn store<C: StoredCredential>(
values: &[(&str, Secret<String>)],
) -> Result<Vec<Field>, KeychainError> {
check::<C>(values)?;
let mut written = Vec::new();
for field in C::FIELDS {
if let Some(value) = given(values, field.name) {
set(C::SERVICE, field.name, value.expose())?;
written.push(*field);
}
}
Ok(written)
}
fn check<C: StoredCredential>(values: &[(&str, Secret<String>)]) -> Result<(), KeychainError> {
for (i, (name, value)) in values.iter().enumerate() {
if !C::FIELDS.iter().any(|field| field.name == *name) {
return Err(KeychainError::invalid(
C::SERVICE,
name,
"not a field of this credential",
));
}
if values[..i].iter().any(|(earlier, _)| earlier == name) {
return Err(KeychainError::invalid(C::SERVICE, name, "given twice"));
}
if value.expose().trim().is_empty() {
return Err(KeychainError::invalid(
C::SERVICE,
name,
"the value is empty",
));
}
}
C::validate(values)
}
pub fn show<C: StoredCredential>() -> Vec<(Field, Result<Secret<String>, KeychainError>)> {
C::FIELDS
.iter()
.map(|field| (*field, get(C::SERVICE, field.name).map(Secret::new)))
.collect()
}
pub fn delete_all<C: StoredCredential>() -> Result<(), KeychainError> {
delete_fields(C::SERVICE, C::FIELDS)
}
pub fn delete_fields(service: &str, fields: &[Field]) -> Result<(), KeychainError> {
for field in fields {
delete(service, field.name)?;
}
Ok(())
}
pub fn get(service: &str, key: &str) -> Result<String, KeychainError> {
let entry = Entry::new(service, key)?;
entry.get_password().map_err(|e| match e {
keyring::Error::NoEntry => KeychainError::NotFound {
service: service.to_string(),
key: key.to_string(),
},
other => KeychainError::Backend(other),
})
}
pub fn set(service: &str, key: &str, value: &str) -> Result<(), KeychainError> {
let entry = Entry::new(service, key)?;
entry.set_password(value)?;
Ok(())
}
pub fn delete(service: &str, key: &str) -> Result<(), KeychainError> {
let entry = Entry::new(service, key)?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(KeychainError::Backend(e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_found_error_includes_service_and_key() {
let err = KeychainError::NotFound {
service: "test-svc".to_string(),
key: "test-key".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("test-svc"), "missing service: {msg}");
assert!(msg.contains("test-key"), "missing key: {msg}");
}
#[test]
fn backend_error_display() {
let err = KeychainError::Backend(keyring::Error::NoEntry);
let msg = err.to_string();
assert!(msg.contains("Keychain error"), "unexpected: {msg}");
}
#[test]
fn every_variant_classifies() {
let rows = [
KeychainError::NotFound {
service: "svc".into(),
key: "key".into(),
},
KeychainError::Backend(keyring::Error::NoEntry),
];
for err in rows {
assert_eq!(err.class(), Class::InvalidRequest, "{err:?}");
assert!(err.is_fault(), "{err:?}");
assert_eq!(err.retry_after(), None, "{err:?}");
assert!(!err.is_retriable(), "{err:?}");
}
}
struct NeverWritten;
impl StoredCredential for NeverWritten {
const SERVICE: &'static str = "polyoxide-test-never-written";
const FIELDS: &'static [Field] = &[Field::secret("key"), Field::plain("label")];
fn validate(values: &[(&str, Secret<String>)]) -> Result<(), KeychainError> {
if given(values, "key").is_some() && given(values, "label").is_none() {
return Err(KeychainError::invalid(
Self::SERVICE,
"label",
"required with key",
));
}
Err(KeychainError::invalid(
Self::SERVICE,
"label",
"this test credential refuses everything",
))
}
}
fn values(pairs: &[(&'static str, &str)]) -> Vec<(&'static str, Secret<String>)> {
pairs
.iter()
.map(|(name, value)| (*name, Secret::new((*value).to_owned())))
.collect()
}
fn refusal(pairs: &[(&'static str, &str)]) -> (String, String) {
match store::<NeverWritten>(&values(pairs)) {
Err(KeychainError::Invalid {
service,
field,
reason,
}) => {
assert_eq!(service, NeverWritten::SERVICE);
(field, reason)
}
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn a_name_that_is_not_a_field_is_refused() {
let (field, reason) = refusal(&[("label", "ok"), ("passphrase", "hunter2")]);
assert_eq!(field, "passphrase");
assert!(reason.contains("not a field"), "{reason}");
}
#[test]
fn an_empty_or_blank_value_is_refused() {
for blank in ["", " ", "\t\n"] {
let (field, reason) = refusal(&[("key", blank), ("label", "ok")]);
assert_eq!(field, "key", "{blank:?}");
assert!(reason.contains("empty"), "{reason}");
}
}
#[test]
fn a_field_given_twice_is_refused() {
let (field, reason) = refusal(&[("label", "a"), ("label", "b")]);
assert_eq!(field, "label");
assert!(reason.contains("twice"), "{reason}");
}
#[test]
fn the_credentials_own_validation_runs_after_the_generic_checks() {
let (field, reason) = refusal(&[("key", "k")]);
assert_eq!(field, "label");
assert_eq!(reason, "required with key");
}
#[test]
fn a_refusal_classifies_as_an_invalid_request_and_never_quotes_the_value() {
let err = store::<NeverWritten>(&values(&[("key", "hunter2"), ("label", "")])).unwrap_err();
assert_eq!(err.class(), Class::InvalidRequest);
assert!(err.is_fault());
assert_eq!(err.retry_after(), None);
let printed = format!("{err} {err:?}");
assert!(!printed.contains("hunter2"), "{printed}");
assert!(
printed.contains(NeverWritten::SERVICE) && printed.contains("label"),
"{printed}"
);
}
#[test]
fn given_finds_a_value_by_its_field_name() {
let values = values(&[("key", "k"), ("label", "l")]);
assert_eq!(
given(&values, "label").map(|v| v.expose().as_str()),
Some("l")
);
assert!(given(&values, "other").is_none());
}
struct RoundTrip;
impl StoredCredential for RoundTrip {
const SERVICE: &'static str = "polyoxide-core-test-stored-credential-roundtrip";
const FIELDS: &'static [Field] = &[Field::secret("key"), Field::plain("label")];
fn validate(_: &[(&str, Secret<String>)]) -> Result<(), KeychainError> {
Ok(())
}
}
#[test]
#[ignore] fn a_stored_credential_round_trips_and_delete_all_removes_every_field() {
let written = store::<RoundTrip>(&values(&[("label", "l"), ("key", "k")])).unwrap();
assert_eq!(
written,
RoundTrip::FIELDS,
"written in the credential's order"
);
let shown: Vec<(&str, String)> = show::<RoundTrip>()
.into_iter()
.map(|(field, value)| (field.name, value.unwrap().expose().clone()))
.collect();
assert_eq!(shown, [("key", "k".to_owned()), ("label", "l".to_owned())]);
delete_all::<RoundTrip>().unwrap();
for (field, value) in show::<RoundTrip>() {
assert!(
matches!(value, Err(KeychainError::NotFound { .. })),
"{} survived delete_all: {value:?}",
field.name
);
}
}
#[test]
fn field_constructors_mark_secrets() {
assert_eq!(
Field::secret("key"),
Field {
name: "key",
secret: true
}
);
assert!(!Field::plain("address").secret);
}
}