use badterm_core::{Directory, Entry};
const SERVICE: &str = "BadTerm BBS client";
pub trait Store {
fn get(&self, account: &str) -> Result<Option<String>, String>;
fn set(&self, account: &str, password: &str) -> Result<(), String>;
fn delete(&self, account: &str) -> Result<(), String>;
}
pub struct Keychain;
impl Keychain {
fn entry(account: &str) -> Result<keyring::Entry, String> {
keyring::Entry::new(SERVICE, account).map_err(|e| e.to_string())
}
}
impl Store for Keychain {
fn get(&self, account: &str) -> Result<Option<String>, String> {
match Self::entry(account)?.get_password() {
Ok(p) => Ok(Some(p)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
fn set(&self, account: &str, password: &str) -> Result<(), String> {
Self::entry(account)?.set_password(password).map_err(|e| e.to_string())
}
fn delete(&self, account: &str) -> Result<(), String> {
match Self::entry(account)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
}
pub fn account(e: &Entry) -> String {
format!("{}@{}", e.username, e.system_id())
}
pub fn password(e: &Entry, store: &dyn Store) -> Result<String, String> {
if !e.password.is_empty() || !e.keychain {
return Ok(e.password.clone());
}
Ok(store.get(&account(e))?.unwrap_or_default())
}
pub fn migrate(dir: &mut Directory, store: &dyn Store) -> Result<(), String> {
for e in dir.entries.iter_mut().filter(|e| !e.password.is_empty()) {
store.set(&account(e), &e.password)?;
e.password.clear();
e.keychain = true;
}
Ok(())
}
pub fn forget(e: &Entry, store: &dyn Store) {
if e.keychain
&& let Err(err) = store.delete(&account(e))
{
log::warn!("removing the password for {} from the keychain: {err}", e.name);
}
}
#[cfg(test)]
pub mod tests {
use std::cell::RefCell;
use std::collections::HashMap;
use super::*;
#[derive(Default)]
pub struct Memory {
pub map: RefCell<HashMap<String, String>>,
pub broken: bool,
}
impl Store for Memory {
fn get(&self, account: &str) -> Result<Option<String>, String> {
if self.broken {
return Err("no keychain".into());
}
Ok(self.map.borrow().get(account).cloned())
}
fn set(&self, account: &str, password: &str) -> Result<(), String> {
if self.broken {
return Err("no keychain".into());
}
self.map.borrow_mut().insert(account.into(), password.into());
Ok(())
}
fn delete(&self, account: &str) -> Result<(), String> {
if self.broken {
return Err("no keychain".into());
}
self.map.borrow_mut().remove(account);
Ok(())
}
}
fn ssh(user: &str, password: &str) -> Entry {
let mut e = Entry::from_address(&format!("ssh://{user}@bbs.example.com")).unwrap();
e.password = password.into();
e
}
#[test]
fn passwords_move_into_the_store() {
let store = Memory::default();
let mut dir = Directory { entries: vec![ssh("guest", "pw"), ssh("other", "")], ..Directory::default() };
migrate(&mut dir, &store).unwrap();
let e = &dir.entries[0];
assert!(e.password.is_empty() && e.keychain);
assert_eq!(store.map.borrow()["guest@ssh://bbs.example.com:22"], "pw");
assert_eq!(password(e, &store).unwrap(), "pw");
assert!(!dir.entries[1].keychain);
assert_eq!(password(&dir.entries[1], &store).unwrap(), "");
forget(e, &store);
assert!(store.map.borrow().is_empty());
}
#[test]
#[ignore]
fn keychain_round_trip() {
let account = format!("ci-{}@telnet://keychain.test:23", std::process::id());
let store = Keychain;
store
.set(&account, "secret")
.unwrap_or_else(|e| panic!("no credential store ({e}): {:?}", keyring::Entry::store_status()));
assert_eq!(store.get(&account).unwrap().as_deref(), Some("secret"));
store.delete(&account).unwrap();
assert_eq!(store.get(&account).unwrap(), None);
}
#[test]
fn without_a_store_passwords_stay_in_the_file() {
let store = Memory { broken: true, ..Memory::default() };
let mut dir = Directory { entries: vec![ssh("guest", "pw")], ..Directory::default() };
assert!(migrate(&mut dir, &store).is_err());
assert_eq!(dir.entries[0].password, "pw");
assert!(!dir.entries[0].keychain);
assert_eq!(password(&dir.entries[0], &store).unwrap(), "pw");
}
}