use anyhow::Result;
#[cfg(target_os = "macos")]
const SERVICE_NAME: &str = "formanator";
#[cfg(target_os = "macos")]
const ACCOUNT_NAME: &str = "forma-access-token";
const MOCK_KEYCHAIN_ENV_VAR: &str = "FORMANATOR_USE_MOCK_KEYCHAIN";
pub fn init() {
if std::env::var_os(MOCK_KEYCHAIN_ENV_VAR).is_some_and(|v| !v.is_empty()) {
keyring::set_default_credential_builder(in_memory::default_credential_builder());
}
}
mod in_memory {
use std::any::Any;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use keyring::credential::{
Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence,
};
use keyring::error::{Error, Result};
type Key = (Option<String>, String, String);
fn store() -> &'static Mutex<HashMap<Key, Vec<u8>>> {
static STORE: OnceLock<Mutex<HashMap<Key, Vec<u8>>>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(HashMap::new()))
}
#[derive(Debug)]
struct InMemoryCredential {
key: Key,
}
impl CredentialApi for InMemoryCredential {
fn set_secret(&self, secret: &[u8]) -> Result<()> {
store()
.lock()
.expect("in-memory keychain mutex poisoned")
.insert(self.key.clone(), secret.to_vec());
Ok(())
}
fn get_secret(&self) -> Result<Vec<u8>> {
store()
.lock()
.expect("in-memory keychain mutex poisoned")
.get(&self.key)
.cloned()
.ok_or(Error::NoEntry)
}
fn delete_credential(&self) -> Result<()> {
let removed = store()
.lock()
.expect("in-memory keychain mutex poisoned")
.remove(&self.key);
if removed.is_some() {
Ok(())
} else {
Err(Error::NoEntry)
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[derive(Debug)]
struct InMemoryCredentialBuilder;
impl CredentialBuilderApi for InMemoryCredentialBuilder {
fn build(
&self,
target: Option<&str>,
service: &str,
user: &str,
) -> Result<Box<Credential>> {
Ok(Box::new(InMemoryCredential {
key: (
target.map(str::to_owned),
service.to_owned(),
user.to_owned(),
),
}))
}
fn as_any(&self) -> &dyn Any {
self
}
fn persistence(&self) -> CredentialPersistence {
CredentialPersistence::ProcessOnly
}
}
pub fn default_credential_builder() -> Box<CredentialBuilder> {
Box::new(InMemoryCredentialBuilder)
}
}
pub fn store_access_token(token: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
let entry = keyring::Entry::new(SERVICE_NAME, ACCOUNT_NAME)?;
entry.set_password(token)?;
}
#[cfg(not(target_os = "macos"))]
{
let _ = token;
}
Ok(())
}
pub fn get_access_token() -> Result<Option<String>> {
#[cfg(target_os = "macos")]
{
match keyring::Entry::new(SERVICE_NAME, ACCOUNT_NAME) {
Ok(entry) => match entry.get_password() {
Ok(password) => Ok(Some(password)),
Err(keyring::error::Error::NoEntry) => Ok(None),
Err(e) => {
eprintln!("Warning: Could not retrieve token from Keychain: {}", e);
Ok(None)
}
},
Err(e) => {
eprintln!("Warning: Could not access Keychain: {}", e);
Ok(None)
}
}
}
#[cfg(not(target_os = "macos"))]
{
Ok(None)
}
}
pub fn delete_access_token() -> Result<()> {
#[cfg(target_os = "macos")]
{
match keyring::Entry::new(SERVICE_NAME, ACCOUNT_NAME) {
Ok(entry) => match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::error::Error::NoEntry) => Ok(()), Err(e) => {
eprintln!("Warning: Could not delete token from Keychain: {}", e);
Ok(())
}
},
Err(_) => {
Ok(())
}
}
}
#[cfg(not(target_os = "macos"))]
{
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn use_mock_keychain() {
unsafe {
std::env::set_var(MOCK_KEYCHAIN_ENV_VAR, "1");
}
init();
}
#[test]
#[serial_test::serial]
fn store_and_retrieve_token() {
use_mock_keychain();
let _ = delete_access_token();
let token = "test-token-12345";
store_access_token(token).expect("store should succeed");
let retrieved = get_access_token().expect("get should succeed");
#[cfg(target_os = "macos")]
assert_eq!(retrieved, Some(token.to_string()));
#[cfg(not(target_os = "macos"))]
{
assert_eq!(retrieved, None);
let _ = token;
}
delete_access_token().expect("cleanup should succeed");
}
#[test]
#[serial_test::serial]
fn get_nonexistent_token_returns_none() {
use_mock_keychain();
let _ = delete_access_token();
let retrieved = get_access_token().expect("get should succeed");
assert_eq!(retrieved, None);
}
#[test]
#[serial_test::serial]
fn delete_token_removes_it() {
use_mock_keychain();
let token = "test-token-to-delete";
store_access_token(token).expect("store should succeed");
delete_access_token().expect("delete should succeed");
let retrieved = get_access_token().expect("get should succeed");
assert_eq!(retrieved, None);
}
}