use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::diagnostic::Diagnostic;
const MAX_CACHE_FILE_BYTES: u64 = 1 << 20;
#[derive(Deserialize, Serialize)]
pub struct ServerEntry {
pub token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub acquired_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl fmt::Debug for ServerEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ServerEntry")
.field("token", &"[REDACTED]")
.field("user", &self.user)
.field("acquired_at", &self.acquired_at)
.field("expires_at", &self.expires_at)
.finish()
}
}
#[derive(Debug, Default)]
pub struct AuthCache {
entries: BTreeMap<String, ServerEntry>,
}
impl AuthCache {
pub fn default_path() -> Result<PathBuf, Diagnostic> {
let home = dirs::home_dir()
.ok_or_else(|| Diagnostic::Internal("could not resolve home directory".to_string()))?;
Ok(home.join(".config").join("dsp-cli").join("auth.toml"))
}
pub fn load() -> Result<Self, Diagnostic> {
let path = Self::default_path()?;
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self, Diagnostic> {
let metadata = match fs::metadata(path) {
Ok(md) => md,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!(path = %path.display(), "auth cache not found; starting empty");
return Ok(Self::default());
}
Err(e) => {
return Err(Diagnostic::Internal(format!(
"failed to stat auth cache at {}: {}",
path.display(),
e
)));
}
};
if metadata.len() > MAX_CACHE_FILE_BYTES {
return Err(Diagnostic::Internal(format!(
"auth cache at {} is too large ({} bytes, max {} bytes); refusing to read",
path.display(),
metadata.len(),
MAX_CACHE_FILE_BYTES
)));
}
let contents = fs::read_to_string(path).map_err(|e| {
Diagnostic::Internal(format!(
"failed to read auth cache at {}: {}",
path.display(),
e
))
})?;
let entries: BTreeMap<String, ServerEntry> = toml::from_str(&contents).map_err(|e| {
Diagnostic::Internal(format!(
"failed to parse auth cache at {}: {}",
path.display(),
e
))
})?;
tracing::debug!(path = %path.display(), "loaded auth cache");
Ok(Self { entries })
}
pub fn save(&self) -> Result<(), Diagnostic> {
let path = Self::default_path()?;
self.save_to(&path)
}
pub fn save_to(&self, path: &Path) -> Result<(), Diagnostic> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Diagnostic::Internal(format!(
"failed to create auth cache directory at {}: {}",
parent.display(),
e
))
})?;
}
let contents = toml::to_string_pretty(&self.entries).map_err(|e| {
Diagnostic::Internal(format!(
"failed to serialise auth cache for {}: {}",
path.display(),
e
))
})?;
write_atomically(path, &contents)?;
tracing::debug!(path = %path.display(), "saved auth cache");
Ok(())
}
pub fn token(&self, server: &str) -> Option<&str> {
self.entries.get(server).map(|e| e.token.as_str())
}
pub fn user(&self, server: &str) -> Option<&str> {
self.entries.get(server).and_then(|e| e.user.as_deref())
}
pub fn acquired_at(&self, server: &str) -> Option<DateTime<Utc>> {
self.entries.get(server).and_then(|e| e.acquired_at)
}
pub fn expires_at(&self, server: &str) -> Option<DateTime<Utc>> {
self.entries.get(server).and_then(|e| e.expires_at)
}
pub fn set_entry(&mut self, server: impl Into<String>, entry: ServerEntry) {
self.entries.insert(server.into(), entry);
}
pub fn set_token(&mut self, server: String, token: String) {
self.set_entry(
server,
ServerEntry {
token,
user: None,
acquired_at: None,
expires_at: None,
},
);
}
pub fn remove(&mut self, server: &str) -> bool {
self.entries.remove(server).is_some()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
fn write_atomically(path: &Path, contents: &str) -> Result<(), Diagnostic> {
let tmp_path = temp_sibling_path(path)?;
write_temp_file(&tmp_path, contents).map_err(|e| {
Diagnostic::Internal(format!(
"failed to write auth cache temp file at {}: {}",
tmp_path.display(),
e
))
})?;
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(Diagnostic::Internal(format!(
"failed to rename auth cache temp file to {}: {}",
path.display(),
e
)));
}
Ok(())
}
fn temp_sibling_path(path: &Path) -> Result<PathBuf, Diagnostic> {
let mut name = path
.file_name()
.ok_or_else(|| {
Diagnostic::Internal(format!(
"auth cache path has no filename component: {}",
path.display()
))
})?
.to_os_string();
name.push(format!(".{}", std::process::id()));
Ok(path.with_file_name(name))
}
#[cfg(unix)]
fn write_temp_file(path: &Path, contents: &str) -> Result<(), std::io::Error> {
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())
}
#[cfg(not(unix))]
fn write_temp_file(path: &Path, contents: &str) -> Result<(), std::io::Error> {
fs::write(path, contents)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn load_from_missing_file_returns_empty_cache() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let cache = AuthCache::load_from(&path).unwrap();
assert!(cache.is_empty());
}
#[test]
fn set_then_load_round_trip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let mut cache = AuthCache::load_from(&path).unwrap();
cache.set_token(
"https://api.dasch.swiss".to_string(),
"tok-abc123".to_string(),
);
cache.save_to(&path).unwrap();
let loaded = AuthCache::load_from(&path).unwrap();
assert_eq!(loaded.token("https://api.dasch.swiss"), Some("tok-abc123"));
}
#[test]
fn multiple_servers_coexist() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let mut cache = AuthCache::load_from(&path).unwrap();
cache.set_token(
"https://api.dasch.swiss".to_string(),
"tok-prod".to_string(),
);
cache.set_token(
"https://api.test.dasch.swiss".to_string(),
"tok-test".to_string(),
);
cache.save_to(&path).unwrap();
let loaded = AuthCache::load_from(&path).unwrap();
assert_eq!(loaded.token("https://api.dasch.swiss"), Some("tok-prod"));
assert_eq!(
loaded.token("https://api.test.dasch.swiss"),
Some("tok-test")
);
}
#[test]
fn set_overwrites_existing_token() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let mut cache = AuthCache::load_from(&path).unwrap();
cache.set_token(
"https://api.dasch.swiss".to_string(),
"old-token".to_string(),
);
cache.save_to(&path).unwrap();
let mut cache2 = AuthCache::load_from(&path).unwrap();
cache2.set_token(
"https://api.dasch.swiss".to_string(),
"new-token".to_string(),
);
cache2.save_to(&path).unwrap();
let loaded = AuthCache::load_from(&path).unwrap();
assert_eq!(loaded.token("https://api.dasch.swiss"), Some("new-token"));
}
#[test]
fn remove_clears_entry() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let mut cache = AuthCache::load_from(&path).unwrap();
cache.set_token(
"https://api.dasch.swiss".to_string(),
"tok-prod".to_string(),
);
cache.save_to(&path).unwrap();
let mut cache2 = AuthCache::load_from(&path).unwrap();
assert!(cache2.remove("https://api.dasch.swiss"));
assert!(!cache2.remove("https://api.dasch.swiss"));
cache2.save_to(&path).unwrap();
let loaded = AuthCache::load_from(&path).unwrap();
assert_eq!(loaded.token("https://api.dasch.swiss"), None);
}
#[test]
fn save_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nested").join("dir").join("auth.toml");
let mut cache = AuthCache::load_from(&path).unwrap();
cache.set_token("https://api.dasch.swiss".to_string(), "tok".to_string());
cache.save_to(&path).unwrap();
assert!(path.exists());
}
#[test]
#[cfg(unix)]
fn save_sets_0600_on_unix() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let mut cache = AuthCache::load_from(&path).unwrap();
cache.set_token("https://api.dasch.swiss".to_string(), "tok".to_string());
cache.save_to(&path).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600, "expected 0600, got {mode:o}");
}
#[test]
fn malformed_toml_returns_internal_diagnostic() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
fs::write(&path, b"not valid toml [[[").unwrap();
let err = AuthCache::load_from(&path).unwrap_err();
assert!(
matches!(err, Diagnostic::Internal(_)),
"expected Diagnostic::Internal, got {:?}",
err
);
let msg = err.to_string();
assert!(
msg.contains(&path.to_string_lossy().to_string()),
"error message should contain the path; got: {msg}"
);
}
#[test]
fn atomic_write_does_not_leave_temp_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let mut cache = AuthCache::load_from(&path).unwrap();
cache.set_token("https://api.dasch.swiss".to_string(), "tok".to_string());
cache.save_to(&path).unwrap();
let tmp_path = temp_sibling_path(&path).unwrap();
assert!(
!tmp_path.exists(),
"temp file should not exist after save: {}",
tmp_path.display()
);
}
#[test]
fn on_disk_shape_uses_standalone_tables() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let mut cache = AuthCache::load_from(&path).unwrap();
cache.set_token("https://api.dasch.swiss".to_string(), "tok-abc".to_string());
cache.save_to(&path).unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(
raw.contains("[\"https://api.dasch.swiss\"]"),
"expected standalone table header, got:\n{raw}"
);
assert!(
raw.contains("token = \"tok-abc\""),
"expected token on its own line, got:\n{raw}"
);
assert!(
!raw.contains("= {"),
"did not expect inline-table shape, got:\n{raw}"
);
}
#[test]
fn server_entry_debug_redacts_token() {
let entry = ServerEntry {
token: "super-secret-jwt".to_string(),
user: None,
acquired_at: None,
expires_at: None,
};
let rendered = format!("{entry:?}");
assert!(
!rendered.contains("super-secret-jwt"),
"Debug impl leaked the token: {rendered}"
);
assert!(
rendered.contains("REDACTED"),
"expected redaction marker, got: {rendered}"
);
}
#[test]
fn server_entry_debug_redacts_token_when_all_fields_populated() {
use chrono::TimeZone;
let entry = ServerEntry {
token: "super-secret-jwt-full".to_string(),
user: Some("user@example.com".to_string()),
acquired_at: Some(Utc.with_ymd_and_hms(2026, 5, 26, 10, 0, 0).unwrap()),
expires_at: Some(Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap()),
};
let rendered = format!("{entry:?}");
assert!(
!rendered.contains("super-secret-jwt-full"),
"Debug impl leaked the token when all fields are set: {rendered}"
);
assert!(
rendered.contains("REDACTED"),
"expected redaction marker, got: {rendered}"
);
assert!(
rendered.contains("user@example.com"),
"expected user in debug output, got: {rendered}"
);
}
#[test]
fn round_trip_entry_with_all_fields() {
use chrono::TimeZone;
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let expires = Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap();
let acquired = Utc.with_ymd_and_hms(2026, 5, 26, 10, 0, 0).unwrap();
let mut cache = AuthCache::default();
cache.set_entry(
"https://api.test.dasch.swiss",
ServerEntry {
token: "tok-full".to_string(),
user: Some("user@example.com".to_string()),
acquired_at: Some(acquired),
expires_at: Some(expires),
},
);
cache.save_to(&path).unwrap();
let loaded = AuthCache::load_from(&path).unwrap();
assert_eq!(
loaded.token("https://api.test.dasch.swiss"),
Some("tok-full")
);
assert_eq!(
loaded.user("https://api.test.dasch.swiss"),
Some("user@example.com")
);
assert_eq!(
loaded.acquired_at("https://api.test.dasch.swiss"),
Some(acquired)
);
assert_eq!(
loaded.expires_at("https://api.test.dasch.swiss"),
Some(expires)
);
}
#[test]
fn load_from_rejects_oversize_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let oversize = vec![b'x'; (MAX_CACHE_FILE_BYTES + 1) as usize];
fs::write(&path, &oversize).unwrap();
let err = AuthCache::load_from(&path).unwrap_err();
assert!(
matches!(err, Diagnostic::Internal(_)),
"expected Diagnostic::Internal, got {:?}",
err
);
let msg = err.to_string();
assert!(
msg.contains("too large"),
"expected 'too large' in error message; got: {msg}"
);
}
#[test]
fn write_atomically_cleans_temp_file_on_rename_failure() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
fs::create_dir(&path).unwrap();
let err = write_atomically(&path, "irrelevant").unwrap_err();
assert!(
matches!(err, Diagnostic::Internal(_)),
"expected Diagnostic::Internal on rename-onto-directory; got {:?}",
err
);
let tmp = temp_sibling_path(&path).unwrap();
assert!(
!tmp.exists(),
"temp file should be cleaned up after rename failure: {}",
tmp.display()
);
}
#[test]
fn round_trip_legacy_entry_token_only() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("auth.toml");
let legacy_toml = "[\"https://api.dasch.swiss\"]\ntoken = \"legacy-tok\"\n";
fs::write(&path, legacy_toml).unwrap();
let loaded = AuthCache::load_from(&path).unwrap();
assert_eq!(loaded.token("https://api.dasch.swiss"), Some("legacy-tok"));
assert_eq!(loaded.user("https://api.dasch.swiss"), None);
assert_eq!(loaded.acquired_at("https://api.dasch.swiss"), None);
assert_eq!(loaded.expires_at("https://api.dasch.swiss"), None);
}
}