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(Debug, Clone)]
40pub struct Cache {
41 dir: PathBuf,
42}
43
44impl Cache {
45 pub fn for_vendor(vendor: &str) -> Result<Self> {
48 let base = xdg_cache_dir()?.join("ai-usagebar").join(vendor);
49 Ok(Self { dir: base })
50 }
51
52 pub fn for_vendor_account(vendor: &str, label: &str) -> Result<Self> {
57 let base = xdg_cache_dir()?
58 .join("ai-usagebar")
59 .join(vendor)
60 .join(label);
61 Ok(Self { dir: base })
62 }
63
64 pub fn at(path: PathBuf) -> Self {
66 Self { dir: path }
67 }
68
69 pub fn ensure_dir(&self) -> Result<()> {
71 fs::create_dir_all(&self.dir).map_err(|e| AppError::io_at(&self.dir, e))
72 }
73
74 pub fn dir(&self) -> &Path {
75 &self.dir
76 }
77
78 pub fn payload_path(&self) -> PathBuf {
79 self.dir.join("usage.json")
80 }
81 pub fn stale_path(&self) -> PathBuf {
82 self.dir.join(".stale")
83 }
84 pub fn last_error_path(&self) -> PathBuf {
85 self.dir.join(".last_error")
86 }
87 pub fn lock_path(&self) -> PathBuf {
88 self.dir.join(".fetch.lock")
89 }
90 pub fn retry_after_path(&self) -> PathBuf {
93 self.dir.join(".retry_after")
94 }
95
96 pub fn payload_age(&self) -> Option<Duration> {
99 let meta = fs::metadata(self.payload_path()).ok()?;
100 let mtime = meta.modified().ok()?;
101 SystemTime::now().duration_since(mtime).ok()
102 }
103
104 pub fn fresh_payload(&self, ttl: Duration) -> Result<Option<Vec<u8>>> {
124 self.fresh_payload_at(ttl, SystemTime::now())
125 }
126
127 pub fn fresh_payload_at(&self, ttl: Duration, now: SystemTime) -> Result<Option<Vec<u8>>> {
131 if let Some(remaining) = self.backoff_remaining_at(now) {
132 if self.payload_age().is_some_and(|age| age <= MAX_STALE) {
133 return self.read_payload().map(Some);
134 }
135 return Err(AppError::Http {
136 status: 429,
137 body: format!("rate limited; next attempt in {}", human_backoff(remaining)),
138 });
139 }
140 let Some(age) = self.payload_age() else {
141 return Ok(None);
142 };
143 if age < ttl {
144 self.read_payload().map(Some)
145 } else {
146 Ok(None)
147 }
148 }
149
150 pub fn note_rate_limit_at(&self, now: SystemTime) {
154 let until = now + RATE_LIMIT_BACKOFF;
155 let secs = until
156 .duration_since(SystemTime::UNIX_EPOCH)
157 .map(|d| d.as_secs())
158 .unwrap_or(0);
159 let _ = atomic_write(&self.retry_after_path(), secs.to_string().as_bytes());
160 }
161
162 pub fn clear_backoff(&self) {
165 let _ = fs::remove_file(self.retry_after_path());
166 }
167
168 pub fn backoff_remaining_at(&self, now: SystemTime) -> Option<Duration> {
172 let raw = fs::read_to_string(self.retry_after_path()).ok()?;
173 let secs = raw.trim().parse::<u64>().ok()?;
174 let until = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(secs))?;
175 let remaining = until.duration_since(now).ok()?;
176 if remaining.is_zero() {
177 None
178 } else {
179 Some(remaining)
180 }
181 }
182
183 pub fn backoff_remaining(&self) -> Option<Duration> {
185 self.backoff_remaining_at(SystemTime::now())
186 }
187
188 pub fn maybe_payload(&self) -> Result<Option<Vec<u8>>> {
194 if !self.payload_path().exists() {
195 return Ok(None);
196 }
197 self.read_payload().map(Some)
198 }
199
200 pub fn fallback_payload(&self, max_stale: Duration) -> Result<Option<Vec<u8>>> {
206 let Some(age) = self.payload_age() else {
207 return Ok(None);
208 };
209 if age > max_stale {
210 return Ok(None);
211 }
212 self.read_payload().map(Some)
213 }
214
215 fn read_payload(&self) -> Result<Vec<u8>> {
216 let p = self.payload_path();
217 let mut f = File::open(&p).map_err(|e| AppError::io_at(&p, e))?;
218 let mut buf = Vec::new();
219 f.read_to_end(&mut buf)
220 .map_err(|e| AppError::io_at(&p, e))?;
221 Ok(buf)
222 }
223
224 pub fn write_payload(&self, bytes: &[u8]) -> Result<()> {
227 self.ensure_dir()?;
228 let mut tmp = tempfile::Builder::new()
229 .prefix(".usage.")
230 .tempfile_in(&self.dir)
231 .map_err(|e| AppError::io_at(&self.dir, e))?;
232 tmp.write_all(bytes)
233 .map_err(|e| AppError::io_at(tmp.path(), e))?;
234 tmp.as_file_mut()
235 .sync_all()
236 .map_err(|e| AppError::io_at(tmp.path(), e))?;
237 tmp.persist(self.payload_path())
238 .map_err(|e| AppError::io_at(self.payload_path(), e.error))?;
239 let _ = fs::remove_file(self.stale_path());
242 let _ = fs::remove_file(self.last_error_path());
243 self.clear_backoff();
244 Ok(())
245 }
246
247 pub fn mark_stale(&self) {
249 let _ = self.ensure_dir();
250 let _ = File::create(self.stale_path());
251 }
252
253 pub fn is_stale(&self) -> bool {
254 self.stale_path().exists()
255 }
256
257 pub fn forget(&self) {
261 let _ = fs::remove_file(self.payload_path());
262 let _ = fs::remove_file(self.stale_path());
263 let _ = fs::remove_file(self.last_error_path());
264 self.clear_backoff();
265 }
266
267 pub fn write_last_error(&self, code: u16, msg: &str) -> (u16, String) {
279 let _ = self.ensure_dir();
280 let path = self.last_error_path();
281 let msg = if matches!(code, 401 | 403) {
285 AUTH_FAILURE_MESSAGE
286 } else {
287 msg
288 };
289 let msg = crate::display::sanitize_untrusted_field(msg);
290 let body = format!("{code}\n{msg}");
291 let _ = atomic_write(&path, body.as_bytes());
292 if code == 429 {
293 self.note_rate_limit_at(SystemTime::now());
294 }
295 (code, msg)
296 }
297
298 pub fn clear_last_error(&self) {
301 let _ = fs::remove_file(self.last_error_path());
302 self.clear_backoff();
303 }
304
305 pub fn read_last_error(&self) -> Option<(u16, String)> {
306 let raw = fs::read_to_string(self.last_error_path()).ok()?;
307 let (code, msg) = raw.split_once('\n').unwrap_or((raw.as_str(), ""));
313 Some((code.parse::<u16>().ok()?, msg.to_string()))
314 }
315}
316
317fn human_backoff(remaining: Duration) -> String {
322 let secs = remaining.as_secs();
323 if secs < 60 {
324 return format!("{secs}s");
325 }
326 let minutes = secs.div_ceil(60);
327 let (hours, minutes) = (minutes / 60, minutes % 60);
328 match (hours, minutes) {
329 (0, m) => format!("{m}m"),
330 (h, 0) => format!("{h}h"),
331 (h, m) => format!("{h}h {m}m"),
332 }
333}
334
335pub async fn acquire_lock_async(path: &Path, timeout: Duration) -> Result<LockGuard> {
348 let path = path.to_path_buf();
349 tokio::task::spawn_blocking(move || acquire_lock(&path, timeout))
350 .await
351 .map_err(|e| AppError::Other(format!("cache lock task failed: {e}")))?
352}
353
354pub fn acquire_lock(path: &Path, timeout: Duration) -> Result<LockGuard> {
355 if let Some(parent) = path.parent() {
356 fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
357 }
358 let f = OpenOptions::new()
359 .create(true)
360 .read(true)
361 .write(true)
362 .truncate(false)
363 .open(path)
364 .map_err(|e| AppError::io_at(path, e))?;
365
366 let deadline = std::time::Instant::now() + timeout;
367 loop {
368 match f.try_lock_exclusive() {
369 Ok(()) => return Ok(LockGuard { file: f }),
370 Err(_) => {
371 if std::time::Instant::now() >= deadline {
372 return Err(AppError::Other(format!(
373 "cache lock timeout after {:?}",
374 timeout
375 )));
376 }
377 std::thread::sleep(Duration::from_millis(50));
378 }
379 }
380 }
381}
382
383pub struct LockGuard {
387 file: File,
388}
389
390impl Drop for LockGuard {
391 fn drop(&mut self) {
392 let _ = FileExt::unlock(&self.file);
393 }
394}
395
396pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
399 let dir = path.parent().ok_or_else(|| {
400 AppError::Other(format!(
401 "atomic_write: path has no parent: {}",
402 path.display()
403 ))
404 })?;
405 fs::create_dir_all(dir).map_err(|e| AppError::io_at(dir, e))?;
406 let mut tmp = tempfile::Builder::new()
407 .prefix(".tmp.")
408 .tempfile_in(dir)
409 .map_err(|e| AppError::io_at(dir, e))?;
410 tmp.write_all(bytes)
411 .map_err(|e| AppError::io_at(tmp.path(), e))?;
412 tmp.as_file_mut()
413 .sync_all()
414 .map_err(|e| AppError::io_at(tmp.path(), e))?;
415 tmp.persist(path)
416 .map_err(|e| AppError::io_at(path, e.error))?;
417 Ok(())
418}
419
420pub(crate) fn xdg_cache_dir() -> Result<PathBuf> {
421 directories::BaseDirs::new()
422 .map(|b| b.cache_dir().to_path_buf())
423 .ok_or_else(|| AppError::Other("could not resolve XDG cache dir (no HOME?)".into()))
424}
425
426pub fn home_dir() -> Result<PathBuf> {
434 directories::BaseDirs::new()
435 .map(|b| b.home_dir().to_path_buf())
436 .ok_or_else(|| AppError::Other("could not resolve home directory (no HOME?)".into()))
437}
438
439pub fn fingerprint_of(secret: &str) -> String {
445 use sha2::{Digest, Sha256};
446 use std::fmt::Write as _;
447
448 let digest = Sha256::digest(secret.as_bytes());
449 let mut hex = String::with_capacity(16);
450 for byte in digest.iter().take(8) {
451 let _ = write!(hex, "{byte:02x}");
452 }
453 hex
454}
455
456#[cfg(test)]
463pub(crate) fn closed_temp_file(name: &str, contents: Option<&str>) -> (tempfile::TempDir, PathBuf) {
464 let dir = tempfile::TempDir::new().unwrap();
465 let path = dir.path().join(name);
466 if let Some(c) = contents {
467 std::fs::write(&path, c).unwrap();
468 }
469 (dir, path)
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use tempfile::TempDir;
476
477 fn fixture() -> (TempDir, Cache) {
478 let td = TempDir::new().unwrap();
479 let cache = Cache::at(td.path().join("anthropic"));
480 cache.ensure_dir().unwrap();
481 (td, cache)
482 }
483
484 #[test]
485 fn ensure_dir_is_idempotent() {
486 let (_td, cache) = fixture();
487 cache.ensure_dir().unwrap();
488 cache.ensure_dir().unwrap();
489 assert!(cache.dir().is_dir());
490 }
491
492 #[test]
493 fn write_then_read_round_trip() {
494 let (_td, cache) = fixture();
495 cache.write_payload(b"hello world").unwrap();
496 let got = cache.maybe_payload().unwrap();
497 assert_eq!(got.as_deref(), Some(&b"hello world"[..]));
498 }
499
500 #[test]
501 fn fingerprint_is_the_first_eight_sha256_bytes_in_lower_hex() {
502 assert_eq!(fingerprint_of("abc"), "ba7816bf8f01cfea");
505 assert_eq!(fingerprint_of("").len(), 16);
506 assert_ne!(fingerprint_of("a"), fingerprint_of("b"));
507 }
508
509 #[test]
510 fn forget_drops_the_payload_and_its_sidecars() {
511 let (_td, cache) = fixture();
512 cache.write_payload(b"previous login").unwrap();
513 cache.mark_stale();
514 cache.write_last_error(401, "expired");
515 cache.note_rate_limit_at(SystemTime::now());
516 cache.forget();
517 assert!(cache.maybe_payload().unwrap().is_none());
518 assert!(!cache.is_stale());
519 assert!(cache.read_last_error().is_none());
520 assert!(cache.backoff_remaining().is_none());
521 assert!(cache.dir().is_dir());
522 }
523
524 #[test]
525 fn maybe_payload_returns_none_when_missing() {
526 let (_td, cache) = fixture();
527 assert!(cache.maybe_payload().unwrap().is_none());
528 }
529
530 #[test]
531 fn fresh_payload_respects_ttl() {
532 let (_td, cache) = fixture();
533 cache.write_payload(b"x").unwrap();
534 assert!(
536 cache
537 .fresh_payload(Duration::from_secs(10))
538 .unwrap()
539 .is_some()
540 );
541 assert!(
543 cache
544 .fresh_payload(Duration::from_secs(0))
545 .unwrap()
546 .is_none()
547 );
548 }
549
550 #[test]
551 fn write_clears_stale_marker_and_last_error() {
552 let (_td, cache) = fixture();
553 cache.mark_stale();
554 cache.write_last_error(429, "rate limited");
555 assert!(cache.is_stale());
556 assert!(cache.read_last_error().is_some());
557
558 cache.write_payload(b"fresh").unwrap();
559 assert!(!cache.is_stale());
560 assert!(cache.read_last_error().is_none());
561 }
562
563 fn t0() -> SystemTime {
567 SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000)
568 }
569
570 fn arm_backoff_at(cache: &Cache, now: SystemTime) {
571 cache.note_rate_limit_at(now);
572 assert!(cache.retry_after_path().exists());
573 }
574
575 #[test]
576 fn a_429_arms_the_backoff_and_other_statuses_do_not() {
577 let (_td, cache) = fixture();
578 cache.write_last_error(500, "upstream down");
579 assert!(cache.backoff_remaining().is_none());
580 assert!(!cache.retry_after_path().exists());
581
582 cache.write_last_error(429, "slow down");
583 let remaining = cache.backoff_remaining().expect("429 must arm the backoff");
584 assert!(remaining <= RATE_LIMIT_BACKOFF, "{remaining:?}");
587 assert!(
588 remaining >= RATE_LIMIT_BACKOFF - Duration::from_secs(5),
589 "{remaining:?}"
590 );
591 assert_eq!(cache.read_last_error(), Some((429, "slow down".into())));
593 }
594
595 #[test]
596 fn backoff_remaining_counts_down_from_the_injected_clock() {
597 let (_td, cache) = fixture();
598 arm_backoff_at(&cache, t0());
599
600 assert_eq!(cache.backoff_remaining_at(t0()), Some(RATE_LIMIT_BACKOFF));
601 assert_eq!(
602 cache.backoff_remaining_at(t0() + Duration::from_secs(60)),
603 Some(RATE_LIMIT_BACKOFF - Duration::from_secs(60))
604 );
605 assert!(
607 cache
608 .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF)
609 .is_none()
610 );
611 assert!(
612 cache
613 .backoff_remaining_at(t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1))
614 .is_none()
615 );
616 }
617
618 #[test]
619 fn during_backoff_with_no_payload_fresh_payload_refuses_the_network() {
620 let (_td, cache) = fixture();
621 arm_backoff_at(&cache, t0());
622
623 let err = cache
624 .fresh_payload_at(DEFAULT_TTL, t0() + Duration::from_secs(19))
625 .expect_err("no payload during backoff must be an error, not a fetch");
626 match err {
627 AppError::Http { status, body } => {
628 assert_eq!(status, 429);
629 assert!(body.contains("next attempt in"), "{body}");
630 assert!(body.ends_with("5m"), "{body}");
632 }
633 other => panic!("expected Http 429, got {other:?}"),
634 }
635 }
636
637 #[test]
642 fn during_backoff_an_expired_but_not_stale_payload_is_served() {
643 let (_td, cache) = fixture();
644 cache.write_payload(b"last good").unwrap();
645 arm_backoff_at(&cache, t0());
646
647 assert!(
649 cache
650 .fresh_payload_at(Duration::ZERO, t0() + RATE_LIMIT_BACKOFF)
651 .unwrap()
652 .is_none()
653 );
654 assert_eq!(
656 cache
657 .fresh_payload_at(Duration::ZERO, t0())
658 .unwrap()
659 .as_deref(),
660 Some(&b"last good"[..])
661 );
662 }
663
664 #[test]
665 fn after_the_backoff_expires_the_ttl_rule_is_back_in_charge() {
666 let (_td, cache) = fixture();
667 cache.write_payload(b"x").unwrap();
668 arm_backoff_at(&cache, t0());
669 let later = t0() + RATE_LIMIT_BACKOFF + Duration::from_secs(1);
670
671 assert!(
672 cache
673 .fresh_payload_at(Duration::from_secs(10), later)
674 .unwrap()
675 .is_some()
676 );
677 assert!(
678 cache
679 .fresh_payload_at(Duration::ZERO, later)
680 .unwrap()
681 .is_none()
682 );
683 fs::remove_file(cache.payload_path()).unwrap();
685 assert!(
686 cache
687 .fresh_payload_at(DEFAULT_TTL, later)
688 .unwrap()
689 .is_none()
690 );
691 }
692
693 #[test]
694 fn a_successful_payload_write_clears_the_backoff() {
695 let (_td, cache) = fixture();
696 arm_backoff_at(&cache, t0());
697 assert!(cache.backoff_remaining_at(t0()).is_some());
698
699 cache.write_payload(b"fresh").unwrap();
700 assert!(cache.backoff_remaining_at(t0()).is_none());
701 assert!(!cache.retry_after_path().exists());
702 }
703
704 #[test]
705 fn clear_last_error_also_clears_the_backoff() {
706 let (_td, cache) = fixture();
707 cache.write_last_error(429, "slow down");
708 assert!(cache.backoff_remaining().is_some());
709
710 cache.clear_last_error();
711 assert!(cache.backoff_remaining().is_none());
712 assert!(!cache.retry_after_path().exists());
713 }
714
715 #[test]
716 fn a_corrupt_retry_after_marker_is_no_backoff() {
717 let (_td, cache) = fixture();
718 for raw in ["", "soon", "-5", "1e9", "12 34"] {
719 fs::write(cache.retry_after_path(), raw).unwrap();
720 assert!(
721 cache.backoff_remaining_at(t0()).is_none(),
722 "{raw:?} must not pin the vendor offline"
723 );
724 assert!(cache.fresh_payload_at(DEFAULT_TTL, t0()).unwrap().is_none());
725 }
726 let until = t0() + Duration::from_secs(90);
729 let secs = until
730 .duration_since(SystemTime::UNIX_EPOCH)
731 .unwrap()
732 .as_secs();
733 fs::write(cache.retry_after_path(), format!("{secs}\n")).unwrap();
734 assert_eq!(
735 cache.backoff_remaining_at(t0()),
736 Some(Duration::from_secs(90))
737 );
738 }
739
740 #[test]
741 fn human_backoff_formats_seconds_minutes_and_hours() {
742 let s = Duration::from_secs;
743 assert_eq!(human_backoff(s(0)), "0s");
744 assert_eq!(human_backoff(s(45)), "45s");
745 assert_eq!(human_backoff(s(59)), "59s");
746 assert_eq!(human_backoff(s(60)), "1m");
747 assert_eq!(human_backoff(s(4 * 60)), "4m");
748 assert_eq!(human_backoff(s(4 * 60 + 1)), "5m");
749 assert_eq!(human_backoff(s(5 * 60)), "5m");
750 assert_eq!(human_backoff(s(60 * 60)), "1h");
751 assert_eq!(human_backoff(s(62 * 60)), "1h 2m");
752 assert_eq!(human_backoff(s(61 * 60 + 30)), "1h 2m");
753 assert_eq!(human_backoff(s(2 * 3600)), "2h");
754 }
755
756 #[test]
757 fn fallback_payload_refuses_a_payload_older_than_the_limit() {
758 let (_td, cache) = fixture();
759 cache.write_payload(b"old").unwrap();
760
761 std::thread::sleep(Duration::from_millis(60));
767
768 assert!(cache.maybe_payload().unwrap().is_some());
771
772 assert!(
776 cache
777 .fallback_payload(Duration::from_millis(5))
778 .unwrap()
779 .is_none()
780 );
781
782 assert_eq!(
785 cache.fallback_payload(MAX_STALE).unwrap().as_deref(),
786 Some(&b"old"[..])
787 );
788 }
789
790 #[test]
791 fn last_error_round_trip() {
792 let (_td, cache) = fixture();
793 cache.write_last_error(503, "service unavailable");
794 let (code, msg) = cache.read_last_error().unwrap();
795 assert_eq!(code, 503);
796 assert_eq!(msg, "service unavailable");
797 }
798
799 #[test]
800 fn last_error_with_empty_message_round_trips() {
801 let (_td, cache) = fixture();
802 cache.write_last_error(429, "");
803 let (code, msg) = cache.read_last_error().unwrap();
804 assert_eq!(code, 429);
805 assert_eq!(msg, "");
806 }
807
808 #[test]
809 fn last_error_replaces_401_body_with_credential_neutral_message() {
810 let (_td, cache) = fixture();
811 cache.write_last_error(401, "PANCEA user@example.test <credential>&token");
812
813 let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
814 assert_eq!(persisted, format!("401\n{AUTH_FAILURE_MESSAGE}"));
815 assert!(!persisted.contains("PANCEA"));
816 assert!(!persisted.contains("<credential>"));
817 }
818
819 #[test]
820 fn last_error_replaces_403_body_with_credential_neutral_message() {
821 let (_td, cache) = fixture();
822 cache.write_last_error(403, "PANCEA account@example.test <credential>&token");
823
824 let persisted = fs::read_to_string(cache.last_error_path()).unwrap();
825 assert_eq!(persisted, format!("403\n{AUTH_FAILURE_MESSAGE}"));
826 assert!(!persisted.contains("PANCEA"));
827 assert!(!persisted.contains("<credential>"));
828 }
829
830 #[test]
836 fn write_last_error_returns_exactly_what_a_later_run_would_read() {
837 for (code, raw) in [
838 (401u16, "PANCEA user@example.test <credential>&token"),
839 (403, "PANCEA account@example.test <credential>&token"),
840 (429, "rate limited, retry in 60s"),
841 (500, "bad\x1b]52;c;Y2FuYXJ5\x07field"),
842 ] {
843 let (_td, cache) = fixture();
844 let returned = cache.write_last_error(code, raw);
845 assert_eq!(
846 returned,
847 cache.read_last_error().unwrap(),
848 "returned pair diverged from the persisted one for {code}"
849 );
850 }
851 }
852
853 #[test]
863 fn no_vendor_builds_a_last_error_pair_from_a_raw_http_body() {
864 let mut sites = Vec::new();
865 for file in crate::guard::rs_files_in("src") {
866 if !file.ends_with("fetch.rs") {
867 continue;
868 }
869 let source = std::fs::read_to_string(&file).expect("readable module");
870 for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
871 if line.contains("(status, body") {
872 sites.push(format!("{}:{}", file.display(), n + 1));
873 }
874 }
875 }
876 assert!(
877 sites.is_empty(),
878 "a last_error pair must be the return of `write_last_error`, which \
879 redacts 401/403 — building one from the raw body puts the response \
880 body in the widget tooltip. Found: {sites:#?}"
881 );
882 }
883
884 #[test]
891 fn only_the_shared_fallback_reads_the_stale_payload() {
892 let mut sites = Vec::new();
893 for file in crate::guard::rs_files_in("src") {
894 if file.ends_with("outcome.rs") || file.ends_with("cache.rs") {
895 continue;
896 }
897 let source = std::fs::read_to_string(&file).expect("readable module");
898 for (n, line) in crate::guard::production_code(&source).lines().enumerate() {
899 if line.contains("fallback_payload(") {
900 sites.push(format!("{}:{}", file.display(), n + 1));
901 }
902 }
903 }
904 assert!(
905 sites.is_empty(),
906 "reach the stale payload through `outcome::fallback`, which decides \
907 what a cold cache means for every vendor at once. Found: {sites:#?}"
908 );
909 }
910
911 #[test]
916 fn the_returned_pair_carries_the_auth_redaction() {
917 for code in [401u16, 403] {
918 let (_td, cache) = fixture();
919 let (returned_code, msg) =
920 cache.write_last_error(code, "PANCEA user@example.test <credential>&token");
921 assert_eq!(returned_code, code);
922 assert_eq!(msg, AUTH_FAILURE_MESSAGE);
923 assert!(!msg.contains("PANCEA"), "{msg}");
924 assert!(!msg.contains("<credential>"), "{msg}");
925 }
926 }
927
928 #[test]
932 fn last_error_round_trips_a_multi_line_message() {
933 let (_td, cache) = fixture();
934 let body = "{\n \"error\": \"quota exhausted\",\n \"retry_after\": 3600\n}";
935 cache.write_last_error(429, body);
936
937 let (code, msg) = cache.read_last_error().unwrap();
938 assert_eq!(code, 429);
939 assert_eq!(msg, body);
940 assert!(
941 msg.contains("quota exhausted"),
942 "message was truncated to its first line: {msg:?}"
943 );
944 }
945
946 #[test]
947 fn last_error_strips_terminal_controls_before_persisting() {
948 let (_td, cache) = fixture();
949 cache.write_last_error(500, "bad\x1b]52;c;Y2FuYXJ5\x07\nnext\tfield");
950
951 let (code, msg) = cache.read_last_error().unwrap();
952 assert_eq!(code, 500);
953 assert_eq!(msg, "bad]52;c;Y2FuYXJ5\nnext field");
954 assert!(
955 msg.contains("Y2FuYXJ5"),
956 "non-auth diagnostic was not preserved"
957 );
958 assert!(!msg.chars().any(|ch| ch.is_control() && ch != '\n'));
959 }
960
961 #[test]
965 fn last_error_reads_files_written_by_the_previous_version() {
966 let (_td, cache) = fixture();
967
968 fs::write(cache.last_error_path(), "503\nservice unavailable").unwrap();
969 assert_eq!(
970 cache.read_last_error(),
971 Some((503, "service unavailable".into()))
972 );
973
974 fs::write(cache.last_error_path(), "429").unwrap();
975 assert_eq!(cache.read_last_error(), Some((429, String::new())));
976
977 fs::write(cache.last_error_path(), "not-a-code\nboom").unwrap();
979 assert!(cache.read_last_error().is_none());
980 }
981
982 #[test]
983 fn lock_serializes_concurrent_acquirers() {
984 let (_td, cache) = fixture();
987 let lock_path = cache.lock_path();
988 let _guard = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
989
990 let res = acquire_lock(&lock_path, Duration::from_millis(100));
991 assert!(matches!(res, Err(AppError::Other(_))));
992 }
993
994 #[tokio::test(flavor = "current_thread")]
1000 async fn async_lock_does_not_stall_the_runtime() {
1001 let (_td, cache) = fixture();
1002 let lock_path = cache.lock_path();
1003 let _held = acquire_lock(&lock_path, Duration::from_millis(500)).unwrap();
1004
1005 let waiter = acquire_lock_async(&lock_path, Duration::from_millis(400));
1007
1008 let mut ticks = 0usize;
1010 let ticker = async {
1011 let mut iv = tokio::time::interval(Duration::from_millis(20));
1012 iv.tick().await;
1013 loop {
1014 iv.tick().await;
1015 ticks += 1;
1016 }
1017 };
1018
1019 tokio::select! {
1020 res = waiter => {
1021 assert!(matches!(res, Err(AppError::Other(_))));
1023 }
1024 _ = ticker => unreachable!("the ticker loops forever"),
1025 }
1026 assert!(
1027 ticks > 1,
1028 "runtime was starved while the lock was contended ({ticks} ticks)"
1029 );
1030 }
1031
1032 #[test]
1033 fn atomic_write_creates_parent_dirs() {
1034 let td = TempDir::new().unwrap();
1035 let nested = td.path().join("a/b/c/file.txt");
1036 atomic_write(&nested, b"abc").unwrap();
1037 assert_eq!(fs::read(&nested).unwrap(), b"abc");
1038 }
1039}