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