use std::{
env, fs,
path::{Path, PathBuf},
};
use directories::ProjectDirs;
use koban::{API_TOKEN_ENV, BASE_URL_ENV, DEFAULT_BASE_URL, KobanError, Result};
use serde::{Deserialize, Serialize};
const CONFIG_DIR_ENV: &str = "KOBAN_CONFIG_DIR";
const CONFIG_FILE: &str = "config.json";
#[cfg(feature = "keychain")]
const KEYCHAIN_SERVICE: &str = "koban";
#[cfg(feature = "keychain")]
const KEYCHAIN_USER: &str = "api_token";
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct StoredConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_token: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub keychain: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TokenSource {
Env,
Keychain,
File,
None,
}
impl TokenSource {
pub(crate) fn label(self) -> &'static str {
match self {
TokenSource::Env => "environment",
TokenSource::Keychain => "keychain",
TokenSource::File => "config file",
TokenSource::None => "none",
}
}
}
#[derive(Debug)]
pub(crate) struct AuthStatus {
pub source: TokenSource,
pub base_url: String,
pub config_path: PathBuf,
}
fn credential_error(message: impl Into<String>) -> KobanError {
KobanError::Credential {
message: message.into(),
}
}
pub(crate) fn config_dir() -> Result<PathBuf> {
if let Some(dir) = env::var_os(CONFIG_DIR_ENV) {
let dir = PathBuf::from(dir);
if dir.as_os_str().is_empty() {
return Err(credential_error(format!(
"{CONFIG_DIR_ENV} is set but empty"
)));
}
return Ok(dir);
}
ProjectDirs::from("", "", "koban")
.map(|dirs| dirs.config_dir().to_path_buf())
.ok_or_else(|| credential_error("could not determine a config directory for this platform"))
}
pub(crate) fn config_path() -> Result<PathBuf> {
Ok(config_dir()?.join(CONFIG_FILE))
}
fn load_file() -> Result<StoredConfig> {
let path = config_path()?;
match fs::read_to_string(&path) {
Ok(contents) => serde_json::from_str(&contents).map_err(|source| {
credential_error(format!("could not parse {}: {source}", path.display()))
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(StoredConfig::default()),
Err(error) => Err(credential_error(format!(
"could not read {}: {error}",
path.display()
))),
}
}
fn write_file(path: &Path, stored: &StoredConfig) -> Result<()> {
let json = serde_json::to_string_pretty(stored)
.map_err(|source| credential_error(format!("could not serialize config: {source}")))?;
write_secure(path, &json)
}
fn write_secure(path: &Path, contents: &str) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|source| {
credential_error(format!("could not write {}: {source}", path.display()))
})?;
file.write_all(contents.as_bytes()).map_err(|source| {
credential_error(format!("could not write {}: {source}", path.display()))
})?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| {
credential_error(format!(
"could not set permissions on {}: {source}",
path.display()
))
})?;
}
#[cfg(not(unix))]
{
fs::write(path, contents).map_err(|source| {
credential_error(format!("could not write {}: {source}", path.display()))
})?;
}
Ok(())
}
pub(crate) fn save(base_url: Option<String>, token: &str, use_keychain: bool) -> Result<PathBuf> {
let dir = config_dir()?;
fs::create_dir_all(&dir).map_err(|source| {
credential_error(format!("could not create {}: {source}", dir.display()))
})?;
let path = dir.join(CONFIG_FILE);
let mut stored = load_file()?;
if base_url.is_some() {
stored.base_url = base_url;
}
if use_keychain {
keychain_set(token)?;
stored.keychain = true;
stored.api_token = None;
} else {
let _ = keychain_delete();
stored.keychain = false;
stored.api_token = Some(token.to_string());
}
write_file(&path, &stored)?;
Ok(path)
}
pub(crate) fn resolve() -> Result<(String, String)> {
let stored = load_file()?;
if let Some(token) = env_non_empty(API_TOKEN_ENV) {
return Ok((resolve_base_url(TokenSource::Env, &stored), token));
}
let (source, token) = resolve_stored_token(&stored)?;
Ok((resolve_base_url(source, &stored), token))
}
fn resolve_stored_token(stored: &StoredConfig) -> Result<(TokenSource, String)> {
if stored.keychain {
return keychain_get()?
.map(|token| (TokenSource::Keychain, token))
.ok_or(KobanError::MissingToken);
}
stored
.api_token
.as_ref()
.filter(|token| !token.trim().is_empty())
.map(|token| (TokenSource::File, token.clone()))
.ok_or(KobanError::MissingToken)
}
fn resolve_base_url(source: TokenSource, stored: &StoredConfig) -> String {
if let Some(base_url) = env_non_empty(BASE_URL_ENV) {
return base_url;
}
match source {
TokenSource::Keychain | TokenSource::File => stored.base_url.clone(),
TokenSource::Env | TokenSource::None => None,
}
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
}
pub(crate) fn stored_base_url() -> Result<Option<String>> {
Ok(load_file()?.base_url)
}
pub(crate) fn status() -> Result<AuthStatus> {
let stored = load_file()?;
let source = if env_non_empty(API_TOKEN_ENV).is_some() {
TokenSource::Env
} else if stored.keychain {
if keychain_get()?.is_some() {
TokenSource::Keychain
} else {
TokenSource::None
}
} else if stored
.api_token
.as_ref()
.is_some_and(|token| !token.trim().is_empty())
{
TokenSource::File
} else {
TokenSource::None
};
let base_url = resolve_base_url(source, &stored);
Ok(AuthStatus {
source,
base_url,
config_path: config_path()?,
})
}
pub(crate) fn clear() -> Result<bool> {
let mut removed = keychain_delete()?;
let path = config_path()?;
if path.exists() {
let mut stored = load_file()?;
if stored.api_token.take().is_some() || stored.keychain {
removed = true;
}
stored.keychain = false;
if stored.base_url.is_some() {
write_file(&path, &stored)?;
} else {
fs::remove_file(&path).map_err(|source| {
credential_error(format!("could not remove {}: {source}", path.display()))
})?;
}
}
Ok(removed)
}
fn env_non_empty(key: &str) -> Option<String> {
env::var(key).ok().filter(|value| !value.trim().is_empty())
}
#[cfg(feature = "keychain")]
fn keychain_entry() -> Result<keyring::Entry> {
keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER)
.map_err(|source| credential_error(format!("keychain: {source}")))
}
#[cfg(feature = "keychain")]
fn keychain_set(token: &str) -> Result<()> {
keychain_entry()?
.set_password(token)
.map_err(|source| credential_error(format!("keychain: {source}")))
}
#[cfg(feature = "keychain")]
fn keychain_get() -> Result<Option<String>> {
match keychain_entry()?.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(source) => Err(credential_error(format!("keychain: {source}"))),
}
}
#[cfg(feature = "keychain")]
fn keychain_delete() -> Result<bool> {
match keychain_entry()?.delete_credential() {
Ok(()) => Ok(true),
Err(keyring::Error::NoEntry) => Ok(false),
Err(source) => Err(credential_error(format!("keychain: {source}"))),
}
}
#[cfg(not(feature = "keychain"))]
fn keychain_set(_token: &str) -> Result<()> {
Err(credential_error(
"this build has no keychain support; rebuild with the default `keychain` feature or store the token without --keychain",
))
}
#[cfg(not(feature = "keychain"))]
fn keychain_get() -> Result<Option<String>> {
Err(credential_error(
"this build has no keychain support; re-store the token without --keychain",
))
}
#[cfg(not(feature = "keychain"))]
fn keychain_delete() -> Result<bool> {
Ok(false)
}