use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use fs2::FileExt;
use crate::error::{AUTH_FAILURE_MESSAGE, AppError, Result};
pub const DEFAULT_TTL: Duration = Duration::from_secs(60);
pub const MAX_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
pub const RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(5 * 60);
#[derive(Clone)]
pub struct Cache {
dir: PathBuf,
redacting: Option<String>,
}
impl std::fmt::Debug for Cache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cache")
.field("dir", &self.dir)
.field("redacting", &self.redacting.is_some())
.finish()
}
}
impl Cache {
pub fn for_vendor(vendor: &str) -> Result<Self> {
let base = xdg_cache_dir()?.join("ai-usagebar").join(vendor);
Ok(Self {
dir: base,
redacting: None,
})
}
pub fn for_vendor_account(vendor: &str, label: &str) -> Result<Self> {
let base = xdg_cache_dir()?
.join("ai-usagebar")
.join(vendor)
.join(label);
Ok(Self {
dir: base,
redacting: None,
})
}
pub fn at(path: PathBuf) -> Self {
Self {
dir: path,
redacting: None,
}
}
pub(crate) fn redacting(mut self, secret: &str) -> Self {
self.redacting = Some(secret.to_string());
self
}
pub fn ensure_dir(&self) -> Result<()> {
fs::create_dir_all(&self.dir).map_err(|e| AppError::io_at(&self.dir, e))
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn payload_path(&self) -> PathBuf {
self.dir.join("usage.json")
}
pub fn stale_path(&self) -> PathBuf {
self.dir.join(".stale")
}
pub fn last_error_path(&self) -> PathBuf {
self.dir.join(".last_error")
}
pub fn lock_path(&self) -> PathBuf {
self.dir.join(".fetch.lock")
}
pub fn retry_after_path(&self) -> PathBuf {
self.dir.join(".retry_after")
}
pub fn payload_age(&self) -> Option<Duration> {
let meta = fs::metadata(self.payload_path()).ok()?;
let mtime = meta.modified().ok()?;
SystemTime::now().duration_since(mtime).ok()
}
pub fn fresh_payload(&self, ttl: Duration) -> Result<Option<Vec<u8>>> {
self.fresh_payload_at(ttl, SystemTime::now())
}
pub fn fresh_payload_at(&self, ttl: Duration, now: SystemTime) -> Result<Option<Vec<u8>>> {
if let Some(remaining) = self.backoff_remaining_at(now) {
if !ttl.is_zero() && self.payload_age().is_some_and(|age| age <= MAX_STALE) {
return self.read_payload().map(Some);
}
return Err(AppError::Http {
status: 429,
body: format!("rate limited; next attempt in {}", human_backoff(remaining)),
});
}
let Some(age) = self.payload_age() else {
return Ok(None);
};
if age < ttl {
self.read_payload().map(Some)
} else {
Ok(None)
}
}
pub fn note_rate_limit_at(&self, now: SystemTime) {
let until = now + RATE_LIMIT_BACKOFF;
let secs = until
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = atomic_write(&self.retry_after_path(), secs.to_string().as_bytes());
}
pub fn clear_backoff(&self) {
let _ = fs::remove_file(self.retry_after_path());
}
pub fn backoff_remaining_at(&self, now: SystemTime) -> Option<Duration> {
let raw = fs::read_to_string(self.retry_after_path()).ok()?;
let secs = raw.trim().parse::<u64>().ok()?;
let until = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs))?;
let remaining = until.duration_since(now).ok()?;
if remaining.is_zero() {
None
} else {
Some(remaining)
}
}
pub fn backoff_remaining(&self) -> Option<Duration> {
self.backoff_remaining_at(SystemTime::now())
}
pub fn maybe_payload(&self) -> Result<Option<Vec<u8>>> {
if !self.payload_path().exists() {
return Ok(None);
}
self.read_payload().map(Some)
}
pub fn fallback_payload(&self, max_stale: Duration) -> Result<Option<Vec<u8>>> {
let Some(age) = self.payload_age() else {
return Ok(None);
};
if age > max_stale {
return Ok(None);
}
self.read_payload().map(Some)
}
fn read_payload(&self) -> Result<Vec<u8>> {
let p = self.payload_path();
let mut f = File::open(&p).map_err(|e| AppError::io_at(&p, e))?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)
.map_err(|e| AppError::io_at(&p, e))?;
Ok(buf)
}
pub fn write_payload(&self, bytes: &[u8]) -> Result<()> {
let owned;
let bytes = match self
.redacting
.as_deref()
.map(|secret| redact_payload(bytes, secret))
{
None | Some(PayloadRedaction::Keep) => bytes,
Some(PayloadRedaction::Replace(redacted)) => {
owned = redacted;
&owned
}
Some(PayloadRedaction::Skip) => return Ok(()),
};
if self
.redacting
.as_deref()
.is_some_and(|secret| !secret.is_empty() && json_strings_contain(bytes, secret))
{
return Ok(());
}
self.ensure_dir()?;
let mut tmp = tempfile::Builder::new()
.prefix(".usage.")
.tempfile_in(&self.dir)
.map_err(|e| AppError::io_at(&self.dir, e))?;
tmp.write_all(bytes)
.map_err(|e| AppError::io_at(tmp.path(), e))?;
tmp.as_file_mut()
.sync_all()
.map_err(|e| AppError::io_at(tmp.path(), e))?;
tmp.persist(self.payload_path())
.map_err(|e| AppError::io_at(self.payload_path(), e.error))?;
let _ = fs::remove_file(self.stale_path());
let _ = fs::remove_file(self.last_error_path());
self.clear_backoff();
Ok(())
}
pub fn mark_stale(&self) {
let _ = self.ensure_dir();
let _ = File::create(self.stale_path());
}
pub fn is_stale(&self) -> bool {
self.stale_path().exists()
}
pub fn forget(&self) {
let _ = fs::remove_file(self.payload_path());
let _ = fs::remove_file(self.stale_path());
let _ = fs::remove_file(self.last_error_path());
self.clear_backoff();
}
pub fn try_forget(&self) -> std::io::Result<()> {
for path in [
self.payload_path(),
self.stale_path(),
self.last_error_path(),
self.retry_after_path(),
] {
match fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Ok(())
}
pub fn scrub_last_error(&self, scrub: impl Fn(&str) -> String) -> std::io::Result<()> {
let path = self.last_error_path();
let original = match fs::read_to_string(&path) {
Ok(original) => original,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
let scrubbed = scrub(&original);
if scrubbed == original {
return Ok(());
}
let mut file = fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&path)?;
file.write_all(scrubbed.as_bytes())
}
pub fn write_last_error(&self, code: u16, msg: &str) -> (u16, String) {
let _ = self.ensure_dir();
let path = self.last_error_path();
let msg = if matches!(code, 401 | 403) {
AUTH_FAILURE_MESSAGE
} else {
msg
};
let msg = if let Some(secret) = &self.redacting {
crate::display::redact_secret(msg, secret)
} else {
msg.to_string()
};
let msg = crate::display::sanitize_untrusted_field(&msg);
let body = format!("{code}\n{msg}");
let _ = atomic_write(&path, body.as_bytes());
if code == 429 {
self.note_rate_limit_at(SystemTime::now());
}
(code, msg)
}
pub fn clear_last_error(&self) {
let _ = fs::remove_file(self.last_error_path());
self.clear_backoff();
}
pub fn read_last_error(&self) -> Option<(u16, String)> {
let raw = fs::read_to_string(self.last_error_path()).ok()?;
let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
Some((code.parse::<u16>().ok()?, msg.to_string()))
}
}
pub(crate) fn human_backoff(remaining: Duration) -> String {
let secs = remaining.as_secs();
if secs < 60 {
return format!("{secs}s");
}
let minutes = secs.div_ceil(60);
let (hours, minutes) = (minutes / 60, minutes % 60);
match (hours, minutes) {
(0, m) => format!("{m}m"),
(h, 0) => format!("{h}h"),
(h, m) => format!("{h}h {m}m"),
}
}
pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
.await
.map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
}
pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
}
let f = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)
.map_err(|e| AppError::io_at(path, e))?;
let deadline = std::time::Instant::now() + timeout;
loop {
match f.try_lock_exclusive() {
Ok(()) => return Ok(LockGuard { file: f }),
Err(_) => {
if std::time::Instant::now() >= deadline {
return Err(AppError::Other(format!(
"cache lock timeout after {:?}",
timeout
)));
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
}
pub struct LockGuard {
file: File,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path.parent().ok_or_else(|| {
AppError::Other(format!(
"atomic_write: path has no parent: {}",
path.display()
))
})?;
fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
let mut tmp = tempfile::Builder::new()
.prefix(".tmp.")
.tempfile_in(dir)
.map_err(|e| AppError::io_at(dir, e))?;
tmp.write_all(bytes)
.map_err(|e| AppError::io_at(tmp.path(), e))?;
tmp.as_file_mut()
.sync_all()
.map_err(|e| AppError::io_at(tmp.path(), e))?;
tmp.persist(path)
.map_err(|e| AppError::io_at(path, e.error))?;
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum PayloadRedaction {
Keep,
Replace(Vec<u8>),
Skip,
}
fn redact_payload(bytes: &[u8], secret: &str) -> PayloadRedaction {
if secret.is_empty() {
return PayloadRedaction::Keep;
}
let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
return if find_bytes(bytes, secret.as_bytes()).is_some() {
PayloadRedaction::Skip
} else {
PayloadRedaction::Keep
};
};
let mut changed = false;
if !redact_json_strings(&mut value, secret, &mut changed) {
return PayloadRedaction::Skip;
}
if !changed {
return PayloadRedaction::Keep;
}
match serde_json::to_vec(&value) {
Ok(redacted) => PayloadRedaction::Replace(redacted),
Err(_) => PayloadRedaction::Skip,
}
}
fn json_strings_contain(bytes: &[u8], secret: &str) -> bool {
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'"' {
i += 1;
continue;
}
i += 1;
let mut text = String::new();
let mut run_start = i;
let mut pending_high: Option<u16> = None;
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] != b'\\' {
i += 1;
continue;
}
text.push_str(&String::from_utf8_lossy(&bytes[run_start..i]));
let Some(&escape) = bytes.get(i + 1) else {
i = bytes.len();
run_start = i;
break;
};
i += 2;
let decoded = match escape {
b'n' => Some('\n'),
b't' => Some('\t'),
b'r' => Some('\r'),
b'b' => Some('\u{8}'),
b'f' => Some('\u{c}'),
b'u' => {
let unit = bytes
.get(i..i + 4)
.and_then(|hex| std::str::from_utf8(hex).ok())
.and_then(|hex| u16::from_str_radix(hex, 16).ok());
i = (i + 4).min(bytes.len());
match (unit, pending_high.take()) {
(Some(low @ 0xDC00..=0xDFFF), Some(high)) => char::from_u32(
0x10000
+ ((u32::from(high) - 0xD800) << 10)
+ (u32::from(low) - 0xDC00),
),
(Some(high @ 0xD800..=0xDBFF), _) => {
pending_high = Some(high);
None
}
(Some(unit), _) => char::from_u32(u32::from(unit)),
(None, _) => None,
}
}
other => Some(char::from(other)),
};
if let Some(ch) = decoded {
text.push(ch);
}
run_start = i;
}
text.push_str(&String::from_utf8_lossy(&bytes[run_start..i]));
if text.contains(secret) {
return true;
}
i += 1;
}
false
}
fn redact_json_strings(value: &mut serde_json::Value, secret: &str, changed: &mut bool) -> bool {
match value {
serde_json::Value::String(text) if text.contains(secret) => {
*text = text.replace(secret, "[key]");
*changed = true;
true
}
serde_json::Value::Array(items) => items
.iter_mut()
.all(|item| redact_json_strings(item, secret, changed)),
serde_json::Value::Object(map) => {
if map.keys().any(|key| key.contains(secret)) {
return false;
}
map.values_mut()
.all(|item| redact_json_strings(item, secret, changed))
}
_ => true,
}
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() {
return Some(0);
}
haystack.windows(needle.len()).position(|w| w == needle)
}
pub(crate) fn xdg_cache_dir() -> Result<PathBuf> {
directories::BaseDirs::new()
.map(|b| b.cache_dir().to_path_buf())
.ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
}
pub fn home_dir() -> Result<PathBuf> {
directories::BaseDirs::new()
.map(|b| b.home_dir().to_path_buf())
.ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
}
pub fn fingerprint_of(secret: &str) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let digest = Sha256::digest(secret.as_bytes());
let mut hex = String::with_capacity(16);
for byte in digest.iter().take(8) {
let _ = write!(hex, "{byte:02x}");
}
hex
}
#[cfg(test)]
pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(name);
if let Some(c) = contents {
std::fs::write(&path, c).unwrap();
}
(dir, path)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixture() -> (TempDir, Cache) {
let td = TempDir::new().unwrap();
let cache = Cache::at(td.path().join("anthropic"));
cache.ensure_dir().unwrap();
(td, cache)
}
#[test]
fn ensure_dir_is_idempotent() {
let (_td, cache) = fixture();
cache.ensure_dir().unwrap();
cache.ensure_dir().unwrap();
assert!(cache.dir().is_dir());
}
#[test]
fn write_then_read_round_trip() {
let (_td, cache) = fixture();
cache.write_payload(b"hello world").unwrap();
let got = cache.maybe_payload().unwrap();
assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
}
#[test]
fn fingerprint_is_the_first_eight_sha256_bytes_in_lower_hex() {
assert_eq!(fingerprint_of("abc"), "ba7816bf8f01cfea");
assert_eq!(fingerprint_of("").len(), 16);
assert_ne!(fingerprint_of("a"), fingerprint_of("b"));
}
#[test]
fn forget_drops_the_payload_and_its_sidecars() {
let (_td, cache) = fixture();
cache.write_payload(b"previous login").unwrap();
cache.mark_stale();
cache.write_last_error(401, "expired");
cache.note_rate_limit_at(SystemTime::now());
cache.forget();
assert!(cache.maybe_payload().unwrap().is_none());
assert!(!cache.is_stale());
assert!(cache.read_last_error().is_none());
assert!(cache.backoff_remaining().is_none());
assert!(cache.dir().is_dir());
}
#[test]
fn try_forget_removes_the_same_files_and_ignores_absent_ones() {
let (_td, cache) = fixture();
cache.write_payload(b"previous login").unwrap();
cache.mark_stale();
cache.write_last_error(401, "expired");
cache.note_rate_limit_at(SystemTime::now());
cache.try_forget().unwrap();
assert!(cache.maybe_payload().unwrap().is_none());
assert!(!cache.is_stale());
assert!(cache.read_last_error().is_none());
assert!(cache.backoff_remaining().is_none());
cache.try_forget().unwrap();
}
#[test]
#[cfg(unix)]
fn try_forget_surfaces_the_first_error_that_is_not_not_found() {
use std::os::unix::fs::PermissionsExt;
let (_td, cache) = fixture();
cache.write_payload(b"previous login").unwrap();
cache.write_last_error(0, "still here");
let dir = cache.dir().to_path_buf();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
let error = cache.try_forget().unwrap_err();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(
cache.payload_path().exists(),
"the removal must not claim success"
);
}
#[test]
fn fresh_payload_zero_ttl_never_serves_a_fresh_payload() {
let (_td, cache) = fixture();
cache.write_payload(b"previous reading").unwrap();
assert!(cache.fresh_payload(Duration::ZERO).unwrap().is_none());
cache.note_rate_limit_at(SystemTime::now());
let error = cache.fresh_payload(Duration::ZERO).unwrap_err();
assert!(error.to_string().contains("rate limited"), "{error}");
assert!(cache.fresh_payload(DEFAULT_TTL).unwrap().is_some());
}
#[test]
#[cfg(unix)]
fn scrub_last_error_rewrites_the_file_in_place_in_a_read_only_dir() {
use std::os::unix::fs::PermissionsExt;
let (_td, cache) = fixture();
cache.write_last_error(0, "key is test-key-value");
let dir = cache.dir().to_path_buf();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
cache.try_forget().unwrap_err();
cache
.scrub_last_error(|raw| raw.replace("test-key-value", "[key]"))
.unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
let raw = fs::read_to_string(cache.last_error_path()).unwrap();
assert!(
!raw.contains("test-key-value"),
"the key must be gone: {raw}"
);
assert!(raw.contains("[key]"), "{raw}");
assert!(raw.starts_with("0\n"), "the code line must survive: {raw}");
}
#[test]
fn scrub_last_error_of_a_missing_file_is_not_a_failure() {
let (_td, cache) = fixture();
cache.scrub_last_error(|raw| raw.to_string()).unwrap();
assert!(!cache.last_error_path().exists(), "no file may be created");
}
#[test]
fn scrub_last_error_untouched_content_leaves_the_file_alone() {
let (_td, cache) = fixture();
cache.write_last_error(0, "nothing to hide");
let before = cache.last_error_path();
let mtime_before = fs::metadata(&before).unwrap().modified().unwrap();
cache.scrub_last_error(|raw| raw.to_string()).unwrap();
let mtime_after = fs::metadata(&before).unwrap().modified().unwrap();
assert_eq!(
mtime_before, mtime_after,
"an unchanged file must not be rewritten"
);
}
#[test]
fn maybe_payload_returns_none_when_missing() {
let (_td, cache) = fixture();
assert!(cache.maybe_payload().unwrap().is_none());
}
#[test]
fn fresh_payload_respects_ttl() {
let (_td, cache) = fixture();
cache.write_payload(b"x").unwrap();
assert!(
cache
.fresh_payload(Duration::from_secs(10))
.unwrap()
.is_some()
);
assert!(
cache
.fresh_payload(Duration::from_secs(0))
.unwrap()
.is_none()
);
}
#[test]
fn write_clears_stale_marker_and_last_error() {
let (_td, cache) = fixture();
cache.mark_stale();
cache.write_last_error(429, "rate limited");
assert!(cache.is_stale());
assert!(cache.read_last_error().is_some());
cache.write_payload(b"fresh").unwrap();
assert!(!cache.is_stale());
assert!(cache.read_last_error().is_none());
}
fn t0() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
}
fn arm_backoff_at(cache: &Cache, now: SystemTime) {
cache.note_rate_limit_at(now);
assert!(cache.retry_after_path().exists());
}
#[test]
fn a_429_arms_the_backoff_and_other_statuses_do_not() {
let (_td, cache) = fixture();
cache.write_last_error(500, "upstream down");
assert!(cache.backoff_remaining().is_none());
assert!(!cache.retry_after_path().exists());
cache.write_last_error(429, "slow down");
let remaining = cache.backoff_remaining().expect("429 must arm the backoff");
assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
assert!(
remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
"{remaining:?}"
);
assert_eq!(cache.read_last_error(), Some((429, "slow down".into())));
}
#[test]
fn backoff_remaining_counts_down_from_the_injected_clock() {
let (_td, cache) = fixture();
arm_backoff_at(&cache, t0());
assert_eq!(cache.backoff_remaining_at(t0()), Some(RATE_LIMIT_BACKOFF));
assert_eq!(
cache.backoff_remaining_at(t0() + Duration::from_secs(60)),
Some(RATE_LIMIT_BACKOFF - Duration::from_secs(60))
);
assert!(
cache
.backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF)
.is_none()
);
assert!(
cache
.backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1))
.is_none()
);
}
#[test]
fn during_backoff_with_no_payload_fresh_payload_refuses_the_network() {
let (_td, cache) = fixture();
arm_backoff_at(&cache, t0());
let err = cache
.fresh_payload_at(DEFAULT_TTL, t0() + Duration::from_secs(19))
.expect_err("no payload during backoff must be an error, not a fetch");
match err {
AppError::Http { status, body } => {
assert_eq!(status, 429);
assert!(body.contains("next attempt in"), "{body}");
assert!(body.ends_with("5m"), "{body}");
}
other => panic!("expected Http 429, got {other:?}"),
}
}
#[test]
fn during_backoff_an_expired_but_not_stale_payload_is_served() {
let (_td, cache) = fixture();
cache.write_payload(b"last good").unwrap();
arm_backoff_at(&cache, t0());
assert!(
cache
.fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
.unwrap()
.is_none()
);
assert_eq!(
cache
.fresh_payload_at(DEFAULT_TTL, t0())
.unwrap()
.as_deref(),
Some(&b"last good"[..])
);
let error = cache.fresh_payload_at(Duration::ZERO, t0()).unwrap_err();
assert!(error.to_string().contains("rate limited"), "{error}");
}
#[test]
fn after_the_backoff_expires_the_ttl_rule_is_back_in_charge() {
let (_td, cache) = fixture();
cache.write_payload(b"x").unwrap();
arm_backoff_at(&cache, t0());
let later = t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1);
assert!(
cache
.fresh_payload_at(Duration::from_secs(10), later)
.unwrap()
.is_some()
);
assert!(
cache
.fresh_payload_at(Duration::ZERO, later)
.unwrap()
.is_none()
);
fs::remove_file(cache.payload_path()).unwrap();
assert!(
cache
.fresh_payload_at(DEFAULT_TTL, later)
.unwrap()
.is_none()
);
}
#[test]
fn a_successful_payload_write_clears_the_backoff() {
let (_td, cache) = fixture();
arm_backoff_at(&cache, t0());
assert!(cache.backoff_remaining_at(t0()).is_some());
cache.write_payload(b"fresh").unwrap();
assert!(cache.backoff_remaining_at(t0()).is_none());
assert!(!cache.retry_after_path().exists());
}
#[test]
fn clear_last_error_also_clears_the_backoff() {
let (_td, cache) = fixture();
cache.write_last_error(429, "slow down");
assert!(cache.backoff_remaining().is_some());
cache.clear_last_error();
assert!(cache.backoff_remaining().is_none());
assert!(!cache.retry_after_path().exists());
}
#[test]
fn a_corrupt_retry_after_marker_is_no_backoff() {
let (_td, cache) = fixture();
for raw in ["", "soon", "-5", "1e9", "12 34"] {
fs::write(cache.retry_after_path(), raw).unwrap();
assert!(
cache.backoff_remaining_at(t0()).is_none(),
"{raw:?} must not pin the vendor offline"
);
assert!(cache.fresh_payload_at(DEFAULT_TTL, t0()).unwrap().is_none());
}
let until = t0() + Duration::from_secs(90);
let secs = until
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
fs::write(cache.retry_after_path(), format!("{secs}\n")).unwrap();
assert_eq!(
cache.backoff_remaining_at(t0()),
Some(Duration::from_secs(90))
);
}
#[test]
fn human_backoff_formats_seconds_minutes_and_hours() {
let s = Duration::from_secs;
assert_eq!(human_backoff(s(0)), "0s");
assert_eq!(human_backoff(s(45)), "45s");
assert_eq!(human_backoff(s(59)), "59s");
assert_eq!(human_backoff(s(60)), "1m");
assert_eq!(human_backoff(s(4 * 60)), "4m");
assert_eq!(human_backoff(s(4 * 60 + 1)), "5m");
assert_eq!(human_backoff(s(5 * 60)), "5m");
assert_eq!(human_backoff(s(60 * 60)), "1h");
assert_eq!(human_backoff(s(62 * 60)), "1h 2m");
assert_eq!(human_backoff(s(61 * 60 + 30)), "1h 2m");
assert_eq!(human_backoff(s(2 * 3600)), "2h");
}
#[test]
fn fallback_payload_refuses_a_payload_older_than_the_limit() {
let (_td, cache) = fixture();
cache.write_payload(b"old").unwrap();
std::thread::sleep(Duration::from_millis(60));
assert!(cache.maybe_payload().unwrap().is_some());
assert!(
cache
.fallback_payload(Duration::from_millis(5))
.unwrap()
.is_none()
);
assert_eq!(
cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
Some(&b"old"[..])
);
}
#[test]
fn last_error_round_trip() {
let (_td, cache) = fixture();
cache.write_last_error(503, "service unavailable");
let (code, msg) = cache.read_last_error().unwrap();
assert_eq!(code, 503);
assert_eq!(msg, "service unavailable");
}
#[test]
fn last_error_with_empty_message_round_trips() {
let (_td, cache) = fixture();
cache.write_last_error(429, "");
let (code, msg) = cache.read_last_error().unwrap();
assert_eq!(code, 429);
assert_eq!(msg, "");
}
#[test]
fn last_error_replaces_401_body_with_credential_neutral_message() {
let (_td, cache) = fixture();
cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
assert!(!persisted.contains("PANCEA"));
assert!(!persisted.contains("<credential>"));
}
#[test]
fn last_error_replaces_403_body_with_credential_neutral_message() {
let (_td, cache) = fixture();
cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
assert!(!persisted.contains("PANCEA"));
assert!(!persisted.contains("<credential>"));
}
#[test]
fn write_last_error_returns_exactly_what_a_later_run_would_read() {
for (code, raw) in [
(401u16, "PANCEA user@example.test <credential>&token"),
(403, "PANCEA account@example.test <credential>&token"),
(429, "rate limited, retry in 60s"),
(500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
] {
let (_td, cache) = fixture();
let returned = cache.write_last_error(code, raw);
assert_eq!(
returned,
cache.read_last_error().unwrap(),
"returned pair diverged from the persisted one for {code}"
);
}
}
#[test]
fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
let mut sites = Vec::new();
for file in crate::guard::rs_files_in("src") {
if !file.ends_with("fetch.rs") {
continue;
}
let source = std::fs::read_to_string(&file).expect("readable module");
for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
if line.contains("(status, body") {
sites.push(format!("{}:{}", file.display(), n + 1));
}
}
}
assert!(
sites.is_empty(),
"a last_error pair must be the return of `write_last_error`, which \
redacts 401/403 — building one from the raw body puts the response \
body in the widget tooltip. Found: {sites:#?}"
);
}
#[test]
fn only_the_shared_fallback_reads_the_stale_payload() {
let mut sites = Vec::new();
for file in crate::guard::rs_files_in("src") {
if file.ends_with("outcome.rs") || file.ends_with("cache.rs") {
continue;
}
let source = std::fs::read_to_string(&file).expect("readable module");
for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
if line.contains("fallback_payload(") {
sites.push(format!("{}:{}", file.display(), n + 1));
}
}
}
assert!(
sites.is_empty(),
"reach the stale payload through `outcome::fallback`, which decides \
what a cold cache means for every vendor at once. Found: {sites:#?}"
);
}
#[test]
fn the_returned_pair_carries_the_auth_redaction() {
for code in [401u16, 403] {
let (_td, cache) = fixture();
let (returned_code, msg) =
cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
assert_eq!(returned_code, code);
assert_eq!(msg, AUTH_FAILURE_MESSAGE);
assert!(!msg.contains("PANCEA"), "{msg}");
assert!(!msg.contains("<credential>"), "{msg}");
}
}
#[test]
fn write_last_error_redacts_the_attached_secret() {
let (_td, cache) = fixture();
let cache = cache.redacting("test-key-value");
let (code, msg) = cache.write_last_error(0, "msg test-key-value");
assert_eq!(code, 0);
assert_eq!(msg, "msg [key]");
assert!(!msg.contains("test-key-value"), "{msg}");
let on_disk = fs::read_to_string(cache.last_error_path()).unwrap();
assert!(!on_disk.contains("test-key-value"), "{on_disk}");
assert_eq!((code, msg), cache.read_last_error().unwrap());
let key = "abcdefghijklmnop";
let (_td2, cache2) = fixture();
let cache2 = cache2.redacting(key);
let (_, msg) = cache2.write_last_error(0, "bad key abcdefgh…");
assert_eq!(
msg,
"The provider's answer quoted the key, so it is not shown."
);
let on_disk = fs::read_to_string(cache2.last_error_path()).unwrap();
assert!(!on_disk.contains(key), "{on_disk}");
let (code, msg) = cache.write_last_error(401, "msg test-key-value");
assert_eq!(code, 401);
assert_eq!(msg, AUTH_FAILURE_MESSAGE);
}
#[test]
fn write_payload_redacts_the_attached_secret() {
let (_td, cache) = fixture();
let cache = cache.redacting("test-key-value");
cache
.write_payload(br#"{"msg":"ok test-key-value"}"#)
.unwrap();
let on_disk = fs::read(cache.payload_path()).unwrap();
assert_eq!(on_disk, br#"{"msg":"ok [key]"}"#);
let (_td, cache) = fixture();
let cache = cache.redacting("test-key-value");
cache
.write_payload(br#"{"a":"test-key-value","b":"test-key-value"}"#)
.unwrap();
let on_disk = fs::read(cache.payload_path()).unwrap();
assert_eq!(on_disk, br#"{"a":"[key]","b":"[key]"}"#);
let (_td, cache) = fixture();
let raw = br#"{"msg":"ok test-key-value"}"#;
cache.write_payload(raw).unwrap();
assert_eq!(fs::read(cache.payload_path()).unwrap(), raw);
}
#[test]
fn write_payload_redacts_escaped_echoes_and_leaves_numbers_alone() {
let (_td, cache) = fixture();
let cache = cache.redacting("test-key-value");
cache
.write_payload(br#"{"msg":"ok \u0074est-key-value"}"#)
.unwrap();
assert_eq!(
fs::read(cache.payload_path()).unwrap(),
br#"{"msg":"ok [key]"}"#
);
let (_td, cache) = fixture();
let cache = cache.redacting("a\"b");
cache.write_payload(br#"{"msg":"ok a\"b"}"#).unwrap();
assert_eq!(
fs::read(cache.payload_path()).unwrap(),
br#"{"msg":"ok [key]"}"#
);
let (_td, cache) = fixture();
let cache = cache.redacting("12");
let raw = br#"{"used":12,"msg":"fine"}"#;
cache.write_payload(raw).unwrap();
assert_eq!(fs::read(cache.payload_path()).unwrap(), raw);
}
#[test]
fn write_payload_skips_a_payload_it_cannot_redact() {
let secret = "test-key-value";
let (_td, cache) = fixture();
let cache = cache.redacting(secret);
cache.write_payload(b"plain text test-key-value").unwrap();
assert!(
cache.maybe_payload().unwrap().is_none(),
"a non-JSON payload quoting the secret must not be persisted"
);
assert!(!cache.payload_path().exists());
let (_td, cache) = fixture();
let cache = cache.redacting(secret);
cache.write_payload(br#"{"msg":"last good"}"#).unwrap();
cache.mark_stale();
cache.write_last_error(500, "an older failure");
cache.write_payload(br#"{"test-key-value":1}"#).unwrap();
assert_eq!(
fs::read(cache.payload_path()).unwrap(),
br#"{"msg":"last good"}"#
);
assert!(cache.is_stale());
assert!(cache.read_last_error().is_some());
}
#[test]
fn write_payload_skips_an_echo_the_parse_cannot_see() {
let (_td, cache) = fixture();
let cache = cache.redacting("test-key-value");
cache
.write_payload(br#"{"note":"test-key-value","note":"fine","data":1}"#)
.unwrap();
assert!(
!cache.payload_path().exists(),
"a duplicate key hid the echo"
);
let deep = format!(
r#"{{"data":1,"extra":{}"ok \u0074est-key-value"{}}}"#,
"[".repeat(160),
"]".repeat(160)
);
assert!(serde_json::from_str::<serde_json::Value>(&deep).is_err());
let (_td, cache) = fixture();
let cache = cache.redacting("test-key-value");
cache.write_payload(deep.as_bytes()).unwrap();
assert!(
!cache.payload_path().exists(),
"a deep document hid the echo"
);
assert!(json_strings_contain(br#""a\u0062c""#, "abc"));
assert!(json_strings_contain(br#""x \ud83d\ude00 y""#, "\u{1f600}"));
assert!(json_strings_contain(br#""say \"hi\"""#, "\"hi\""));
assert!(!json_strings_contain(br#"{"n":12}"#, "12"));
}
#[test]
fn last_error_round_trips_a_multi_line_message() {
let (_td, cache) = fixture();
let body = "{\n \"error\": \"quota exhausted\",\n \"retry_after\": 3600\n}";
cache.write_last_error(429, body);
let (code, msg) = cache.read_last_error().unwrap();
assert_eq!(code, 429);
assert_eq!(msg, body);
assert!(
msg.contains("quota exhausted"),
"message was truncated to its first line: {msg:?}"
);
}
#[test]
fn last_error_strips_terminal_controls_before_persisting() {
let (_td, cache) = fixture();
cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
let (code, msg) = cache.read_last_error().unwrap();
assert_eq!(code, 500);
assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
assert!(
msg.contains("Y2FuYXJ5"),
"non-auth diagnostic was not preserved"
);
assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
}
#[test]
fn last_error_reads_files_written_by_the_previous_version() {
let (_td, cache) = fixture();
fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
assert_eq!(
cache.read_last_error(),
Some((503, "service unavailable".into()))
);
fs::write(cache.last_error_path(), "429").unwrap();
assert_eq!(cache.read_last_error(), Some((429, String::new())));
fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
assert!(cache.read_last_error().is_none());
}
#[test]
fn lock_serializes_concurrent_acquirers() {
let (_td, cache) = fixture();
let lock_path = cache.lock_path();
let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
let res = acquire_lock(&lock_path, Duration::from_millis(100));
assert!(matches!(res, Err(AppError::Other(_))));
}
#[tokio::test(flavor = "current_thread")]
async fn async_lock_does_not_stall_the_runtime() {
let (_td, cache) = fixture();
let lock_path = cache.lock_path();
let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
let mut ticks = 0usize;
let ticker = async {
let mut iv = tokio::time::interval(Duration::from_millis(20));
iv.tick().await;
loop {
iv.tick().await;
ticks += 1;
}
};
tokio::select! {
res = waiter => {
assert!(matches!(res, Err(AppError::Other(_))));
}
_ = ticker => unreachable!("the ticker loops forever"),
}
assert!(
ticks > 1,
"runtime was starved while the lock was contended ({ticks} ticks)"
);
}
#[test]
fn atomic_write_creates_parent_dirs() {
let td = TempDir::new().unwrap();
let nested = td.path().join("a/b/c/file.txt");
atomic_write(&nested, b"abc").unwrap();
assert_eq!(fs::read(&nested).unwrap(), b"abc");
}
}