use std::{
fs,
path::{Path, PathBuf},
};
use keyring::{Entry, Error as KeyringError};
use serde::{Deserialize, Serialize};
use crate::{AppError, Result, config::CredentialStoreMode, paths};
const KEYCHAIN_SERVICE: &str = "io.github.chojs23.concord.discord-token.v1";
const DEFAULT_ACCOUNT_ID: &str = "default";
const KEYCHAIN_ACCOUNT_PREFIX: &str = "account:";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TokenSaveLocation {
Keychain,
PlaintextFile,
}
pub fn load_token(store: CredentialStoreMode) -> Result<Option<String>> {
let account_id = selected_account_id();
match store {
CredentialStoreMode::Auto => match load_keychain_token(&account_id) {
Ok(Some(token)) => Ok(Some(token)),
Ok(None) | Err(_) => load_fallback_token(&account_id),
},
CredentialStoreMode::Keychain => load_keychain_token(&account_id),
CredentialStoreMode::Plain => load_fallback_token(&account_id),
}
}
pub fn save_token(token: &str, store: CredentialStoreMode) -> Result<TokenSaveLocation> {
let token = normalize_token(token)?;
let account_id = selected_account_id();
match store {
CredentialStoreMode::Auto => match save_keychain_token(&account_id, &token) {
Ok(()) => Ok(TokenSaveLocation::Keychain),
Err(_) => {
save_fallback_token(&account_id, &token)?;
Ok(TokenSaveLocation::PlaintextFile)
}
},
CredentialStoreMode::Keychain => {
save_keychain_token(&account_id, &token)
.map_err(|source| AppError::CredentialKeychain { source })?;
Ok(TokenSaveLocation::Keychain)
}
CredentialStoreMode::Plain => {
save_fallback_token(&account_id, &token)?;
Ok(TokenSaveLocation::PlaintextFile)
}
}
}
fn credential_path() -> Result<PathBuf> {
paths::credential_file().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not resolve user data directory",
)
.into()
})
}
pub fn credential_path_display() -> String {
"your configured credential store".to_owned()
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(default)]
struct CredentialFile {
selected_account: String,
accounts: Vec<StoredAccount>,
}
impl Default for CredentialFile {
fn default() -> Self {
Self {
selected_account: DEFAULT_ACCOUNT_ID.to_owned(),
accounts: Vec::new(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
struct StoredAccount {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
token: String,
}
impl CredentialFile {
fn selected_account_id(&self) -> String {
normalized_account_id(&self.selected_account).unwrap_or_else(default_account_id)
}
fn token_for_account(&self, account_id: &str) -> Option<String> {
self.accounts
.iter()
.find(|account| normalized_account_id(&account.id).as_deref() == Some(account_id))
.and_then(|account| normalize_token(&account.token).ok())
}
fn upsert_token(&mut self, account_id: &str, token: String) {
self.selected_account = account_id.to_owned();
if let Some(account) = self
.accounts
.iter_mut()
.find(|account| normalized_account_id(&account.id).as_deref() == Some(account_id))
{
account.id = account_id.to_owned();
account.token = token;
return;
}
self.accounts.push(StoredAccount {
id: account_id.to_owned(),
label: None,
token,
});
}
}
fn selected_account_id() -> String {
match read_credential_file() {
Ok(Some(credentials)) => credentials.selected_account_id(),
Ok(None) | Err(_) => default_account_id(),
}
}
fn load_keychain_token(account_id: &str) -> Result<Option<String>> {
let entry =
keychain_entry(account_id).map_err(|source| AppError::CredentialKeychain { source })?;
match entry.get_password() {
Ok(token) => Ok(normalize_token(&token).ok()),
Err(KeyringError::NoEntry) => Ok(None),
Err(source) => Err(AppError::CredentialKeychain { source }),
}
}
fn save_keychain_token(account_id: &str, token: &str) -> std::result::Result<(), KeyringError> {
keychain_entry(account_id)?.set_password(token)
}
fn keychain_entry(account_id: &str) -> std::result::Result<Entry, KeyringError> {
Entry::new(KEYCHAIN_SERVICE, &keychain_account(account_id))
}
fn keychain_account(account_id: &str) -> String {
format!("{KEYCHAIN_ACCOUNT_PREFIX}{account_id}")
}
fn load_fallback_token(account_id: &str) -> Result<Option<String>> {
Ok(read_credential_file()?.and_then(|credentials| credentials.token_for_account(account_id)))
}
fn save_fallback_token(account_id: &str, token: &str) -> Result<()> {
let mut credentials = read_credential_file()?.unwrap_or_default();
credentials.upsert_token(account_id, token.to_owned());
write_credential_file(&credentials)
}
fn read_credential_file() -> Result<Option<CredentialFile>> {
let path = credential_path()?;
match fs::read_to_string(&path) {
Ok(content) => toml::from_str::<CredentialFile>(&content)
.map(Some)
.map_err(|source| AppError::CredentialTomlDeserialize { source }),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn write_credential_file(credentials: &CredentialFile) -> Result<()> {
let path = credential_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
set_private_dir_permissions(parent)?;
}
let content = toml::to_string_pretty(credentials)
.map_err(|source| AppError::CredentialTomlSerialize { source })?;
write_private_file(&path, &content)
}
fn normalize_token(token: &str) -> std::result::Result<String, AppError> {
let token = token.trim();
if token.is_empty() {
return Err(AppError::EmptyDiscordToken);
}
Ok(token.to_owned())
}
fn normalized_account_id(account_id: &str) -> Option<String> {
let account_id = account_id.trim();
if account_id.is_empty() {
return None;
}
Some(account_id.to_owned())
}
fn default_account_id() -> String {
DEFAULT_ACCOUNT_ID.to_owned()
}
#[cfg(unix)]
fn set_private_dir_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(path)?.permissions();
permissions.set_mode(0o700);
fs::set_permissions(path, permissions)?;
Ok(())
}
#[cfg(not(unix))]
fn set_private_dir_permissions(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn write_private_file(path: &Path, token: &str) -> Result<()> {
use std::{
io::Write,
os::unix::fs::{OpenOptionsExt, PermissionsExt},
};
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)?;
file.write_all(token.as_bytes())?;
let mut permissions = file.metadata()?.permissions();
permissions.set_mode(0o600);
fs::set_permissions(path, permissions)?;
Ok(())
}
#[cfg(not(unix))]
fn write_private_file(path: &Path, token: &str) -> Result<()> {
fs::write(path, token)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::{
AppError,
token_store::{CredentialFile, StoredAccount, normalize_token},
};
#[test]
fn normalize_token_trims_and_rejects_empty_values() {
assert_eq!(
normalize_token(" token ").expect("token should normalize"),
"token"
);
let error = normalize_token(" ").expect_err("blank token must fail");
assert!(matches!(error, AppError::EmptyDiscordToken));
}
#[test]
fn credential_file_defaults_to_default_account() {
let credentials = CredentialFile::default();
assert_eq!(credentials.selected_account_id(), "default");
assert_eq!(credentials.token_for_account("default"), None);
}
#[test]
fn credential_file_reads_selected_account_token() {
let credentials = CredentialFile {
selected_account: "personal".to_owned(),
accounts: vec![
StoredAccount {
id: "default".to_owned(),
label: None,
token: "default-token".to_owned(),
},
StoredAccount {
id: "personal".to_owned(),
label: Some("Personal".to_owned()),
token: " selected-token ".to_owned(),
},
],
};
assert_eq!(credentials.selected_account_id(), "personal");
assert_eq!(
credentials.token_for_account("personal").as_deref(),
Some("selected-token")
);
}
#[test]
fn credential_file_upserts_account_token() {
let mut credentials = CredentialFile::default();
credentials.upsert_token("personal", "new-token".to_owned());
credentials.upsert_token("personal", "updated-token".to_owned());
assert_eq!(credentials.selected_account_id(), "personal");
assert_eq!(credentials.accounts.len(), 1);
assert_eq!(
credentials.token_for_account("personal").as_deref(),
Some("updated-token")
);
}
}