Skip to main content

rustlavel_cache/
file.rs

1//! The file driver: one file per key under a configurable directory.
2//!
3//! Useful when a single-process application wants a cache that survives a
4//! restart without running Redis. Two properties matter more than speed here:
5//!
6//! * **A key can never escape the directory.** Cache keys routinely contain
7//!   user input (`user:{email}`, a URL, a path), so the file name is a hash of
8//!   the key, not the key. `../../etc/passwd` and `a/b/c` become 32 hex
9//!   characters like everything else.
10//! * **A reader never sees half a write.** Payloads are written to a temporary
11//!   file and renamed into place, which is atomic on every platform rustlavel
12//!   targets.
13//!
14//! The hash is FNV-1a rather than `DefaultHasher` because `DefaultHasher` is
15//! explicitly allowed to change between Rust releases: an upgrade would silently
16//! orphan every file on disk.
17
18use crate::store::{BoxFuture, Cache, counter_value, decode, prefixed, record};
19use rustlavel_core::{Error, Json, Result};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24use tokio::sync::Mutex;
25
26/// The extension every cache file carries, so `flush` can clear the directory
27/// without deleting something an operator put there by hand.
28const EXTENSION: &str = "cache";
29
30/// Locks are sharded by key so a read-modify-write on one counter does not
31/// block an unrelated one.
32const LOCKS: usize = 16;
33
34/// Distinguishes concurrent temporary files within one process.
35static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
36
37struct Inner {
38    directory: PathBuf,
39    prefix: String,
40    /// Serialises read-modify-write sequences (`increment`) inside this
41    /// process. Two *processes* sharing a directory can still interleave — the
42    /// file system offers no compare-and-swap — which is why the file driver is
43    /// documented as unsuitable for a rate limiter behind multiple workers.
44    locks: Vec<Mutex<()>>,
45}
46
47/// A cache stored on disk. Cloning shares one directory and one lock table.
48#[derive(Clone)]
49pub struct FileStore {
50    inner: Arc<Inner>,
51}
52
53impl FileStore {
54    /// Create a store rooted at `directory`, creating the directory if needed.
55    pub fn new(directory: impl Into<PathBuf>) -> Result<Self> {
56        FileStore::with_prefix(directory, "")
57    }
58
59    pub fn with_prefix(directory: impl Into<PathBuf>, prefix: impl Into<String>) -> Result<Self> {
60        let directory = directory.into();
61        std::fs::create_dir_all(&directory).map_err(|e| {
62            Error::msg(format!("cannot create the cache directory `{}`: {e}", directory.display()))
63        })?;
64
65        Ok(FileStore {
66            inner: Arc::new(Inner {
67                directory,
68                prefix: prefix.into(),
69                locks: (0..LOCKS).map(|_| Mutex::new(())).collect(),
70            }),
71        })
72    }
73
74    pub fn directory(&self) -> &Path {
75        &self.inner.directory
76    }
77
78    /// The file backing a key. Public so a test can prove where a hostile key
79    /// actually lands.
80    pub fn path_for(&self, key: &str) -> PathBuf {
81        let full = prefixed(&self.inner.prefix, key);
82        self.inner.directory.join(format!("{}.{EXTENSION}", fingerprint(&full)))
83    }
84
85    fn lock_for(&self, key: &str) -> &Mutex<()> {
86        &self.inner.locks[(fnv1a(key.as_bytes(), FNV_OFFSET) as usize) % LOCKS]
87    }
88
89    /// Read an entry, deleting it when it has expired or does not belong to
90    /// this key.
91    async fn read(&self, key: &str, full: &str) -> Option<Json> {
92        let path = self.path_for(key);
93        let raw = tokio::fs::read_to_string(&path).await.ok()?;
94
95        let document = decode(&raw)?;
96
97        // Two different keys can hash to the same file. Storing the key inside
98        // the file turns that from a wrong answer into a miss.
99        if document.get("key").and_then(Json::as_str) != Some(full) {
100            return None;
101        }
102
103        if let Some(expires_at) = document.get("expires_at").and_then(Json::as_f64)
104            && expires_at <= now_millis()
105        {
106            let _ = tokio::fs::remove_file(&path).await;
107            return None;
108        }
109
110        document.get("value").cloned()
111    }
112
113    async fn write(&self, key: &str, full: &str, value: Json, expires_at: Option<f64>) -> Result<()> {
114        let document = Json::object([
115            ("key", Json::from(full)),
116            ("expires_at", expires_at.map_or(Json::Null, Json::from)),
117            ("value", value),
118        ]);
119
120        let path = self.path_for(key);
121        // A unique temporary name: two tasks writing the same key must not
122        // truncate each other's half-written file.
123        let temporary = path.with_extension(format!(
124            "{}.{}.tmp",
125            std::process::id(),
126            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
127        ));
128
129        tokio::fs::write(&temporary, document.to_string()).await.map_err(|e| {
130            Error::msg(format!("cannot write the cache file `{}`: {e}", temporary.display()))
131        })?;
132
133        // Rename is atomic, so a concurrent reader sees either the old file or
134        // the new one and never a truncated document.
135        tokio::fs::rename(&temporary, &path).await.map_err(|e| {
136            let _ = std::fs::remove_file(&temporary);
137            Error::msg(format!("cannot replace the cache file `{}`: {e}", path.display()))
138        })
139    }
140
141    /// The remaining life of a key, in milliseconds since the epoch form used
142    /// on disk. `None` for a missing key, `Some(None)` for an immortal one.
143    async fn expiry(&self, key: &str, full: &str) -> Option<Option<f64>> {
144        let raw = tokio::fs::read_to_string(self.path_for(key)).await.ok()?;
145        let document = decode(&raw)?;
146        if document.get("key").and_then(Json::as_str) != Some(full) {
147            return None;
148        }
149        match document.get("expires_at").and_then(Json::as_f64) {
150            Some(at) if at <= now_millis() => None,
151            other => Some(other),
152        }
153    }
154}
155
156impl Cache for FileStore {
157    fn driver(&self) -> &'static str {
158        "file"
159    }
160
161    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Json>>> {
162        Box::pin(async move {
163            let full = prefixed(&self.inner.prefix, key);
164            let found = self.read(key, &full).await;
165            record(found.is_some(), "file", key);
166            Ok(found)
167        })
168    }
169
170    fn put<'a>(&'a self, key: &'a str, value: Json, ttl: Duration) -> BoxFuture<'a, Result<()>> {
171        Box::pin(async move {
172            let full = prefixed(&self.inner.prefix, key);
173            if ttl.is_zero() {
174                let _ = tokio::fs::remove_file(self.path_for(key)).await;
175                return Ok(());
176            }
177            let expires_at = now_millis() + ttl.as_millis() as f64;
178            self.write(key, &full, value, Some(expires_at)).await
179        })
180    }
181
182    fn forever<'a>(&'a self, key: &'a str, value: Json) -> BoxFuture<'a, Result<()>> {
183        Box::pin(async move {
184            let full = prefixed(&self.inner.prefix, key);
185            self.write(key, &full, value, None).await
186        })
187    }
188
189    fn forget<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<bool>> {
190        Box::pin(async move {
191            let full = prefixed(&self.inner.prefix, key);
192            let _guard = self.lock_for(&full).lock().await;
193
194            // Reading first means an already-expired file reports `false`,
195            // matching what a `get` would have said.
196            let existed = self.read(key, &full).await.is_some();
197            let _ = tokio::fs::remove_file(self.path_for(key)).await;
198            Ok(existed)
199        })
200    }
201
202    fn flush(&self) -> BoxFuture<'_, Result<()>> {
203        Box::pin(async move {
204            let mut entries = match tokio::fs::read_dir(&self.inner.directory).await {
205                Ok(entries) => entries,
206                // Nothing to flush is not a failure.
207                Err(_) => return Ok(()),
208            };
209
210            while let Some(entry) = entries.next_entry().await.map_err(Error::from)? {
211                let path = entry.path();
212                if path.extension().is_some_and(|ext| ext == EXTENSION) {
213                    let _ = tokio::fs::remove_file(&path).await;
214                }
215            }
216            Ok(())
217        })
218    }
219
220    fn increment<'a>(&'a self, key: &'a str, by: i64) -> BoxFuture<'a, Result<i64>> {
221        Box::pin(async move {
222            let full = prefixed(&self.inner.prefix, key);
223            let _guard = self.lock_for(&full).lock().await;
224
225            let current = self.read(key, &full).await;
226            let next = counter_value(current.as_ref()) + by;
227
228            // Increment must not shorten the life of an existing counter.
229            let expires_at = self.expiry(key, &full).await.flatten();
230            self.write(key, &full, Json::from(next), expires_at).await?;
231            Ok(next)
232        })
233    }
234
235    fn increment_within<'a>(
236        &'a self,
237        key: &'a str,
238        by: i64,
239        ttl: Duration,
240    ) -> BoxFuture<'a, Result<i64>> {
241        Box::pin(async move {
242            let full = prefixed(&self.inner.prefix, key);
243            let _guard = self.lock_for(&full).lock().await;
244
245            match self.read(key, &full).await {
246                Some(current) => {
247                    let next = counter_value(Some(&current)) + by;
248                    let expires_at = self.expiry(key, &full).await.flatten();
249                    self.write(key, &full, Json::from(next), expires_at).await?;
250                    Ok(next)
251                }
252                None => {
253                    // This call created the counter, so it sets the window.
254                    let expires_at = now_millis() + ttl.as_millis() as f64;
255                    self.write(key, &full, Json::from(by), Some(expires_at)).await?;
256                    Ok(by)
257                }
258            }
259        })
260    }
261
262    fn ttl<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<Option<Duration>>> {
263        Box::pin(async move {
264            let full = prefixed(&self.inner.prefix, key);
265            Ok(self
266                .expiry(key, &full)
267                .await
268                .flatten()
269                .map(|at| Duration::from_millis((at - now_millis()).max(0.0) as u64)))
270        })
271    }
272}
273
274fn now_millis() -> f64 {
275    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as f64
276}
277
278const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
279const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
280
281fn fnv1a(bytes: &[u8], offset: u64) -> u64 {
282    let mut hash = offset;
283    for byte in bytes {
284        hash ^= *byte as u64;
285        hash = hash.wrapping_mul(FNV_PRIME);
286    }
287    hash
288}
289
290/// A stable 128-bit fingerprint, rendered as 32 lowercase hex characters.
291///
292/// Two independent FNV-1a passes — forwards from one offset, backwards from
293/// another — because a single 64-bit hash would see collisions at a few million
294/// keys, and a collision costs a needless miss on every read of both keys.
295fn fingerprint(key: &str) -> String {
296    let forward = fnv1a(key.as_bytes(), FNV_OFFSET);
297    let reversed: Vec<u8> = key.bytes().rev().collect();
298    let backward = fnv1a(&reversed, FNV_OFFSET ^ 0x5555_5555_5555_5555);
299    format!("{forward:016x}{backward:016x}")
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::store::CacheExt;
306
307    /// Each test gets its own directory: they run concurrently, and a shared
308    /// one would mean `flush` in one test deleting another test's entries.
309    fn scratch(name: &str) -> PathBuf {
310        let dir = std::env::temp_dir().join(format!(
311            "rustlavel-cache-{name}-{}-{}",
312            std::process::id(),
313            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
314        ));
315        let _ = std::fs::remove_dir_all(&dir);
316        dir
317    }
318
319    #[tokio::test]
320    async fn a_key_containing_slashes_and_dot_dot_cannot_escape_the_directory() {
321        let dir = scratch("traversal");
322        let cache = FileStore::new(&dir).unwrap();
323
324        for hostile in [
325            "../../etc/passwd",
326            "/etc/shadow",
327            "a/b/c",
328            "..",
329            "....//....//root",
330            "C:\\Windows\\system32",
331        ] {
332            cache.forever(hostile, Json::from("owned")).await.unwrap();
333
334            let path = cache.path_for(hostile);
335            let parent = path.parent().expect("every cache file has a parent");
336            assert_eq!(
337                parent.canonicalize().unwrap(),
338                dir.canonicalize().unwrap(),
339                "`{hostile}` escaped to {}",
340                path.display()
341            );
342            assert!(cache.get(hostile).await.unwrap().is_some(), "`{hostile}` must still round-trip");
343        }
344
345        // And nothing at all was created outside the directory.
346        let strays: Vec<_> = std::fs::read_dir(&dir)
347            .unwrap()
348            .filter_map(|e| e.ok())
349            .filter(|e| e.path().is_dir())
350            .collect();
351        assert!(strays.is_empty(), "the file driver must never create subdirectories");
352    }
353
354    #[tokio::test]
355    async fn every_file_name_is_a_fixed_length_hash() {
356        let dir = scratch("hashnames");
357        let cache = FileStore::new(&dir).unwrap();
358        cache.forever("a very long key with spaces / and ../ inside", Json::from(1)).await.unwrap();
359
360        let name = std::fs::read_dir(&dir)
361            .unwrap()
362            .filter_map(|e| e.ok())
363            .map(|e| e.file_name().to_string_lossy().into_owned())
364            .next()
365            .expect("one file");
366
367        assert_eq!(name.len(), 32 + ".cache".len());
368        assert!(name.trim_end_matches(".cache").chars().all(|c| c.is_ascii_hexdigit()));
369    }
370
371    #[test]
372    fn the_fingerprint_is_stable_and_separates_similar_keys() {
373        // Hard-coded so a change to the hashing is a deliberate, visible break
374        // rather than a silent invalidation of everyone's cache directory.
375        assert_eq!(fingerprint("users:1"), fingerprint("users:1"));
376        assert_ne!(fingerprint("users:1"), fingerprint("users:2"));
377        assert_ne!(fingerprint("ab"), fingerprint("ba"), "a reversed key must not collide");
378        assert_eq!(fingerprint("").len(), 32);
379    }
380
381    #[tokio::test]
382    async fn a_value_survives_a_new_store_over_the_same_directory() {
383        let dir = scratch("persist");
384        FileStore::new(&dir)
385            .unwrap()
386            .forever("kept", Json::from("across restarts"))
387            .await
388            .unwrap();
389
390        let reopened = FileStore::new(&dir).unwrap();
391        assert_eq!(reopened.get("kept").await.unwrap(), Some(Json::from("across restarts")));
392    }
393
394    #[tokio::test]
395    async fn an_expired_file_is_deleted_when_it_is_read() {
396        let dir = scratch("expiry");
397        let cache = FileStore::new(&dir).unwrap();
398        cache.put("brief", Json::from(1), Duration::from_millis(30)).await.unwrap();
399
400        tokio::time::sleep(Duration::from_millis(60)).await;
401        assert_eq!(cache.get("brief").await.unwrap(), None);
402        assert!(!cache.path_for("brief").exists(), "the read should have removed the file");
403    }
404
405    #[tokio::test]
406    async fn a_file_belonging_to_another_key_reads_as_a_miss() {
407        let dir = scratch("collision");
408        let cache = FileStore::new(&dir).unwrap();
409        cache.forever("real", Json::from("value")).await.unwrap();
410
411        // Simulate a hash collision by hand-writing another key's document
412        // into the file `imposter` would use.
413        let document = Json::object([
414            ("key", Json::from("somebody-else")),
415            ("expires_at", Json::Null),
416            ("value", Json::from("stolen")),
417        ]);
418        std::fs::write(cache.path_for("imposter"), document.to_string()).unwrap();
419
420        assert_eq!(cache.get("imposter").await.unwrap(), None);
421        assert_eq!(cache.get("real").await.unwrap(), Some(Json::from("value")));
422    }
423
424    #[tokio::test]
425    async fn flush_leaves_files_the_cache_does_not_own() {
426        let dir = scratch("flush");
427        let cache = FileStore::new(&dir).unwrap();
428        cache.forever("mine", Json::from(1)).await.unwrap();
429        std::fs::write(dir.join("README.txt"), "not the cache's business").unwrap();
430
431        cache.flush().await.unwrap();
432
433        assert_eq!(cache.get("mine").await.unwrap(), None);
434        assert!(dir.join("README.txt").exists());
435    }
436
437    #[tokio::test]
438    async fn concurrent_increments_on_one_key_stay_exact_within_a_process() {
439        let dir = scratch("increments");
440        let cache = Arc::new(FileStore::new(&dir).unwrap());
441
442        let mut tasks = Vec::new();
443        for _ in 0..8 {
444            let cache = Arc::clone(&cache);
445            tasks.push(tokio::spawn(async move {
446                for _ in 0..25 {
447                    cache.increment("hits", 1).await.unwrap();
448                }
449            }));
450        }
451        for task in tasks {
452            task.await.unwrap();
453        }
454
455        assert_eq!(cache.get("hits").await.unwrap(), Some(Json::from(200)));
456    }
457
458    #[tokio::test]
459    async fn remember_writes_through_to_disk_exactly_once() {
460        let dir = scratch("remember");
461        let cache = FileStore::new(&dir).unwrap();
462
463        let first = cache
464            .remember("answer", Duration::from_secs(60), || async { Ok(Json::from(42)) })
465            .await
466            .unwrap();
467        let second = cache
468            .remember("answer", Duration::from_secs(60), || async {
469                panic!("the second call must be a hit")
470            })
471            .await
472            .unwrap();
473
474        assert_eq!(first, Json::from(42));
475        assert_eq!(second, Json::from(42));
476    }
477}