1use 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
23pub const DEFAULT_TTL: Duration = Duration::from_secs(60);
25
26pub const MAX_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
29
30pub const RATE_LIMIT_BACKOFF: Duration = Duration::from_secs(5 * 60);
35
36#[derive(Clone)]
40pub struct Cache {
41 dir: PathBuf,
42 redacting: Option<String>,
46}
47
48impl std::fmt::Debug for Cache {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("Cache")
53 .field("dir", &self.dir)
54 .field("redacting", &self.redacting.is_some())
55 .finish()
56 }
57}
58
59impl Cache {
60 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 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 pub fn at(path: PathBuf) -> Self {
87 Self {
88 dir: path,
89 redacting: None,
90 }
91 }
92
93 pub(crate) fn redacting(mut self, secret: &str) -> Self {
100 self.redacting = Some(secret.to_string());
101 self
102 }
103
104 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 pub fn retry_after_path(&self) -> PathBuf {
128 self.dir.join(".retry_after")
129 }
130
131 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 pub fn fresh_payload(&self, ttl: Duration) -> Result<Option<Vec<u8>>> {
164 self.fresh_payload_at(ttl, SystemTime::now())
165 }
166
167 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 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 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 pub fn clear_backoff(&self) {
209 let _ = fs::remove_file(self.retry_after_path());
210 }
211
212 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 pub fn backoff_remaining(&self) -> Option<Duration> {
229 self.backoff_remaining_at(SystemTime::now())
230 }
231
232 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 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 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 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 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 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 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 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 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 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 let msg = if matches!(code, 401 | 403) {
414 AUTH_FAILURE_MESSAGE
415 } else {
416 msg
417 };
418 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 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 let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
452 Some((code.parse::<u16>().ok()?, msg.to_string()))
453 }
454}
455
456pub(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
474pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
487 let path = path.to_path_buf();
488 tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
489 .await
490 .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
491}
492
493pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
494 if let Some(parent) = path.parent() {
495 fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
496 }
497 let f = OpenOptions::new()
498 .create(true)
499 .read(true)
500 .write(true)
501 .truncate(false)
502 .open(path)
503 .map_err(|e| AppError::io_at(path, e))?;
504
505 wait_for_lock(path, timeout, || f.try_lock_exclusive())?;
506 Ok(LockGuard { file: f })
507}
508
509fn wait_for_lock(
512 path: &Path,
513 timeout: Duration,
514 mut try_lock: impl FnMut() -> std::io::Result<()>,
515) -> Result<()> {
516 let deadline = std::time::Instant::now() + timeout;
517 loop {
518 match try_lock() {
519 Ok(()) => return Ok(()),
520 Err(error)
521 if error.kind() == std::io::ErrorKind::WouldBlock
522 || error.raw_os_error() == fs2::lock_contended_error().raw_os_error() =>
523 {
524 if std::time::Instant::now() >= deadline {
525 return Err(AppError::Other(format!(
526 "cache lock timeout after {:?}",
527 timeout
528 )));
529 }
530 std::thread::sleep(Duration::from_millis(50));
531 }
532 Err(error) => return Err(AppError::io_at(path, error)),
533 }
534 }
535}
536
537pub struct LockGuard {
541 file: File,
542}
543
544impl Drop for LockGuard {
545 fn drop(&mut self) {
546 let _ = FileExt::unlock(&self.file);
547 }
548}
549
550pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
553 let dir = path.parent().ok_or_else(|| {
554 AppError::Other(format!(
555 "atomic_write: path has no parent: {}",
556 path.display()
557 ))
558 })?;
559 fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
560 let mut tmp = tempfile::Builder::new()
561 .prefix(".tmp.")
562 .tempfile_in(dir)
563 .map_err(|e| AppError::io_at(dir, e))?;
564 tmp.write_all(bytes)
565 .map_err(|e| AppError::io_at(tmp.path(), e))?;
566 tmp.as_file_mut()
567 .sync_all()
568 .map_err(|e| AppError::io_at(tmp.path(), e))?;
569 tmp.persist(path)
570 .map_err(|e| AppError::io_at(path, e.error))?;
571 Ok(())
572}
573
574#[derive(Debug, PartialEq, Eq)]
584enum PayloadRedaction {
585 Keep,
587 Replace(Vec<u8>),
590 Skip,
593}
594
595fn redact_payload(bytes: &[u8], secret: &str) -> PayloadRedaction {
603 if secret.is_empty() {
604 return PayloadRedaction::Keep;
605 }
606 let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
607 return if find_bytes(bytes, secret.as_bytes()).is_some() {
608 PayloadRedaction::Skip
609 } else {
610 PayloadRedaction::Keep
611 };
612 };
613 let mut changed = false;
614 if !redact_json_strings(&mut value, secret, &mut changed) {
615 return PayloadRedaction::Skip;
616 }
617 if !changed {
618 return PayloadRedaction::Keep;
619 }
620 match serde_json::to_vec(&value) {
621 Ok(redacted) => PayloadRedaction::Replace(redacted),
622 Err(_) => PayloadRedaction::Skip,
623 }
624}
625
626fn json_strings_contain(bytes: &[u8], secret: &str) -> bool {
631 let mut i = 0;
632 while i < bytes.len() {
633 if bytes[i] != b'"' {
634 i += 1;
635 continue;
636 }
637 i += 1;
638 let mut text = String::new();
639 let mut run_start = i;
640 let mut pending_high: Option<u16> = None;
641 while i < bytes.len() && bytes[i] != b'"' {
642 if bytes[i] != b'\\' {
643 i += 1;
644 continue;
645 }
646 text.push_str(&String::from_utf8_lossy(&bytes[run_start..i]));
647 let Some(&escape) = bytes.get(i + 1) else {
648 i = bytes.len();
649 run_start = i;
650 break;
651 };
652 i += 2;
653 let decoded = match escape {
654 b'n' => Some('\n'),
655 b't' => Some('\t'),
656 b'r' => Some('\r'),
657 b'b' => Some('\u{8}'),
658 b'f' => Some('\u{c}'),
659 b'u' => {
660 let unit = bytes
661 .get(i..i + 4)
662 .and_then(|hex| std::str::from_utf8(hex).ok())
663 .and_then(|hex| u16::from_str_radix(hex, 16).ok());
664 i = (i + 4).min(bytes.len());
665 match (unit, pending_high.take()) {
666 (Some(low @ 0xDC00..=0xDFFF), Some(high)) => char::from_u32(
667 0x10000
668 + ((u32::from(high) - 0xD800) << 10)
669 + (u32::from(low) - 0xDC00),
670 ),
671 (Some(high @ 0xD800..=0xDBFF), _) => {
672 pending_high = Some(high);
673 None
674 }
675 (Some(unit), _) => char::from_u32(u32::from(unit)),
676 (None, _) => None,
677 }
678 }
679 other => Some(char::from(other)),
680 };
681 if let Some(ch) = decoded {
682 text.push(ch);
683 }
684 run_start = i;
685 }
686 text.push_str(&String::from_utf8_lossy(&bytes[run_start..i]));
687 if text.contains(secret) {
688 return true;
689 }
690 i += 1;
691 }
692 false
693}
694
695fn redact_json_strings(value: &mut serde_json::Value, secret: &str, changed: &mut bool) -> bool {
699 match value {
700 serde_json::Value::String(text) if text.contains(secret) => {
701 *text = text.replace(secret, "[key]");
702 *changed = true;
703 true
704 }
705 serde_json::Value::Array(items) => items
706 .iter_mut()
707 .all(|item| redact_json_strings(item, secret, changed)),
708 serde_json::Value::Object(map) => {
709 if map.keys().any(|key| key.contains(secret)) {
710 return false;
711 }
712 map.values_mut()
713 .all(|item| redact_json_strings(item, secret, changed))
714 }
715 _ => true,
716 }
717}
718
719fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
720 if needle.is_empty() {
721 return Some(0);
722 }
723 haystack.windows(needle.len()).position(|w| w == needle)
724}
725
726pub(crate) fn xdg_cache_dir() -> Result<PathBuf> {
727 directories::BaseDirs::new()
728 .map(|b| b.cache_dir().to_path_buf())
729 .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
730}
731
732pub fn home_dir() -> Result<PathBuf> {
740 directories::BaseDirs::new()
741 .map(|b| b.home_dir().to_path_buf())
742 .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
743}
744
745pub fn fingerprint_of(secret: &str) -> String {
751 use sha2::{Digest, Sha256};
752 use std::fmt::Write as _;
753
754 let digest = Sha256::digest(secret.as_bytes());
755 let mut hex = String::with_capacity(16);
756 for byte in digest.iter().take(8) {
757 let _ = write!(hex, "{byte:02x}");
758 }
759 hex
760}
761
762#[cfg(test)]
769pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
770 let dir = tempfile::TempDir::new().unwrap();
771 let path = dir.path().join(name);
772 if let Some(c) = contents {
773 std::fs::write(&path, c).unwrap();
774 }
775 (dir, path)
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781 use tempfile::TempDir;
782
783 fn fixture() -> (TempDir, Cache) {
784 let td = TempDir::new().unwrap();
785 let cache = Cache::at(td.path().join("anthropic"));
786 cache.ensure_dir().unwrap();
787 (td, cache)
788 }
789
790 #[test]
791 fn ensure_dir_is_idempotent() {
792 let (_td, cache) = fixture();
793 cache.ensure_dir().unwrap();
794 cache.ensure_dir().unwrap();
795 assert!(cache.dir().is_dir());
796 }
797
798 #[test]
799 fn write_then_read_round_trip() {
800 let (_td, cache) = fixture();
801 cache.write_payload(b"hello world").unwrap();
802 let got = cache.maybe_payload().unwrap();
803 assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
804 }
805
806 #[test]
807 fn fingerprint_is_the_first_eight_sha256_bytes_in_lower_hex() {
808 assert_eq!(fingerprint_of("abc"), "ba7816bf8f01cfea");
811 assert_eq!(fingerprint_of("").len(), 16);
812 assert_ne!(fingerprint_of("a"), fingerprint_of("b"));
813 }
814
815 #[test]
816 fn forget_drops_the_payload_and_its_sidecars() {
817 let (_td, cache) = fixture();
818 cache.write_payload(b"previous login").unwrap();
819 cache.mark_stale();
820 cache.write_last_error(401, "expired");
821 cache.note_rate_limit_at(SystemTime::now());
822 cache.forget();
823 assert!(cache.maybe_payload().unwrap().is_none());
824 assert!(!cache.is_stale());
825 assert!(cache.read_last_error().is_none());
826 assert!(cache.backoff_remaining().is_none());
827 assert!(cache.dir().is_dir());
828 }
829
830 #[test]
831 fn try_forget_removes_the_same_files_and_ignores_absent_ones() {
832 let (_td, cache) = fixture();
833 cache.write_payload(b"previous login").unwrap();
834 cache.mark_stale();
835 cache.write_last_error(401, "expired");
836 cache.note_rate_limit_at(SystemTime::now());
837 cache.try_forget().unwrap();
839 assert!(cache.maybe_payload().unwrap().is_none());
840 assert!(!cache.is_stale());
841 assert!(cache.read_last_error().is_none());
842 assert!(cache.backoff_remaining().is_none());
843 cache.try_forget().unwrap();
845 }
846
847 #[test]
848 #[cfg(unix)]
849 fn try_forget_surfaces_the_first_error_that_is_not_not_found() {
850 use std::os::unix::fs::PermissionsExt;
851 let (_td, cache) = fixture();
852 cache.write_payload(b"previous login").unwrap();
853 cache.write_last_error(0, "still here");
854 let dir = cache.dir().to_path_buf();
858 fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
859 let error = cache.try_forget().unwrap_err();
860 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
861 assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
862 assert!(
863 cache.payload_path().exists(),
864 "the removal must not claim success"
865 );
866 }
867
868 #[test]
869 fn fresh_payload_zero_ttl_never_serves_a_fresh_payload() {
870 let (_td, cache) = fixture();
871 cache.write_payload(b"previous reading").unwrap();
872 assert!(cache.fresh_payload(Duration::ZERO).unwrap().is_none());
875 cache.note_rate_limit_at(SystemTime::now());
878 let error = cache.fresh_payload(Duration::ZERO).unwrap_err();
879 assert!(error.to_string().contains("rate limited"), "{error}");
880 assert!(cache.fresh_payload(DEFAULT_TTL).unwrap().is_some());
883 }
884
885 #[test]
886 #[cfg(unix)]
887 fn scrub_last_error_rewrites_the_file_in_place_in_a_read_only_dir() {
888 use std::os::unix::fs::PermissionsExt;
889 let (_td, cache) = fixture();
890 cache.write_last_error(0, "key is test-key-value");
891 let dir = cache.dir().to_path_buf();
894 fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
895 cache.try_forget().unwrap_err();
896 cache
899 .scrub_last_error(|raw| raw.replace("test-key-value", "[key]"))
900 .unwrap();
901 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
902 let raw = fs::read_to_string(cache.last_error_path()).unwrap();
903 assert!(
904 !raw.contains("test-key-value"),
905 "the key must be gone: {raw}"
906 );
907 assert!(raw.contains("[key]"), "{raw}");
908 assert!(raw.starts_with("0\n"), "the code line must survive: {raw}");
909 }
910
911 #[test]
912 fn scrub_last_error_of_a_missing_file_is_not_a_failure() {
913 let (_td, cache) = fixture();
914 cache.scrub_last_error(|raw| raw.to_string()).unwrap();
915 assert!(!cache.last_error_path().exists(), "no file may be created");
916 }
917
918 #[test]
919 fn scrub_last_error_untouched_content_leaves_the_file_alone() {
920 let (_td, cache) = fixture();
921 cache.write_last_error(0, "nothing to hide");
922 let before = cache.last_error_path();
923 let mtime_before = fs::metadata(&before).unwrap().modified().unwrap();
924 cache.scrub_last_error(|raw| raw.to_string()).unwrap();
925 let mtime_after = fs::metadata(&before).unwrap().modified().unwrap();
926 assert_eq!(
927 mtime_before, mtime_after,
928 "an unchanged file must not be rewritten"
929 );
930 }
931
932 #[test]
933 fn maybe_payload_returns_none_when_missing() {
934 let (_td, cache) = fixture();
935 assert!(cache.maybe_payload().unwrap().is_none());
936 }
937
938 #[test]
939 fn fresh_payload_respects_ttl() {
940 let (_td, cache) = fixture();
941 cache.write_payload(b"x").unwrap();
942 assert!(
944 cache
945 .fresh_payload(Duration::from_secs(10))
946 .unwrap()
947 .is_some()
948 );
949 assert!(
951 cache
952 .fresh_payload(Duration::from_secs(0))
953 .unwrap()
954 .is_none()
955 );
956 }
957
958 #[test]
959 fn write_clears_stale_marker_and_last_error() {
960 let (_td, cache) = fixture();
961 cache.mark_stale();
962 cache.write_last_error(429, "rate limited");
963 assert!(cache.is_stale());
964 assert!(cache.read_last_error().is_some());
965
966 cache.write_payload(b"fresh").unwrap();
967 assert!(!cache.is_stale());
968 assert!(cache.read_last_error().is_none());
969 }
970
971 fn t0() -> SystemTime {
975 SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
976 }
977
978 fn arm_backoff_at(cache: &Cache, now: SystemTime) {
979 cache.note_rate_limit_at(now);
980 assert!(cache.retry_after_path().exists());
981 }
982
983 #[test]
984 fn a_429_arms_the_backoff_and_other_statuses_do_not() {
985 let (_td, cache) = fixture();
986 cache.write_last_error(500, "upstream down");
987 assert!(cache.backoff_remaining().is_none());
988 assert!(!cache.retry_after_path().exists());
989
990 cache.write_last_error(429, "slow down");
991 let remaining = cache.backoff_remaining().expect("429 must arm the backoff");
992 assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
995 assert!(
996 remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
997 "{remaining:?}"
998 );
999 assert_eq!(cache.read_last_error(), Some((429, "slow down".into())));
1001 }
1002
1003 #[test]
1004 fn backoff_remaining_counts_down_from_the_injected_clock() {
1005 let (_td, cache) = fixture();
1006 arm_backoff_at(&cache, t0());
1007
1008 assert_eq!(cache.backoff_remaining_at(t0()), Some(RATE_LIMIT_BACKOFF));
1009 assert_eq!(
1010 cache.backoff_remaining_at(t0() + Duration::from_secs(60)),
1011 Some(RATE_LIMIT_BACKOFF - Duration::from_secs(60))
1012 );
1013 assert!(
1015 cache
1016 .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF)
1017 .is_none()
1018 );
1019 assert!(
1020 cache
1021 .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1))
1022 .is_none()
1023 );
1024 }
1025
1026 #[test]
1027 fn during_backoff_with_no_payload_fresh_payload_refuses_the_network() {
1028 let (_td, cache) = fixture();
1029 arm_backoff_at(&cache, t0());
1030
1031 let err = cache
1032 .fresh_payload_at(DEFAULT_TTL, t0() + Duration::from_secs(19))
1033 .expect_err("no payload during backoff must be an error, not a fetch");
1034 match err {
1035 AppError::Http { status, body } => {
1036 assert_eq!(status, 429);
1037 assert!(body.contains("next attempt in"), "{body}");
1038 assert!(body.ends_with("5m"), "{body}");
1040 }
1041 other => panic!("expected Http 429, got {other:?}"),
1042 }
1043 }
1044
1045 #[test]
1050 fn during_backoff_an_expired_but_not_stale_payload_is_served() {
1051 let (_td, cache) = fixture();
1052 cache.write_payload(b"last good").unwrap();
1053 arm_backoff_at(&cache, t0());
1054
1055 assert!(
1057 cache
1058 .fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
1059 .unwrap()
1060 .is_none()
1061 );
1062 assert_eq!(
1065 cache
1066 .fresh_payload_at(DEFAULT_TTL, t0())
1067 .unwrap()
1068 .as_deref(),
1069 Some(&b"last good"[..])
1070 );
1071 let error = cache.fresh_payload_at(Duration::ZERO, t0()).unwrap_err();
1075 assert!(error.to_string().contains("rate limited"), "{error}");
1076 }
1077
1078 #[test]
1079 fn after_the_backoff_expires_the_ttl_rule_is_back_in_charge() {
1080 let (_td, cache) = fixture();
1081 cache.write_payload(b"x").unwrap();
1082 arm_backoff_at(&cache, t0());
1083 let later = t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1);
1084
1085 assert!(
1086 cache
1087 .fresh_payload_at(Duration::from_secs(10), later)
1088 .unwrap()
1089 .is_some()
1090 );
1091 assert!(
1092 cache
1093 .fresh_payload_at(Duration::ZERO, later)
1094 .unwrap()
1095 .is_none()
1096 );
1097 fs::remove_file(cache.payload_path()).unwrap();
1099 assert!(
1100 cache
1101 .fresh_payload_at(DEFAULT_TTL, later)
1102 .unwrap()
1103 .is_none()
1104 );
1105 }
1106
1107 #[test]
1108 fn a_successful_payload_write_clears_the_backoff() {
1109 let (_td, cache) = fixture();
1110 arm_backoff_at(&cache, t0());
1111 assert!(cache.backoff_remaining_at(t0()).is_some());
1112
1113 cache.write_payload(b"fresh").unwrap();
1114 assert!(cache.backoff_remaining_at(t0()).is_none());
1115 assert!(!cache.retry_after_path().exists());
1116 }
1117
1118 #[test]
1119 fn clear_last_error_also_clears_the_backoff() {
1120 let (_td, cache) = fixture();
1121 cache.write_last_error(429, "slow down");
1122 assert!(cache.backoff_remaining().is_some());
1123
1124 cache.clear_last_error();
1125 assert!(cache.backoff_remaining().is_none());
1126 assert!(!cache.retry_after_path().exists());
1127 }
1128
1129 #[test]
1130 fn a_corrupt_retry_after_marker_is_no_backoff() {
1131 let (_td, cache) = fixture();
1132 for raw in ["", "soon", "-5", "1e9", "12 34"] {
1133 fs::write(cache.retry_after_path(), raw).unwrap();
1134 assert!(
1135 cache.backoff_remaining_at(t0()).is_none(),
1136 "{raw:?} must not pin the vendor offline"
1137 );
1138 assert!(cache.fresh_payload_at(DEFAULT_TTL, t0()).unwrap().is_none());
1139 }
1140 let until = t0() + Duration::from_secs(90);
1143 let secs = until
1144 .duration_since(SystemTime::UNIX_EPOCH)
1145 .unwrap()
1146 .as_secs();
1147 fs::write(cache.retry_after_path(), format!("{secs}\n")).unwrap();
1148 assert_eq!(
1149 cache.backoff_remaining_at(t0()),
1150 Some(Duration::from_secs(90))
1151 );
1152 }
1153
1154 #[test]
1155 fn human_backoff_formats_seconds_minutes_and_hours() {
1156 let s = Duration::from_secs;
1157 assert_eq!(human_backoff(s(0)), "0s");
1158 assert_eq!(human_backoff(s(45)), "45s");
1159 assert_eq!(human_backoff(s(59)), "59s");
1160 assert_eq!(human_backoff(s(60)), "1m");
1161 assert_eq!(human_backoff(s(4 * 60)), "4m");
1162 assert_eq!(human_backoff(s(4 * 60 + 1)), "5m");
1163 assert_eq!(human_backoff(s(5 * 60)), "5m");
1164 assert_eq!(human_backoff(s(60 * 60)), "1h");
1165 assert_eq!(human_backoff(s(62 * 60)), "1h 2m");
1166 assert_eq!(human_backoff(s(61 * 60 + 30)), "1h 2m");
1167 assert_eq!(human_backoff(s(2 * 3600)), "2h");
1168 }
1169
1170 #[test]
1171 fn fallback_payload_refuses_a_payload_older_than_the_limit() {
1172 let (_td, cache) = fixture();
1173 cache.write_payload(b"old").unwrap();
1174
1175 std::thread::sleep(Duration::from_millis(60));
1181
1182 assert!(cache.maybe_payload().unwrap().is_some());
1185
1186 assert!(
1190 cache
1191 .fallback_payload(Duration::from_millis(5))
1192 .unwrap()
1193 .is_none()
1194 );
1195
1196 assert_eq!(
1199 cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
1200 Some(&b"old"[..])
1201 );
1202 }
1203
1204 #[test]
1205 fn last_error_round_trip() {
1206 let (_td, cache) = fixture();
1207 cache.write_last_error(503, "service unavailable");
1208 let (code, msg) = cache.read_last_error().unwrap();
1209 assert_eq!(code, 503);
1210 assert_eq!(msg, "service unavailable");
1211 }
1212
1213 #[test]
1214 fn last_error_with_empty_message_round_trips() {
1215 let (_td, cache) = fixture();
1216 cache.write_last_error(429, "");
1217 let (code, msg) = cache.read_last_error().unwrap();
1218 assert_eq!(code, 429);
1219 assert_eq!(msg, "");
1220 }
1221
1222 #[test]
1223 fn last_error_replaces_401_body_with_credential_neutral_message() {
1224 let (_td, cache) = fixture();
1225 cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
1226
1227 let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
1228 assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
1229 assert!(!persisted.contains("PANCEA"));
1230 assert!(!persisted.contains("<credential>"));
1231 }
1232
1233 #[test]
1234 fn last_error_replaces_403_body_with_credential_neutral_message() {
1235 let (_td, cache) = fixture();
1236 cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
1237
1238 let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
1239 assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
1240 assert!(!persisted.contains("PANCEA"));
1241 assert!(!persisted.contains("<credential>"));
1242 }
1243
1244 #[test]
1250 fn write_last_error_returns_exactly_what_a_later_run_would_read() {
1251 for (code, raw) in [
1252 (401u16, "PANCEA user@example.test <credential>&token"),
1253 (403, "PANCEA account@example.test <credential>&token"),
1254 (429, "rate limited, retry in 60s"),
1255 (500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
1256 ] {
1257 let (_td, cache) = fixture();
1258 let returned = cache.write_last_error(code, raw);
1259 assert_eq!(
1260 returned,
1261 cache.read_last_error().unwrap(),
1262 "returned pair diverged from the persisted one for {code}"
1263 );
1264 }
1265 }
1266
1267 #[test]
1277 fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
1278 let mut sites = Vec::new();
1279 for file in crate::guard::rs_files_in("src") {
1280 if !file.ends_with("fetch.rs") {
1281 continue;
1282 }
1283 let source = std::fs::read_to_string(&file).expect("readable module");
1284 for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
1285 if line.contains("(status, body") {
1286 sites.push(format!("{}:{}", file.display(), n + 1));
1287 }
1288 }
1289 }
1290 assert!(
1291 sites.is_empty(),
1292 "a last_error pair must be the return of `write_last_error`, which \
1293 redacts 401/403 — building one from the raw body puts the response \
1294 body in the widget tooltip. Found: {sites:#?}"
1295 );
1296 }
1297
1298 #[test]
1305 fn only_the_shared_fallback_reads_the_stale_payload() {
1306 let mut sites = Vec::new();
1307 for file in crate::guard::rs_files_in("src") {
1308 if file.ends_with("outcome.rs") || file.ends_with("cache.rs") {
1309 continue;
1310 }
1311 let source = std::fs::read_to_string(&file).expect("readable module");
1312 for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
1313 if line.contains("fallback_payload(") {
1314 sites.push(format!("{}:{}", file.display(), n + 1));
1315 }
1316 }
1317 }
1318 assert!(
1319 sites.is_empty(),
1320 "reach the stale payload through `outcome::fallback`, which decides \
1321 what a cold cache means for every vendor at once. Found: {sites:#?}"
1322 );
1323 }
1324
1325 #[test]
1330 fn the_returned_pair_carries_the_auth_redaction() {
1331 for code in [401u16, 403] {
1332 let (_td, cache) = fixture();
1333 let (returned_code, msg) =
1334 cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
1335 assert_eq!(returned_code, code);
1336 assert_eq!(msg, AUTH_FAILURE_MESSAGE);
1337 assert!(!msg.contains("PANCEA"), "{msg}");
1338 assert!(!msg.contains("<credential>"), "{msg}");
1339 }
1340 }
1341
1342 #[test]
1348 fn write_last_error_redacts_the_attached_secret() {
1349 let (_td, cache) = fixture();
1350 let cache = cache.redacting("test-key-value");
1351
1352 let (code, msg) = cache.write_last_error(0, "msg test-key-value");
1353 assert_eq!(code, 0);
1354 assert_eq!(msg, "msg [key]");
1355 assert!(!msg.contains("test-key-value"), "{msg}");
1356 let on_disk = fs::read_to_string(cache.last_error_path()).unwrap();
1357 assert!(!on_disk.contains("test-key-value"), "{on_disk}");
1358 assert_eq!((code, msg), cache.read_last_error().unwrap());
1360
1361 let key = "abcdefghijklmnop";
1364 let (_td2, cache2) = fixture();
1365 let cache2 = cache2.redacting(key);
1366 let (_, msg) = cache2.write_last_error(0, "bad key abcdefgh…");
1367 assert_eq!(
1368 msg,
1369 "The provider's answer quoted the key, so it is not shown."
1370 );
1371 let on_disk = fs::read_to_string(cache2.last_error_path()).unwrap();
1372 assert!(!on_disk.contains(key), "{on_disk}");
1373
1374 let (code, msg) = cache.write_last_error(401, "msg test-key-value");
1376 assert_eq!(code, 401);
1377 assert_eq!(msg, AUTH_FAILURE_MESSAGE);
1378 }
1379
1380 #[test]
1387 fn write_payload_redacts_the_attached_secret() {
1388 let (_td, cache) = fixture();
1389 let cache = cache.redacting("test-key-value");
1390
1391 cache
1392 .write_payload(br#"{"msg":"ok test-key-value"}"#)
1393 .unwrap();
1394 let on_disk = fs::read(cache.payload_path()).unwrap();
1395 assert_eq!(on_disk, br#"{"msg":"ok [key]"}"#);
1396
1397 let (_td, cache) = fixture();
1399 let cache = cache.redacting("test-key-value");
1400 cache
1401 .write_payload(br#"{"a":"test-key-value","b":"test-key-value"}"#)
1402 .unwrap();
1403 let on_disk = fs::read(cache.payload_path()).unwrap();
1404 assert_eq!(on_disk, br#"{"a":"[key]","b":"[key]"}"#);
1405
1406 let (_td, cache) = fixture();
1408 let raw = br#"{"msg":"ok test-key-value"}"#;
1409 cache.write_payload(raw).unwrap();
1410 assert_eq!(fs::read(cache.payload_path()).unwrap(), raw);
1411 }
1412
1413 #[test]
1418 fn write_payload_redacts_escaped_echoes_and_leaves_numbers_alone() {
1419 let (_td, cache) = fixture();
1421 let cache = cache.redacting("test-key-value");
1422 cache
1423 .write_payload(br#"{"msg":"ok \u0074est-key-value"}"#)
1424 .unwrap();
1425 assert_eq!(
1426 fs::read(cache.payload_path()).unwrap(),
1427 br#"{"msg":"ok [key]"}"#
1428 );
1429
1430 let (_td, cache) = fixture();
1432 let cache = cache.redacting("a\"b");
1433 cache.write_payload(br#"{"msg":"ok a\"b"}"#).unwrap();
1434 assert_eq!(
1435 fs::read(cache.payload_path()).unwrap(),
1436 br#"{"msg":"ok [key]"}"#
1437 );
1438
1439 let (_td, cache) = fixture();
1442 let cache = cache.redacting("12");
1443 let raw = br#"{"used":12,"msg":"fine"}"#;
1444 cache.write_payload(raw).unwrap();
1445 assert_eq!(fs::read(cache.payload_path()).unwrap(), raw);
1446 }
1447
1448 #[test]
1455 fn write_payload_skips_a_payload_it_cannot_redact() {
1456 let secret = "test-key-value";
1457
1458 let (_td, cache) = fixture();
1460 let cache = cache.redacting(secret);
1461 cache.write_payload(b"plain text test-key-value").unwrap();
1462 assert!(
1463 cache.maybe_payload().unwrap().is_none(),
1464 "a non-JSON payload quoting the secret must not be persisted"
1465 );
1466 assert!(!cache.payload_path().exists());
1467
1468 let (_td, cache) = fixture();
1470 let cache = cache.redacting(secret);
1471 cache.write_payload(br#"{"msg":"last good"}"#).unwrap();
1472 cache.mark_stale();
1473 cache.write_last_error(500, "an older failure");
1474 cache.write_payload(br#"{"test-key-value":1}"#).unwrap();
1475 assert_eq!(
1476 fs::read(cache.payload_path()).unwrap(),
1477 br#"{"msg":"last good"}"#
1478 );
1479 assert!(cache.is_stale());
1480 assert!(cache.read_last_error().is_some());
1481 }
1482
1483 #[test]
1488 fn write_payload_skips_an_echo_the_parse_cannot_see() {
1489 let (_td, cache) = fixture();
1490 let cache = cache.redacting("test-key-value");
1491 cache
1492 .write_payload(br#"{"note":"test-key-value","note":"fine","data":1}"#)
1493 .unwrap();
1494 assert!(
1495 !cache.payload_path().exists(),
1496 "a duplicate key hid the echo"
1497 );
1498
1499 let deep = format!(
1500 r#"{{"data":1,"extra":{}"ok \u0074est-key-value"{}}}"#,
1501 "[".repeat(160),
1502 "]".repeat(160)
1503 );
1504 assert!(serde_json::from_str::<serde_json::Value>(&deep).is_err());
1505 let (_td, cache) = fixture();
1506 let cache = cache.redacting("test-key-value");
1507 cache.write_payload(deep.as_bytes()).unwrap();
1508 assert!(
1509 !cache.payload_path().exists(),
1510 "a deep document hid the echo"
1511 );
1512
1513 assert!(json_strings_contain(br#""a\u0062c""#, "abc"));
1515 assert!(json_strings_contain(br#""x \ud83d\ude00 y""#, "\u{1f600}"));
1516 assert!(json_strings_contain(br#""say \"hi\"""#, "\"hi\""));
1517 assert!(!json_strings_contain(br#"{"n":12}"#, "12"));
1518 }
1519
1520 #[test]
1524 fn last_error_round_trips_a_multi_line_message() {
1525 let (_td, cache) = fixture();
1526 let body = "{\n \"error\": \"quota exhausted\",\n \"retry_after\": 3600\n}";
1527 cache.write_last_error(429, body);
1528
1529 let (code, msg) = cache.read_last_error().unwrap();
1530 assert_eq!(code, 429);
1531 assert_eq!(msg, body);
1532 assert!(
1533 msg.contains("quota exhausted"),
1534 "message was truncated to its first line: {msg:?}"
1535 );
1536 }
1537
1538 #[test]
1539 fn last_error_strips_terminal_controls_before_persisting() {
1540 let (_td, cache) = fixture();
1541 cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
1542
1543 let (code, msg) = cache.read_last_error().unwrap();
1544 assert_eq!(code, 500);
1545 assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
1546 assert!(
1547 msg.contains("Y2FuYXJ5"),
1548 "non-auth diagnostic was not preserved"
1549 );
1550 assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
1551 }
1552
1553 #[test]
1557 fn last_error_reads_files_written_by_the_previous_version() {
1558 let (_td, cache) = fixture();
1559
1560 fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
1561 assert_eq!(
1562 cache.read_last_error(),
1563 Some((503, "service unavailable".into()))
1564 );
1565
1566 fs::write(cache.last_error_path(), "429").unwrap();
1567 assert_eq!(cache.read_last_error(), Some((429, String::new())));
1568
1569 fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
1571 assert!(cache.read_last_error().is_none());
1572 }
1573
1574 #[test]
1575 fn unsupported_lock_preserves_io_error_instead_of_timeout() {
1576 let path = Path::new("synthetic-credentials.lock");
1577 let mut attempts = 0;
1578 let result = wait_for_lock(path, Duration::ZERO, || {
1579 attempts += 1;
1580 Err(std::io::Error::from(std::io::ErrorKind::Unsupported))
1581 });
1582 assert_eq!(attempts, 1);
1583 match result {
1584 Err(AppError::Io {
1585 path: actual,
1586 source,
1587 }) => {
1588 assert_eq!(actual, path);
1589 assert_eq!(source.kind(), std::io::ErrorKind::Unsupported);
1590 }
1591 _ => panic!("unsupported locking must preserve the I/O error"),
1592 }
1593 }
1594
1595 #[test]
1596 fn permission_denied_lock_is_not_retried_as_contention() {
1597 let result = wait_for_lock(Path::new("synthetic.lock"), Duration::ZERO, || {
1598 Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
1599 });
1600 assert!(matches!(result, Err(AppError::Io { source, .. })
1601 if source.kind() == std::io::ErrorKind::PermissionDenied));
1602 }
1603
1604 #[test]
1605 fn contended_lock_still_reports_timeout() {
1606 let result = wait_for_lock(Path::new("synthetic.lock"), Duration::ZERO, || {
1607 Err(fs2::lock_contended_error())
1608 });
1609 assert!(matches!(result, Err(AppError::Other(message)) if message.contains("timeout")));
1610 }
1611
1612 #[test]
1613 fn lock_serializes_concurrent_acquirers() {
1614 let (_td, cache) = fixture();
1617 let lock_path = cache.lock_path();
1618 let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
1619
1620 let res = acquire_lock(&lock_path, Duration::from_millis(100));
1621 assert!(matches!(res, Err(AppError::Other(_))));
1622 }
1623
1624 #[tokio::test(flavor = "current_thread")]
1630 async fn async_lock_does_not_stall_the_runtime() {
1631 let (_td, cache) = fixture();
1632 let lock_path = cache.lock_path();
1633 let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
1634
1635 let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
1637
1638 let mut ticks = 0usize;
1640 let ticker = async {
1641 let mut iv = tokio::time::interval(Duration::from_millis(20));
1642 iv.tick().await;
1643 loop {
1644 iv.tick().await;
1645 ticks += 1;
1646 }
1647 };
1648
1649 tokio::select! {
1650 res = waiter => {
1651 assert!(matches!(res, Err(AppError::Other(_))));
1653 }
1654 _ = ticker => unreachable!("the ticker loops forever"),
1655 }
1656 assert!(
1657 ticks > 1,
1658 "runtime was starved while the lock was contended ({ticks} ticks)"
1659 );
1660 }
1661
1662 #[test]
1663 fn atomic_write_creates_parent_dirs() {
1664 let td = TempDir::new().unwrap();
1665 let nested = td.path().join("a/b/c/file.txt");
1666 atomic_write(&nested, b"abc").unwrap();
1667 assert_eq!(fs::read(&nested).unwrap(), b"abc");
1668 }
1669}