use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const TOKEN_ENV: &str = "LIFIC_TOKEN";
pub const URL_ENV: &str = "LIFIC_URL";
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()
}
pub fn origin_of(url: &str) -> Option<String> {
let parsed = reqwest::Url::parse(url.trim()).ok()?;
let scheme = parsed.scheme().to_ascii_lowercase();
let default_port = match scheme.as_str() {
"http" => 80,
"https" => 443,
_ => return None,
};
let host = parsed.host_str()?.to_ascii_lowercase();
if host.is_empty() {
return None;
}
let port = parsed.port().unwrap_or(default_port);
Some(format!("{scheme}://{host}:{port}"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvToken {
Absent,
Bound(String),
Unbound,
}
#[must_use = "the caller decides what to do with the env token"]
pub fn env_token_for(env_token: Option<&str>, env_url: Option<&str>, target_url: &str) -> EnvToken {
let Some(token) = env_token.map(str::trim).filter(|t| !t.is_empty()) else {
return EnvToken::Absent;
};
let Some(env_url) = env_url.map(str::trim).filter(|u| !u.is_empty()) else {
return EnvToken::Unbound;
};
match (origin_of(env_url), origin_of(target_url)) {
(Some(bound), Some(target)) if bound == target => EnvToken::Bound(token.to_owned()),
_ => EnvToken::Unbound,
}
}
fn warn_env_token_unbound(target_url: &str) {
static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
return;
}
eprintln!(
"warning: {TOKEN_ENV} is set but not bound to {target_url}; ignoring it and using the \
stored credential for that server instead. Set {URL_ENV} to that server to send the \
env token there."
);
}
fn default_file_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("lific").join("credentials.json"))
}
fn default_client_file_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("lific").join("clients.json"))
}
fn client_store() -> Option<FileStore> {
default_client_file_path().map(FileStore::new)
}
fn store_client_id_in(store: &FileStore, base_url: &str, client_id: &str) {
let _ = store.store(&normalize_base_url(base_url), client_id);
}
fn load_client_id_from(store: &FileStore, base_url: &str) -> Option<String> {
store.load(&normalize_base_url(base_url))
}
fn forget_client_id_in(store: &FileStore, base_url: &str) {
let _ = store.delete(&normalize_base_url(base_url));
}
pub fn store_client_id(base_url: &str, client_id: &str) {
if let Some(store) = client_store() {
store_client_id_in(&store, base_url, client_id);
}
}
pub fn load_client_id(base_url: &str) -> Option<String> {
load_client_id_from(&client_store()?, base_url)
}
pub fn forget_client_id(base_url: &str) {
if let Some(store) = client_store() {
forget_client_id_in(&store, base_url);
}
}
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> {
load_with_source(base_url).map(|(token, _)| token)
}
#[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)> {
match env_token_for(
std::env::var(TOKEN_ENV).ok().as_deref(),
std::env::var(URL_ENV).ok().as_deref(),
base_url,
) {
EnvToken::Bound(token) => return Some((token, TokenSource::Env)),
EnvToken::Unbound => warn_env_token_unbound(base_url),
EnvToken::Absent => {}
}
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().is_some_and(|p| FileStore::new(p).delete(&key).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, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("credentials.json");
(FileStore::new(path), tmp)
}
#[test]
fn a_client_id_round_trips_per_server_and_can_be_forgotten() {
let tmp = tempfile::tempdir().unwrap();
let store = FileStore::new(tmp.path().join("clients.json"));
assert_eq!(load_client_id_from(&store, "http://127.0.0.1:3998"), None);
store_client_id_in(&store, "http://127.0.0.1:3998", "client-abc");
assert_eq!(
load_client_id_from(&store, "http://127.0.0.1:3998/"),
Some("client-abc".to_string())
);
store_client_id_in(&store, "https://lific.example", "client-xyz");
assert_eq!(
load_client_id_from(&store, "https://lific.example"),
Some("client-xyz".to_string())
);
assert_eq!(
load_client_id_from(&store, "http://127.0.0.1:3998"),
Some("client-abc".to_string())
);
forget_client_id_in(&store, "http://127.0.0.1:3998/");
assert_eq!(load_client_id_from(&store, "http://127.0.0.1:3998"), None);
assert_eq!(
load_client_id_from(&store, "https://lific.example"),
Some("client-xyz".to_string()),
"forgetting one server must not clear another"
);
}
#[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 tmp = tempfile::tempdir().unwrap();
let path = tmp
.path()
.join("deep")
.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"));
}
#[test]
fn origin_of_makes_default_ports_explicit() {
assert_eq!(
origin_of("https://h.example").unwrap(),
"https://h.example:443"
);
assert_eq!(
origin_of("https://h.example:443").unwrap(),
origin_of("https://h.example").unwrap()
);
assert_eq!(
origin_of("http://h.example:80").unwrap(),
origin_of("http://h.example").unwrap()
);
assert_ne!(
origin_of("http://h.example").unwrap(),
origin_of("https://h.example").unwrap()
);
}
#[test]
fn origin_of_ignores_case_path_and_trailing_slash() {
let base = origin_of("https://Lific.Example:3998").unwrap();
assert_eq!(origin_of("https://lific.example:3998/").unwrap(), base);
assert_eq!(
origin_of("HTTPS://LIFIC.EXAMPLE:3998/a/b?q=1#f").unwrap(),
base
);
assert_eq!(origin_of(" https://lific.example:3998 ").unwrap(), base);
}
#[test]
fn origin_of_rejects_non_http_and_unparseable_urls() {
assert_eq!(origin_of("file:///etc/passwd"), None);
assert_eq!(origin_of("ftp://h.example"), None);
assert_eq!(origin_of("not a url"), None);
assert_eq!(origin_of(""), None);
}
#[test]
fn env_token_attaches_when_target_origin_matches_env_url() {
assert_eq!(
env_token_for(
Some("env-tok"),
Some("https://ci.example"),
"https://ci.example"
),
EnvToken::Bound("env-tok".into())
);
assert_eq!(
env_token_for(
Some(" env-tok "),
Some("https://CI.Example:443/"),
"https://ci.example/api/issues"
),
EnvToken::Bound("env-tok".into())
);
assert_eq!(
env_token_for(
Some("env-tok"),
Some("http://127.0.0.1:3998"),
"http://127.0.0.1:3998/"
),
EnvToken::Bound("env-tok".into())
);
}
#[test]
fn env_token_is_dropped_when_target_origin_differs() {
assert_eq!(
env_token_for(
Some("env-tok"),
Some("https://ci.example"),
"https://hostile.example"
),
EnvToken::Unbound
);
assert_eq!(
env_token_for(
Some("env-tok"),
Some("http://127.0.0.1:3998"),
"http://127.0.0.1:4000"
),
EnvToken::Unbound
);
assert_eq!(
env_token_for(
Some("env-tok"),
Some("https://ci.example"),
"http://ci.example"
),
EnvToken::Unbound
);
assert_eq!(
env_token_for(
Some("env-tok"),
Some("https://ci.example"),
"https://evil.ci.example"
),
EnvToken::Unbound
);
assert_eq!(
env_token_for(Some("env-tok"), Some("not a url"), "https://ci.example"),
EnvToken::Unbound
);
assert_eq!(
env_token_for(Some("env-tok"), Some("https://ci.example"), "not a url"),
EnvToken::Unbound
);
}
#[test]
fn env_token_is_dropped_when_env_url_is_unset() {
assert_eq!(
env_token_for(Some("env-tok"), None, "https://config.example"),
EnvToken::Unbound
);
assert_eq!(
env_token_for(Some("env-tok"), Some(" "), "https://config.example"),
EnvToken::Unbound
);
}
#[test]
fn absent_env_token_leaves_stored_backends_alone() {
assert_eq!(
env_token_for(None, Some("https://ci.example"), "https://ci.example"),
EnvToken::Absent
);
assert_eq!(
env_token_for(
Some(" "),
Some("https://ci.example"),
"https://ci.example"
),
EnvToken::Absent
);
assert_eq!(
env_token_for(None, None, "https://ci.example"),
EnvToken::Absent
);
}
use crate::test_env::lock_lific_token_env_blocking;
#[test]
fn unbound_env_var_does_not_shadow_stored_credentials() {
let _lock = lock_lific_token_env_blocking();
let target = "http://unbound-envtest.invalid:1";
unsafe { std::env::set_var(TOKEN_ENV, "env-tok") };
let got = load(target);
unsafe { std::env::remove_var(TOKEN_ENV) };
assert_ne!(
got.as_deref(),
Some("env-tok"),
"an env token not bound to the target origin must never be sent there"
);
}
#[test]
fn empty_env_var_is_ignored() {
let _lock = lock_lific_token_env_blocking();
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");
}
}