1use 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
25const DEFAULT_MAX_CAPACITY_BYTES: u64 = 1024 * 1024 * 1024;
28
29static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
32
33#[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 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 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 pub fn unbounded(mut self) -> Self {
135 self.max_capacity_bytes = None;
136 self.rebuild();
137 self
138 }
139
140 pub fn weighted_size(&self) -> u64 {
144 self.index.weighted_size()
145 }
146
147 pub fn entry_count(&self) -> u64 {
150 self.index.entry_count()
151 }
152
153 pub async fn run_pending_tasks(&self) {
157 self.index.run_pending_tasks();
158 }
159
160 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#[derive(Clone, Hash, PartialEq, Eq)]
172struct VariantId {
173 key_hash: String,
174 variant_hash: String,
175}
176
177fn 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
196fn 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#[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 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 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
343pub 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 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#[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
473struct 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
532fn 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 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 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 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 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 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}