Skip to main content

dsp_cli/config/
auth_cache.rs

1//! On-disk token cache at `~/.config/dsp-cli/auth.toml`. See ADR-0007 and ADR-0012.
2//!
3//! Tokens are keyed by server URL. The file is written atomically via a
4//! `<filename>.<pid>` sibling file followed by a `rename`, so the original
5//! file is intact if a crash interrupts mid-write. Concurrent writes use a
6//! last-writer-wins policy: whichever `rename` runs last wins. The `<pid>`
7//! suffix prevents temp-file collisions between concurrent invocations.
8
9use 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
20/// Hard cap on `auth.toml` size. A real cache holds tens of bytes per server
21/// entry; anything past 1 MiB is almost certainly a misconfigured symlink and
22/// must not be slurped into memory before the TOML parse can reject it.
23const MAX_CACHE_FILE_BYTES: u64 = 1 << 20;
24
25/// One entry in the cache — token plus optional metadata.
26///
27/// The `Debug` impl below is manual to redact the token. The struct holds a
28/// secret; `#[derive(Debug)]` would expose it via any future `tracing::debug!`
29/// or panic message that captured a `ServerEntry`. The `user` and timestamp
30/// fields are not secrets and are shown in cleartext.
31///
32/// All new fields (`user`, `acquired_at`, `expires_at`) are `Option<...>` so
33/// legacy `auth.toml` files from before v2 (token-only entries) still parse.
34#[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/// In-memory view of `~/.config/dsp-cli/auth.toml`.
57///
58/// Load with [`AuthCache::load`] (or [`AuthCache::load_from`] in tests).
59/// Mutate with [`AuthCache::set_token`] and [`AuthCache::remove`].
60/// Persist with [`AuthCache::save`] (or [`AuthCache::save_to`] in tests).
61///
62/// Internal layout is a `BTreeMap` (not `HashMap`) so the on-disk TOML has
63/// deterministic key order. The `toml` crate serialises a `BTreeMap<String, T>`
64/// at the root as a sequence of standalone `[key]` tables, which is the shape
65/// ADR-0007 specifies — no wrapper struct or `#[serde(flatten)]` needed.
66#[derive(Debug, Default)]
67pub struct AuthCache {
68    entries: BTreeMap<String, ServerEntry>,
69}
70
71impl AuthCache {
72    /// The canonical cache path: `~/.config/dsp-cli/auth.toml`.
73    ///
74    /// Returns an error if the home directory cannot be resolved.
75    pub fn default_path() -> Result<PathBuf, Diagnostic> {
76        // ADR-0007 specifies the literal `~/.config/dsp-cli/auth.toml`. Do not
77        // substitute `dirs::config_dir()` — that returns `~/Library/Application
78        // Support/dsp-cli` on macOS, which contradicts the ADR.
79        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    /// Load the cache from [`Self::default_path`].
85    ///
86    /// A missing file is not an error — it means no tokens are cached yet.
87    pub fn load() -> Result<Self, Diagnostic> {
88        let path = Self::default_path()?;
89        Self::load_from(&path)
90    }
91
92    /// Load the cache from an explicit path.
93    ///
94    /// A missing file is not an error — returns an empty cache. Files whose
95    /// reported size exceeds `MAX_CACHE_FILE_BYTES` are rejected before the
96    /// contents are read, so a symlink pointing at a huge file fails fast.
97    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    /// Save the cache to [`Self::default_path`].
141    ///
142    /// Creates the parent directory if it does not exist. On Unix, the file is
143    /// created with mode `0600`.
144    pub fn save(&self) -> Result<(), Diagnostic> {
145        let path = Self::default_path()?;
146        self.save_to(&path)
147    }
148
149    /// Save the cache to an explicit path.
150    ///
151    /// Creates the parent directory if it does not exist. On Unix, the file is
152    /// created with mode `0600`.
153    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    /// Return the cached token for `server`, if any.
178    pub fn token(&self, server: &str) -> Option<&str> {
179        self.entries.get(server).map(|e| e.token.as_str())
180    }
181
182    /// Return the cached user for `server`, if any.
183    pub fn user(&self, server: &str) -> Option<&str> {
184        self.entries.get(server).and_then(|e| e.user.as_deref())
185    }
186
187    /// Return the `acquired_at` timestamp for `server`, if any.
188    pub fn acquired_at(&self, server: &str) -> Option<DateTime<Utc>> {
189        self.entries.get(server).and_then(|e| e.acquired_at)
190    }
191
192    /// Return the `expires_at` timestamp for `server`, if any.
193    pub fn expires_at(&self, server: &str) -> Option<DateTime<Utc>> {
194        self.entries.get(server).and_then(|e| e.expires_at)
195    }
196
197    /// Insert or replace a fully-populated entry for `server`.
198    ///
199    /// Prefer this over `set_token` when the full entry shape is available
200    /// (e.g. after a login that returns user + expiry). `set_token` remains
201    /// as a thin convenience for callers that only have a token.
202    pub fn set_entry(&mut self, server: impl Into<String>, entry: ServerEntry) {
203        self.entries.insert(server.into(), entry);
204    }
205
206    /// Insert or replace the token for `server`.
207    ///
208    /// Convenience wrapper that constructs a partial `ServerEntry` with only
209    /// the token set, leaving `user`, `acquired_at`, and `expires_at` as
210    /// `None`. Use `set_entry` when the full shape is available.
211    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    /// Remove the token for `server`.
224    ///
225    /// Returns `true` if an entry was present and removed, `false` otherwise.
226    pub fn remove(&mut self, server: &str) -> bool {
227        self.entries.remove(server).is_some()
228    }
229
230    /// Return `true` if no tokens are cached.
231    pub fn is_empty(&self) -> bool {
232        self.entries.is_empty()
233    }
234}
235
236/// Write `contents` to `path` atomically via a `<filename>.<pid>` temp file
237/// in the same directory, then `rename`.
238///
239/// On Unix the temp file is created with mode `0600` at creation time,
240/// eliminating the brief window where the file could be readable under the
241/// caller's umask. On non-Unix the file is written without permission
242/// tightening (Windows support is a known limitation — see ADR-0007).
243fn 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        // Rename failed but the temp file (mode 0600) is still on disk. Make a
256        // best-effort attempt to remove it so the directory does not accrete
257        // `auth.toml.<pid>` residue across failed writes. The cleanup result
258        // is intentionally discarded — the original rename error is what we
259        // want to surface to the caller.
260        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
270/// `<path>` → `<dirname>/<filename>.<pid>`. Derived from the actual filename
271/// (not via `Path::with_extension`, which would replace the existing extension
272/// and silently misbehave if `path` ever ended in something other than `.toml`).
273fn 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/// Platform-specific temp file write. On Unix, opens with mode `0600`
288/// at creation; on other platforms, uses a plain write.
289#[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        // remove returns true when the key is present
397        assert!(cache2.remove("https://api.dasch.swiss"));
398        // remove returns false when the key is absent
399        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        // Pins the contract from ADR-0007: each server URL is a top-level
474        // standalone table, not an inline table. This catches the
475        // `#[serde(flatten)]` / `BTreeMap` interaction risk the plan flagged.
476        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        // The cache holds secrets; the Debug impl must not leak the token if
501        // something accidentally formats a ServerEntry (e.g. tracing::debug!).
502        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        // Regression guard: adding user/timestamp fields to ServerEntry must not
522        // accidentally cause the Debug impl to reveal the token via a derived impl.
523        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        // User and timestamps should appear in cleartext.
540        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        // Verify serialise → deserialise round-trip for a fully-populated ServerEntry.
549        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        // Regression guard for the symlink-to-huge-file slurp surfaced during the
590        // 004 security review: stat the file first; reject before reading if it's
591        // past the cap. Writes just over the cap (~1 MiB + 1 B).
592        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        // Force `rename` to fail by making the target path a directory: POSIX
613        // rename of a regular file onto a directory is invalid. The cleanup
614        // branch must remove the temp sibling so directories don't accrete
615        // `auth.toml.<pid>` residue across failed writes.
616        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        // Backward compat: an auth.toml written before the schema bump (token-only)
638        // must still parse, with the new Option fields deserialising as None.
639        let dir = TempDir::new().unwrap();
640        let path = dir.path().join("auth.toml");
641
642        // Write a legacy-style TOML (only `token` field, no user/acquired_at/expires_at).
643        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}