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 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
522pub 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
535pub 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#[derive(Debug, PartialEq, Eq)]
569enum PayloadRedaction {
570 Keep,
572 Replace(Vec<u8>),
575 Skip,
578}
579
580fn 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
611fn 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
680fn 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
717pub 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
730pub 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#[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 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 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 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 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 assert!(cache.fresh_payload(Duration::ZERO).unwrap().is_none());
860 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 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 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 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 assert!(
929 cache
930 .fresh_payload(Duration::from_secs(10))
931 .unwrap()
932 .is_some()
933 );
934 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 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 assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
980 assert!(
981 remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
982 "{remaining:?}"
983 );
984 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 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 assert!(body.ends_with("5m"), "{body}");
1025 }
1026 other => panic!("expected Http 429, got {other:?}"),
1027 }
1028 }
1029
1030 #[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 assert!(
1042 cache
1043 .fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
1044 .unwrap()
1045 .is_none()
1046 );
1047 assert_eq!(
1050 cache
1051 .fresh_payload_at(DEFAULT_TTL, t0())
1052 .unwrap()
1053 .as_deref(),
1054 Some(&b"last good"[..])
1055 );
1056 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 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 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 std::thread::sleep(Duration::from_millis(60));
1166
1167 assert!(cache.maybe_payload().unwrap().is_some());
1170
1171 assert!(
1175 cache
1176 .fallback_payload(Duration::from_millis(5))
1177 .unwrap()
1178 .is_none()
1179 );
1180
1181 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 #[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 #[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 #[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 #[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 #[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 assert_eq!((code, msg), cache.read_last_error().unwrap());
1345
1346 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 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 #[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 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 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 #[test]
1403 fn write_payload_redacts_escaped_echoes_and_leaves_numbers_alone() {
1404 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 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 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 #[test]
1440 fn write_payload_skips_a_payload_it_cannot_redact() {
1441 let secret = "test-key-value";
1442
1443 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 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 #[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 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 #[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 #[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 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 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 #[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 let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
1584
1585 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 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}