1use std::path::PathBuf;
15use std::time::Duration;
16
17pub type Fallback = Box<dyn Fn() -> Result<Vec<u8>, FileCacheError>>;
22
23#[derive(Debug, thiserror::Error)]
25pub enum FileCacheError {
26 #[error("maxAge must be positive, got {0:?}")]
27 InvalidMaxAge(Duration),
28 #[error("entry expired and refreshing failed: {0}")]
29 FallbackFailed(String),
30 #[error(transparent)]
31 Io(#[from] std::io::Error),
32 #[error("cancelled")]
33 Cancelled,
34}
35
36pub trait Cache {
49 fn get(&self, key: &str, fallback: Fallback) -> Result<Vec<u8>, FileCacheError> {
50 self.get_with_cancellation(key, fallback, false)
51 }
52
53 fn get_file(&self, key: &str, fallback: Fallback) -> Result<PathBuf, FileCacheError>;
54
55 fn get_with_cancellation(
56 &self,
57 key: &str,
58 fallback: Fallback,
59 cancelled: bool,
60 ) -> Result<Vec<u8>, FileCacheError>;
61}
62
63pub struct FileCache {
65 path: PathBuf,
66 max_age: Duration,
67}
68
69pub fn new(name: &str, max_age: Duration) -> Result<Box<dyn Cache>, FileCacheError> {
80 if max_age.is_zero() {
81 return Err(FileCacheError::InvalidMaxAge(max_age));
82 }
83
84 let path = name_to_path(name)?;
85 new_at(&path, max_age)
86}
87
88fn name_to_path(name: &str) -> Result<PathBuf, FileCacheError> {
90 let safe: String = name
91 .chars()
92 .map(|c| {
93 if c.is_ascii_alphanumeric() || c == '_' {
94 c
95 } else {
96 '_'
97 }
98 })
99 .collect();
100 let dir_name = format!("{safe}_{}", &sha256_hex(name.as_bytes())[..8]);
101
102 let user_cache_dir = dirs::cache_dir().ok_or_else(|| {
103 FileCacheError::Io(std::io::Error::other(
104 "could not determine user cache directory",
105 ))
106 })?;
107
108 Ok(user_cache_dir.join("xianyun").join(dir_name))
109}
110
111pub fn new_at(path: &std::path::Path, max_age: Duration) -> Result<Box<dyn Cache>, FileCacheError> {
113 if max_age.is_zero() {
114 return Err(FileCacheError::InvalidMaxAge(max_age));
115 }
116
117 std::fs::create_dir_all(path)?;
118
119 Ok(Box::new(FileCache {
120 path: path.to_path_buf(),
121 max_age,
122 }))
123}
124
125impl FileCache {
126 fn key_to_path(&self, key: &str) -> PathBuf {
127 self.path.join(sha256_hex(key.as_bytes()))
128 }
129
130 fn read_fresh(
133 &self,
134 path: &std::path::Path,
135 ) -> Result<Option<(Vec<u8>, bool)>, std::io::Error> {
136 let metadata = match std::fs::metadata(path) {
137 Ok(m) => m,
138 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
139 Err(e) => return Err(e),
140 };
141
142 let is_expired = metadata
143 .modified()
144 .ok()
145 .and_then(|m| m.elapsed().ok())
146 .is_none_or(|elapsed| elapsed > self.max_age);
147
148 let data = match std::fs::read(path) {
149 Ok(d) => d,
150 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
151 Err(e) => return Err(e),
152 };
153
154 Ok(Some((data, !is_expired)))
155 }
156
157 fn refresh(&self, key: &str, fallback: &Fallback) -> Result<Vec<u8>, FileCacheError> {
164 let path = self.key_to_path(key);
165
166 let existing = self.read_fresh(&path)?;
167 if let Some((data, true)) = &existing {
168 return Ok(data.clone());
169 }
170
171 match fallback() {
172 Ok(data) => {
173 write_atomic(&path, &data)?;
174 Ok(data)
175 }
176 Err(err) => {
177 if existing.is_some() {
178 Err(FileCacheError::FallbackFailed(err.to_string()))
187 } else {
188 Err(err)
191 }
192 }
193 }
194 }
195}
196
197fn write_atomic(path: &std::path::Path, data: &[u8]) -> Result<(), std::io::Error> {
199 let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
200 let mut tmp = crate::tempfile::Tempfile::new(&dir.to_string_lossy(), ".caproni-tmp-*")?;
201 tmp.overwrite(data)?;
202 std::fs::rename(tmp.name(), path)?;
203 std::mem::forget(tmp);
207 Ok(())
208}
209
210fn sha256_hex(data: &[u8]) -> String {
211 crate::sha256::digest_to_hex(data)
212}
213
214impl Cache for FileCache {
215 fn get_with_cancellation(
216 &self,
217 key: &str,
218 fallback: Fallback,
219 cancelled: bool,
220 ) -> Result<Vec<u8>, FileCacheError> {
221 if cancelled {
222 return Err(FileCacheError::Cancelled);
223 }
224 self.refresh(key, &fallback)
225 }
226
227 fn get_file(&self, key: &str, fallback: Fallback) -> Result<PathBuf, FileCacheError> {
228 let path = self.key_to_path(key);
229 self.refresh(key, &fallback)?;
230 Ok(path)
231 }
232}
233
234pub fn noop() -> Box<dyn Cache> {
242 let temp_dir = crate::tempfile::ensure_temp_dir_exists()
243 .unwrap_or_else(|e| panic!("creating noop cache dir: {e}"));
244 noop_in(&temp_dir)
245}
246
247pub fn noop_in(base_dir: &std::path::Path) -> Box<dyn Cache> {
249 if let Err(e) = std::fs::create_dir_all(base_dir) {
250 panic!("creating noop cache dir: {e}");
251 }
252
253 let dir = mkdtemp(base_dir, "caproni-noop-")
254 .unwrap_or_else(|e| panic!("creating noop cache dir: {e}"));
255
256 Box::new(NoopCache { dir })
257}
258
259fn mkdtemp(base: &std::path::Path, prefix: &str) -> Result<PathBuf, std::io::Error> {
262 for _ in 0..10_000 {
263 let random = format!("{:x}", rand_suffix());
264 let candidate = base.join(format!("{prefix}{random}"));
265 match std::fs::create_dir(&candidate) {
266 Ok(()) => return Ok(candidate),
267 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
268 Err(e) => return Err(e),
269 }
270 }
271 Err(std::io::Error::other("mkdtemp: exhausted retries"))
272}
273
274fn rand_suffix() -> u64 {
275 use std::time::{SystemTime, UNIX_EPOCH};
276 let nanos = SystemTime::now()
277 .duration_since(UNIX_EPOCH)
278 .unwrap_or_default()
279 .as_nanos();
280 let pid = u64::from(std::process::id());
281 (nanos as u64) ^ pid.wrapping_mul(0x9E37_79B9_7F4A_7C15)
282}
283
284struct NoopCache {
285 dir: PathBuf,
286}
287
288impl Cache for NoopCache {
289 fn get_with_cancellation(
290 &self,
291 _key: &str,
292 fallback: Fallback,
293 cancelled: bool,
294 ) -> Result<Vec<u8>, FileCacheError> {
295 if cancelled {
296 return Err(FileCacheError::Cancelled);
297 }
298 fallback()
299 }
300
301 fn get_file(&self, _key: &str, fallback: Fallback) -> Result<PathBuf, FileCacheError> {
302 let data = fallback()?;
303 let mut tmp =
304 crate::tempfile::Tempfile::new(&self.dir.to_string_lossy(), "caproni-remote-*")?;
305 tmp.overwrite(&data)?;
306 let path = tmp.name().to_path_buf();
307 std::mem::forget(tmp);
312 Ok(path)
313 }
314}
315
316impl Drop for NoopCache {
317 fn drop(&mut self) {
318 let _ = std::fs::remove_dir_all(&self.dir);
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use std::sync::Arc;
326 use std::sync::atomic::{AtomicI32, Ordering};
327 use std::thread;
328
329 fn new_test_cache(max_age: Duration) -> Box<dyn Cache> {
330 let tmp_dir = Box::leak(Box::new(tempfile::tempdir().unwrap()));
335 new_at(tmp_dir.path(), max_age).unwrap()
336 }
337
338 fn fallback_fn(data: Vec<u8>) -> (Fallback, Arc<AtomicI32>) {
341 let calls = Arc::new(AtomicI32::new(0));
342 let calls_clone = calls.clone();
343
344 let fb: Fallback = Box::new(move || {
345 calls_clone.fetch_add(1, Ordering::SeqCst);
346 Ok(data.clone())
347 });
348
349 (fb, calls)
350 }
351
352 #[test]
353 fn new_zero_max_age_rejected() {
354 let result = new("test", Duration::ZERO);
355 assert!(matches!(result, Err(FileCacheError::InvalidMaxAge(_))));
356 }
357
358 #[test]
363 fn new_creates_a_usable_cache() {
364 let cache = new("xianyun-filecache-new-test", Duration::from_secs(3600)).unwrap();
365 let (fb, calls) = fallback_fn(b"from new()".to_vec());
366
367 let data = cache.get("probe-key", fb).unwrap();
368 assert_eq!(data, b"from new()");
369 assert_eq!(calls.load(Ordering::SeqCst), 1);
370
371 let path = name_to_path("xianyun-filecache-new-test").unwrap();
374 let _ = std::fs::remove_dir_all(path);
375 }
376
377 #[test]
382 fn name_to_path_sanitizes_and_disambiguates() {
383 let a = name_to_path("my cache/name!").unwrap();
384 let b = name_to_path("my-cache-name-").unwrap();
385
386 assert!(a.to_string_lossy().contains("xianyun"));
387 assert_ne!(a, b, "different names must not collide after sanitization");
388
389 let a_again = name_to_path("my cache/name!").unwrap();
391 assert_eq!(a, a_again);
392 }
393
394 #[test]
395 fn get_cache_miss() {
396 let c = new_test_cache(Duration::from_secs(3600));
397 let (fb, calls) = fallback_fn(b"woof".to_vec());
398
399 let data = c.get("key1", fb).unwrap();
400 assert_eq!(data, b"woof");
401 assert_eq!(
402 calls.load(Ordering::SeqCst),
403 1,
404 "fallback should be called once on miss"
405 );
406 }
407
408 #[test]
409 fn get_cache_hit() {
410 let c = new_test_cache(Duration::from_secs(3600));
411 let (fb, calls) = fallback_fn(b"woof".to_vec());
412
413 c.get("key1", fb).unwrap();
414
415 let (fb2, _) = fallback_fn(b"woof".to_vec());
416 let data = c.get("key1", fb2).unwrap();
417 assert_eq!(data, b"woof");
418 assert_eq!(
419 calls.load(Ordering::SeqCst),
420 1,
421 "fallback should not be called again on hit"
422 );
423 }
424
425 #[test]
426 fn get_stale_entry() {
427 let c = new_test_cache(Duration::from_millis(1));
428
429 let (fb1, calls1) = fallback_fn(b"old".to_vec());
430 c.get("key1", fb1).unwrap();
431
432 thread::sleep(Duration::from_millis(5));
433
434 let (fb2, calls2) = fallback_fn(b"new".to_vec());
435 let data = c.get("key1", fb2).unwrap();
436 assert_eq!(data, b"new");
437 assert_eq!(calls1.load(Ordering::SeqCst), 1);
438 assert_eq!(
439 calls2.load(Ordering::SeqCst),
440 1,
441 "fallback should be called once on stale hit"
442 );
443 }
444
445 #[test]
447 fn get_stale_entry_still_returns_data() {
448 let c = new_test_cache(Duration::from_millis(1));
449 let (fb, _) = fallback_fn(b"woof".to_vec());
450 c.get("key1", fb).unwrap();
451
452 thread::sleep(Duration::from_millis(5));
453
454 let failing: Fallback = Box::new(|| {
455 Err(FileCacheError::Io(std::io::Error::new(
456 std::io::ErrorKind::UnexpectedEof,
457 "eof",
458 )))
459 });
460 let err = c.get("key1", failing).unwrap_err();
461 assert!(matches!(err, FileCacheError::FallbackFailed(_)));
462 }
463
464 #[test]
465 fn get_fallback_error() {
466 let c = new_test_cache(Duration::from_secs(3600));
467 let fb: Fallback = Box::new(|| {
468 Err(FileCacheError::Io(std::io::Error::other(
469 "upstream exploded",
470 )))
471 });
472
473 let err = c.get("key1", fb).unwrap_err();
474 assert!(matches!(err, FileCacheError::Io(_)));
475 }
476
477 #[test]
478 fn get_context_cancelled() {
479 let c = new_test_cache(Duration::from_secs(3600));
484 let called = Arc::new(AtomicI32::new(0));
485 let called_clone = called.clone();
486 let fb: Fallback = Box::new(move || {
487 called_clone.fetch_add(1, Ordering::SeqCst);
488 Ok(b"data".to_vec())
489 });
490
491 let err = c.get_with_cancellation("key1", fb, true).unwrap_err();
492 assert!(matches!(err, FileCacheError::Cancelled));
493 assert_eq!(
494 called.load(Ordering::SeqCst),
495 0,
496 "fallback must not be called when already cancelled"
497 );
498 }
499
500 #[test]
501 fn get_different_keys_are_independent() {
502 let c = new_test_cache(Duration::from_secs(3600));
503
504 let (fb1, _) = fallback_fn(b"val1".to_vec());
505 let (fb2, _) = fallback_fn(b"val2".to_vec());
506
507 let data1 = c.get("key1", fb1).unwrap();
508 let data2 = c.get("key2", fb2).unwrap();
509
510 assert_eq!(data1, b"val1");
511 assert_eq!(data2, b"val2");
512 }
513
514 #[test]
515 fn get_concurrent_calls_same_key() {
516 let c = new_test_cache(Duration::from_secs(3600));
523 let calls = Arc::new(AtomicI32::new(0));
524 let calls_clone = calls.clone();
525
526 let fb: Fallback = Box::new(move || {
527 calls_clone.fetch_add(1, Ordering::SeqCst);
528 thread::sleep(Duration::from_millis(20));
529 Ok(b"shared".to_vec())
530 });
531
532 let data = c.get("key1", fb).unwrap();
533 assert_eq!(data, b"shared");
534 assert!(
536 calls.load(Ordering::SeqCst) <= 1,
537 "fallback should run at most once for concurrent callers"
538 );
539 }
540
541 #[test]
542 fn get_atomic_write_no_corruption() {
543 let tmp_dir = tempfile::tempdir().unwrap();
544 let c = new_at(tmp_dir.path(), Duration::from_secs(3600)).unwrap();
545
546 let payload = b"the quick brown fox".to_vec();
547 let (fb, _) = fallback_fn(payload.clone());
548
549 c.get("key1", fb).unwrap();
550
551 let c2 = new_at(tmp_dir.path(), Duration::from_secs(3600)).unwrap();
554
555 let never_called: Fallback =
556 Box::new(|| panic!("fallback should not be called on a warm cache"));
557 let data = c2.get("key1", never_called).unwrap();
558 assert_eq!(data, payload);
559 }
560
561 #[test]
564 fn noop_temp_dir_doesnt_panic() {
565 let tmp_dir = tempfile::tempdir().unwrap();
566 let tmp_dir_path = tmp_dir.path().join("bork");
567
568 let cache = noop_in(&tmp_dir_path);
574 let (fb, _) = fallback_fn(b"noop".to_vec());
575 assert_eq!(cache.get("any-key", fb).unwrap(), b"noop");
576
577 let entries: Vec<_> = std::fs::read_dir(&tmp_dir_path).unwrap().collect();
578 assert_eq!(entries.len(), 1);
579 }
580}