use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const TOKEN_ENV: &str = "LIFIC_TOKEN";
const KEYRING_SERVICE: &str = "lific";
pub fn normalize_base_url(base: &str) -> String {
let trimmed = base.trim().trim_end_matches('/');
let Ok(mut url) = reqwest::Url::parse(trimmed) else {
return trimmed.to_owned();
};
let scheme = url.scheme().to_ascii_lowercase();
let _ = url.set_scheme(&scheme);
if let Some(host) = url.host_str().map(str::to_ascii_lowercase) {
let _ = url.set_host(Some(&host));
}
url.to_string().trim_end_matches('/').to_owned()
}
fn default_file_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("lific").join("credentials.json"))
}
pub struct FileStore {
path: PathBuf,
}
impl FileStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
fn read_map(&self) -> BTreeMap<String, String> {
match std::fs::read_to_string(&self.path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => BTreeMap::new(),
}
}
fn write_map(&self, map: &BTreeMap<String, String>) -> std::io::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
set_dir_private(parent);
}
let json = serde_json::to_string_pretty(map).map_err(std::io::Error::other)?;
std::fs::write(&self.path, json)?;
set_file_private(&self.path);
Ok(())
}
pub fn store(&self, key: &str, token: &str) -> std::io::Result<()> {
let mut map = self.read_map();
map.insert(key.to_string(), token.to_string());
self.write_map(&map)
}
pub fn load(&self, key: &str) -> Option<String> {
self.read_map().get(key).cloned()
}
pub fn delete(&self, key: &str) -> std::io::Result<bool> {
let mut map = self.read_map();
let removed = map.remove(key).is_some();
if removed {
self.write_map(&map)?;
}
Ok(removed)
}
}
fn set_file_private(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
{
let _ = path;
}
}
fn set_dir_private(dir: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
{
let _ = dir;
}
}
pub fn store(base_url: &str, token: &str) -> Result<(), String> {
let key = normalize_base_url(base_url);
match keyring_store(&key, token) {
Ok(()) => Ok(()),
Err(e) => {
let store = FileStore::new(
default_file_path().ok_or_else(|| "cannot resolve config dir".to_string())?,
);
eprintln!(
"warning: OS keyring unavailable ({e}); storing token in PLAINTEXT at {} (0600). \
Set up a Secret Service/Keychain to secure it, or use {TOKEN_ENV} to avoid on-disk storage.",
store.path.display()
);
store
.store(&key, token)
.map_err(|e| format!("failed to write credentials file: {e}"))
}
}
}
pub fn load(base_url: &str) -> Option<String> {
if let Ok(tok) = std::env::var(TOKEN_ENV) {
let tok = tok.trim().to_string();
if !tok.is_empty() {
return Some(tok);
}
}
let key = normalize_base_url(base_url);
if let Some(tok) = keyring_load(&key) {
return Some(tok);
}
default_file_path().and_then(|p| FileStore::new(p).load(&key))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenSource {
Env,
Keyring,
File,
}
impl TokenSource {
pub fn label(self) -> &'static str {
match self {
TokenSource::Env => "LIFIC_TOKEN env",
TokenSource::Keyring => "OS keyring",
TokenSource::File => "credentials file",
}
}
}
pub fn load_with_source(base_url: &str) -> Option<(String, TokenSource)> {
if let Ok(tok) = std::env::var(TOKEN_ENV) {
let tok = tok.trim().to_string();
if !tok.is_empty() {
return Some((tok, TokenSource::Env));
}
}
let key = normalize_base_url(base_url);
if let Some(tok) = keyring_load(&key) {
return Some((tok, TokenSource::Keyring));
}
default_file_path()
.and_then(|p| FileStore::new(p).load(&key))
.map(|tok| (tok, TokenSource::File))
}
pub fn delete(base_url: &str) -> bool {
let key = normalize_base_url(base_url);
let kr = keyring_delete(&key);
let file = default_file_path()
.map(|p| FileStore::new(p).delete(&key).unwrap_or(false))
.unwrap_or(false);
kr || file
}
fn keyring_entry(key: &str) -> Result<keyring::Entry, keyring::Error> {
keyring::Entry::new(KEYRING_SERVICE, key)
}
fn keyring_store(key: &str, token: &str) -> Result<(), String> {
let entry = keyring_entry(key).map_err(|e| e.to_string())?;
entry.set_password(token).map_err(|e| e.to_string())
}
fn keyring_load(key: &str) -> Option<String> {
keyring_entry(key).ok()?.get_password().ok()
}
fn keyring_delete(key: &str) -> bool {
match keyring_entry(key) {
Ok(entry) => entry.delete_credential().is_ok(),
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_store() -> (FileStore, tempdir::Guard) {
let dir = std::env::temp_dir().join(format!(
"lific_creds_{}_{}",
std::process::id(),
rand::random::<u32>()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("credentials.json");
(FileStore::new(path), tempdir::Guard(dir))
}
mod tempdir {
pub struct Guard(pub std::path::PathBuf);
impl Drop for Guard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
#[test]
fn normalize_base_url_strips_trailing_slash_and_lowercases_scheme_and_host() {
assert_eq!(
normalize_base_url("http://Example.com:3998/"),
"http://example.com:3998"
);
assert_eq!(
normalize_base_url(" https://LIFIC.example "),
"https://lific.example"
);
assert_eq!(
normalize_base_url("http://127.0.0.1:3998"),
normalize_base_url("http://127.0.0.1:3998/")
);
}
#[test]
fn normalize_base_url_preserves_path_case() {
assert_eq!(
normalize_base_url(" HTTPS://LIFIC.Example/CaseSensitive/Path/ "),
"https://lific.example/CaseSensitive/Path"
);
}
#[test]
fn file_store_round_trip() {
let (store, _g) = tmp_store();
assert_eq!(store.load("http://a"), None);
store.store("http://a", "tok-a").unwrap();
store.store("http://b", "tok-b").unwrap();
assert_eq!(store.load("http://a").as_deref(), Some("tok-a"));
assert_eq!(store.load("http://b").as_deref(), Some("tok-b"));
store.store("http://a", "tok-a2").unwrap();
assert_eq!(store.load("http://a").as_deref(), Some("tok-a2"));
}
#[test]
fn file_store_delete_removes_only_target() {
let (store, _g) = tmp_store();
store.store("http://a", "tok-a").unwrap();
store.store("http://b", "tok-b").unwrap();
assert!(store.delete("http://a").unwrap(), "delete reports removal");
assert_eq!(store.load("http://a"), None);
assert_eq!(store.load("http://b").as_deref(), Some("tok-b"));
assert!(!store.delete("http://missing").unwrap());
}
#[cfg(unix)]
#[test]
fn file_store_writes_0600_file_and_0700_dir() {
use std::os::unix::fs::PermissionsExt;
let (store, _g) = tmp_store();
store.store("http://a", "secret").unwrap();
let file_mode = std::fs::metadata(&store.path).unwrap().permissions().mode() & 0o777;
assert_eq!(file_mode, 0o600, "credentials file must be 0600");
let dir_mode = std::fs::metadata(store.path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700, "parent dir must be 0700");
}
#[test]
fn file_store_creates_missing_parent_dir() {
let base = std::env::temp_dir().join(format!(
"lific_creds_deep_{}_{}",
std::process::id(),
rand::random::<u32>()
));
let _g = tempdir::Guard(base.clone());
let path = base.join("nested").join("credentials.json");
let store = FileStore::new(path.clone());
store.store("http://a", "tok").unwrap();
assert!(path.exists());
assert_eq!(store.load("http://a").as_deref(), Some("tok"));
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn env_var_takes_precedence_over_file() {
let _lock = ENV_LOCK.lock().unwrap();
let (store, _g) = tmp_store();
store.store(&normalize_base_url("http://envtest"), "file-tok").unwrap();
unsafe { std::env::set_var(TOKEN_ENV, "env-tok") };
let got = load("http://envtest");
unsafe { std::env::remove_var(TOKEN_ENV) };
assert_eq!(got.as_deref(), Some("env-tok"));
}
#[test]
fn empty_env_var_is_ignored() {
let _lock = ENV_LOCK.lock().unwrap();
unsafe { std::env::set_var(TOKEN_ENV, " ") };
let got_source = load_with_source("http://noenv-empty");
unsafe { std::env::remove_var(TOKEN_ENV) };
assert!(got_source.is_none() || got_source.unwrap().1 != TokenSource::Env);
}
#[test]
fn token_source_labels() {
assert_eq!(TokenSource::Env.label(), "LIFIC_TOKEN env");
assert_eq!(TokenSource::Keyring.label(), "OS keyring");
assert_eq!(TokenSource::File.label(), "credentials file");
}
}