Skip to main content

dsp_cli/config/
auth_cache.rs

1//! On-disk token cache at `~/.config/dsp-cli/auth.toml`. See dsp-cli/ADR-0007 and dsp-cli/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::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
19/// Hard cap on `auth.toml` size. A real cache holds tens of bytes per server
20/// entry; anything past 1 MiB is almost certainly a misconfigured symlink and
21/// must not be slurped into memory before the TOML parse can reject it.
22const MAX_CACHE_FILE_BYTES: u64 = 1 << 20;
23
24/// One entry in the cache — token plus optional metadata.
25///
26/// The `Debug` impl below is manual to redact the token. The struct holds a
27/// secret; `#[derive(Debug)]` would expose it via any future `tracing::debug!`
28/// or panic message that captured a `ServerEntry`. The `user` and timestamp
29/// fields are not secrets and are shown in cleartext.
30///
31/// All new fields (`user`, `acquired_at`, `expires_at`) are `Option<...>` so
32/// legacy `auth.toml` files from before v2 (token-only entries) still parse.
33#[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/// In-memory view of `~/.config/dsp-cli/auth.toml`.
56///
57/// Load with [`AuthCache::load`] (or [`AuthCache::load_from`] in tests).
58/// Mutate with [`AuthCache::set_token`] and [`AuthCache::remove`].
59/// Persist with [`AuthCache::save`] (or [`AuthCache::save_to`] in tests).
60///
61/// Internal layout is a `BTreeMap` (not `HashMap`) so the on-disk TOML has
62/// deterministic key order. The `toml` crate serialises a `BTreeMap<String, T>`
63/// at the root as a sequence of standalone `[key]` tables, which is the shape
64/// dsp-cli/ADR-0007 specifies — no wrapper struct or `#[serde(flatten)]` needed.
65#[derive(Debug, Default)]
66pub struct AuthCache {
67    entries: BTreeMap<String, ServerEntry>,
68}
69
70impl AuthCache {
71    /// The canonical cache path: `~/.config/dsp-cli/auth.toml`.
72    ///
73    /// Returns an error if the home directory cannot be resolved.
74    pub fn default_path() -> Result<PathBuf, Diagnostic> {
75        // dsp-cli/ADR-0007 specifies the literal `~/.config/dsp-cli/auth.toml`. Do not
76        // substitute `dirs::config_dir()` — that returns `~/Library/Application
77        // Support/dsp-cli` on macOS, which contradicts the ADR.
78        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    /// Load the cache from [`Self::default_path`].
84    ///
85    /// A missing file is not an error — it means no tokens are cached yet.
86    pub fn load() -> Result<Self, Diagnostic> {
87        let path = Self::default_path()?;
88        Self::load_from(&path)
89    }
90
91    /// Load the cache from an explicit path.
92    ///
93    /// A missing file is not an error — returns an empty cache. Files whose
94    /// reported size exceeds `MAX_CACHE_FILE_BYTES` are rejected before the
95    /// contents are read, so a symlink pointing at a huge file fails fast.
96    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    /// Save the cache to [`Self::default_path`].
130    ///
131    /// Creates the parent directory if it does not exist. On Unix, the file is
132    /// created with mode `0600`.
133    pub fn save(&self) -> Result<(), Diagnostic> {
134        let path = Self::default_path()?;
135        self.save_to(&path)
136    }
137
138    /// Save the cache to an explicit path.
139    ///
140    /// Creates the parent directory if it does not exist. On Unix, the file is
141    /// created with mode `0600`.
142    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    /// Return the cached token for `server`, if any.
159    pub fn token(&self, server: &str) -> Option<&str> {
160        self.entries.get(server).map(|e| e.token.as_str())
161    }
162
163    /// Return the cached user for `server`, if any.
164    pub fn user(&self, server: &str) -> Option<&str> {
165        self.entries.get(server).and_then(|e| e.user.as_deref())
166    }
167
168    /// Return the `acquired_at` timestamp for `server`, if any.
169    pub fn acquired_at(&self, server: &str) -> Option<DateTime<Utc>> {
170        self.entries.get(server).and_then(|e| e.acquired_at)
171    }
172
173    /// Return the `expires_at` timestamp for `server`, if any.
174    pub fn expires_at(&self, server: &str) -> Option<DateTime<Utc>> {
175        self.entries.get(server).and_then(|e| e.expires_at)
176    }
177
178    /// Insert or replace a fully-populated entry for `server`.
179    ///
180    /// Prefer this over `set_token` when the full entry shape is available
181    /// (e.g. after a login that returns user + expiry). `set_token` remains
182    /// as a thin convenience for callers that only have a token.
183    pub fn set_entry(&mut self, server: impl Into<String>, entry: ServerEntry) {
184        self.entries.insert(server.into(), entry);
185    }
186
187    /// Insert or replace the token for `server`.
188    ///
189    /// Convenience wrapper that constructs a partial `ServerEntry` with only
190    /// the token set, leaving `user`, `acquired_at`, and `expires_at` as
191    /// `None`. Use `set_entry` when the full shape is available.
192    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    /// Remove the token for `server`.
197    ///
198    /// Returns `true` if an entry was present and removed, `false` otherwise.
199    pub fn remove(&mut self, server: &str) -> bool {
200        self.entries.remove(server).is_some()
201    }
202
203    /// Return `true` if no tokens are cached.
204    pub fn is_empty(&self) -> bool {
205        self.entries.is_empty()
206    }
207}
208
209/// Write `contents` to `path` atomically via a `<filename>.<pid>` temp file
210/// in the same directory, then `rename`.
211///
212/// On Unix the temp file is created with mode `0600` at creation time,
213/// eliminating the brief window where the file could be readable under the
214/// caller's umask. On non-Unix the file is written without permission
215/// tightening (Windows support is a known limitation — see dsp-cli/ADR-0007).
216fn 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        // Rename failed but the temp file (mode 0600) is still on disk. Make a
225        // best-effort attempt to remove it so the directory does not accrete
226        // `auth.toml.<pid>` residue across failed writes. The cleanup result
227        // is intentionally discarded — the original rename error is what we
228        // want to surface to the caller.
229        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
239/// `<path>` → `<dirname>/<filename>.<pid>`. Derived from the actual filename
240/// (not via `Path::with_extension`, which would replace the existing extension
241/// and silently misbehave if `path` ever ended in something other than `.toml`).
242fn 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/// Platform-specific temp file write. On Unix, opens with mode `0600`
252/// at creation; on other platforms, uses a plain write.
253#[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        // remove returns true when the key is present
341        assert!(cache2.remove("https://api.dasch.swiss"));
342        // remove returns false when the key is absent
343        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        // Pins the contract from dsp-cli/ADR-0007: each server URL is a top-level
418        // standalone table, not an inline table. This catches the
419        // `#[serde(flatten)]` / `BTreeMap` interaction risk the plan flagged.
420        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        // The cache holds secrets; the Debug impl must not leak the token if
442        // something accidentally formats a ServerEntry (e.g. tracing::debug!).
443        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        // Regression guard: adding user/timestamp fields to ServerEntry must not
460        // accidentally cause the Debug impl to reveal the token via a derived impl.
461        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        // User and timestamps should appear in cleartext.
475        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        // Verify serialise → deserialise round-trip for a fully-populated ServerEntry.
484        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        // Regression guard for the symlink-to-huge-file slurp surfaced during the
513        // 004 security review: stat the file first; reject before reading if it's
514        // past the cap. Writes just over the cap (~1 MiB + 1 B).
515        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        // Force `rename` to fail by making the target path a directory: POSIX
533        // rename of a regular file onto a directory is invalid. The cleanup
534        // branch must remove the temp sibling so directories don't accrete
535        // `auth.toml.<pid>` residue across failed writes.
536        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        // Backward compat: an auth.toml written before the schema bump (token-only)
558        // must still parse, with the new Option fields deserialising as None.
559        let dir = TempDir::new().unwrap();
560        let path = dir.path().join("auth.toml");
561
562        // Write a legacy-style TOML (only `token` field, no user/acquired_at/expires_at).
563        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}