Skip to main content

trillium_cache/
fs.rs

1//! Filesystem-backed [`CacheStorage`].
2
3use crate::{
4    CacheKey, CachePolicy, CacheStorage, PutHandle, StoredEntry, fs_shims, policy::PolicyRepr,
5};
6use futures_lite::{AsyncRead, AsyncWrite, AsyncWriteExt};
7use moka::{notification::RemovalCause, sync::Cache};
8use sha2::{Digest, Sha256};
9use std::{
10    fmt::{self, Debug, Formatter, Write as _},
11    io,
12    path::{Path, PathBuf},
13    pin::Pin,
14    sync::{
15        Arc,
16        atomic::{AtomicU64, Ordering},
17    },
18    task::{Context, Poll},
19};
20use trillium_http::{Body, BodySource, Headers};
21
22const META_SUFFIX: &str = ".meta";
23const BODY_SUFFIX: &str = ".body";
24
25// Disk caches are cheap to grow relative to memory, so the default ceiling is larger than
26// `InMemoryStorage`'s.
27const DEFAULT_MAX_CAPACITY_BYTES: u64 = 1024 * 1024 * 1024;
28
29// Disambiguates concurrent temporary files under one directory. Process-local; on-disk
30// temporaries from a previous run are never read (only committed files are).
31static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
32
33/// Filesystem-backed cache storage rooted at a directory.
34///
35/// Persists cached responses under a root directory so they survive process restarts. Each
36/// response is two files: a `<hash>.meta` sidecar holding the [`CachePolicy`] and any trailers
37/// as an rkyv-encoded binary blob, and a `<hash>.body` holding the raw body bytes and nothing
38/// else. Bodies stream in and out — [`put`] writes to a temporary file the caller feeds
39/// incrementally, and [`open`] streams the stored body back without loading it into memory. The
40/// metadata is not human-readable; it is optimized for compact, fast loading rather than
41/// inspection.
42///
43/// Defaults to a 1 GiB byte cap; override with
44/// [`with_max_capacity_bytes`][Self::with_max_capacity_bytes] or remove it with
45/// [`unbounded`][Self::unbounded].
46///
47/// `Clone` is cheap — clones share the same root and capacity index, and see each other's
48/// writes.
49///
50/// # Layout
51///
52/// Entries live at `<root>/<key-hash>/<variant-hash>.{meta,body}`. The key hash is a SHA-256
53/// of the request method and URL; the variant hash is a SHA-256 of the `Vary` signature, so
54/// the multiple variants of one URL are sibling files in the same directory and [`get`]
55/// enumerates them by reading that directory. Writing a variant that already exists replaces
56/// it.
57///
58/// # Durability
59///
60/// Writes commit by renaming a fully-written temporary file into place, and the `.meta` is
61/// written last — a reader treats it as the commit marker, so a half-written or abandoned entry
62/// (a [`PutHandle`] dropped without [`finalize`]) is never visible to [`get`].
63///
64/// # Capacity
65///
66/// A byte cap (1 GiB by default) bounds the total stored body size. When a write would push
67/// the total past the cap, least-recently-used variants are evicted — their `.meta` and
68/// `.body` files deleted — until the cache fits. The cap counts body bytes only, per variant,
69/// matching the granularity of the on-disk layout. Reads count as use, so a frequently-served
70/// variant outlives idle ones. Override with [`with_max_capacity_bytes`] or remove the cap with
71/// [`unbounded`].
72///
73/// The cap is tracked in an in-memory index built by scanning the root at construction, so it
74/// survives restarts (recency resets to whatever order the scan encounters). A directory that
75/// grew past the current cap under an older, unbounded configuration is trimmed to fit on the
76/// next construction.
77///
78/// # Runtime
79///
80/// Filesystem access goes through the runtime selected by the `smol`, `tokio`, or `async-std`
81/// feature. Enabling `fs` without one of those compiles but panics on use.
82///
83/// [`put`]: CacheStorage::put
84/// [`get`]: CacheStorage::get
85/// [`open`]: StoredEntry::open
86/// [`finalize`]: PutHandle::finalize
87/// [`with_max_capacity_bytes`]: FileSystemStorage::with_max_capacity_bytes
88/// [`unbounded`]: FileSystemStorage::unbounded
89#[derive(Clone)]
90pub struct FileSystemStorage {
91    root: Arc<PathBuf>,
92    index: Cache<VariantId, u64>,
93    max_capacity_bytes: Option<u64>,
94}
95
96impl Debug for FileSystemStorage {
97    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
98        f.debug_struct("FileSystemStorage")
99            .field("root", &self.root)
100            .field("weighted_size", &self.index.weighted_size())
101            .field("max_capacity_bytes", &self.max_capacity_bytes)
102            .finish()
103    }
104}
105
106impl FileSystemStorage {
107    /// Construct a storage rooted at `root` with a 1 GiB byte cap. The directory is created
108    /// on demand as entries are written; it need not exist yet. If it exists, it is scanned
109    /// to seed the capacity index, so previously stored entries count against the cap.
110    pub fn new(root: impl Into<PathBuf>) -> Self {
111        let root = Arc::new(root.into());
112        let max_capacity_bytes = Some(DEFAULT_MAX_CAPACITY_BYTES);
113        let index = build_index(Arc::clone(&root), max_capacity_bytes);
114        scan_root(&root, &index);
115        Self {
116            root,
117            index,
118            max_capacity_bytes,
119        }
120    }
121
122    /// Set the maximum total stored body size, in bytes. Least-recently-used variants are
123    /// evicted — their files deleted — when a write would exceed this cap. Defaults to
124    /// 1 GiB. Re-scans the root, so a directory already over the new cap is trimmed to fit.
125    pub fn with_max_capacity_bytes(mut self, bytes: u64) -> Self {
126        self.max_capacity_bytes = Some(bytes);
127        self.rebuild();
128        self
129    }
130
131    /// Remove the size cap. Stored bytes grow without bound. Useful in tests and short-lived
132    /// processes; a cache living on shared disk should prefer the default capped
133    /// configuration.
134    pub fn unbounded(mut self) -> Self {
135        self.max_capacity_bytes = None;
136        self.rebuild();
137        self
138    }
139
140    /// Approximate total stored body size, in bytes, currently counted against the cap.
141    /// Eventually consistent — call [`run_pending_tasks`][Self::run_pending_tasks] first for
142    /// a settled value.
143    pub fn weighted_size(&self) -> u64 {
144        self.index.weighted_size()
145    }
146
147    /// Approximate count of stored variants. Eventually consistent — call
148    /// [`run_pending_tasks`][Self::run_pending_tasks] first for a settled value.
149    pub fn entry_count(&self) -> u64 {
150        self.index.entry_count()
151    }
152
153    /// Flush pending eviction bookkeeping, including deletion of files for evicted variants.
154    /// Call before reading [`weighted_size`][Self::weighted_size] or
155    /// [`entry_count`][Self::entry_count] when an exact value matters.
156    pub async fn run_pending_tasks(&self) {
157        self.index.run_pending_tasks();
158    }
159
160    // The capacity index has no resize API; rebuilding it and re-scanning the root applies a
161    // new cap while preserving on-disk entries (unlike the in-memory backend, disk data
162    // survives a reconfigure).
163    fn rebuild(&mut self) {
164        self.index = build_index(Arc::clone(&self.root), self.max_capacity_bytes);
165        scan_root(&self.root, &self.index);
166    }
167}
168
169// Identity of one stored variant, sufficient to reconstruct its `.meta`/`.body` paths under
170// a known root. Keys the capacity index.
171#[derive(Clone, Hash, PartialEq, Eq)]
172struct VariantId {
173    key_hash: String,
174    variant_hash: String,
175}
176
177// Build the capacity index. The eviction listener deletes a variant's files when moka
178// evicts it for size or expiry; replacement and explicit invalidation are handled at their
179// call sites, so the listener ignores those causes.
180fn build_index(root: Arc<PathBuf>, max_capacity_bytes: Option<u64>) -> Cache<VariantId, u64> {
181    let mut builder = Cache::<VariantId, u64>::builder()
182        .weigher(|_key, &body_len| u32::try_from(body_len).unwrap_or(u32::MAX))
183        .eviction_listener(move |id: Arc<VariantId>, _body_len, cause: RemovalCause| {
184            if cause.was_evicted() {
185                let dir = root.join(&id.key_hash);
186                let _ = std::fs::remove_file(dir.join(format!("{}{META_SUFFIX}", id.variant_hash)));
187                let _ = std::fs::remove_file(dir.join(format!("{}{BODY_SUFFIX}", id.variant_hash)));
188            }
189        });
190    if let Some(cap) = max_capacity_bytes {
191        builder = builder.max_capacity(cap);
192    }
193    builder.build()
194}
195
196// Seed the index from the root, counting each committed variant's body length against the
197// cap. Runs on the calling thread with blocking IO — a one-time construction cost — and
198// forces eviction so an over-cap directory is trimmed before the storage is used.
199fn scan_root(root: &Path, index: &Cache<VariantId, u64>) {
200    let Ok(key_dirs) = std::fs::read_dir(root) else {
201        return;
202    };
203    for key_entry in key_dirs.flatten() {
204        let key_dir = key_entry.path();
205        let Some(key_hash) = file_stem_string(&key_dir) else {
206            continue;
207        };
208        let Ok(files) = std::fs::read_dir(&key_dir) else {
209            continue;
210        };
211        for file in files.flatten() {
212            let path = file.path();
213            let Some(variant_hash) = path
214                .file_name()
215                .and_then(|name| name.to_str())
216                .and_then(|name| name.strip_suffix(META_SUFFIX))
217                .map(str::to_string)
218            else {
219                continue;
220            };
221            let body = key_dir.join(format!("{variant_hash}{BODY_SUFFIX}"));
222            let Ok(metadata) = std::fs::metadata(&body) else {
223                continue;
224            };
225            index.insert(
226                VariantId {
227                    key_hash: key_hash.clone(),
228                    variant_hash,
229                },
230                metadata.len(),
231            );
232        }
233    }
234    index.run_pending_tasks();
235}
236
237fn file_stem_string(path: &Path) -> Option<String> {
238    path.file_name()
239        .and_then(|name| name.to_str())
240        .map(str::to_string)
241}
242
243// The rkyv-encoded sidecar written alongside each body. `PolicyRepr` recomputes the derived
244// cache-control fields on load, so only the directly-captured policy fields are stored.
245#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
246struct StoredMeta {
247    policy: PolicyRepr,
248    trailers: Option<Headers>,
249}
250
251impl CacheStorage for FileSystemStorage {
252    type PutHandle = FsPutHandle;
253    type StoredEntry = FsStoredEntry;
254
255    async fn get(&self, key: &CacheKey) -> Vec<Self::StoredEntry> {
256        let key_hash = key_hash(key);
257        let dir = self.root.join(&key_hash);
258        let Ok(paths) = fs_shims::read_dir_paths(&dir).await else {
259            return Vec::new();
260        };
261
262        let mut entries = Vec::new();
263        for path in paths {
264            let Some(variant_hash) = path
265                .file_name()
266                .and_then(|name| name.to_str())
267                .and_then(|name| name.strip_suffix(META_SUFFIX))
268                .map(str::to_string)
269            else {
270                continue;
271            };
272            let Ok(bytes) = fs_shims::read(&path).await else {
273                continue;
274            };
275            let Ok(meta) = deserialize_meta(&bytes) else {
276                continue;
277            };
278            // Count the lookup as use so a frequently-served variant survives eviction.
279            self.index.get(&VariantId {
280                key_hash: key_hash.clone(),
281                variant_hash: variant_hash.clone(),
282            });
283            entries.push(FsStoredEntry {
284                meta_path: path,
285                body_path: dir.join(format!("{variant_hash}{BODY_SUFFIX}")),
286                policy: meta.policy.into(),
287                trailers: meta.trailers,
288            });
289        }
290        entries
291    }
292
293    async fn put(&self, key: CacheKey, policy: CachePolicy) -> io::Result<Self::PutHandle> {
294        let key_hash = key_hash(&key);
295        let dir = self.root.join(&key_hash);
296        fs_shims::create_dir_all(&dir).await?;
297
298        let variant_hash = variant_hash(&policy);
299        let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
300        let body_tmp = dir.join(format!("{variant_hash}{BODY_SUFFIX}.tmp.{n}"));
301        let writer = fs_shims::create(&body_tmp).await?;
302
303        Ok(FsPutHandle {
304            writer,
305            body_tmp,
306            body_final: dir.join(format!("{variant_hash}{BODY_SUFFIX}")),
307            meta_tmp: dir.join(format!("{variant_hash}{META_SUFFIX}.tmp.{n}")),
308            meta_final: dir.join(format!("{variant_hash}{META_SUFFIX}")),
309            policy,
310            index: self.index.clone(),
311            variant_id: VariantId {
312                key_hash,
313                variant_hash,
314            },
315            written: 0,
316            committed: false,
317        })
318    }
319
320    async fn invalidate(&self, key: &CacheKey) {
321        let key_hash = key_hash(key);
322        let dir = self.root.join(&key_hash);
323        // Prune the index before removing files; the whole directory goes at once, so the
324        // per-variant eviction listener would be redundant (it skips explicit removals).
325        if let Ok(paths) = fs_shims::read_dir_paths(&dir).await {
326            for path in paths {
327                if let Some(variant_hash) = path
328                    .file_name()
329                    .and_then(|name| name.to_str())
330                    .and_then(|name| name.strip_suffix(META_SUFFIX))
331                {
332                    self.index.invalidate(&VariantId {
333                        key_hash: key_hash.clone(),
334                        variant_hash: variant_hash.to_string(),
335                    });
336                }
337            }
338        }
339        let _ = fs_shims::remove_dir_all(&dir).await;
340    }
341}
342
343/// Streaming [`PutHandle`] for [`FileSystemStorage`].
344///
345/// Body bytes are written to a temporary file as they arrive; [`finalize`][Self::finalize]
346/// renames the body into place and writes the metadata sidecar. Dropping without finalizing
347/// removes the temporary body and stores nothing.
348pub struct FsPutHandle {
349    writer: fs_shims::Writer,
350    body_tmp: PathBuf,
351    body_final: PathBuf,
352    meta_tmp: PathBuf,
353    meta_final: PathBuf,
354    policy: CachePolicy,
355    index: Cache<VariantId, u64>,
356    variant_id: VariantId,
357    written: u64,
358    committed: bool,
359}
360
361impl Debug for FsPutHandle {
362    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
363        f.debug_struct("FsPutHandle")
364            .field("body_final", &self.body_final)
365            .finish_non_exhaustive()
366    }
367}
368
369impl AsyncWrite for FsPutHandle {
370    fn poll_write(
371        self: Pin<&mut Self>,
372        cx: &mut Context<'_>,
373        buf: &[u8],
374    ) -> Poll<io::Result<usize>> {
375        let this = self.get_mut();
376        let poll = Pin::new(&mut this.writer).poll_write(cx, buf);
377        if let Poll::Ready(Ok(n)) = &poll {
378            this.written += *n as u64;
379        }
380        poll
381    }
382
383    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
384        Pin::new(&mut self.get_mut().writer).poll_flush(cx)
385    }
386
387    fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
388        Pin::new(&mut self.get_mut().writer).poll_close(cx)
389    }
390}
391
392impl PutHandle for FsPutHandle {
393    async fn finalize(mut self, trailers: Option<Headers>) -> io::Result<()> {
394        self.writer.close().await?;
395        fs_shims::rename(&self.body_tmp, &self.body_final).await?;
396
397        let meta = StoredMeta {
398            policy: PolicyRepr::from(&self.policy),
399            trailers,
400        };
401        let bytes = serialize_meta(&meta)?;
402        fs_shims::write(&self.meta_tmp, &bytes).await?;
403        fs_shims::rename(&self.meta_tmp, &self.meta_final).await?;
404
405        // Account the committed body against the cap. Re-inserting the same variant replaces
406        // its prior weight; the eviction listener ignores the replacement.
407        self.index.insert(self.variant_id.clone(), self.written);
408
409        self.committed = true;
410        Ok(())
411    }
412}
413
414impl Drop for FsPutHandle {
415    fn drop(&mut self) {
416        if !self.committed {
417            let _ = std::fs::remove_file(&self.body_tmp);
418        }
419    }
420}
421
422/// One stored response returned by [`FileSystemStorage::get`].
423///
424/// Holds the metadata; the body stays on disk until [`open`][Self::open] streams it. `Clone`
425/// copies the metadata and re-opens the body file on demand.
426#[derive(Clone)]
427pub struct FsStoredEntry {
428    meta_path: PathBuf,
429    body_path: PathBuf,
430    policy: CachePolicy,
431    trailers: Option<Headers>,
432}
433
434impl Debug for FsStoredEntry {
435    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
436        f.debug_struct("FsStoredEntry")
437            .field("body_path", &self.body_path)
438            .field("has_trailers", &self.trailers.is_some())
439            .finish_non_exhaustive()
440    }
441}
442
443impl StoredEntry for FsStoredEntry {
444    fn policy(&self) -> &CachePolicy {
445        &self.policy
446    }
447
448    async fn refresh_policy(&mut self, new_policy: CachePolicy) -> io::Result<()> {
449        let meta = StoredMeta {
450            policy: PolicyRepr::from(&new_policy),
451            trailers: self.trailers.clone(),
452        };
453        let bytes = serialize_meta(&meta)?;
454        let tmp = temp_sibling(&self.meta_path);
455        fs_shims::write(&tmp, &bytes).await?;
456        fs_shims::rename(&tmp, &self.meta_path).await?;
457
458        self.policy = new_policy;
459        Ok(())
460    }
461
462    async fn open(self) -> io::Result<Body> {
463        let len = fs_shims::metadata_len(&self.body_path).await?;
464        let reader = fs_shims::open(&self.body_path).await?;
465        let source = FsBodySource {
466            reader,
467            trailers: self.trailers,
468        };
469        Ok(Body::new_with_trailers(source, Some(len)))
470    }
471}
472
473// BodySource over a stored body file. Reads stream straight from the file; trailers surface
474// after EOF.
475struct FsBodySource {
476    reader: fs_shims::Reader,
477    trailers: Option<Headers>,
478}
479
480impl AsyncRead for FsBodySource {
481    fn poll_read(
482        self: Pin<&mut Self>,
483        cx: &mut Context<'_>,
484        buf: &mut [u8],
485    ) -> Poll<io::Result<usize>> {
486        Pin::new(&mut self.get_mut().reader).poll_read(cx, buf)
487    }
488}
489
490impl BodySource for FsBodySource {
491    fn trailers(self: Pin<&mut Self>) -> Option<Headers> {
492        self.get_mut().trailers.take()
493    }
494}
495
496fn hash_hex(bytes: &[u8]) -> String {
497    let mut hasher = Sha256::new();
498    hasher.update(bytes);
499    finalize_hex(hasher)
500}
501
502fn key_hash(key: &CacheKey) -> String {
503    hash_hex(key.to_string().as_bytes())
504}
505
506fn variant_hash(policy: &CachePolicy) -> String {
507    let mut hasher = Sha256::new();
508    for (name, value) in &policy.vary_snapshot {
509        hasher.update(name.as_bytes());
510        hasher.update([0]);
511        match value {
512            Some(value) => {
513                hasher.update([1]);
514                hasher.update(value.as_bytes());
515            }
516            None => hasher.update([0]),
517        }
518        hasher.update([0]);
519    }
520    finalize_hex(hasher)
521}
522
523fn finalize_hex(hasher: Sha256) -> String {
524    let digest = hasher.finalize();
525    let mut out = String::with_capacity(digest.len() * 2);
526    for byte in digest {
527        write!(out, "{byte:02x}").expect("writing to a String cannot fail");
528    }
529    out
530}
531
532// A unique sibling temp path for atomically rewriting `path`.
533fn temp_sibling(path: &Path) -> PathBuf {
534    let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
535    let mut name = path.as_os_str().to_owned();
536    name.push(format!(".tmp.{n}"));
537    PathBuf::from(name)
538}
539
540fn serialize_meta(meta: &StoredMeta) -> io::Result<rkyv::util::AlignedVec> {
541    rkyv::to_bytes::<rkyv::rancor::Error>(meta)
542        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
543}
544
545fn deserialize_meta(bytes: &[u8]) -> io::Result<StoredMeta> {
546    // A disk read lands in a buffer aligned only to 1, but rkyv's validated access requires
547    // the archived root to be aligned; copy into an `AlignedVec` before decoding.
548    let mut aligned = rkyv::util::AlignedVec::<16>::new();
549    aligned.extend_from_slice(bytes);
550    rkyv::from_bytes::<StoredMeta, rkyv::rancor::Error>(&aligned)
551        .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::test_helpers::*;
558    use futures_lite::{AsyncReadExt, AsyncWriteExt};
559    use std::time::{Duration, SystemTime};
560    use tempfile::TempDir;
561    use trillium_client::Conn;
562    use trillium_http::{KnownHeaderName::*, Method, Status};
563    use trillium_testing::{TestResult, harness, test};
564
565    fn key() -> CacheKey {
566        CacheKey::new(Method::Get, "http://example.com/".parse().unwrap())
567    }
568
569    fn new_storage() -> (TempDir, FileSystemStorage) {
570        let dir = tempfile::tempdir().unwrap();
571        let storage = FileSystemStorage::new(dir.path());
572        (dir, storage)
573    }
574
575    async fn store_at(storage: &FileSystemStorage, url: &str, body: &[u8]) {
576        let key = CacheKey::new(Method::Get, url.parse().unwrap());
577        let conn = exchange(
578            Method::Get,
579            &[],
580            Status::Ok,
581            &[(CacheControl, "max-age=600")],
582        );
583        let policy = policy_from(&conn, SystemTime::now(), private_cache());
584        let mut handle = storage.put(key, policy).await.unwrap();
585        handle.write_all(body).await.unwrap();
586        handle.finalize(None).await.unwrap();
587    }
588
589    async fn store(storage: &FileSystemStorage, key: CacheKey, conn: &Conn, body: &[u8]) {
590        let policy = policy_from(conn, SystemTime::now(), private_cache());
591        let mut handle = storage.put(key, policy).await.unwrap();
592        handle.write_all(body).await.unwrap();
593        handle.finalize(None).await.unwrap();
594    }
595
596    async fn read_body(entry: FsStoredEntry) -> Vec<u8> {
597        let mut body = entry.open().await.unwrap();
598        let mut buf = Vec::new();
599        body.read_to_end(&mut buf).await.unwrap();
600        buf
601    }
602
603    #[test(harness)]
604    async fn get_missing_key_returns_empty() -> TestResult {
605        let (_dir, storage) = new_storage();
606        assert!(storage.get(&key()).await.is_empty());
607        Ok(())
608    }
609
610    #[test(harness)]
611    async fn put_then_get_round_trips_through_disk() -> TestResult {
612        let (_dir, storage) = new_storage();
613        let conn = exchange(
614            Method::Get,
615            &[],
616            Status::Ok,
617            &[(CacheControl, "max-age=600")],
618        );
619        store(&storage, key(), &conn, b"hello").await;
620        let result = storage.get(&key()).await;
621        assert_eq!(result.len(), 1);
622        assert_eq!(read_body(result[0].clone()).await, b"hello");
623        Ok(())
624    }
625
626    #[test(harness)]
627    async fn put_with_same_vary_replaces() -> TestResult {
628        let (_dir, storage) = new_storage();
629        let conn = exchange(
630            Method::Get,
631            &[(AcceptEncoding, "gzip")],
632            Status::Ok,
633            &[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
634        );
635        store(&storage, key(), &conn, b"v1").await;
636        store(&storage, key(), &conn, b"v2").await;
637        let result = storage.get(&key()).await;
638        assert_eq!(result.len(), 1);
639        assert_eq!(read_body(result[0].clone()).await, b"v2");
640        Ok(())
641    }
642
643    #[test(harness)]
644    async fn put_with_different_vary_appends() -> TestResult {
645        let (_dir, storage) = new_storage();
646        let gzip = exchange(
647            Method::Get,
648            &[(AcceptEncoding, "gzip")],
649            Status::Ok,
650            &[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
651        );
652        let br = exchange(
653            Method::Get,
654            &[(AcceptEncoding, "br")],
655            Status::Ok,
656            &[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
657        );
658        store(&storage, key(), &gzip, b"gz").await;
659        store(&storage, key(), &br, b"br").await;
660        assert_eq!(storage.get(&key()).await.len(), 2);
661        Ok(())
662    }
663
664    #[test(harness)]
665    async fn invalidate_removes_all_entries_for_key() -> TestResult {
666        let (_dir, storage) = new_storage();
667        let conn = exchange(
668            Method::Get,
669            &[],
670            Status::Ok,
671            &[(CacheControl, "max-age=600")],
672        );
673        store(&storage, key(), &conn, b"x").await;
674        storage.invalidate(&key()).await;
675        assert!(storage.get(&key()).await.is_empty());
676        Ok(())
677    }
678
679    #[test(harness)]
680    async fn invalidate_does_not_touch_other_keys() -> TestResult {
681        let (_dir, storage) = new_storage();
682        let conn = exchange(
683            Method::Get,
684            &[],
685            Status::Ok,
686            &[(CacheControl, "max-age=600")],
687        );
688        let key_a = CacheKey::new(Method::Get, "http://a.example/".parse().unwrap());
689        let key_b = CacheKey::new(Method::Get, "http://b.example/".parse().unwrap());
690        store(&storage, key_a.clone(), &conn, b"a").await;
691        store(&storage, key_b.clone(), &conn, b"b").await;
692        storage.invalidate(&key_a).await;
693        assert!(storage.get(&key_a).await.is_empty());
694        assert_eq!(storage.get(&key_b).await.len(), 1);
695        Ok(())
696    }
697
698    #[test(harness)]
699    async fn drop_put_handle_without_finalize_discards() -> TestResult {
700        let (_dir, storage) = new_storage();
701        let conn = exchange(
702            Method::Get,
703            &[],
704            Status::Ok,
705            &[(CacheControl, "max-age=600")],
706        );
707        let policy = policy_from(&conn, SystemTime::now(), private_cache());
708        let mut handle = storage.put(key(), policy).await.unwrap();
709        handle.write_all(b"partial").await.unwrap();
710        drop(handle);
711        assert!(storage.get(&key()).await.is_empty());
712        Ok(())
713    }
714
715    #[test(harness)]
716    async fn refresh_policy_updates_meta_and_keeps_body() -> TestResult {
717        let (_dir, storage) = new_storage();
718        let conn = exchange(
719            Method::Get,
720            &[],
721            Status::Ok,
722            &[(CacheControl, "max-age=600")],
723        );
724        store(&storage, key(), &conn, b"body").await;
725
726        let mut entries = storage.get(&key()).await;
727        let original_time = entries[0].policy().response_time;
728        let refreshed = exchange(
729            Method::Get,
730            &[],
731            Status::Ok,
732            &[(CacheControl, "max-age=1200")],
733        );
734        let new_policy = policy_from(
735            &refreshed,
736            original_time + Duration::from_secs(100),
737            private_cache(),
738        );
739        entries[0].refresh_policy(new_policy).await.unwrap();
740
741        let fresh = storage.get(&key()).await;
742        assert_eq!(fresh.len(), 1);
743        assert_ne!(fresh[0].policy().response_time, original_time);
744        assert_eq!(read_body(fresh[0].clone()).await, b"body");
745        Ok(())
746    }
747
748    #[test(harness)]
749    async fn trailers_round_trip() -> TestResult {
750        let (_dir, storage) = new_storage();
751        let conn = exchange(
752            Method::Get,
753            &[],
754            Status::Ok,
755            &[(CacheControl, "max-age=600")],
756        );
757        let policy = policy_from(&conn, SystemTime::now(), private_cache());
758        let mut handle = storage.put(key(), policy).await.unwrap();
759        handle.write_all(b"data").await.unwrap();
760        let mut trailers = Headers::new();
761        trailers.insert("x-checksum", "abc123");
762        handle.finalize(Some(trailers)).await.unwrap();
763
764        let entry = storage.get(&key()).await.remove(0);
765        let mut body = entry.open().await.unwrap();
766        let mut buf = Vec::new();
767        body.read_to_end(&mut buf).await.unwrap();
768        assert_eq!(buf, b"data");
769        let trailers = body
770            .trailers()
771            .expect("stored trailers should surface after EOF");
772        assert_eq!(trailers.get_str("x-checksum"), Some("abc123"));
773        Ok(())
774    }
775
776    #[test(harness)]
777    async fn persists_across_new_storage_on_same_root() -> TestResult {
778        let dir = tempfile::tempdir().unwrap();
779        let conn = exchange(
780            Method::Get,
781            &[],
782            Status::Ok,
783            &[(CacheControl, "max-age=600")],
784        );
785        {
786            let storage = FileSystemStorage::new(dir.path());
787            store(&storage, key(), &conn, b"persisted").await;
788        }
789
790        // A brand-new storage over the same directory sees the prior instance's entry.
791        let reopened = FileSystemStorage::new(dir.path());
792        let result = reopened.get(&key()).await;
793        assert_eq!(result.len(), 1);
794        assert_eq!(read_body(result[0].clone()).await, b"persisted");
795        Ok(())
796    }
797
798    #[test(harness)]
799    async fn size_cap_evicts_and_deletes_files() -> TestResult {
800        // Cap at 1 KiB; write ten 600-byte bodies under distinct URLs.
801        let dir = tempfile::tempdir().unwrap();
802        let storage = FileSystemStorage::new(dir.path()).with_max_capacity_bytes(1024);
803        let body = vec![b'x'; 600];
804        for i in 0..10 {
805            store_at(&storage, &format!("http://example.com/{i}"), &body).await;
806        }
807        storage.run_pending_tasks().await;
808        assert!(
809            storage.weighted_size() <= 1024,
810            "weighted size {} should be within cap of 1024",
811            storage.weighted_size()
812        );
813
814        // A fresh unbounded scan of the same root reflects only the files still on disk, so
815        // the low total proves evicted variants' files were actually deleted, not just
816        // forgotten by the index.
817        let reopened = FileSystemStorage::new(dir.path()).unbounded();
818        assert!(
819            reopened.weighted_size() <= 1024,
820            "on-disk bytes {} should be within cap of 1024",
821            reopened.weighted_size()
822        );
823        Ok(())
824    }
825
826    #[test(harness)]
827    async fn rebuild_scan_trims_over_cap_directory() -> TestResult {
828        let dir = tempfile::tempdir().unwrap();
829        let body = vec![b'x'; 600];
830        {
831            let unbounded = FileSystemStorage::new(dir.path()).unbounded();
832            for i in 0..10 {
833                store_at(&unbounded, &format!("http://example.com/{i}"), &body).await;
834            }
835            unbounded.run_pending_tasks().await;
836            assert_eq!(unbounded.entry_count(), 10);
837        }
838
839        // Reopening with a cap trims the pre-existing directory to fit during construction.
840        let capped = FileSystemStorage::new(dir.path()).with_max_capacity_bytes(1024);
841        assert!(
842            capped.weighted_size() <= 1024,
843            "weighted size {} should be within cap of 1024",
844            capped.weighted_size()
845        );
846        Ok(())
847    }
848
849    #[test(harness)]
850    async fn unbounded_keeps_all_entries() -> TestResult {
851        let dir = tempfile::tempdir().unwrap();
852        let storage = FileSystemStorage::new(dir.path()).unbounded();
853        let body = vec![b'x'; 600];
854        for i in 0..10 {
855            store_at(&storage, &format!("http://example.com/{i}"), &body).await;
856        }
857        storage.run_pending_tasks().await;
858        assert_eq!(storage.entry_count(), 10);
859        assert_eq!(storage.weighted_size(), 6000);
860        Ok(())
861    }
862
863    #[test(harness)]
864    async fn replacing_a_variant_does_not_double_count() -> TestResult {
865        let (_dir, storage) = new_storage();
866        store_at(&storage, "http://example.com/", &vec![b'x'; 600]).await;
867        store_at(&storage, "http://example.com/", &vec![b'y'; 300]).await;
868        storage.run_pending_tasks().await;
869        assert_eq!(storage.entry_count(), 1);
870        assert_eq!(storage.weighted_size(), 300);
871        Ok(())
872    }
873}