Skip to main content

xianyun_executor/
filecache.rs

1//! A simple file-based cache for storing data on disk temporarily.
2//!
3//! Ported from `internal/filecache/filecache.go`, `interface.go`,
4//! `noop.go`, and `singlegroup.go`.
5//!
6//! Go's `SingleGroup[T any]` wraps `golang.org/x/sync/singleflight`,
7//! collapsing concurrent calls for the same key into one execution. The
8//! idiomatic Rust equivalent is `tokio::sync::OnceCell`-per-key or a
9//! `dashmap`-backed in-flight map with `tokio::sync::Notify`; this crate
10//! doesn't pull in either dependency since nothing ported here needs
11//! cross-call coalescing yet (each `Cache` call is independent). See
12//! `PORT_NOTES.md`.
13
14use std::path::PathBuf;
15use std::time::Duration;
16
17/// Ported from the `Fallback` function type. Go's version takes a
18/// `context.Context`; this port's fallback signature omits it for now
19/// (see `crate::exec::context`'s discussion of ambient context) and will
20/// need revisiting once cancellation is threaded through in Phase 4.
21pub type Fallback = Box<dyn Fn() -> Result<Vec<u8>, FileCacheError>>;
22
23/// Ported from `ErrInvalidMaxAge`/`ErrFallbackFailed`.
24#[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
36/// Ported from the `Cache` interface.
37///
38/// Go's `Get` takes a `context.Context` and checks `ctx.Err()` before
39/// calling the fallback, so an already-cancelled context short-circuits
40/// without ever running it (`TestGet_ContextCancelled`). This port has
41/// no ambient context type to thread through (see the module docs), so
42/// `get_with_cancellation` takes that one bit of `ctx.Err()` state as an
43/// explicit `cancelled: bool` parameter instead -- the same "inject
44/// instead of read/carry ambient state" pattern used elsewhere in this
45/// crate (`apply_sudo_with`, `has_gitlab_zoom_config`, `is_enabled_with`).
46/// `get` is a default method for callers that don't need cancellation at
47/// all; it always passes `cancelled = false`.
48pub 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
63/// Ported from the unexported `fileCache` struct.
64pub struct FileCache {
65    path: PathBuf,
66    max_age: Duration,
67}
68
69/// Ported from `New`. Derives the cache path from `name` and the OS user
70/// cache directory (`dirs::cache_dir()`, the Rust-idiomatic equivalent of
71/// Go's `os.UserCacheDir()`: `$XDG_CACHE_HOME`/`~/.cache` on Linux,
72/// `~/Library/Caches` on macOS), then delegates to [`new_at`]. There is
73/// no Go test for this path directly (`filecache_test.go` only tests the
74/// zero-`maxAge` rejection here; everything else goes through
75/// [`new_at`]), matching the same "ambient OS state has no test to port
76/// against" situation as `datadir::dir_in`'s caller-resolves-`$HOME`
77/// design -- this is simple enough, and specified precisely enough by the
78/// Go source, not to need one invented.
79pub 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
88/// Ported from the package-private `nameToPath`.
89fn 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
111/// Ported from `NewAt`.
112pub 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    /// Ported from `readFresh`. Returns `(data, is_fresh)`, or `None` on
131    /// a cache miss.
132    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    /// Ported from `refresh`. Go's `sg.Do` singleflight collapse across
158    /// threads is not modeled here: `Cache` is `Box<dyn Cache>`, not
159    /// `Arc<dyn Cache + Send + Sync>`, so it cannot be shared across
160    /// threads as written (see `get_concurrent_calls_same_key`'s test
161    /// comment and PORT_NOTES.md). Phase 4 must decide the concurrency
162    /// story before adding real singleflight collapsing.
163    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                    // Stale entry: the fallback's own error is wrapped as
179                    // `FallbackFailed` ("entry expired and refreshing
180                    // failed"), matching Go's `ErrFallbackFailed` wrap.
181                    // Go's `refresh` also returns `originalData` alongside
182                    // this error for a graceful fallback to stale data;
183                    // Rust's `Result` can't carry both, so callers that
184                    // need the stale bytes on failure should call
185                    // `read_fresh` themselves.
186                    Err(FileCacheError::FallbackFailed(err.to_string()))
187                } else {
188                    // Plain miss (no existing entry at all): propagate the
189                    // fallback's error as-is rather than wrapping it.
190                    Err(err)
191                }
192            }
193        }
194    }
195}
196
197/// Ported from `writeAtomic`.
198fn 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    // The rename moved the file away from under the Tempfile's Drop-based
204    // cleanup; forget it so Drop doesn't try to remove the now-renamed
205    // (nonexistent-at-that-path) file.
206    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
234/// Ported from `Noop` in `noop.go`, with the base temp directory taken
235/// as an explicit parameter (`noop_in`) rather than read from
236/// `$TMPDIR`/`os.TempDir()` internally, for the same reason as
237/// `datadir::dir_in`: Go's test isolates this with `t.Setenv("TMPDIR",
238/// ...)`, which has no safe equivalent under Rust's default
239/// multi-threaded test runner. `noop()` is the zero-argument production
240/// entry point.
241pub 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
247/// See `noop`'s doc comment for why `base_dir` is explicit here.
248pub 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
259/// Creates a uniquely-named subdirectory of `base`, mirroring Go's
260/// `os.MkdirTemp`'s "prefix + random suffix" contract.
261fn 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        // Unlike the FileCache case, this file is the deliverable the
308        // caller reads back, not an internal staging file, so it must
309        // survive past this function; Go's version never deletes it
310        // either (only the parent dir is cleaned up, via a finalizer).
311        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        // `tempfile::tempdir()`'s `TempDir` guard deletes the directory on
331        // drop; leaking it here keeps the cache directory alive for the
332        // rest of the test (this helper only returns the `Box<dyn Cache>`,
333        // so there is no later point to hold the guard).
334        let tmp_dir = Box::leak(Box::new(tempfile::tempdir().unwrap()));
335        new_at(tmp_dir.path(), max_age).unwrap()
336    }
337
338    /// Returns a fallback that records how many times it has been called
339    /// and always returns the given data.
340    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    /// New coverage (no direct Go test to port -- `filecache_test.go` only
359    /// exercises `New`'s zero-`maxAge` rejection, same as
360    /// `new_zero_max_age_rejected` above): a real cache directory gets
361    /// created under the user cache dir, and it's actually usable.
362    #[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        // Clean up: this test is the one place in the suite that touches
372        // the real user cache directory rather than a tempdir.
373        let path = name_to_path("xianyun-filecache-new-test").unwrap();
374        let _ = std::fs::remove_dir_all(path);
375    }
376
377    /// Ported from the shape of Go's `nameToPath`: non-alphanumeric
378    /// characters are replaced with `_`, and the directory name is
379    /// suffixed with an 8-character hash so two different names that
380    /// collide after sanitization still get distinct directories.
381    #[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        // Same name always resolves to the same path (deterministic).
390        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    /// This is useful for a graceful fallback to stale data.
446    #[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        // Go: context.WithCancel + an immediate cancel() before calling
480        // Get. This port has no ambient context type (see the module
481        // docs), so `get_with_cancellation`'s explicit `cancelled: bool`
482        // stands in for the already-cancelled context.
483        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        // NOTE: `Cache` here is `Box<dyn Cache>`, not `Arc<dyn Cache>`, so
517        // it cannot be shared across threads as written. Phase 4 needs to
518        // decide the concurrent-access story for `Cache` (likely `Arc<dyn
519        // Cache + Send + Sync>`) before this test can spawn real threads;
520        // ported here as a same-thread sanity check of the call count in
521        // the meantime.
522        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        // singleflight should collapse all concurrent calls into at most one.
535        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        // A fresh Cache pointing at the same temp directory bypasses the
552        // in-process singleflight state.
553        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    // --- Ported from noop_test.go ---
562
563    #[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        // Go: t.Setenv("TMPDIR", tempDir), then Noop() (which internally
569        // resolves TMPDIR via EnsureTempDirExists). This port takes the
570        // base temp directory as an explicit parameter (`noop_in`)
571        // instead, for the reason given on `noop`'s doc comment; the
572        // env-var-free equivalent is passing `tmp_dir_path` directly.
573        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}