1use std::collections::BTreeMap;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::{fmt, fs};
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17use crate::diagnostic::Diagnostic;
18
19const MAX_CACHE_FILE_BYTES: u64 = 1 << 20;
23
24#[derive(Deserialize, Serialize)]
34pub struct ServerEntry {
35 pub token: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub user: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub acquired_at: Option<DateTime<Utc>>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub expires_at: Option<DateTime<Utc>>,
42}
43
44impl fmt::Debug for ServerEntry {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 f.debug_struct("ServerEntry")
47 .field("token", &"[REDACTED]")
48 .field("user", &self.user)
49 .field("acquired_at", &self.acquired_at)
50 .field("expires_at", &self.expires_at)
51 .finish()
52 }
53}
54
55#[derive(Debug, Default)]
66pub struct AuthCache {
67 entries: BTreeMap<String, ServerEntry>,
68}
69
70impl AuthCache {
71 pub fn default_path() -> Result<PathBuf, Diagnostic> {
75 let home =
79 dirs::home_dir().ok_or_else(|| Diagnostic::Internal("could not resolve home directory".to_string()))?;
80 Ok(home.join(".config").join("dsp-cli").join("auth.toml"))
81 }
82
83 pub fn load() -> Result<Self, Diagnostic> {
87 let path = Self::default_path()?;
88 Self::load_from(&path)
89 }
90
91 pub fn load_from(path: &Path) -> Result<Self, Diagnostic> {
97 let metadata = match fs::metadata(path) {
98 Ok(md) => md,
99 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
100 tracing::debug!(path = %path.display(), "auth cache not found; starting empty");
101 return Ok(Self::default());
102 }
103 Err(e) => {
104 return Err(Diagnostic::Internal(format!(
105 "failed to stat auth cache at {}: {}",
106 path.display(),
107 e
108 )));
109 }
110 };
111
112 if metadata.len() > MAX_CACHE_FILE_BYTES {
113 return Err(Diagnostic::Internal(format!(
114 "auth cache at {} is too large ({} bytes, max {} bytes); refusing to read",
115 path.display(),
116 metadata.len(),
117 MAX_CACHE_FILE_BYTES
118 )));
119 }
120
121 let contents = fs::read_to_string(path)
122 .map_err(|e| Diagnostic::Internal(format!("failed to read auth cache at {}: {}", path.display(), e)))?;
123 let entries: BTreeMap<String, ServerEntry> = toml::from_str(&contents)
124 .map_err(|e| Diagnostic::Internal(format!("failed to parse auth cache at {}: {}", path.display(), e)))?;
125 tracing::debug!(path = %path.display(), "loaded auth cache");
126 Ok(Self { entries })
127 }
128
129 pub fn save(&self) -> Result<(), Diagnostic> {
134 let path = Self::default_path()?;
135 self.save_to(&path)
136 }
137
138 pub fn save_to(&self, path: &Path) -> Result<(), Diagnostic> {
143 if let Some(parent) = path.parent() {
144 fs::create_dir_all(parent).map_err(|e| {
145 Diagnostic::Internal(format!("failed to create auth cache directory at {}: {}", parent.display(), e))
146 })?;
147 }
148
149 let contents = toml::to_string_pretty(&self.entries).map_err(|e| {
150 Diagnostic::Internal(format!("failed to serialise auth cache for {}: {}", path.display(), e))
151 })?;
152
153 write_atomically(path, &contents)?;
154 tracing::debug!(path = %path.display(), "saved auth cache");
155 Ok(())
156 }
157
158 pub fn token(&self, server: &str) -> Option<&str> {
160 self.entries.get(server).map(|e| e.token.as_str())
161 }
162
163 pub fn user(&self, server: &str) -> Option<&str> {
165 self.entries.get(server).and_then(|e| e.user.as_deref())
166 }
167
168 pub fn acquired_at(&self, server: &str) -> Option<DateTime<Utc>> {
170 self.entries.get(server).and_then(|e| e.acquired_at)
171 }
172
173 pub fn expires_at(&self, server: &str) -> Option<DateTime<Utc>> {
175 self.entries.get(server).and_then(|e| e.expires_at)
176 }
177
178 pub fn set_entry(&mut self, server: impl Into<String>, entry: ServerEntry) {
184 self.entries.insert(server.into(), entry);
185 }
186
187 pub fn set_token(&mut self, server: String, token: String) {
193 self.set_entry(server, ServerEntry { token, user: None, acquired_at: None, expires_at: None });
194 }
195
196 pub fn remove(&mut self, server: &str) -> bool {
200 self.entries.remove(server).is_some()
201 }
202
203 pub fn is_empty(&self) -> bool {
205 self.entries.is_empty()
206 }
207}
208
209fn write_atomically(path: &Path, contents: &str) -> Result<(), Diagnostic> {
217 let tmp_path = temp_sibling_path(path)?;
218
219 write_temp_file(&tmp_path, contents).map_err(|e| {
220 Diagnostic::Internal(format!("failed to write auth cache temp file at {}: {}", tmp_path.display(), e))
221 })?;
222
223 if let Err(e) = fs::rename(&tmp_path, path) {
224 let _ = fs::remove_file(&tmp_path);
230 return Err(Diagnostic::Internal(format!(
231 "failed to rename auth cache temp file to {}: {}",
232 path.display(),
233 e
234 )));
235 }
236 Ok(())
237}
238
239fn temp_sibling_path(path: &Path) -> Result<PathBuf, Diagnostic> {
243 let mut name = path
244 .file_name()
245 .ok_or_else(|| Diagnostic::Internal(format!("auth cache path has no filename component: {}", path.display())))?
246 .to_os_string();
247 name.push(format!(".{}", std::process::id()));
248 Ok(path.with_file_name(name))
249}
250
251#[cfg(unix)]
254fn write_temp_file(path: &Path, contents: &str) -> Result<(), std::io::Error> {
255 use std::os::unix::fs::OpenOptionsExt;
256
257 let mut file = fs::OpenOptions::new()
258 .write(true)
259 .create(true)
260 .truncate(true)
261 .mode(0o600)
262 .open(path)?;
263 file.write_all(contents.as_bytes())
264}
265
266#[cfg(not(unix))]
267fn write_temp_file(path: &Path, contents: &str) -> Result<(), std::io::Error> {
268 fs::write(path, contents)
269}
270
271#[cfg(test)]
272mod tests {
273 use tempfile::TempDir;
274
275 use super::*;
276
277 #[test]
278 fn load_from_missing_file_returns_empty_cache() {
279 let dir = TempDir::new().unwrap();
280 let path = dir.path().join("auth.toml");
281 let cache = AuthCache::load_from(&path).unwrap();
282 assert!(cache.is_empty());
283 }
284
285 #[test]
286 fn set_then_load_round_trip() {
287 let dir = TempDir::new().unwrap();
288 let path = dir.path().join("auth.toml");
289
290 let mut cache = AuthCache::load_from(&path).unwrap();
291 cache.set_token("https://api.dasch.swiss".to_string(), "tok-abc123".to_string());
292 cache.save_to(&path).unwrap();
293
294 let loaded = AuthCache::load_from(&path).unwrap();
295 assert_eq!(loaded.token("https://api.dasch.swiss"), Some("tok-abc123"));
296 }
297
298 #[test]
299 fn multiple_servers_coexist() {
300 let dir = TempDir::new().unwrap();
301 let path = dir.path().join("auth.toml");
302
303 let mut cache = AuthCache::load_from(&path).unwrap();
304 cache.set_token("https://api.dasch.swiss".to_string(), "tok-prod".to_string());
305 cache.set_token("https://api.test.dasch.swiss".to_string(), "tok-test".to_string());
306 cache.save_to(&path).unwrap();
307
308 let loaded = AuthCache::load_from(&path).unwrap();
309 assert_eq!(loaded.token("https://api.dasch.swiss"), Some("tok-prod"));
310 assert_eq!(loaded.token("https://api.test.dasch.swiss"), Some("tok-test"));
311 }
312
313 #[test]
314 fn set_overwrites_existing_token() {
315 let dir = TempDir::new().unwrap();
316 let path = dir.path().join("auth.toml");
317
318 let mut cache = AuthCache::load_from(&path).unwrap();
319 cache.set_token("https://api.dasch.swiss".to_string(), "old-token".to_string());
320 cache.save_to(&path).unwrap();
321
322 let mut cache2 = AuthCache::load_from(&path).unwrap();
323 cache2.set_token("https://api.dasch.swiss".to_string(), "new-token".to_string());
324 cache2.save_to(&path).unwrap();
325
326 let loaded = AuthCache::load_from(&path).unwrap();
327 assert_eq!(loaded.token("https://api.dasch.swiss"), Some("new-token"));
328 }
329
330 #[test]
331 fn remove_clears_entry() {
332 let dir = TempDir::new().unwrap();
333 let path = dir.path().join("auth.toml");
334
335 let mut cache = AuthCache::load_from(&path).unwrap();
336 cache.set_token("https://api.dasch.swiss".to_string(), "tok-prod".to_string());
337 cache.save_to(&path).unwrap();
338
339 let mut cache2 = AuthCache::load_from(&path).unwrap();
340 assert!(cache2.remove("https://api.dasch.swiss"));
342 assert!(!cache2.remove("https://api.dasch.swiss"));
344 cache2.save_to(&path).unwrap();
345
346 let loaded = AuthCache::load_from(&path).unwrap();
347 assert_eq!(loaded.token("https://api.dasch.swiss"), None);
348 }
349
350 #[test]
351 fn save_creates_parent_directory() {
352 let dir = TempDir::new().unwrap();
353 let path = dir.path().join("nested").join("dir").join("auth.toml");
354
355 let mut cache = AuthCache::load_from(&path).unwrap();
356 cache.set_token("https://api.dasch.swiss".to_string(), "tok".to_string());
357 cache.save_to(&path).unwrap();
358
359 assert!(path.exists());
360 }
361
362 #[test]
363 #[cfg(unix)]
364 fn save_sets_0600_on_unix() {
365 use std::os::unix::fs::PermissionsExt;
366
367 let dir = TempDir::new().unwrap();
368 let path = dir.path().join("auth.toml");
369
370 let mut cache = AuthCache::load_from(&path).unwrap();
371 cache.set_token("https://api.dasch.swiss".to_string(), "tok".to_string());
372 cache.save_to(&path).unwrap();
373
374 let mode = fs::metadata(&path).unwrap().permissions().mode();
375 assert_eq!(mode & 0o777, 0o600, "expected 0600, got {mode:o}");
376 }
377
378 #[test]
379 fn malformed_toml_returns_internal_diagnostic() {
380 let dir = TempDir::new().unwrap();
381 let path = dir.path().join("auth.toml");
382
383 fs::write(&path, b"not valid toml [[[").unwrap();
384
385 let err = AuthCache::load_from(&path).unwrap_err();
386 assert!(
387 matches!(err, Diagnostic::Internal(_)),
388 "expected Diagnostic::Internal, got {:?}",
389 err
390 );
391 let msg = err.to_string();
392 assert!(
393 msg.contains(&path.to_string_lossy().to_string()),
394 "error message should contain the path; got: {msg}"
395 );
396 }
397
398 #[test]
399 fn atomic_write_does_not_leave_temp_file() {
400 let dir = TempDir::new().unwrap();
401 let path = dir.path().join("auth.toml");
402
403 let mut cache = AuthCache::load_from(&path).unwrap();
404 cache.set_token("https://api.dasch.swiss".to_string(), "tok".to_string());
405 cache.save_to(&path).unwrap();
406
407 let tmp_path = temp_sibling_path(&path).unwrap();
408 assert!(
409 !tmp_path.exists(),
410 "temp file should not exist after save: {}",
411 tmp_path.display()
412 );
413 }
414
415 #[test]
416 fn on_disk_shape_uses_standalone_tables() {
417 let dir = TempDir::new().unwrap();
421 let path = dir.path().join("auth.toml");
422
423 let mut cache = AuthCache::load_from(&path).unwrap();
424 cache.set_token("https://api.dasch.swiss".to_string(), "tok-abc".to_string());
425 cache.save_to(&path).unwrap();
426
427 let raw = fs::read_to_string(&path).unwrap();
428 assert!(
429 raw.contains("[\"https://api.dasch.swiss\"]"),
430 "expected standalone table header, got:\n{raw}"
431 );
432 assert!(
433 raw.contains("token = \"tok-abc\""),
434 "expected token on its own line, got:\n{raw}"
435 );
436 assert!(!raw.contains("= {"), "did not expect inline-table shape, got:\n{raw}");
437 }
438
439 #[test]
440 fn server_entry_debug_redacts_token() {
441 let entry = ServerEntry {
444 token: "super-secret-jwt".to_string(),
445 user: None,
446 acquired_at: None,
447 expires_at: None,
448 };
449 let rendered = format!("{entry:?}");
450 assert!(
451 !rendered.contains("super-secret-jwt"),
452 "Debug impl leaked the token: {rendered}"
453 );
454 assert!(rendered.contains("REDACTED"), "expected redaction marker, got: {rendered}");
455 }
456
457 #[test]
458 fn server_entry_debug_redacts_token_when_all_fields_populated() {
459 use chrono::TimeZone;
462 let entry = ServerEntry {
463 token: "super-secret-jwt-full".to_string(),
464 user: Some("user@example.com".to_string()),
465 acquired_at: Some(Utc.with_ymd_and_hms(2026, 5, 26, 10, 0, 0).unwrap()),
466 expires_at: Some(Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap()),
467 };
468 let rendered = format!("{entry:?}");
469 assert!(
470 !rendered.contains("super-secret-jwt-full"),
471 "Debug impl leaked the token when all fields are set: {rendered}"
472 );
473 assert!(rendered.contains("REDACTED"), "expected redaction marker, got: {rendered}");
474 assert!(
476 rendered.contains("user@example.com"),
477 "expected user in debug output, got: {rendered}"
478 );
479 }
480
481 #[test]
482 fn round_trip_entry_with_all_fields() {
483 use chrono::TimeZone;
485 let dir = TempDir::new().unwrap();
486 let path = dir.path().join("auth.toml");
487
488 let expires = Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap();
489 let acquired = Utc.with_ymd_and_hms(2026, 5, 26, 10, 0, 0).unwrap();
490
491 let mut cache = AuthCache::default();
492 cache.set_entry(
493 "https://api.test.dasch.swiss",
494 ServerEntry {
495 token: "tok-full".to_string(),
496 user: Some("user@example.com".to_string()),
497 acquired_at: Some(acquired),
498 expires_at: Some(expires),
499 },
500 );
501 cache.save_to(&path).unwrap();
502
503 let loaded = AuthCache::load_from(&path).unwrap();
504 assert_eq!(loaded.token("https://api.test.dasch.swiss"), Some("tok-full"));
505 assert_eq!(loaded.user("https://api.test.dasch.swiss"), Some("user@example.com"));
506 assert_eq!(loaded.acquired_at("https://api.test.dasch.swiss"), Some(acquired));
507 assert_eq!(loaded.expires_at("https://api.test.dasch.swiss"), Some(expires));
508 }
509
510 #[test]
511 fn load_from_rejects_oversize_file() {
512 let dir = TempDir::new().unwrap();
516 let path = dir.path().join("auth.toml");
517 let oversize = vec![b'x'; (MAX_CACHE_FILE_BYTES + 1) as usize];
518 fs::write(&path, &oversize).unwrap();
519
520 let err = AuthCache::load_from(&path).unwrap_err();
521 assert!(
522 matches!(err, Diagnostic::Internal(_)),
523 "expected Diagnostic::Internal, got {:?}",
524 err
525 );
526 let msg = err.to_string();
527 assert!(msg.contains("too large"), "expected 'too large' in error message; got: {msg}");
528 }
529
530 #[test]
531 fn write_atomically_cleans_temp_file_on_rename_failure() {
532 let dir = TempDir::new().unwrap();
537 let path = dir.path().join("auth.toml");
538 fs::create_dir(&path).unwrap();
539
540 let err = write_atomically(&path, "irrelevant").unwrap_err();
541 assert!(
542 matches!(err, Diagnostic::Internal(_)),
543 "expected Diagnostic::Internal on rename-onto-directory; got {:?}",
544 err
545 );
546
547 let tmp = temp_sibling_path(&path).unwrap();
548 assert!(
549 !tmp.exists(),
550 "temp file should be cleaned up after rename failure: {}",
551 tmp.display()
552 );
553 }
554
555 #[test]
556 fn round_trip_legacy_entry_token_only() {
557 let dir = TempDir::new().unwrap();
560 let path = dir.path().join("auth.toml");
561
562 let legacy_toml = "[\"https://api.dasch.swiss\"]\ntoken = \"legacy-tok\"\n";
564 fs::write(&path, legacy_toml).unwrap();
565
566 let loaded = AuthCache::load_from(&path).unwrap();
567 assert_eq!(loaded.token("https://api.dasch.swiss"), Some("legacy-tok"));
568 assert_eq!(loaded.user("https://api.dasch.swiss"), None);
569 assert_eq!(loaded.acquired_at("https://api.dasch.swiss"), None);
570 assert_eq!(loaded.expires_at("https://api.dasch.swiss"), None);
571 }
572}