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    let deadline = std::time::Instant::now() + timeout;
506    loop {
507        match f.try_lock_exclusive() {
508            Ok(()) => return Ok(LockGuard { file: f }),
509            Err(_) => {
510                if std::time::Instant::now() >= deadline {
511                    return Err(AppError::Other(format!(
512                        "cache lock timeout after {:?}",
513                        timeout
514                    )));
515                }
516                std::thread::sleep(Duration::from_millis(50));
517            }
518        }
519    }
520}
521
522/// Releases the flock on drop. Holding this across an `.await` is fine as
523/// long as you don't move it across tasks (we always use it in `tokio::main`
524/// on a single thread).
525pub struct LockGuard {
526    file: File,
527}
528
529impl Drop for LockGuard {
530    fn drop(&mut self) {
531        let _ = FileExt::unlock(&self.file);
532    }
533}
534
535/// Atomic write helper used by `write_last_error`. Public for vendors that
536/// need to write small sidecar files (credentials, etc.).
537pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
538    let dir = path.parent().ok_or_else(|| {
539        AppError::Other(format!(
540            "atomic_write: path has no parent: {}",
541            path.display()
542        ))
543    })?;
544    fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
545    let mut tmp = tempfile::Builder::new()
546        .prefix(".tmp.")
547        .tempfile_in(dir)
548        .map_err(|e| AppError::io_at(dir, e))?;
549    tmp.write_all(bytes)
550        .map_err(|e| AppError::io_at(tmp.path(), e))?;
551    tmp.as_file_mut()
552        .sync_all()
553        .map_err(|e| AppError::io_at(tmp.path(), e))?;
554    tmp.persist(path)
555        .map_err(|e| AppError::io_at(path, e.error))?;
556    Ok(())
557}
558
559/// Whether [`Cache::write_payload`] may redact the attached secret at the
560/// byte level: the payload is the raw JSON bytes the provider sent, and a
561/// replacement is exact and structure-safe only when the secret is built
562/// from characters that cannot change inside a JSON string — no quote that
563/// closes the string, no backslash that escapes, no control that must be
564/// escaped, and no non-ASCII whose UTF-8 bytes a byte-level scan could
565/// straddle.
566/// What [`Cache::write_payload`] persists for a payload and the secret
567/// attached to the cache.
568#[derive(Debug, PartialEq, Eq)]
569enum PayloadRedaction {
570    /// The payload does not quote the secret: write it as it arrived.
571    Keep,
572    /// A JSON payload whose strings quoted the secret, re-serialized with
573    /// every occurrence replaced by `[key]`.
574    Replace(Vec<u8>),
575    /// The payload quotes the secret where it cannot be replaced safely (it
576    /// is not JSON, or an object key holds it): do not persist it.
577    Skip,
578}
579
580/// Decide on the decoded value, never on the raw bytes: a JSON string may
581/// carry the secret escaped (`\u0074…`, a quote as `\"`), and a byte-level
582/// replace could also hit a number that happens to equal a short secret.
583/// So a JSON payload is parsed, every string value with the secret gets it
584/// replaced, and the document is re-serialized only when something changed;
585/// numbers and structure are never touched. A payload that is not JSON is
586/// only checked for the literal secret, and skipped when it holds it.
587fn redact_payload(bytes: &[u8], secret: &str) -> PayloadRedaction {
588    if secret.is_empty() {
589        return PayloadRedaction::Keep;
590    }
591    let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
592        return if find_bytes(bytes, secret.as_bytes()).is_some() {
593            PayloadRedaction::Skip
594        } else {
595            PayloadRedaction::Keep
596        };
597    };
598    let mut changed = false;
599    if !redact_json_strings(&mut value, secret, &mut changed) {
600        return PayloadRedaction::Skip;
601    }
602    if !changed {
603        return PayloadRedaction::Keep;
604    }
605    match serde_json::to_vec(&value) {
606        Ok(redacted) => PayloadRedaction::Replace(redacted),
607        Err(_) => PayloadRedaction::Skip,
608    }
609}
610
611/// Whether any JSON string literal in `bytes`, decoded (escapes, `\u`
612/// surrogate pairs), contains `secret`. A scan over the raw text rather than
613/// a parse: it sees every literal, duplicates and any depth included, and
614/// never fails on a document a parser would refuse.
615fn json_strings_contain(bytes: &[u8], secret: &str) -> bool {
616    let mut i = 0;
617    while i < bytes.len() {
618        if bytes[i] != b'"' {
619            i += 1;
620            continue;
621        }
622        i += 1;
623        let mut text = String::new();
624        let mut run_start = i;
625        let mut pending_high: Option<u16> = None;
626        while i < bytes.len() && bytes[i] != b'"' {
627            if bytes[i] != b'\\' {
628                i += 1;
629                continue;
630            }
631            text.push_str(&String::from_utf8_lossy(&bytes[run_start..i]));
632            let Some(&escape) = bytes.get(i + 1) else {
633                i = bytes.len();
634                run_start = i;
635                break;
636            };
637            i += 2;
638            let decoded = match escape {
639                b'n' => Some('\n'),
640                b't' => Some('\t'),
641                b'r' => Some('\r'),
642                b'b' => Some('\u{8}'),
643                b'f' => Some('\u{c}'),
644                b'u' => {
645                    let unit = bytes
646                        .get(i..i + 4)
647                        .and_then(|hex| std::str::from_utf8(hex).ok())
648                        .and_then(|hex| u16::from_str_radix(hex, 16).ok());
649                    i = (i + 4).min(bytes.len());
650                    match (unit, pending_high.take()) {
651                        (Some(low @ 0xDC00..=0xDFFF), Some(high)) => char::from_u32(
652                            0x10000
653                                + ((u32::from(high) - 0xD800) << 10)
654                                + (u32::from(low) - 0xDC00),
655                        ),
656                        (Some(high @ 0xD800..=0xDBFF), _) => {
657                            pending_high = Some(high);
658                            None
659                        }
660                        (Some(unit), _) => char::from_u32(u32::from(unit)),
661                        (None, _) => None,
662                    }
663                }
664                other => Some(char::from(other)),
665            };
666            if let Some(ch) = decoded {
667                text.push(ch);
668            }
669            run_start = i;
670        }
671        text.push_str(&String::from_utf8_lossy(&bytes[run_start..i]));
672        if text.contains(secret) {
673            return true;
674        }
675        i += 1;
676    }
677    false
678}
679
680/// Replace the secret in every string of `value`; `false` when an object
681/// key holds it (a key cannot be rewritten without changing the data's
682/// shape, so the payload is skipped instead).
683fn redact_json_strings(value: &mut serde_json::Value, secret: &str, changed: &mut bool) -> bool {
684    match value {
685        serde_json::Value::String(text) if text.contains(secret) => {
686            *text = text.replace(secret, "[key]");
687            *changed = true;
688            true
689        }
690        serde_json::Value::Array(items) => items
691            .iter_mut()
692            .all(|item| redact_json_strings(item, secret, changed)),
693        serde_json::Value::Object(map) => {
694            if map.keys().any(|key| key.contains(secret)) {
695                return false;
696            }
697            map.values_mut()
698                .all(|item| redact_json_strings(item, secret, changed))
699        }
700        _ => true,
701    }
702}
703
704fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
705    if needle.is_empty() {
706        return Some(0);
707    }
708    haystack.windows(needle.len()).position(|w| w == needle)
709}
710
711pub(crate) fn xdg_cache_dir() -> Result<PathBuf> {
712    directories::BaseDirs::new()
713        .map(|b| b.cache_dir().to_path_buf())
714        .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
715}
716
717/// The user's home directory, resolved cross-platform via `directories`
718/// (`$HOME` on Unix/macOS, `%USERPROFILE%` / the Known Folder on Windows).
719///
720/// The OAuth-credential vendors (`anthropic`, `openai`) read their CLI-managed
721/// files from fixed dotfiles under `$HOME`; they share this resolver the same
722/// way they already share [`atomic_write`], so home resolution lives in one
723/// place rather than being reimplemented per vendor.
724pub fn home_dir() -> Result<PathBuf> {
725    directories::BaseDirs::new()
726        .map(|b| b.home_dir().to_path_buf())
727        .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
728}
729
730/// The identity a vendor cache is scoped to: the first 8 bytes (16 hex chars)
731/// of the SHA-256 of the credential. A re-login changes it, so one login's
732/// figures are never served as another's; the secret itself is never stored.
733/// Existing cache files carry this exact value, so the truncation is a
734/// compatibility contract, not a tunable.
735pub fn fingerprint_of(secret: &str) -> String {
736    use sha2::{Digest, Sha256};
737    use std::fmt::Write as _;
738
739    let digest = Sha256::digest(secret.as_bytes());
740    let mut hex = String::with_capacity(16);
741    for byte in digest.iter().take(8) {
742        let _ = write!(hex, "{byte:02x}");
743    }
744    hex
745}
746
747/// Test-only: a named file inside a fresh `TempDir` with **no open handle** on
748/// it. [`atomic_write`] replaces its destination via rename, which on Windows
749/// fails while the destination is held open (as a live `NamedTempFile` handle
750/// would be) — so tests that exercise a write-back must target a closed file.
751/// Returns the dir (the caller keeps it alive) and the file's path; the file
752/// exists only when `contents` is given.
753#[cfg(test)]
754pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
755    let dir = tempfile::TempDir::new().unwrap();
756    let path = dir.path().join(name);
757    if let Some(c) = contents {
758        std::fs::write(&path, c).unwrap();
759    }
760    (dir, path)
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use tempfile::TempDir;
767
768    fn fixture() -> (TempDir, Cache) {
769        let td = TempDir::new().unwrap();
770        let cache = Cache::at(td.path().join("anthropic"));
771        cache.ensure_dir().unwrap();
772        (td, cache)
773    }
774
775    #[test]
776    fn ensure_dir_is_idempotent() {
777        let (_td, cache) = fixture();
778        cache.ensure_dir().unwrap();
779        cache.ensure_dir().unwrap();
780        assert!(cache.dir().is_dir());
781    }
782
783    #[test]
784    fn write_then_read_round_trip() {
785        let (_td, cache) = fixture();
786        cache.write_payload(b"hello world").unwrap();
787        let got = cache.maybe_payload().unwrap();
788        assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
789    }
790
791    #[test]
792    fn fingerprint_is_the_first_eight_sha256_bytes_in_lower_hex() {
793        // SHA-256("abc") = ba7816bf 8f01cfea 414140de ... — pinned because
794        // existing on-disk caches are keyed by exactly this value.
795        assert_eq!(fingerprint_of("abc"), "ba7816bf8f01cfea");
796        assert_eq!(fingerprint_of("").len(), 16);
797        assert_ne!(fingerprint_of("a"), fingerprint_of("b"));
798    }
799
800    #[test]
801    fn forget_drops_the_payload_and_its_sidecars() {
802        let (_td, cache) = fixture();
803        cache.write_payload(b"previous login").unwrap();
804        cache.mark_stale();
805        cache.write_last_error(401, "expired");
806        cache.note_rate_limit_at(SystemTime::now());
807        cache.forget();
808        assert!(cache.maybe_payload().unwrap().is_none());
809        assert!(!cache.is_stale());
810        assert!(cache.read_last_error().is_none());
811        assert!(cache.backoff_remaining().is_none());
812        assert!(cache.dir().is_dir());
813    }
814
815    #[test]
816    fn try_forget_removes_the_same_files_and_ignores_absent_ones() {
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        // Same files as `forget`, and a missing file is not a failure.
823        cache.try_forget().unwrap();
824        assert!(cache.maybe_payload().unwrap().is_none());
825        assert!(!cache.is_stale());
826        assert!(cache.read_last_error().is_none());
827        assert!(cache.backoff_remaining().is_none());
828        // A second pass over the now-empty directory still succeeds.
829        cache.try_forget().unwrap();
830    }
831
832    #[test]
833    #[cfg(unix)]
834    fn try_forget_surfaces_the_first_error_that_is_not_not_found() {
835        use std::os::unix::fs::PermissionsExt;
836        let (_td, cache) = fixture();
837        cache.write_payload(b"previous login").unwrap();
838        cache.write_last_error(0, "still here");
839        // A read-only directory cannot remove its files: `forget` would
840        // swallow that and leave the old payload to be served, but
841        // `try_forget` must hand the failure back.
842        let dir = cache.dir().to_path_buf();
843        fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
844        let error = cache.try_forget().unwrap_err();
845        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
846        assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
847        assert!(
848            cache.payload_path().exists(),
849            "the removal must not claim success"
850        );
851    }
852
853    #[test]
854    fn fresh_payload_zero_ttl_never_serves_a_fresh_payload() {
855        let (_td, cache) = fixture();
856        cache.write_payload(b"previous reading").unwrap();
857        // Written this same millisecond: the age is zero, and `age < ttl`
858        // is false for a zero `ttl`, so the ordinary path cannot serve it.
859        assert!(cache.fresh_payload(Duration::ZERO).unwrap().is_none());
860        // ...and neither through the backoff branch, which would otherwise
861        // serve a payload inside MAX_STALE with the TTL "notwithstanding".
862        cache.note_rate_limit_at(SystemTime::now());
863        let error = cache.fresh_payload(Duration::ZERO).unwrap_err();
864        assert!(error.to_string().contains("rate limited"), "{error}");
865        // The ordinary callers keep their behavior: with a positive TTL the
866        // backoff still serves the fresh payload.
867        assert!(cache.fresh_payload(DEFAULT_TTL).unwrap().is_some());
868    }
869
870    #[test]
871    #[cfg(unix)]
872    fn scrub_last_error_rewrites_the_file_in_place_in_a_read_only_dir() {
873        use std::os::unix::fs::PermissionsExt;
874        let (_td, cache) = fixture();
875        cache.write_last_error(0, "key is test-key-value");
876        // The bust this scrub backs up failed: the directory refuses
877        // removals.
878        let dir = cache.dir().to_path_buf();
879        fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
880        cache.try_forget().unwrap_err();
881        // The last resort: the directory refuses new files, but the
882        // existing one is still writable in place.
883        cache
884            .scrub_last_error(|raw| raw.replace("test-key-value", "[key]"))
885            .unwrap();
886        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
887        let raw = fs::read_to_string(cache.last_error_path()).unwrap();
888        assert!(
889            !raw.contains("test-key-value"),
890            "the key must be gone: {raw}"
891        );
892        assert!(raw.contains("[key]"), "{raw}");
893        assert!(raw.starts_with("0\n"), "the code line must survive: {raw}");
894    }
895
896    #[test]
897    fn scrub_last_error_of_a_missing_file_is_not_a_failure() {
898        let (_td, cache) = fixture();
899        cache.scrub_last_error(|raw| raw.to_string()).unwrap();
900        assert!(!cache.last_error_path().exists(), "no file may be created");
901    }
902
903    #[test]
904    fn scrub_last_error_untouched_content_leaves_the_file_alone() {
905        let (_td, cache) = fixture();
906        cache.write_last_error(0, "nothing to hide");
907        let before = cache.last_error_path();
908        let mtime_before = fs::metadata(&before).unwrap().modified().unwrap();
909        cache.scrub_last_error(|raw| raw.to_string()).unwrap();
910        let mtime_after = fs::metadata(&before).unwrap().modified().unwrap();
911        assert_eq!(
912            mtime_before, mtime_after,
913            "an unchanged file must not be rewritten"
914        );
915    }
916
917    #[test]
918    fn maybe_payload_returns_none_when_missing() {
919        let (_td, cache) = fixture();
920        assert!(cache.maybe_payload().unwrap().is_none());
921    }
922
923    #[test]
924    fn fresh_payload_respects_ttl() {
925        let (_td, cache) = fixture();
926        cache.write_payload(b"x").unwrap();
927        // Fresh = within a generous TTL.
928        assert!(
929            cache
930                .fresh_payload(Duration::from_secs(10))
931                .unwrap()
932                .is_some()
933        );
934        // Force "stale" by passing a zero TTL — payload is older than 0s.
935        assert!(
936            cache
937                .fresh_payload(Duration::from_secs(0))
938                .unwrap()
939                .is_none()
940        );
941    }
942
943    #[test]
944    fn write_clears_stale_marker_and_last_error() {
945        let (_td, cache) = fixture();
946        cache.mark_stale();
947        cache.write_last_error(429, "rate limited");
948        assert!(cache.is_stale());
949        assert!(cache.read_last_error().is_some());
950
951        cache.write_payload(b"fresh").unwrap();
952        assert!(!cache.is_stale());
953        assert!(cache.read_last_error().is_none());
954    }
955
956    // ---- rate-limit backoff ------------------------------------------------
957
958    /// A fixed instant well past the epoch so the arithmetic never underflows.
959    fn t0() -> SystemTime {
960        SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
961    }
962
963    fn arm_backoff_at(cache: &Cache, now: SystemTime) {
964        cache.note_rate_limit_at(now);
965        assert!(cache.retry_after_path().exists());
966    }
967
968    #[test]
969    fn a_429_arms_the_backoff_and_other_statuses_do_not() {
970        let (_td, cache) = fixture();
971        cache.write_last_error(500, "upstream down");
972        assert!(cache.backoff_remaining().is_none());
973        assert!(!cache.retry_after_path().exists());
974
975        cache.write_last_error(429, "slow down");
976        let remaining = cache.backoff_remaining().expect("429 must arm the backoff");
977        // Written against the real clock a moment ago: within a few seconds of
978        // the full window, never above it.
979        assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
980        assert!(
981            remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
982            "{remaining:?}"
983        );
984        // The persisted `.last_error` is untouched by the backoff bookkeeping.
985        assert_eq!(cache.read_last_error(), Some((429, "slow down".into())));
986    }
987
988    #[test]
989    fn backoff_remaining_counts_down_from_the_injected_clock() {
990        let (_td, cache) = fixture();
991        arm_backoff_at(&cache, t0());
992
993        assert_eq!(cache.backoff_remaining_at(t0()), Some(RATE_LIMIT_BACKOFF));
994        assert_eq!(
995            cache.backoff_remaining_at(t0() + Duration::from_secs(60)),
996            Some(RATE_LIMIT_BACKOFF - Duration::from_secs(60))
997        );
998        // Exactly at expiry and beyond: no backoff.
999        assert!(
1000            cache
1001                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF)
1002                .is_none()
1003        );
1004        assert!(
1005            cache
1006                .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1))
1007                .is_none()
1008        );
1009    }
1010
1011    #[test]
1012    fn during_backoff_with_no_payload_fresh_payload_refuses_the_network() {
1013        let (_td, cache) = fixture();
1014        arm_backoff_at(&cache, t0());
1015
1016        let err = cache
1017            .fresh_payload_at(DEFAULT_TTL, t0() + Duration::from_secs(19))
1018            .expect_err("no payload during backoff must be an error, not a fetch");
1019        match err {
1020            AppError::Http { status, body } => {
1021                assert_eq!(status, 429);
1022                assert!(body.contains("next attempt in"), "{body}");
1023                // 5m − 19s = 4m41s, rounded up to the next minute.
1024                assert!(body.ends_with("5m"), "{body}");
1025            }
1026            other => panic!("expected Http 429, got {other:?}"),
1027        }
1028    }
1029
1030    /// The whole point of the backoff: a vendor that still has a figure keeps
1031    /// showing it instead of re-hitting the limit every poll. The payload is
1032    /// written now with a zero TTL, so the ordinary fast path would reject it
1033    /// as expired; only the backoff branch can be the one returning it.
1034    #[test]
1035    fn during_backoff_an_expired_but_not_stale_payload_is_served() {
1036        let (_td, cache) = fixture();
1037        cache.write_payload(b"last good").unwrap();
1038        arm_backoff_at(&cache, t0());
1039
1040        // Control: without a backoff, TTL 0 means "not fresh".
1041        assert!(
1042            cache
1043                .fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
1044                .unwrap()
1045                .is_none()
1046        );
1047        // Under backoff the same payload is the answer for the ordinary
1048        // callers: a positive TTL keeps its policy unchanged...
1049        assert_eq!(
1050            cache
1051                .fresh_payload_at(DEFAULT_TTL, t0())
1052                .unwrap()
1053                .as_deref(),
1054            Some(&b"last good"[..])
1055        );
1056        // ...but a wire-only read (zero TTL) never serves a payload: its
1057        // answer must be a reading made now, and the backoff still refuses
1058        // the network.
1059        let error = cache.fresh_payload_at(Duration::ZERO, t0()).unwrap_err();
1060        assert!(error.to_string().contains("rate limited"), "{error}");
1061    }
1062
1063    #[test]
1064    fn after_the_backoff_expires_the_ttl_rule_is_back_in_charge() {
1065        let (_td, cache) = fixture();
1066        cache.write_payload(b"x").unwrap();
1067        arm_backoff_at(&cache, t0());
1068        let later = t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1);
1069
1070        assert!(
1071            cache
1072                .fresh_payload_at(Duration::from_secs(10), later)
1073                .unwrap()
1074                .is_some()
1075        );
1076        assert!(
1077            cache
1078                .fresh_payload_at(Duration::ZERO, later)
1079                .unwrap()
1080                .is_none()
1081        );
1082        // And with no payload at all, expiry means a plain "go fetch".
1083        fs::remove_file(cache.payload_path()).unwrap();
1084        assert!(
1085            cache
1086                .fresh_payload_at(DEFAULT_TTL, later)
1087                .unwrap()
1088                .is_none()
1089        );
1090    }
1091
1092    #[test]
1093    fn a_successful_payload_write_clears_the_backoff() {
1094        let (_td, cache) = fixture();
1095        arm_backoff_at(&cache, t0());
1096        assert!(cache.backoff_remaining_at(t0()).is_some());
1097
1098        cache.write_payload(b"fresh").unwrap();
1099        assert!(cache.backoff_remaining_at(t0()).is_none());
1100        assert!(!cache.retry_after_path().exists());
1101    }
1102
1103    #[test]
1104    fn clear_last_error_also_clears_the_backoff() {
1105        let (_td, cache) = fixture();
1106        cache.write_last_error(429, "slow down");
1107        assert!(cache.backoff_remaining().is_some());
1108
1109        cache.clear_last_error();
1110        assert!(cache.backoff_remaining().is_none());
1111        assert!(!cache.retry_after_path().exists());
1112    }
1113
1114    #[test]
1115    fn a_corrupt_retry_after_marker_is_no_backoff() {
1116        let (_td, cache) = fixture();
1117        for raw in ["", "soon", "-5", "1e9", "12 34"] {
1118            fs::write(cache.retry_after_path(), raw).unwrap();
1119            assert!(
1120                cache.backoff_remaining_at(t0()).is_none(),
1121                "{raw:?} must not pin the vendor offline"
1122            );
1123            assert!(cache.fresh_payload_at(DEFAULT_TTL, t0()).unwrap().is_none());
1124        }
1125        // Surrounding whitespace is tolerated, though — a trailing newline is
1126        // the sort of thing a hand edit leaves behind.
1127        let until = t0() + Duration::from_secs(90);
1128        let secs = until
1129            .duration_since(SystemTime::UNIX_EPOCH)
1130            .unwrap()
1131            .as_secs();
1132        fs::write(cache.retry_after_path(), format!("{secs}\n")).unwrap();
1133        assert_eq!(
1134            cache.backoff_remaining_at(t0()),
1135            Some(Duration::from_secs(90))
1136        );
1137    }
1138
1139    #[test]
1140    fn human_backoff_formats_seconds_minutes_and_hours() {
1141        let s = Duration::from_secs;
1142        assert_eq!(human_backoff(s(0)), "0s");
1143        assert_eq!(human_backoff(s(45)), "45s");
1144        assert_eq!(human_backoff(s(59)), "59s");
1145        assert_eq!(human_backoff(s(60)), "1m");
1146        assert_eq!(human_backoff(s(4 * 60)), "4m");
1147        assert_eq!(human_backoff(s(4 * 60 + 1)), "5m");
1148        assert_eq!(human_backoff(s(5 * 60)), "5m");
1149        assert_eq!(human_backoff(s(60 * 60)), "1h");
1150        assert_eq!(human_backoff(s(62 * 60)), "1h 2m");
1151        assert_eq!(human_backoff(s(61 * 60 + 30)), "1h 2m");
1152        assert_eq!(human_backoff(s(2 * 3600)), "2h");
1153    }
1154
1155    #[test]
1156    fn fallback_payload_refuses_a_payload_older_than_the_limit() {
1157        let (_td, cache) = fixture();
1158        cache.write_payload(b"old").unwrap();
1159
1160        // Let the payload acquire real age rather than rewriting its mtime:
1161        // Windows denies reopening the just-persisted file for an attribute
1162        // write, and the boundary being tested is the same either way. The
1163        // margin is ~12x the threshold so filesystem timestamp granularity
1164        // cannot make this flaky.
1165        std::thread::sleep(Duration::from_millis(60));
1166
1167        // Still readable when age is not considered — `maybe_payload` is the
1168        // unbounded reader, which is exactly why failure paths must not use it.
1169        assert!(cache.maybe_payload().unwrap().is_some());
1170
1171        // Past the limit, the failure path gets nothing and the caller has to
1172        // surface the real error. `MAX_STALE` was dead code before this:
1173        // every fallback served history forever.
1174        assert!(
1175            cache
1176                .fallback_payload(Duration::from_millis(5))
1177                .unwrap()
1178                .is_none()
1179        );
1180
1181        // Inside the window it is still served, so the guard is a limit and
1182        // not a blanket refusal.
1183        assert_eq!(
1184            cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
1185            Some(&b"old"[..])
1186        );
1187    }
1188
1189    #[test]
1190    fn last_error_round_trip() {
1191        let (_td, cache) = fixture();
1192        cache.write_last_error(503, "service unavailable");
1193        let (code, msg) = cache.read_last_error().unwrap();
1194        assert_eq!(code, 503);
1195        assert_eq!(msg, "service unavailable");
1196    }
1197
1198    #[test]
1199    fn last_error_with_empty_message_round_trips() {
1200        let (_td, cache) = fixture();
1201        cache.write_last_error(429, "");
1202        let (code, msg) = cache.read_last_error().unwrap();
1203        assert_eq!(code, 429);
1204        assert_eq!(msg, "");
1205    }
1206
1207    #[test]
1208    fn last_error_replaces_401_body_with_credential_neutral_message() {
1209        let (_td, cache) = fixture();
1210        cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
1211
1212        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
1213        assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
1214        assert!(!persisted.contains("PANCEA"));
1215        assert!(!persisted.contains("<credential>"));
1216    }
1217
1218    #[test]
1219    fn last_error_replaces_403_body_with_credential_neutral_message() {
1220        let (_td, cache) = fixture();
1221        cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
1222
1223        let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
1224        assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
1225        assert!(!persisted.contains("PANCEA"));
1226        assert!(!persisted.contains("<credential>"));
1227    }
1228
1229    /// The invariant that keeps the displayed message from drifting away from
1230    /// the persisted one: what comes back is what a later run would read from
1231    /// disk, so a caller that shows the return value cannot show anything the
1232    /// cache refused to keep. Asserted for the redacting arm and the ordinary
1233    /// one, since only the first rewrites the message.
1234    #[test]
1235    fn write_last_error_returns_exactly_what_a_later_run_would_read() {
1236        for (code, raw) in [
1237            (401u16, "PANCEA user@example.test <credential>&token"),
1238            (403, "PANCEA account@example.test <credential>&token"),
1239            (429, "rate limited, retry in 60s"),
1240            (500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
1241        ] {
1242            let (_td, cache) = fixture();
1243            let returned = cache.write_last_error(code, raw);
1244            assert_eq!(
1245                returned,
1246                cache.read_last_error().unwrap(),
1247                "returned pair diverged from the persisted one for {code}"
1248            );
1249        }
1250    }
1251
1252    /// The defect recurred once under a second name — six vendors wrote
1253    /// `Some((status, body))` inline and six more built a `diag` local first —
1254    /// so the sweep that fixed the first six missed the rest. This forbids the
1255    /// shape rather than the spelling: a `last_error` pair must come from
1256    /// [`Cache::write_last_error`], which is the only thing that redacts.
1257    ///
1258    /// `error_to_pair` in `cursor`, `kimi` and `kiro` is untouched by this: it
1259    /// redacts on its own and destructures as `(*status, body)`, which is not
1260    /// the borrowed shape a leak takes.
1261    #[test]
1262    fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
1263        let mut sites = Vec::new();
1264        for file in crate::guard::rs_files_in("src") {
1265            if !file.ends_with("fetch.rs") {
1266                continue;
1267            }
1268            let source = std::fs::read_to_string(&file).expect("readable module");
1269            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
1270                if line.contains("(status, body") {
1271                    sites.push(format!("{}:{}", file.display(), n + 1));
1272                }
1273            }
1274        }
1275        assert!(
1276            sites.is_empty(),
1277            "a last_error pair must be the return of `write_last_error`, which \
1278             redacts 401/403 — building one from the raw body puts the response \
1279             body in the widget tooltip. Found: {sites:#?}"
1280        );
1281    }
1282
1283    /// The cold-cache decision — serve a stale figure, or surface the error
1284    /// that caused the refresh to fail — is `outcome::fallback`'s alone. It
1285    /// drifted into two disagreeing generations once, when each vendor owned a
1286    /// copy: five replaced the original error with a generic "no usable cache"
1287    /// while thirteen returned it. `fallback_payload` is the entry point to
1288    /// that decision, so a second caller is a second copy in the making.
1289    #[test]
1290    fn only_the_shared_fallback_reads_the_stale_payload() {
1291        let mut sites = Vec::new();
1292        for file in crate::guard::rs_files_in("src") {
1293            if file.ends_with("outcome.rs") || file.ends_with("cache.rs") {
1294                continue;
1295            }
1296            let source = std::fs::read_to_string(&file).expect("readable module");
1297            for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
1298                if line.contains("fallback_payload(") {
1299                    sites.push(format!("{}:{}", file.display(), n + 1));
1300                }
1301            }
1302        }
1303        assert!(
1304            sites.is_empty(),
1305            "reach the stale payload through `outcome::fallback`, which decides \
1306             what a cold cache means for every vendor at once. Found: {sites:#?}"
1307        );
1308    }
1309
1310    /// The bug this closes: the pair handed to the widget was built from the
1311    /// raw body in parallel with the redacted one going to disk, so the run
1312    /// that hit the `401` showed the body and only the *next* run showed the
1313    /// neutral message. The returned pair carries the redaction.
1314    #[test]
1315    fn the_returned_pair_carries_the_auth_redaction() {
1316        for code in [401u16, 403] {
1317            let (_td, cache) = fixture();
1318            let (returned_code, msg) =
1319                cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
1320            assert_eq!(returned_code, code);
1321            assert_eq!(msg, AUTH_FAILURE_MESSAGE);
1322            assert!(!msg.contains("PANCEA"), "{msg}");
1323            assert!(!msg.contains("<credential>"), "{msg}");
1324        }
1325    }
1326
1327    /// A provider that quotes the key a fetch was sent with (Z.AI returns
1328    /// the reason in `msg` on an HTTP 200) must not persist it: the cache
1329    /// attached to that key redacts at the first point of writing — before
1330    /// the control-char cleanup and before the atomic write — and the
1331    /// returned pair is the redacted one, so file and memory agree.
1332    #[test]
1333    fn write_last_error_redacts_the_attached_secret() {
1334        let (_td, cache) = fixture();
1335        let cache = cache.redacting("test-key-value");
1336
1337        let (code, msg) = cache.write_last_error(0, "msg test-key-value");
1338        assert_eq!(code, 0);
1339        assert_eq!(msg, "msg [key]");
1340        assert!(!msg.contains("test-key-value"), "{msg}");
1341        let on_disk = fs::read_to_string(cache.last_error_path()).unwrap();
1342        assert!(!on_disk.contains("test-key-value"), "{on_disk}");
1343        // What a later run would read is exactly what the call returned.
1344        assert_eq!((code, msg), cache.read_last_error().unwrap());
1345
1346        // A partial echo of a key of eight characters or more is the key
1347        // with a missing tail: the whole message is the fixed note.
1348        let key = "abcdefghijklmnop";
1349        let (_td2, cache2) = fixture();
1350        let cache2 = cache2.redacting(key);
1351        let (_, msg) = cache2.write_last_error(0, "bad key abcdefgh…");
1352        assert_eq!(
1353            msg,
1354            "The provider's answer quoted the key, so it is not shown."
1355        );
1356        let on_disk = fs::read_to_string(cache2.last_error_path()).unwrap();
1357        assert!(!on_disk.contains(key), "{on_disk}");
1358
1359        // 401/403 keep the fixed authentication message.
1360        let (code, msg) = cache.write_last_error(401, "msg test-key-value");
1361        assert_eq!(code, 401);
1362        assert_eq!(msg, AUTH_FAILURE_MESSAGE);
1363    }
1364
1365    /// A provider's *successful* answer can quote the key it was sent with
1366    /// (Z.AI carries `msg` inside an HTTP 200): the cache attached to that
1367    /// key redacts the payload at the first point of writing, on its decoded
1368    /// strings — every occurrence is replaced, the JSON survives, and the
1369    /// partial-echo heuristic does not reach the payload (it would destroy
1370    /// data).
1371    #[test]
1372    fn write_payload_redacts_the_attached_secret() {
1373        let (_td, cache) = fixture();
1374        let cache = cache.redacting("test-key-value");
1375
1376        cache
1377            .write_payload(br#"{"msg":"ok test-key-value"}"#)
1378            .unwrap();
1379        let on_disk = fs::read(cache.payload_path()).unwrap();
1380        assert_eq!(on_disk, br#"{"msg":"ok [key]"}"#);
1381
1382        // Every exact occurrence is replaced, not only the first.
1383        let (_td, cache) = fixture();
1384        let cache = cache.redacting("test-key-value");
1385        cache
1386            .write_payload(br#"{"a":"test-key-value","b":"test-key-value"}"#)
1387            .unwrap();
1388        let on_disk = fs::read(cache.payload_path()).unwrap();
1389        assert_eq!(on_disk, br#"{"a":"[key]","b":"[key]"}"#);
1390
1391        // No attached secret: the bytes are persisted as they arrived.
1392        let (_td, cache) = fixture();
1393        let raw = br#"{"msg":"ok test-key-value"}"#;
1394        cache.write_payload(raw).unwrap();
1395        assert_eq!(fs::read(cache.payload_path()).unwrap(), raw);
1396    }
1397
1398    /// The decision is made on decoded strings, not raw bytes: an echo that
1399    /// arrives escaped in valid JSON is still redacted, a secret with a quote
1400    /// is redacted inside its (escaped) string, and a number equal to a
1401    /// short secret is not a string, so the payload is written unchanged.
1402    #[test]
1403    fn write_payload_redacts_escaped_echoes_and_leaves_numbers_alone() {
1404        // `\u0074` is `t`: the bytes never hold the key literally.
1405        let (_td, cache) = fixture();
1406        let cache = cache.redacting("test-key-value");
1407        cache
1408            .write_payload(br#"{"msg":"ok \u0074est-key-value"}"#)
1409            .unwrap();
1410        assert_eq!(
1411            fs::read(cache.payload_path()).unwrap(),
1412            br#"{"msg":"ok [key]"}"#
1413        );
1414
1415        // A quote in the key: valid JSON escapes it, and it is redacted.
1416        let (_td, cache) = fixture();
1417        let cache = cache.redacting("a\"b");
1418        cache.write_payload(br#"{"msg":"ok a\"b"}"#).unwrap();
1419        assert_eq!(
1420            fs::read(cache.payload_path()).unwrap(),
1421            br#"{"msg":"ok [key]"}"#
1422        );
1423
1424        // A short numeric key equal to a number: numbers are not strings,
1425        // so the payload is written exactly as it arrived.
1426        let (_td, cache) = fixture();
1427        let cache = cache.redacting("12");
1428        let raw = br#"{"used":12,"msg":"fine"}"#;
1429        cache.write_payload(raw).unwrap();
1430        assert_eq!(fs::read(cache.payload_path()).unwrap(), raw);
1431    }
1432
1433    /// A payload that is not JSON and quotes the secret cannot be redacted
1434    /// safely, so it is not persisted at all — the fetch still returns its
1435    /// in-memory value, only the cache keeps no payload of that answer — and
1436    /// the rest of the cache is left exactly as it was: an older payload is
1437    /// not clobbered, and the success side effects do not run. An object key
1438    /// that holds the secret is skipped the same way.
1439    #[test]
1440    fn write_payload_skips_a_payload_it_cannot_redact() {
1441        let secret = "test-key-value";
1442
1443        // A cold cache stays cold: no payload file is written.
1444        let (_td, cache) = fixture();
1445        let cache = cache.redacting(secret);
1446        cache.write_payload(b"plain text test-key-value").unwrap();
1447        assert!(
1448            cache.maybe_payload().unwrap().is_none(),
1449            "a non-JSON payload quoting the secret must not be persisted"
1450        );
1451        assert!(!cache.payload_path().exists());
1452
1453        // An existing payload survives untouched, and no side effect ran.
1454        let (_td, cache) = fixture();
1455        let cache = cache.redacting(secret);
1456        cache.write_payload(br#"{"msg":"last good"}"#).unwrap();
1457        cache.mark_stale();
1458        cache.write_last_error(500, "an older failure");
1459        cache.write_payload(br#"{"test-key-value":1}"#).unwrap();
1460        assert_eq!(
1461            fs::read(cache.payload_path()).unwrap(),
1462            br#"{"msg":"last good"}"#
1463        );
1464        assert!(cache.is_stale());
1465        assert!(cache.read_last_error().is_some());
1466    }
1467
1468    /// What the parse cannot see is caught on the bytes before writing: a
1469    /// duplicate key whose first value quotes the secret (the parsed tree
1470    /// keeps only the last value) and an escaped echo inside a document too
1471    /// deep for the parser are both left out of the cache.
1472    #[test]
1473    fn write_payload_skips_an_echo_the_parse_cannot_see() {
1474        let (_td, cache) = fixture();
1475        let cache = cache.redacting("test-key-value");
1476        cache
1477            .write_payload(br#"{"note":"test-key-value","note":"fine","data":1}"#)
1478            .unwrap();
1479        assert!(
1480            !cache.payload_path().exists(),
1481            "a duplicate key hid the echo"
1482        );
1483
1484        let deep = format!(
1485            r#"{{"data":1,"extra":{}"ok \u0074est-key-value"{}}}"#,
1486            "[".repeat(160),
1487            "]".repeat(160)
1488        );
1489        assert!(serde_json::from_str::<serde_json::Value>(&deep).is_err());
1490        let (_td, cache) = fixture();
1491        let cache = cache.redacting("test-key-value");
1492        cache.write_payload(deep.as_bytes()).unwrap();
1493        assert!(
1494            !cache.payload_path().exists(),
1495            "a deep document hid the echo"
1496        );
1497
1498        // The scan decodes escapes and surrogate pairs, and skips numbers.
1499        assert!(json_strings_contain(br#""a\u0062c""#, "abc"));
1500        assert!(json_strings_contain(br#""x \ud83d\ude00 y""#, "\u{1f600}"));
1501        assert!(json_strings_contain(br#""say \"hi\"""#, "\"hi\""));
1502        assert!(!json_strings_contain(br#"{"n":12}"#, "12"));
1503    }
1504
1505    /// The regression this guards: vendors write the raw HTTP body, which is
1506    /// usually multi-line JSON. The reader kept only line 2, so the tooltip
1507    /// showed `{` and dropped the actual API explanation.
1508    #[test]
1509    fn last_error_round_trips_a_multi_line_message() {
1510        let (_td, cache) = fixture();
1511        let body = "{\n  \"error\": \"quota exhausted\",\n  \"retry_after\": 3600\n}";
1512        cache.write_last_error(429, body);
1513
1514        let (code, msg) = cache.read_last_error().unwrap();
1515        assert_eq!(code, 429);
1516        assert_eq!(msg, body);
1517        assert!(
1518            msg.contains("quota exhausted"),
1519            "message was truncated to its first line: {msg:?}"
1520        );
1521    }
1522
1523    #[test]
1524    fn last_error_strips_terminal_controls_before_persisting() {
1525        let (_td, cache) = fixture();
1526        cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
1527
1528        let (code, msg) = cache.read_last_error().unwrap();
1529        assert_eq!(code, 500);
1530        assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
1531        assert!(
1532            msg.contains("Y2FuYXJ5"),
1533            "non-auth diagnostic was not preserved"
1534        );
1535        assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
1536    }
1537
1538    /// A user upgrades with a `.last_error` already on disk; it must still
1539    /// parse. Trailing-newline-free files (the whole marker being just a code)
1540    /// count too — that is the one shape the old `lines()` reader tolerated.
1541    #[test]
1542    fn last_error_reads_files_written_by_the_previous_version() {
1543        let (_td, cache) = fixture();
1544
1545        fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
1546        assert_eq!(
1547            cache.read_last_error(),
1548            Some((503, "service unavailable".into()))
1549        );
1550
1551        fs::write(cache.last_error_path(), "429").unwrap();
1552        assert_eq!(cache.read_last_error(), Some((429, String::new())));
1553
1554        // A non-numeric first line is still no error at all, never a fake 0.
1555        fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
1556        assert!(cache.read_last_error().is_none());
1557    }
1558
1559    #[test]
1560    fn lock_serializes_concurrent_acquirers() {
1561        // First lock succeeds; while held, a second non-blocking attempt
1562        // should time out quickly.
1563        let (_td, cache) = fixture();
1564        let lock_path = cache.lock_path();
1565        let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
1566
1567        let res = acquire_lock(&lock_path, Duration::from_millis(100));
1568        assert!(matches!(res, Err(AppError::Other(_))));
1569    }
1570
1571    /// The regression this guards: `acquire_lock` parks the thread in a sleep
1572    /// loop, so on the TUI's current-thread runtime a contended lock froze
1573    /// keyboard input, the refresh timer and every other vendor's fetch until
1574    /// it timed out. `acquire_lock_async` moves the wait to the blocking pool,
1575    /// so unrelated timers must keep firing while the lock is held elsewhere.
1576    #[tokio::test(flavor = "current_thread")]
1577    async fn async_lock_does_not_stall_the_runtime() {
1578        let (_td, cache) = fixture();
1579        let lock_path = cache.lock_path();
1580        let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
1581
1582        // This will wait the full timeout — it can never win the lock.
1583        let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
1584
1585        // Meanwhile the runtime must still be able to make progress.
1586        let mut ticks = 0usize;
1587        let ticker = async {
1588            let mut iv = tokio::time::interval(Duration::from_millis(20));
1589            iv.tick().await;
1590            loop {
1591                iv.tick().await;
1592                ticks += 1;
1593            }
1594        };
1595
1596        tokio::select! {
1597            res = waiter => {
1598                // The lock attempt is expected to time out.
1599                assert!(matches!(res, Err(AppError::Other(_))));
1600            }
1601            _ = ticker => unreachable!("the ticker loops forever"),
1602        }
1603        assert!(
1604            ticks > 1,
1605            "runtime was starved while the lock was contended ({ticks} ticks)"
1606        );
1607    }
1608
1609    #[test]
1610    fn atomic_write_creates_parent_dirs() {
1611        let td = TempDir::new().unwrap();
1612        let nested = td.path().join("a/b/c/file.txt");
1613        atomic_write(&nested, b"abc").unwrap();
1614        assert_eq!(fs::read(&nested).unwrap(), b"abc");
1615    }
1616}