1use 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
26const EXTENSION: &str = "cache";
29
30const LOCKS: usize = 16;
33
34static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
36
37struct Inner {
38 directory: PathBuf,
39 prefix: String,
40 locks: Vec<Mutex<()>>,
45}
46
47#[derive(Clone)]
49pub struct FileStore {
50 inner: Arc<Inner>,
51}
52
53impl FileStore {
54 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 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 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 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 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 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 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 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 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 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(¤t)) + 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 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
290fn 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 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 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 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 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}