Skip to main content

ai_usagebar/
cache.rs

1//! Per-vendor on-disk cache with atomic writes, TTL checks, and inter-process
2//! locking.
3//!
4//! Mirrors claudebar's cache layout but per-vendor:
5//!   `~/.cache/ai-usagebar/<vendor>/usage.json`         payload
6//!   `~/.cache/ai-usagebar/<vendor>/.stale`             marker (cache is stale)
7//!   `~/.cache/ai-usagebar/<vendor>/.last_error`        HTTP code\nmessage
8//!   `~/.cache/ai-usagebar/<vendor>/.retry_after`       unix seconds; no network before this
9//!   `~/.cache/ai-usagebar/<vendor>/.fetch.lock`        flock target
10//!
11//! Multi-monitor safety: callers should `acquire_lock()` before the refresh+
12//! fetch window, mirroring claudebar:402-407's `exec 9>"$_lockfile" / flock`.
13
14use std::fs::{self, File, OpenOptions};
15use std::io::{Read, Write};
16use std::path::{Path, PathBuf};
17use std::time::{Duration, SystemTime};
18
19use fs2::FileExt;
20
21use crate::error::{AUTH_FAILURE_MESSAGE, AppError, Result};
22
23/// Default TTL — claudebar's `CACHE_TTL=60`.
24pub const DEFAULT_TTL: Duration = Duration::from_secs(60);
25
26/// Maximum staleness before we refuse to serve cached data even on failure.
27/// Mirrors claudebar's `WEEKLY_WINDOW` (7 days).
28pub const MAX_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
29
30/// How long a vendor is left alone after it answered HTTP 429. Without this
31/// every 60 s poll re-hit a rate-limited endpoint, which only extends the
32/// limit; five minutes is long enough for the common per-minute windows to
33/// roll over and short enough that the bar recovers unattended.
34pub const RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(5 * 60);
35
36/// Per-vendor cache directory and helper API.
37///
38/// Construct with [`Cache::for_vendor`]; the directory is created lazily.
39#[derive(Clone)]
40pub struct Cache {
41    dir: PathBuf,
42    /// The key the fetch under this cache uses, attached with
43    /// [`Cache::redacting`]: a `.last_error` that quotes it is redacted at
44    /// the first point of writing, before the provider's answer is persisted.
45    redacting: Option<String>,
46}
47
48impl std::fmt::Debug for Cache {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        // The redacting secret is the API key itself: report that it is
51        // attached, never what it is.
52        f.debug_struct("Cache")
53            .field("dir", &self.dir)
54            .field("redacting", &self.redacting.is_some())
55            .finish()
56    }
57}
58
59impl Cache {
60    /// Build a cache rooted at `~/.cache/ai-usagebar/<vendor>` (or under
61    /// `$XDG_CACHE_HOME` when set).
62    pub fn for_vendor(vendor: &str) -> Result<Self> {
63        let base = xdg_cache_dir()?.join("ai-usagebar").join(vendor);
64        Ok(Self {
65            dir: base,
66            redacting: None,
67        })
68    }
69
70    /// Cache for a specific named account of a vendor, rooted at
71    /// `~/.cache/ai-usagebar/<vendor>/<label>`. Only *extra* accounts use
72    /// this; the default account keeps [`Cache::for_vendor`] so its path never
73    /// moves (issue #14, back-compat rule 2).
74    pub fn for_vendor_account(vendor: &str, label: &str) -> Result<Self> {
75        let base = xdg_cache_dir()?
76            .join("ai-usagebar")
77            .join(vendor)
78            .join(label);
79        Ok(Self {
80            dir: base,
81            redacting: None,
82        })
83    }
84
85    /// Cache rooted at an arbitrary directory — for tests.
86    pub fn at(path: PathBuf) -> Self {
87        Self {
88            dir: path,
89            redacting: None,
90        }
91    }
92
93    /// Attach the key the fetch under this cache uses, so a `.last_error`
94    /// that the provider's answer quotes is redacted at the first point of
95    /// writing: [`Self::write_last_error`] strips it before the control-char
96    /// cleanup and before the atomic write, and returns the redacted pair so
97    /// the in-memory copy and the file agree. The secret is held without a
98    /// `Debug` that prints it.
99    pub(crate) fn redacting(mut self, secret: &str) -> Self {
100        self.redacting = Some(secret.to_string());
101        self
102    }
103
104    /// Ensure the directory exists. Safe to call repeatedly.
105    pub fn ensure_dir(&self) -> Result<()> {
106        fs::create_dir_all(&self.dir).map_err(|e| AppError::io_at(&self.dir, e))
107    }
108
109    pub fn dir(&self) -> &Path {
110        &self.dir
111    }
112
113    pub fn payload_path(&self) -> PathBuf {
114        self.dir.join("usage.json")
115    }
116    pub fn stale_path(&self) -> PathBuf {
117        self.dir.join(".stale")
118    }
119    pub fn last_error_path(&self) -> PathBuf {
120        self.dir.join(".last_error")
121    }
122    pub fn lock_path(&self) -> PathBuf {
123        self.dir.join(".fetch.lock")
124    }
125    /// Rate-limit backoff marker: unix epoch seconds (plain decimal text)
126    /// before which no request should be made to this vendor.
127    pub fn retry_after_path(&self) -> PathBuf {
128        self.dir.join(".retry_after")
129    }
130
131    /// Age of the payload (`None` if it doesn't exist). Used by the widget to
132    /// decide whether the 60s cache window applies.
133    pub fn payload_age(&self) -> Option<Duration> {
134        let meta = fs::metadata(self.payload_path()).ok()?;
135        let mtime = meta.modified().ok()?;
136        SystemTime::now().duration_since(mtime).ok()
137    }
138
139    /// Returns the cached payload only if it is younger than `ttl`. Used as
140    /// the fast path in `_fetch_usage` (claudebar:343-349).
141    ///
142    /// This is the **one pre-network hook shared by every vendor**: each
143    /// `fetch_snapshot` calls it before opening a connection and records HTTP
144    /// failures through [`Cache::write_last_error`]. That makes this the single
145    /// place a cross-vendor request policy can live without nineteen private
146    /// copies drifting apart — which is why the rate-limit backoff is applied
147    /// here rather than in each vendor.
148    ///
149    /// Policy, in order:
150    /// 1. While a 429 backoff is armed ([`Cache::backoff_remaining_at`]), no
151    ///    request is made. A payload still inside [`MAX_STALE`] is served as
152    ///    the answer, TTL notwithstanding, so the bar keeps its last good figure
153    ///    without touching the network. With nothing worth showing this returns
154    ///    an [`AppError::Http`] with status 429 whose body names the time until
155    ///    the next attempt; the vendor's `?` propagates it and the network is
156    ///    never reached. `.last_error` is left as the vendor wrote it. A
157    ///    wire-only read (`ttl = 0`) never serves a payload in either branch:
158    ///    its answer must be a reading made now, and one left by another
159    ///    producer is not. Claude Desktop's prepare also reads with a zero
160    ///    TTL and refuses a cached reading either way; under a backoff it now
161    ///    gets the 429 that says when to retry.
162    /// 2. Otherwise the ordinary TTL check runs unchanged.
163    pub fn fresh_payload(&self, ttl: Duration) -> Result<Option<Vec<u8>>> {
164        self.fresh_payload_at(ttl, SystemTime::now())
165    }
166
167    /// [`Cache::fresh_payload`] with an injected clock for the backoff check.
168    /// The TTL comparison still reads the payload's mtime against the real
169    /// clock via [`Cache::payload_age`].
170    pub fn fresh_payload_at(&self, ttl: Duration, now: SystemTime) -> Result<Option<Vec<u8>>> {
171        if let Some(remaining) = self.backoff_remaining_at(now) {
172            // The wire-only read skips the backoff's serve branch: a payload
173            // another producer left is not a reading made under the key this
174            // caller just wrote, even though the bar's ordinary callers keep
175            // their last good figure.
176            if !ttl.is_zero() && self.payload_age().is_some_and(|age| age <= MAX_STALE) {
177                return self.read_payload().map(Some);
178            }
179            return Err(AppError::Http {
180                status: 429,
181                body: format!("rate limited; next attempt in {}", human_backoff(remaining)),
182            });
183        }
184        let Some(age) = self.payload_age() else {
185            return Ok(None);
186        };
187        if age < ttl {
188            self.read_payload().map(Some)
189        } else {
190            Ok(None)
191        }
192    }
193
194    /// Arm the rate-limit backoff: no request until `now + RATE_LIMIT_BACKOFF`.
195    /// Best-effort, never errors — a cache dir that cannot be written costs a
196    /// retry, not a crash.
197    pub fn note_rate_limit_at(&self, now: SystemTime) {
198        let until = now + RATE_LIMIT_BACKOFF;
199        let secs = until
200            .duration_since(SystemTime::UNIX_EPOCH)
201            .map(|d| d.as_secs())
202            .unwrap_or(0);
203        let _ = atomic_write(&self.retry_after_path(), secs.to_string().as_bytes());
204    }
205
206    /// Best-effort removal of the backoff marker. A successful payload write
207    /// and an explicit `clear_last_error` both end the backoff.
208    pub fn clear_backoff(&self) {
209        let _ = fs::remove_file(self.retry_after_path());
210    }
211
212    /// Time left on an armed backoff, as of `now`. `None` when the marker is
213    /// missing, unparseable, or already in the past — a corrupt marker must
214    /// never pin a vendor offline.
215    pub fn backoff_remaining_at(&self, now: SystemTime) -> Option<Duration> {
216        let raw = fs::read_to_string(self.retry_after_path()).ok()?;
217        let secs = raw.trim().parse::<u64>().ok()?;
218        let until = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs))?;
219        let remaining = until.duration_since(now).ok()?;
220        if remaining.is_zero() {
221            None
222        } else {
223            Some(remaining)
224        }
225    }
226
227    /// [`Cache::backoff_remaining_at`] against the real clock.
228    pub fn backoff_remaining(&self) -> Option<Duration> {
229        self.backoff_remaining_at(SystemTime::now())
230    }
231
232    /// Read the payload regardless of age. `Err` if the file exists but is
233    /// unreadable; `Ok(None)` if it just doesn't exist.
234    ///
235    /// Prefer [`Cache::fallback_payload`] on failure paths — this one imposes
236    /// no age limit, so it will happily hand back a month-old figure.
237    pub fn maybe_payload(&self) -> Result<Option<Vec<u8>>> {
238        if !self.payload_path().exists() {
239            return Ok(None);
240        }
241        self.read_payload().map(Some)
242    }
243
244    /// Payload for the *failure* path: the last good value, but only while it
245    /// is still worth showing. Beyond `max_stale` this returns `Ok(None)` so
246    /// the caller surfaces the real error instead of presenting week-old
247    /// numbers as if they were current — a bar that silently freezes on
248    /// history is worse than one that says it cannot reach the API.
249    pub fn fallback_payload(&self, max_stale: Duration) -> Result<Option<Vec<u8>>> {
250        let Some(age) = self.payload_age() else {
251            return Ok(None);
252        };
253        if age > max_stale {
254            return Ok(None);
255        }
256        self.read_payload().map(Some)
257    }
258
259    fn read_payload(&self) -> Result<Vec<u8>> {
260        let p = self.payload_path();
261        let mut f = File::open(&p).map_err(|e| AppError::io_at(&p, e))?;
262        let mut buf = Vec::new();
263        f.read_to_end(&mut buf)
264            .map_err(|e| AppError::io_at(&p, e))?;
265        Ok(buf)
266    }
267
268    /// Atomically write a new payload. Uses `tempfile + persist` (POSIX
269    /// rename), matching claudebar's `mktemp + mv` invariant.
270    ///
271    /// A cache attached to a key via [`Cache::redacting`] redacts the
272    /// payload too: a provider's *successful* answer can quote the key the
273    /// fetch was sent with (Z.AI carries `msg` inside an HTTP 200), and the
274    /// payload is the second sink. See [`redact_payload`]: a JSON payload is
275    /// redacted on its decoded strings (an escaped echo counts, and numbers
276    /// and structure are never touched); a payload that is not JSON and
277    /// quotes the key is not persisted at all — the fetch still returns its
278    /// in-memory value, and the cache is left exactly as it was. The
279    /// partial-echo heuristic does not apply here: a payload is data, and
280    /// replacing its whole text would destroy it. Caches without an
281    /// attached secret are untouched.
282    pub fn write_payload(&self, bytes: &[u8]) -> Result<()> {
283        let owned;
284        let bytes = match self
285            .redacting
286            .as_deref()
287            .map(|secret| redact_payload(bytes, secret))
288        {
289            None | Some(PayloadRedaction::Keep) => bytes,
290            Some(PayloadRedaction::Replace(redacted)) => {
291                owned = redacted;
292                &owned
293            }
294            Some(PayloadRedaction::Skip) => return Ok(()),
295        };
296        // The parse above sees one value per object key and stops at a depth
297        // limit, so a duplicate key or a deep document can hide an echo from
298        // it. Before anything is written, every JSON string literal in the
299        // bytes is decoded and checked (a payload that is not JSON was
300        // already checked for the literal secret above).
301        if self
302            .redacting
303            .as_deref()
304            .is_some_and(|secret| !secret.is_empty() && json_strings_contain(bytes, secret))
305        {
306            return Ok(());
307        }
308        self.ensure_dir()?;
309        let mut tmp = tempfile::Builder::new()
310            .prefix(".usage.")
311            .tempfile_in(&self.dir)
312            .map_err(|e| AppError::io_at(&self.dir, e))?;
313        tmp.write_all(bytes)
314            .map_err(|e| AppError::io_at(tmp.path(), e))?;
315        tmp.as_file_mut()
316            .sync_all()
317            .map_err(|e| AppError::io_at(tmp.path(), e))?;
318        tmp.persist(self.payload_path())
319            .map_err(|e| AppError::io_at(self.payload_path(), e.error))?;
320        // A successful write clears any stale marker, and a successful
321        // response is proof the rate limit has lifted.
322        let _ = fs::remove_file(self.stale_path());
323        let _ = fs::remove_file(self.last_error_path());
324        self.clear_backoff();
325        Ok(())
326    }
327
328    /// Mark the cache as stale. Idempotent.
329    pub fn mark_stale(&self) {
330        let _ = self.ensure_dir();
331        let _ = File::create(self.stale_path());
332    }
333
334    pub fn is_stale(&self) -> bool {
335        self.stale_path().exists()
336    }
337
338    /// Drop the payload and its sidecars when the credential behind this cache
339    /// changed hands: they describe the previous login, and a fresh payload
340    /// would be served as the new one's. Best-effort, like the other markers.
341    pub fn forget(&self) {
342        let _ = fs::remove_file(self.payload_path());
343        let _ = fs::remove_file(self.stale_path());
344        let _ = fs::remove_file(self.last_error_path());
345        self.clear_backoff();
346    }
347
348    /// [`Cache::forget`] with the removals surfaced: the same files are
349    /// removed, and the first error that is not `NotFound` is returned. A
350    /// missing file is not a failure. The key check uses this so an
351    /// invalidation it could not prove (a read-only cache directory) cannot
352    /// be mistaken for one: a payload left in place would be served as a
353    /// reading made under the key just written.
354    pub fn try_forget(&self) -> std::io::Result<()> {
355        for path in [
356            self.payload_path(),
357            self.stale_path(),
358            self.last_error_path(),
359            self.retry_after_path(),
360        ] {
361            match fs::remove_file(&path) {
362                Ok(()) => {}
363                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
364                Err(error) => return Err(error),
365            }
366        }
367        Ok(())
368    }
369
370    /// Rewrite the `.last_error` in place with the scrubbed content — the
371    /// key check's last resort when the final [`Cache::try_forget`] fails:
372    /// the file already holds the provider's answer, which may name the
373    /// key. Opens the existing file for writing with truncate and never
374    /// creates one: a cache directory may be read-only for creation while
375    /// the file itself is still writable. A missing file is not a failure —
376    /// there is nothing to scrub — and so is content the scrub leaves
377    /// unchanged.
378    pub fn scrub_last_error(&self, scrub: impl Fn(&str) -> String) -> std::io::Result<()> {
379        let path = self.last_error_path();
380        let original = match fs::read_to_string(&path) {
381            Ok(original) => original,
382            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
383            Err(error) => return Err(error),
384        };
385        let scrubbed = scrub(&original);
386        if scrubbed == original {
387            return Ok(());
388        }
389        let mut file = fs::OpenOptions::new()
390            .write(true)
391            .truncate(true)
392            .open(&path)?;
393        file.write_all(scrubbed.as_bytes())
394    }
395
396    /// Write the `.last_error` marker — first line `code`, everything after it
397    /// `msg`. Best-effort, never errors (matches claudebar:478-486 which
398    /// silently continues if the cache dir isn't writable).
399    ///
400    /// **Returns exactly what was written**, so a caller that also puts the
401    /// failure in its [`crate::vendor::VendorOutcome`] can hand over this pair
402    /// instead of deriving a second one from the raw body. The two must not be
403    /// computed separately: persisting a redacted message while the in-memory
404    /// copy kept the original is how a `401` body reached the widget tooltip on
405    /// the one run that had a warm cache to fall back on. Callers that only
406    /// persist can keep ignoring the return.
407    pub fn write_last_error(&self, code: u16, msg: &str) -> (u16, String) {
408        let _ = self.ensure_dir();
409        let path = self.last_error_path();
410        // Authentication failure bodies routinely include account identifiers or
411        // partial credential details. Do not persist them; other status bodies
412        // remain useful diagnostics after their usual control-char cleanup.
413        let msg = if matches!(code, 401 | 403) {
414            AUTH_FAILURE_MESSAGE
415        } else {
416            msg
417        };
418        // A provider can quote the key the fetch was sent with in its answer
419        // (Z.AI puts the failure reason, and with it the key, in `msg` on an
420        // HTTP 200). The first point of writing is here, so a cache attached
421        // to that key redacts the message before the cleanup below and before
422        // the write, and the pair returned is the redacted one.
423        let msg = if let Some(secret) = &self.redacting {
424            crate::display::redact_secret(msg, secret)
425        } else {
426            msg.to_string()
427        };
428        let msg = crate::display::sanitize_untrusted_field(&msg);
429        let body = format!("{code}\n{msg}");
430        let _ = atomic_write(&path, body.as_bytes());
431        if code == 429 {
432            self.note_rate_limit_at(SystemTime::now());
433        }
434        (code, msg)
435    }
436
437    /// Best-effort removal of the `.last_error` marker, and of the backoff
438    /// that a 429 among those errors may have armed.
439    pub fn clear_last_error(&self) {
440        let _ = fs::remove_file(self.last_error_path());
441        self.clear_backoff();
442    }
443
444    pub fn read_last_error(&self) -> Option<(u16, String)> {
445        let raw = fs::read_to_string(self.last_error_path()).ok()?;
446        // The message is *everything* past the first newline, not just the next
447        // line: vendors store the raw HTTP body here and those are routinely
448        // multi-line JSON, so taking one line truncated the user's diagnostic.
449        // Files from before this fix parse unchanged — the writer always framed
450        // them this way, only the reader threw the tail away.
451        let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
452        Some((code.parse::<u16>().ok()?, msg.to_string()))
453    }
454}
455
456/// Render a backoff remainder for a tooltip: seconds below a minute, whole
457/// minutes rounded *up* above it (`4m01s` reads as `5m` — promising less
458/// wait than the real one would make the next poll look broken), and hours
459/// once the minutes pass sixty (`1h 2m`, `1h`).
460pub(crate) fn human_backoff(remaining: Duration) -> String {
461    let secs = remaining.as_secs();
462    if secs < 60 {
463        return format!("{secs}s");
464    }
465    let minutes = secs.div_ceil(60);
466    let (hours, minutes) = (minutes / 60, minutes % 60);
467    match (hours, minutes) {
468        (0, m) => format!("{m}m"),
469        (h, 0) => format!("{h}h"),
470        (h, m) => format!("{h}h {m}m"),
471    }
472}
473
474/// Acquire an exclusive flock on `path`, blocking up to `timeout`.
475/// Returned guard releases the lock on drop.
476///
477/// The flock file is created if missing, but its content is unused — only
478/// the lock matters.
479/// Async wrapper around [`acquire_lock`].
480///
481/// The blocking version parks the calling thread in a sleep loop for up to
482/// `timeout`. On a current-thread runtime — which is what the TUI uses — that
483/// stalls *everything*: keyboard input, the refresh timer, and every other
484/// vendor's in-flight request. Running the wait on the blocking pool keeps the
485/// reactor free while a contended lock is waited on.
486pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
487    let path = path.to_path_buf();
488    tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
489        .await
490        .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
491}
492
493pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
494    if let Some(parent) = path.parent() {
495        fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
496    }
497    let f = OpenOptions::new()
498        .create(true)
499        .read(true)
500        .write(true)
501        .truncate(false)
502        .open(path)
503        .map_err(|e| AppError::io_at(path, e))?;
504
505    wait_for_lock(path, timeout, || f.try_lock_exclusive())?;
506    Ok(LockGuard { file: f })
507}
508
509/// Inject only the lock syscall so unsupported filesystems are testable
510/// without touching a real user's credentials or requiring a WSL mount.
511fn wait_for_lock(
512    path: &Path,
513    timeout: Duration,
514    mut try_lock: impl FnMut() -> std::io::Result<()>,
515) -> Result<()> {
516    let deadline = std::time::Instant::now() + timeout;
517    loop {
518        match try_lock() {
519            Ok(()) => return Ok(()),
520            Err(error)
521                if error.kind() == std::io::ErrorKind::WouldBlock
522                    || error.raw_os_error() == fs2::lock_contended_error().raw_os_error() =>
523            {
524                if std::time::Instant::now() >= deadline {
525                    return Err(AppError::Other(format!(
526                        "cache lock timeout after {:?}",
527                        timeout
528                    )));
529                }
530                std::thread::sleep(Duration::from_millis(50));
531            }
532            Err(error) => return Err(AppError::io_at(path, error)),
533        }
534    }
535}
536
537/// Releases the flock on drop. Holding this across an `.await` is fine as
538/// long as you don't move it across tasks (we always use it in `tokio::main`
539/// on a single thread).
540pub struct LockGuard {
541    file: File,
542}
543
544impl Drop for LockGuard {
545    fn drop(&mut self) {
546        let _ = FileExt::unlock(&self.file);
547    }
548}
549
550/// Atomic write helper used by `write_last_error`. Public for vendors that
551/// need to write small sidecar files (credentials, etc.).
552pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
553    let dir = path.parent().ok_or_else(|| {
554        AppError::Other(format!(
555            "atomic_write: path has no parent: {}",
556            path.display()
557        ))
558    })?;
559    fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
560    let mut tmp = tempfile::Builder::new()
561        .prefix(".tmp.")
562        .tempfile_in(dir)
563        .map_err(|e| AppError::io_at(dir, e))?;
564    tmp.write_all(bytes)
565        .map_err(|e| AppError::io_at(tmp.path(), e))?;
566    tmp.as_file_mut()
567        .sync_all()
568        .map_err(|e| AppError::io_at(tmp.path(), e))?;
569    tmp.persist(path)
570        .map_err(|e| AppError::io_at(path, e.error))?;
571    Ok(())
572}
573
574/// Whether [`Cache::write_payload`] may redact the attached secret at the
575/// byte level: the payload is the raw JSON bytes the provider sent, and a
576/// replacement is exact and structure-safe only when the secret is built
577/// from characters that cannot change inside a JSON string — no quote that
578/// closes the string, no backslash that escapes, no control that must be
579/// escaped, and no non-ASCII whose UTF-8 bytes a byte-level scan could
580/// straddle.
581/// What [`Cache::write_payload`] persists for a payload and the secret
582/// attached to the cache.
583#[derive(Debug, PartialEq, Eq)]
584enum PayloadRedaction {
585    /// The payload does not quote the secret: write it as it arrived.
586    Keep,
587    /// A JSON payload whose strings quoted the secret, re-serialized with
588    /// every occurrence replaced by `[key]`.
589    Replace(Vec<u8>),
590    /// The payload quotes the secret where it cannot be replaced safely (it
591    /// is not JSON, or an object key holds it): do not persist it.
592    Skip,
593}
594
595/// Decide on the decoded value, never on the raw bytes: a JSON string may
596/// carry the secret escaped (`\u0074…`, a quote as `\"`), and a byte-level
597/// replace could also hit a number that happens to equal a short secret.
598/// So a JSON payload is parsed, every string value with the secret gets it
599/// replaced, and the document is re-serialized only when something changed;
600/// numbers and structure are never touched. A payload that is not JSON is
601/// only checked for the literal secret, and skipped when it holds it.
602fn redact_payload(bytes: &[u8], secret: &str) -> PayloadRedaction {
603    if secret.is_empty() {
604        return PayloadRedaction::Keep;
605    }
606    let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
607        return if find_bytes(bytes, secret.as_bytes()).is_some() {
608            PayloadRedaction::Skip
609        } else {
610            PayloadRedaction::Keep
611        };
612    };
613    let mut changed = false;
614    if !redact_json_strings(&mut value, secret, &mut changed) {
615        return PayloadRedaction::Skip;
616    }
617    if !changed {
618        return PayloadRedaction::Keep;
619    }
620    match serde_json::to_vec(&value) {
621        Ok(redacted) => PayloadRedaction::Replace(redacted),
622        Err(_) => PayloadRedaction::Skip,
623    }
624}
625
626/// Whether any JSON string literal in `bytes`, decoded (escapes, `\u`
627/// surrogate pairs), contains `secret`. A scan over the raw text rather than
628/// a parse: it sees every literal, duplicates and any depth included, and
629/// never fails on a document a parser would refuse.
630fn json_strings_contain(bytes: &[u8], secret: &str) -> bool {
631    let mut i = 0;
632    while i < bytes.len() {
633        if bytes[i] != b'"' {
634            i += 1;
635            continue;
636        }
637        i += 1;
638        let mut text = String::new();
639        let mut run_start = i;
640        let mut pending_high: Option<u16> = None;
641        while i < bytes.len() && bytes[i] != b'"' {
642            if bytes[i] != b'\\' {
643                i += 1;
644                continue;
645            }
646            text.push_str(&String::from_utf8_lossy(&bytes[run_start..i]));
647            let Some(&escape) = bytes.get(i + 1) else {
648                i = bytes.len();
649                run_start = i;
650                break;
651            };
652            i += 2;
653            let decoded = match escape {
654                b'n' => Some('\n'),
655                b't' => Some('\t'),
656                b'r' => Some('\r'),
657                b'b' => Some('\u{8}'),
658                b'f' => Some('\u{c}'),
659                b'u' => {
660                    let unit = bytes
661                        .get(i..i + 4)
662                        .and_then(|hex| std::str::from_utf8(hex).ok())
663                        .and_then(|hex| u16::from_str_radix(hex, 16).ok());
664                    i = (i + 4).min(bytes.len());
665                    match (unit, pending_high.take()) {
666                        (Some(low @ 0xDC00..=0xDFFF), Some(high)) => char::from_u32(
667                            0x10000
668                                + ((u32::from(high) - 0xD800) << 10)
669                                + (u32::from(low) - 0xDC00),
670                        ),
671                        (Some(high @ 0xD800..=0xDBFF), _) => {
672                            pending_high = Some(high);
673                            None
674                        }
675                        (Some(unit), _) => char::from_u32(u32::from(unit)),
676                        (None, _) => None,
677                    }
678                }
679                other => Some(char::from(other)),
680            };
681            if let Some(ch) = decoded {
682                text.push(ch);
683            }
684            run_start = i;
685        }
686        text.push_str(&String::from_utf8_lossy(&bytes[run_start..i]));
687        if text.contains(secret) {
688            return true;
689        }
690        i += 1;
691    }
692    false
693}
694
695/// Replace the secret in every string of `value`; `false` when an object
696/// key holds it (a key cannot be rewritten without changing the data's
697/// shape, so the payload is skipped instead).
698fn redact_json_strings(value: &mut serde_json::Value, secret: &str, changed: &mut bool) -> bool {
699    match value {
700        serde_json::Value::String(text) if text.contains(secret) => {
701            *text = text.replace(secret, "[key]");
702            *changed = true;
703            true
704        }
705        serde_json::Value::Array(items) => items
706            .iter_mut()
707            .all(|item| redact_json_strings(item, secret, changed)),
708        serde_json::Value::Object(map) => {
709            if map.keys().any(|key| key.contains(secret)) {
710                return false;
711            }
712            map.values_mut()
713                .all(|item| redact_json_strings(item, secret, changed))
714        }
715        _ => true,
716    }
717}
718
719fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
720    if needle.is_empty() {
721        return Some(0);
722    }
723    haystack.windows(needle.len()).position(|w| w == needle)
724}
725
726pub(crate) fn xdg_cache_dir() -> Result<PathBuf> {
727    directories::BaseDirs::new()
728        .map(|b| b.cache_dir().to_path_buf())
729        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
730}
731
732/// The user's home directory, resolved cross-platform via `directories`
733/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
734///
735/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
736/// files from fixed dotfiles under `$HOME`; they share this resolver the same
737/// way they already share [`atomic_write`], so home resolution lives in one
738/// place rather than being reimplemented per vendor.
739pub fn home_dir() -> Result<PathBuf> {
740    directories::BaseDirs::new()
741        .map(|b| b.home_dir().to_path_buf())
742        .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
743}
744
745/// The identity a vendor cache is scoped to: the first 8 bytes (16 hex chars)
746/// of the SHA-256 of the credential. A re-login changes it, so one login's
747/// figures are never served as another's; the secret itself is never stored.
748/// Existing cache files carry this exact value, so the truncation is a
749/// compatibility contract, not a tunable.
750pub fn fingerprint_of(secret: &str) -> String {
751    use sha2::{Digest, Sha256};
752    use std::fmt::Write as _;
753
754    let digest = Sha256::digest(secret.as_bytes());
755    let mut hex = String::with_capacity(16);
756    for byte in digest.iter().take(8) {
757        let _ = write!(hex, "{byte:02x}");
758    }
759    hex
760}
761
762/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
763/// it. [`atomic_write`] replaces its destination via rename, which on Windows
764/// fails while the destination is held open (as a live `NamedTempFile` handle
765/// would be) — so tests that exercise a write-back must target a closed file.
766/// Returns the dir (the caller keeps it alive) and the file's path; the file
767/// exists only when `contents` is given.
768#[cfg(test)]
769pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
770    let dir = tempfile::TempDir::new().unwrap();
771    let path = dir.path().join(name);
772    if let Some(c) = contents {
773        std::fs::write(&path, c).unwrap();
774    }
775    (dir, path)
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781    use tempfile::TempDir;
782
783    fn fixture() -> (TempDir, Cache) {
784        let td = TempDir::new().unwrap();
785        let cache = Cache::at(td.path().join("anthropic"));
786        cache.ensure_dir().unwrap();
787        (td, cache)
788    }
789
790    #[test]
791    fn ensure_dir_is_idempotent() {
792        let (_td, cache) = fixture();
793        cache.ensure_dir().unwrap();
794        cache.ensure_dir().unwrap();
795        assert!(cache.dir().is_dir());
796    }
797
798    #[test]
799    fn write_then_read_round_trip() {
800        let (_td, cache) = fixture();
801        cache.write_payload(b"hello world").unwrap();
802        let got = cache.maybe_payload().unwrap();
803        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
804    }
805
806    #[test]
807    fn fingerprint_is_the_first_eight_sha256_bytes_in_lower_hex() {
808        // SHA-256("abc") = ba7816bf 8f01cfea 414140de ... — pinned because
809        // existing on-disk caches are keyed by exactly this value.
810        assert_eq!(fingerprint_of("abc"), "ba7816bf8f01cfea");
811        assert_eq!(fingerprint_of("").len(), 16);
812        assert_ne!(fingerprint_of("a"), fingerprint_of("b"));
813    }
814
815    #[test]
816    fn forget_drops_the_payload_and_its_sidecars() {
817        let (_td, cache) = fixture();
818        cache.write_payload(b"previous login").unwrap();
819        cache.mark_stale();
820        cache.write_last_error(401, "expired");
821        cache.note_rate_limit_at(SystemTime::now());
822        cache.forget();
823        assert!(cache.maybe_payload().unwrap().is_none());
824        assert!(!cache.is_stale());
825        assert!(cache.read_last_error().is_none());
826        assert!(cache.backoff_remaining().is_none());
827        assert!(cache.dir().is_dir());
828    }
829
830    #[test]
831    fn try_forget_removes_the_same_files_and_ignores_absent_ones() {
832        let (_td, cache) = fixture();
833        cache.write_payload(b"previous login").unwrap();
834        cache.mark_stale();
835        cache.write_last_error(401, "expired");
836        cache.note_rate_limit_at(SystemTime::now());
837        // Same files as `forget`, and a missing file is not a failure.
838        cache.try_forget().unwrap();
839        assert!(cache.maybe_payload().unwrap().is_none());
840        assert!(!cache.is_stale());
841        assert!(cache.read_last_error().is_none());
842        assert!(cache.backoff_remaining().is_none());
843        // A second pass over the now-empty directory still succeeds.
844        cache.try_forget().unwrap();
845    }
846
847    #[test]
848    #[cfg(unix)]
849    fn try_forget_surfaces_the_first_error_that_is_not_not_found() {
850        use std::os::unix::fs::PermissionsExt;
851        let (_td, cache) = fixture();
852        cache.write_payload(b"previous login").unwrap();
853        cache.write_last_error(0, "still here");
854        // A read-only directory cannot remove its files: `forget` would
855        // swallow that and leave the old payload to be served, but
856        // `try_forget` must hand the failure back.
857        let dir = cache.dir().to_path_buf();
858        fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
859        let error = cache.try_forget().unwrap_err();
860        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
861        assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
862        assert!(
863            cache.payload_path().exists(),
864            "the removal must not claim success"
865        );
866    }
867
868    #[test]
869    fn fresh_payload_zero_ttl_never_serves_a_fresh_payload() {
870        let (_td, cache) = fixture();
871        cache.write_payload(b"previous reading").unwrap();
872        // Written this same millisecond: the age is zero, and `age < ttl`
873        // is false for a zero `ttl`, so the ordinary path cannot serve it.
874        assert!(cache.fresh_payload(Duration::ZERO).unwrap().is_none());
875        // ...and neither through the backoff branch, which would otherwise
876        // serve a payload inside MAX_STALE with the TTL "notwithstanding".
877        cache.note_rate_limit_at(SystemTime::now());
878        let error = cache.fresh_payload(Duration::ZERO).unwrap_err();
879        assert!(error.to_string().contains("rate limited"), "{error}");
880        // The ordinary callers keep their behavior: with a positive TTL the
881        // backoff still serves the fresh payload.
882        assert!(cache.fresh_payload(DEFAULT_TTL).unwrap().is_some());
883    }
884
885    #[test]
886    #[cfg(unix)]
887    fn scrub_last_error_rewrites_the_file_in_place_in_a_read_only_dir() {
888        use std::os::unix::fs::PermissionsExt;
889        let (_td, cache) = fixture();
890        cache.write_last_error(0, "key is test-key-value");
891        // The bust this scrub backs up failed: the directory refuses
892        // removals.
893        let dir = cache.dir().to_path_buf();
894        fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
895        cache.try_forget().unwrap_err();
896        // The last resort: the directory refuses new files, but the
897        // existing one is still writable in place.
898        cache
899            .scrub_last_error(|raw| raw.replace("test-key-value", "[key]"))
900            .unwrap();
901        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
902        let raw = fs::read_to_string(cache.last_error_path()).unwrap();
903        assert!(
904            !raw.contains("test-key-value"),
905            "the key must be gone: {raw}"
906        );
907        assert!(raw.contains("[key]"), "{raw}");
908        assert!(raw.starts_with("0\n"), "the code line must survive: {raw}");
909    }
910
911    #[test]
912    fn scrub_last_error_of_a_missing_file_is_not_a_failure() {
913        let (_td, cache) = fixture();
914        cache.scrub_last_error(|raw| raw.to_string()).unwrap();
915        assert!(!cache.last_error_path().exists(), "no file may be created");
916    }
917
918    #[test]
919    fn scrub_last_error_untouched_content_leaves_the_file_alone() {
920        let (_td, cache) = fixture();
921        cache.write_last_error(0, "nothing to hide");
922        let before = cache.last_error_path();
923        let mtime_before = fs::metadata(&before).unwrap().modified().unwrap();
924        cache.scrub_last_error(|raw| raw.to_string()).unwrap();
925        let mtime_after = fs::metadata(&before).unwrap().modified().unwrap();
926        assert_eq!(
927            mtime_before, mtime_after,
928            "an unchanged file must not be rewritten"
929        );
930    }
931
932    #[test]
933    fn maybe_payload_returns_none_when_missing() {
934        let (_td, cache) = fixture();
935        assert!(cache.maybe_payload().unwrap().is_none());
936    }
937
938    #[test]
939    fn fresh_payload_respects_ttl() {
940        let (_td, cache) = fixture();
941        cache.write_payload(b"x").unwrap();
942        // Fresh = within a generous TTL.
943        assert!(
944            cache
945                .fresh_payload(Duration::from_secs(10))
946                .unwrap()
947                .is_some()
948        );
949        // Force "stale" by passing a zero TTL — payload is older than 0s.
950        assert!(
951            cache
952                .fresh_payload(Duration::from_secs(0))
953                .unwrap()
954                .is_none()
955        );
956    }
957
958    #[test]
959    fn write_clears_stale_marker_and_last_error() {
960        let (_td, cache) = fixture();
961        cache.mark_stale();
962        cache.write_last_error(429, "rate limited");
963        assert!(cache.is_stale());
964        assert!(cache.read_last_error().is_some());
965
966        cache.write_payload(b"fresh").unwrap();
967        assert!(!cache.is_stale());
968        assert!(cache.read_last_error().is_none());
969    }
970
971    // ---- rate-limit backoff ------------------------------------------------
972
973    /// A fixed instant well past the epoch so the arithmetic never underflows.
974    fn t0() -> SystemTime {
975        SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
976    }
977
978    fn arm_backoff_at(cache: &Cache, now: SystemTime) {
979        cache.note_rate_limit_at(now);
980        assert!(cache.retry_after_path().exists());
981    }
982
983    #[test]
984    fn a_429_arms_the_backoff_and_other_statuses_do_not() {
985        let (_td, cache) = fixture();
986        cache.write_last_error(500, "upstream down");
987        assert!(cache.backoff_remaining().is_none());
988        assert!(!cache.retry_after_path().exists());
989
990        cache.write_last_error(429, "slow down");
991        let remaining = cache.backoff_remaining().expect("429 must arm the backoff");
992        // Written against the real clock a moment ago: within a few seconds of
993        // the full window, never above it.
994        assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
995        assert!(
996            remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
997            "{remaining:?}"
998        );
999        // The persisted `.last_error` is untouched by the backoff bookkeeping.
1000        assert_eq!(cache.read_last_error(), Some((429, "slow down".into())));
1001    }
1002
1003    #[test]
1004    fn backoff_remaining_counts_down_from_the_injected_clock() {
1005        let (_td, cache) = fixture();
1006        arm_backoff_at(&cache, t0());
1007
1008        assert_eq!(cache.backoff_remaining_at(t0()), Some(RATE_LIMIT_BACKOFF));
1009        assert_eq!(
1010            cache.backoff_remaining_at(t0() + Duration::from_secs(60)),
1011            Some(RATE_LIMIT_BACKOFF - Duration::from_secs(60))
1012        );
1013        // Exactly at expiry and beyond: no backoff.
1014        assert!(
1015            cache
1016                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF)
1017                .is_none()
1018        );
1019        assert!(
1020            cache
1021                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1))
1022                .is_none()
1023        );
1024    }
1025
1026    #[test]
1027    fn during_backoff_with_no_payload_fresh_payload_refuses_the_network() {
1028        let (_td, cache) = fixture();
1029        arm_backoff_at(&cache, t0());
1030
1031        let err = cache
1032            .fresh_payload_at(DEFAULT_TTL, t0() + Duration::from_secs(19))
1033            .expect_err("no payload during backoff must be an error, not a fetch");
1034        match err {
1035            AppError::Http { status, body } => {
1036                assert_eq!(status, 429);
1037                assert!(body.contains("next attempt in"), "{body}");
1038                // 5m − 19s = 4m41s, rounded up to the next minute.
1039                assert!(body.ends_with("5m"), "{body}");
1040            }
1041            other => panic!("expected Http 429, got {other:?}"),
1042        }
1043    }
1044
1045    /// The whole point of the backoff: a vendor that still has a figure keeps
1046    /// showing it instead of re-hitting the limit every poll. The payload is
1047    /// written now with a zero TTL, so the ordinary fast path would reject it
1048    /// as expired; only the backoff branch can be the one returning it.
1049    #[test]
1050    fn during_backoff_an_expired_but_not_stale_payload_is_served() {
1051        let (_td, cache) = fixture();
1052        cache.write_payload(b"last good").unwrap();
1053        arm_backoff_at(&cache, t0());
1054
1055        // Control: without a backoff, TTL 0 means "not fresh".
1056        assert!(
1057            cache
1058                .fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
1059                .unwrap()
1060                .is_none()
1061        );
1062        // Under backoff the same payload is the answer for the ordinary
1063        // callers: a positive TTL keeps its policy unchanged...
1064        assert_eq!(
1065            cache
1066                .fresh_payload_at(DEFAULT_TTL, t0())
1067                .unwrap()
1068                .as_deref(),
1069            Some(&b"last good"[..])
1070        );
1071        // ...but a wire-only read (zero TTL) never serves a payload: its
1072        // answer must be a reading made now, and the backoff still refuses
1073        // the network.
1074        let error = cache.fresh_payload_at(Duration::ZERO, t0()).unwrap_err();
1075        assert!(error.to_string().contains("rate limited"), "{error}");
1076    }
1077
1078    #[test]
1079    fn after_the_backoff_expires_the_ttl_rule_is_back_in_charge() {
1080        let (_td, cache) = fixture();
1081        cache.write_payload(b"x").unwrap();
1082        arm_backoff_at(&cache, t0());
1083        let later = t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1);
1084
1085        assert!(
1086            cache
1087                .fresh_payload_at(Duration::from_secs(10), later)
1088                .unwrap()
1089                .is_some()
1090        );
1091        assert!(
1092            cache
1093                .fresh_payload_at(Duration::ZERO, later)
1094                .unwrap()
1095                .is_none()
1096        );
1097        // And with no payload at all, expiry means a plain "go fetch".
1098        fs::remove_file(cache.payload_path()).unwrap();
1099        assert!(
1100            cache
1101                .fresh_payload_at(DEFAULT_TTL, later)
1102                .unwrap()
1103                .is_none()
1104        );
1105    }
1106
1107    #[test]
1108    fn a_successful_payload_write_clears_the_backoff() {
1109        let (_td, cache) = fixture();
1110        arm_backoff_at(&cache, t0());
1111        assert!(cache.backoff_remaining_at(t0()).is_some());
1112
1113        cache.write_payload(b"fresh").unwrap();
1114        assert!(cache.backoff_remaining_at(t0()).is_none());
1115        assert!(!cache.retry_after_path().exists());
1116    }
1117
1118    #[test]
1119    fn clear_last_error_also_clears_the_backoff() {
1120        let (_td, cache) = fixture();
1121        cache.write_last_error(429, "slow down");
1122        assert!(cache.backoff_remaining().is_some());
1123
1124        cache.clear_last_error();
1125        assert!(cache.backoff_remaining().is_none());
1126        assert!(!cache.retry_after_path().exists());
1127    }
1128
1129    #[test]
1130    fn a_corrupt_retry_after_marker_is_no_backoff() {
1131        let (_td, cache) = fixture();
1132        for raw in ["", "soon", "-5", "1e9", "12 34"] {
1133            fs::write(cache.retry_after_path(), raw).unwrap();
1134            assert!(
1135                cache.backoff_remaining_at(t0()).is_none(),
1136                "{raw:?} must not pin the vendor offline"
1137            );
1138            assert!(cache.fresh_payload_at(DEFAULT_TTL, t0()).unwrap().is_none());
1139        }
1140        // Surrounding whitespace is tolerated, though — a trailing newline is
1141        // the sort of thing a hand edit leaves behind.
1142        let until = t0() + Duration::from_secs(90);
1143        let secs = until
1144            .duration_since(SystemTime::UNIX_EPOCH)
1145            .unwrap()
1146            .as_secs();
1147        fs::write(cache.retry_after_path(), format!("{secs}\n")).unwrap();
1148        assert_eq!(
1149            cache.backoff_remaining_at(t0()),
1150            Some(Duration::from_secs(90))
1151        );
1152    }
1153
1154    #[test]
1155    fn human_backoff_formats_seconds_minutes_and_hours() {
1156        let s = Duration::from_secs;
1157        assert_eq!(human_backoff(s(0)), "0s");
1158        assert_eq!(human_backoff(s(45)), "45s");
1159        assert_eq!(human_backoff(s(59)), "59s");
1160        assert_eq!(human_backoff(s(60)), "1m");
1161        assert_eq!(human_backoff(s(4 * 60)), "4m");
1162        assert_eq!(human_backoff(s(4 * 60 + 1)), "5m");
1163        assert_eq!(human_backoff(s(5 * 60)), "5m");
1164        assert_eq!(human_backoff(s(60 * 60)), "1h");
1165        assert_eq!(human_backoff(s(62 * 60)), "1h 2m");
1166        assert_eq!(human_backoff(s(61 * 60 + 30)), "1h 2m");
1167        assert_eq!(human_backoff(s(2 * 3600)), "2h");
1168    }
1169
1170    #[test]
1171    fn fallback_payload_refuses_a_payload_older_than_the_limit() {
1172        let (_td, cache) = fixture();
1173        cache.write_payload(b"old").unwrap();
1174
1175        // Let the payload acquire real age rather than rewriting its mtime:
1176        // Windows denies reopening the just-persisted file for an attribute
1177        // write, and the boundary being tested is the same either way. The
1178        // margin is ~12x the threshold so filesystem timestamp granularity
1179        // cannot make this flaky.
1180        std::thread::sleep(Duration::from_millis(60));
1181
1182        // Still readable when age is not considered — `maybe_payload` is the
1183        // unbounded reader, which is exactly why failure paths must not use it.
1184        assert!(cache.maybe_payload().unwrap().is_some());
1185
1186        // Past the limit, the failure path gets nothing and the caller has to
1187        // surface the real error. `MAX_STALE` was dead code before this:
1188        // every fallback served history forever.
1189        assert!(
1190            cache
1191                .fallback_payload(Duration::from_millis(5))
1192                .unwrap()
1193                .is_none()
1194        );
1195
1196        // Inside the window it is still served, so the guard is a limit and
1197        // not a blanket refusal.
1198        assert_eq!(
1199            cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
1200            Some(&b"old"[..])
1201        );
1202    }
1203
1204    #[test]
1205    fn last_error_round_trip() {
1206        let (_td, cache) = fixture();
1207        cache.write_last_error(503, "service unavailable");
1208        let (code, msg) = cache.read_last_error().unwrap();
1209        assert_eq!(code, 503);
1210        assert_eq!(msg, "service unavailable");
1211    }
1212
1213    #[test]
1214    fn last_error_with_empty_message_round_trips() {
1215        let (_td, cache) = fixture();
1216        cache.write_last_error(429, "");
1217        let (code, msg) = cache.read_last_error().unwrap();
1218        assert_eq!(code, 429);
1219        assert_eq!(msg, "");
1220    }
1221
1222    #[test]
1223    fn last_error_replaces_401_body_with_credential_neutral_message() {
1224        let (_td, cache) = fixture();
1225        cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
1226
1227        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
1228        assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
1229        assert!(!persisted.contains("PANCEA"));
1230        assert!(!persisted.contains("<credential>"));
1231    }
1232
1233    #[test]
1234    fn last_error_replaces_403_body_with_credential_neutral_message() {
1235        let (_td, cache) = fixture();
1236        cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
1237
1238        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
1239        assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
1240        assert!(!persisted.contains("PANCEA"));
1241        assert!(!persisted.contains("<credential>"));
1242    }
1243
1244    /// The invariant that keeps the displayed message from drifting away from
1245    /// the persisted one: what comes back is what a later run would read from
1246    /// disk, so a caller that shows the return value cannot show anything the
1247    /// cache refused to keep. Asserted for the redacting arm and the ordinary
1248    /// one, since only the first rewrites the message.
1249    #[test]
1250    fn write_last_error_returns_exactly_what_a_later_run_would_read() {
1251        for (code, raw) in [
1252            (401u16, "PANCEA user@example.test <credential>&token"),
1253            (403, "PANCEA account@example.test <credential>&token"),
1254            (429, "rate limited, retry in 60s"),
1255            (500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
1256        ] {
1257            let (_td, cache) = fixture();
1258            let returned = cache.write_last_error(code, raw);
1259            assert_eq!(
1260                returned,
1261                cache.read_last_error().unwrap(),
1262                "returned pair diverged from the persisted one for {code}"
1263            );
1264        }
1265    }
1266
1267    /// The defect recurred once under a second name — six vendors wrote
1268    /// `Some((status, body))` inline and six more built a `diag` local first —
1269    /// so the sweep that fixed the first six missed the rest. This forbids the
1270    /// shape rather than the spelling: a `last_error` pair must come from
1271    /// [`Cache::write_last_error`], which is the only thing that redacts.
1272    ///
1273    /// `error_to_pair` in `cursor`, `kimi` and `kiro` is untouched by this: it
1274    /// redacts on its own and destructures as `(*status, body)`, which is not
1275    /// the borrowed shape a leak takes.
1276    #[test]
1277    fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
1278        let mut sites = Vec::new();
1279        for file in crate::guard::rs_files_in("src") {
1280            if !file.ends_with("fetch.rs") {
1281                continue;
1282            }
1283            let source = std::fs::read_to_string(&file).expect("readable module");
1284            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
1285                if line.contains("(status, body") {
1286                    sites.push(format!("{}:{}", file.display(), n + 1));
1287                }
1288            }
1289        }
1290        assert!(
1291            sites.is_empty(),
1292            "a last_error pair must be the return of `write_last_error`, which \
1293             redacts 401/403 — building one from the raw body puts the response \
1294             body in the widget tooltip. Found: {sites:#?}"
1295        );
1296    }
1297
1298    /// The cold-cache decision — serve a stale figure, or surface the error
1299    /// that caused the refresh to fail — is `outcome::fallback`'s alone. It
1300    /// drifted into two disagreeing generations once, when each vendor owned a
1301    /// copy: five replaced the original error with a generic "no usable cache"
1302    /// while thirteen returned it. `fallback_payload` is the entry point to
1303    /// that decision, so a second caller is a second copy in the making.
1304    #[test]
1305    fn only_the_shared_fallback_reads_the_stale_payload() {
1306        let mut sites = Vec::new();
1307        for file in crate::guard::rs_files_in("src") {
1308            if file.ends_with("outcome.rs") || file.ends_with("cache.rs") {
1309                continue;
1310            }
1311            let source = std::fs::read_to_string(&file).expect("readable module");
1312            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
1313                if line.contains("fallback_payload(") {
1314                    sites.push(format!("{}:{}", file.display(), n + 1));
1315                }
1316            }
1317        }
1318        assert!(
1319            sites.is_empty(),
1320            "reach the stale payload through `outcome::fallback`, which decides \
1321             what a cold cache means for every vendor at once. Found: {sites:#?}"
1322        );
1323    }
1324
1325    /// The bug this closes: the pair handed to the widget was built from the
1326    /// raw body in parallel with the redacted one going to disk, so the run
1327    /// that hit the `401` showed the body and only the *next* run showed the
1328    /// neutral message. The returned pair carries the redaction.
1329    #[test]
1330    fn the_returned_pair_carries_the_auth_redaction() {
1331        for code in [401u16, 403] {
1332            let (_td, cache) = fixture();
1333            let (returned_code, msg) =
1334                cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
1335            assert_eq!(returned_code, code);
1336            assert_eq!(msg, AUTH_FAILURE_MESSAGE);
1337            assert!(!msg.contains("PANCEA"), "{msg}");
1338            assert!(!msg.contains("<credential>"), "{msg}");
1339        }
1340    }
1341
1342    /// A provider that quotes the key a fetch was sent with (Z.AI returns
1343    /// the reason in `msg` on an HTTP 200) must not persist it: the cache
1344    /// attached to that key redacts at the first point of writing — before
1345    /// the control-char cleanup and before the atomic write — and the
1346    /// returned pair is the redacted one, so file and memory agree.
1347    #[test]
1348    fn write_last_error_redacts_the_attached_secret() {
1349        let (_td, cache) = fixture();
1350        let cache = cache.redacting("test-key-value");
1351
1352        let (code, msg) = cache.write_last_error(0, "msg test-key-value");
1353        assert_eq!(code, 0);
1354        assert_eq!(msg, "msg [key]");
1355        assert!(!msg.contains("test-key-value"), "{msg}");
1356        let on_disk = fs::read_to_string(cache.last_error_path()).unwrap();
1357        assert!(!on_disk.contains("test-key-value"), "{on_disk}");
1358        // What a later run would read is exactly what the call returned.
1359        assert_eq!((code, msg), cache.read_last_error().unwrap());
1360
1361        // A partial echo of a key of eight characters or more is the key
1362        // with a missing tail: the whole message is the fixed note.
1363        let key = "abcdefghijklmnop";
1364        let (_td2, cache2) = fixture();
1365        let cache2 = cache2.redacting(key);
1366        let (_, msg) = cache2.write_last_error(0, "bad key abcdefgh…");
1367        assert_eq!(
1368            msg,
1369            "The provider's answer quoted the key, so it is not shown."
1370        );
1371        let on_disk = fs::read_to_string(cache2.last_error_path()).unwrap();
1372        assert!(!on_disk.contains(key), "{on_disk}");
1373
1374        // 401/403 keep the fixed authentication message.
1375        let (code, msg) = cache.write_last_error(401, "msg test-key-value");
1376        assert_eq!(code, 401);
1377        assert_eq!(msg, AUTH_FAILURE_MESSAGE);
1378    }
1379
1380    /// A provider's *successful* answer can quote the key it was sent with
1381    /// (Z.AI carries `msg` inside an HTTP 200): the cache attached to that
1382    /// key redacts the payload at the first point of writing, on its decoded
1383    /// strings — every occurrence is replaced, the JSON survives, and the
1384    /// partial-echo heuristic does not reach the payload (it would destroy
1385    /// data).
1386    #[test]
1387    fn write_payload_redacts_the_attached_secret() {
1388        let (_td, cache) = fixture();
1389        let cache = cache.redacting("test-key-value");
1390
1391        cache
1392            .write_payload(br#"{"msg":"ok test-key-value"}"#)
1393            .unwrap();
1394        let on_disk = fs::read(cache.payload_path()).unwrap();
1395        assert_eq!(on_disk, br#"{"msg":"ok [key]"}"#);
1396
1397        // Every exact occurrence is replaced, not only the first.
1398        let (_td, cache) = fixture();
1399        let cache = cache.redacting("test-key-value");
1400        cache
1401            .write_payload(br#"{"a":"test-key-value","b":"test-key-value"}"#)
1402            .unwrap();
1403        let on_disk = fs::read(cache.payload_path()).unwrap();
1404        assert_eq!(on_disk, br#"{"a":"[key]","b":"[key]"}"#);
1405
1406        // No attached secret: the bytes are persisted as they arrived.
1407        let (_td, cache) = fixture();
1408        let raw = br#"{"msg":"ok test-key-value"}"#;
1409        cache.write_payload(raw).unwrap();
1410        assert_eq!(fs::read(cache.payload_path()).unwrap(), raw);
1411    }
1412
1413    /// The decision is made on decoded strings, not raw bytes: an echo that
1414    /// arrives escaped in valid JSON is still redacted, a secret with a quote
1415    /// is redacted inside its (escaped) string, and a number equal to a
1416    /// short secret is not a string, so the payload is written unchanged.
1417    #[test]
1418    fn write_payload_redacts_escaped_echoes_and_leaves_numbers_alone() {
1419        // `\u0074` is `t`: the bytes never hold the key literally.
1420        let (_td, cache) = fixture();
1421        let cache = cache.redacting("test-key-value");
1422        cache
1423            .write_payload(br#"{"msg":"ok \u0074est-key-value"}"#)
1424            .unwrap();
1425        assert_eq!(
1426            fs::read(cache.payload_path()).unwrap(),
1427            br#"{"msg":"ok [key]"}"#
1428        );
1429
1430        // A quote in the key: valid JSON escapes it, and it is redacted.
1431        let (_td, cache) = fixture();
1432        let cache = cache.redacting("a\"b");
1433        cache.write_payload(br#"{"msg":"ok a\"b"}"#).unwrap();
1434        assert_eq!(
1435            fs::read(cache.payload_path()).unwrap(),
1436            br#"{"msg":"ok [key]"}"#
1437        );
1438
1439        // A short numeric key equal to a number: numbers are not strings,
1440        // so the payload is written exactly as it arrived.
1441        let (_td, cache) = fixture();
1442        let cache = cache.redacting("12");
1443        let raw = br#"{"used":12,"msg":"fine"}"#;
1444        cache.write_payload(raw).unwrap();
1445        assert_eq!(fs::read(cache.payload_path()).unwrap(), raw);
1446    }
1447
1448    /// A payload that is not JSON and quotes the secret cannot be redacted
1449    /// safely, so it is not persisted at all — the fetch still returns its
1450    /// in-memory value, only the cache keeps no payload of that answer — and
1451    /// the rest of the cache is left exactly as it was: an older payload is
1452    /// not clobbered, and the success side effects do not run. An object key
1453    /// that holds the secret is skipped the same way.
1454    #[test]
1455    fn write_payload_skips_a_payload_it_cannot_redact() {
1456        let secret = "test-key-value";
1457
1458        // A cold cache stays cold: no payload file is written.
1459        let (_td, cache) = fixture();
1460        let cache = cache.redacting(secret);
1461        cache.write_payload(b"plain text test-key-value").unwrap();
1462        assert!(
1463            cache.maybe_payload().unwrap().is_none(),
1464            "a non-JSON payload quoting the secret must not be persisted"
1465        );
1466        assert!(!cache.payload_path().exists());
1467
1468        // An existing payload survives untouched, and no side effect ran.
1469        let (_td, cache) = fixture();
1470        let cache = cache.redacting(secret);
1471        cache.write_payload(br#"{"msg":"last good"}"#).unwrap();
1472        cache.mark_stale();
1473        cache.write_last_error(500, "an older failure");
1474        cache.write_payload(br#"{"test-key-value":1}"#).unwrap();
1475        assert_eq!(
1476            fs::read(cache.payload_path()).unwrap(),
1477            br#"{"msg":"last good"}"#
1478        );
1479        assert!(cache.is_stale());
1480        assert!(cache.read_last_error().is_some());
1481    }
1482
1483    /// What the parse cannot see is caught on the bytes before writing: a
1484    /// duplicate key whose first value quotes the secret (the parsed tree
1485    /// keeps only the last value) and an escaped echo inside a document too
1486    /// deep for the parser are both left out of the cache.
1487    #[test]
1488    fn write_payload_skips_an_echo_the_parse_cannot_see() {
1489        let (_td, cache) = fixture();
1490        let cache = cache.redacting("test-key-value");
1491        cache
1492            .write_payload(br#"{"note":"test-key-value","note":"fine","data":1}"#)
1493            .unwrap();
1494        assert!(
1495            !cache.payload_path().exists(),
1496            "a duplicate key hid the echo"
1497        );
1498
1499        let deep = format!(
1500            r#"{{"data":1,"extra":{}"ok \u0074est-key-value"{}}}"#,
1501            "[".repeat(160),
1502            "]".repeat(160)
1503        );
1504        assert!(serde_json::from_str::<serde_json::Value>(&deep).is_err());
1505        let (_td, cache) = fixture();
1506        let cache = cache.redacting("test-key-value");
1507        cache.write_payload(deep.as_bytes()).unwrap();
1508        assert!(
1509            !cache.payload_path().exists(),
1510            "a deep document hid the echo"
1511        );
1512
1513        // The scan decodes escapes and surrogate pairs, and skips numbers.
1514        assert!(json_strings_contain(br#""a\u0062c""#, "abc"));
1515        assert!(json_strings_contain(br#""x \ud83d\ude00 y""#, "\u{1f600}"));
1516        assert!(json_strings_contain(br#""say \"hi\"""#, "\"hi\""));
1517        assert!(!json_strings_contain(br#"{"n":12}"#, "12"));
1518    }
1519
1520    /// The regression this guards: vendors write the raw HTTP body, which is
1521    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
1522    /// showed `{` and dropped the actual API explanation.
1523    #[test]
1524    fn last_error_round_trips_a_multi_line_message() {
1525        let (_td, cache) = fixture();
1526        let body = "{\n  \"error\": \"quota exhausted\",\n  \"retry_after\": 3600\n}";
1527        cache.write_last_error(429, body);
1528
1529        let (code, msg) = cache.read_last_error().unwrap();
1530        assert_eq!(code, 429);
1531        assert_eq!(msg, body);
1532        assert!(
1533            msg.contains("quota exhausted"),
1534            "message was truncated to its first line: {msg:?}"
1535        );
1536    }
1537
1538    #[test]
1539    fn last_error_strips_terminal_controls_before_persisting() {
1540        let (_td, cache) = fixture();
1541        cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
1542
1543        let (code, msg) = cache.read_last_error().unwrap();
1544        assert_eq!(code, 500);
1545        assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
1546        assert!(
1547            msg.contains("Y2FuYXJ5"),
1548            "non-auth diagnostic was not preserved"
1549        );
1550        assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
1551    }
1552
1553    /// A user upgrades with a `.last_error` already on disk; it must still
1554    /// parse. Trailing-newline-free files (the whole marker being just a code)
1555    /// count too — that is the one shape the old `lines()` reader tolerated.
1556    #[test]
1557    fn last_error_reads_files_written_by_the_previous_version() {
1558        let (_td, cache) = fixture();
1559
1560        fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
1561        assert_eq!(
1562            cache.read_last_error(),
1563            Some((503, "service unavailable".into()))
1564        );
1565
1566        fs::write(cache.last_error_path(), "429").unwrap();
1567        assert_eq!(cache.read_last_error(), Some((429, String::new())));
1568
1569        // A non-numeric first line is still no error at all, never a fake 0.
1570        fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
1571        assert!(cache.read_last_error().is_none());
1572    }
1573
1574    #[test]
1575    fn unsupported_lock_preserves_io_error_instead_of_timeout() {
1576        let path = Path::new("synthetic-credentials.lock");
1577        let mut attempts = 0;
1578        let result = wait_for_lock(path, Duration::ZERO, || {
1579            attempts += 1;
1580            Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
1581        });
1582        assert_eq!(attempts, 1);
1583        match result {
1584            Err(AppError::Io {
1585                path: actual,
1586                source,
1587            }) => {
1588                assert_eq!(actual, path);
1589                assert_eq!(source.kind(), std::io::ErrorKind::Unsupported);
1590            }
1591            _ => panic!("unsupported locking must preserve the I/O error"),
1592        }
1593    }
1594
1595    #[test]
1596    fn permission_denied_lock_is_not_retried_as_contention() {
1597        let result = wait_for_lock(Path::new("synthetic.lock"), Duration::ZERO, || {
1598            Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
1599        });
1600        assert!(matches!(result, Err(AppError::Io { source, .. })
1601            if source.kind() == std::io::ErrorKind::PermissionDenied));
1602    }
1603
1604    #[test]
1605    fn contended_lock_still_reports_timeout() {
1606        let result = wait_for_lock(Path::new("synthetic.lock"), Duration::ZERO, || {
1607            Err(fs2::lock_contended_error())
1608        });
1609        assert!(matches!(result, Err(AppError::Other(message)) if message.contains("timeout")));
1610    }
1611
1612    #[test]
1613    fn lock_serializes_concurrent_acquirers() {
1614        // First lock succeeds; while held, a second non-blocking attempt
1615        // should time out quickly.
1616        let (_td, cache) = fixture();
1617        let lock_path = cache.lock_path();
1618        let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
1619
1620        let res = acquire_lock(&lock_path, Duration::from_millis(100));
1621        assert!(matches!(res, Err(AppError::Other(_))));
1622    }
1623
1624    /// The regression this guards: `acquire_lock` parks the thread in a sleep
1625    /// loop, so on the TUI's current-thread runtime a contended lock froze
1626    /// keyboard input, the refresh timer and every other vendor's fetch until
1627    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
1628    /// so unrelated timers must keep firing while the lock is held elsewhere.
1629    #[tokio::test(flavor = "current_thread")]
1630    async fn async_lock_does_not_stall_the_runtime() {
1631        let (_td, cache) = fixture();
1632        let lock_path = cache.lock_path();
1633        let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
1634
1635        // This will wait the full timeout — it can never win the lock.
1636        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
1637
1638        // Meanwhile the runtime must still be able to make progress.
1639        let mut ticks = 0usize;
1640        let ticker = async {
1641            let mut iv = tokio::time::interval(Duration::from_millis(20));
1642            iv.tick().await;
1643            loop {
1644                iv.tick().await;
1645                ticks += 1;
1646            }
1647        };
1648
1649        tokio::select! {
1650            res = waiter => {
1651                // The lock attempt is expected to time out.
1652                assert!(matches!(res, Err(AppError::Other(_))));
1653            }
1654            _ = ticker => unreachable!("the ticker loops forever"),
1655        }
1656        assert!(
1657            ticks > 1,
1658            "runtime was starved while the lock was contended ({ticks} ticks)"
1659        );
1660    }
1661
1662    #[test]
1663    fn atomic_write_creates_parent_dirs() {
1664        let td = TempDir::new().unwrap();
1665        let nested = td.path().join("a/b/c/file.txt");
1666        atomic_write(&nested, b"abc").unwrap();
1667        assert_eq!(fs::read(&nested).unwrap(), b"abc");
1668    }
1669}