1use std::collections::HashMap;
48use std::fs;
49use std::io::{Read, Seek, SeekFrom, Write};
50use std::path::{Path, PathBuf};
51use std::sync::atomic::{AtomicU64, Ordering};
52use std::sync::Arc;
53use std::time::{Duration, SystemTime, UNIX_EPOCH};
54
55use async_trait::async_trait;
56
57use crate::backend::{run_blocking, Backend, HealthStatus, TtlInspectable};
58use crate::error::{BackendError, BackendErrorKind};
59
60const MAGIC: &[u8; 2] = b"CK";
62const FORMAT_VERSION: u8 = 1;
63const HEADER_SIZE: usize = 14;
64const EXPIRY_OFFSET: u64 = 6;
65
66const MAX_TTL_SECS: u64 = 10 * 365 * 24 * 60 * 60;
68
69const TEMP_FILE_MAX_AGE: Duration = Duration::from_secs(60);
71
72static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
75
76fn now_secs() -> u64 {
79 SystemTime::now()
81 .duration_since(UNIX_EPOCH)
82 .map(|d| d.as_secs())
83 .unwrap_or(0)
84}
85
86fn build_header(expiry_unix_secs: u64) -> [u8; HEADER_SIZE] {
87 let mut h = [0u8; HEADER_SIZE];
88 h[0..2].copy_from_slice(MAGIC);
89 h[2] = FORMAT_VERSION;
90 h[6..14].copy_from_slice(&expiry_unix_secs.to_be_bytes());
92 h
93}
94
95enum ParsedHeader {
96 Corrupt,
98 Expired,
100 Live { expiry: u64 },
102}
103
104fn parse_header(bytes: &[u8]) -> ParsedHeader {
105 if bytes.len() < HEADER_SIZE || &bytes[0..2] != MAGIC || bytes[2] != FORMAT_VERSION {
106 return ParsedHeader::Corrupt;
107 }
108 let mut expiry_be = [0u8; 8];
109 expiry_be.copy_from_slice(&bytes[6..14]);
110 let expiry = u64::from_be_bytes(expiry_be);
111 if expiry > 0 && now_secs() > expiry {
112 return ParsedHeader::Expired;
113 }
114 ParsedHeader::Live { expiry }
115}
116
117fn fill_header(file: &mut fs::File) -> std::io::Result<([u8; HEADER_SIZE], usize)> {
119 let mut header = [0u8; HEADER_SIZE];
120 let mut filled = 0;
121 while filled < HEADER_SIZE {
122 match file.read(&mut header[filled..]) {
123 Ok(0) => break, Ok(n) => filled += n,
125 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
126 Err(e) => return Err(e),
127 }
128 }
129 Ok((header, filled))
130}
131
132fn classify_io(e: &std::io::Error) -> BackendErrorKind {
136 use std::io::ErrorKind as K;
137 match e.kind() {
138 K::StorageFull => BackendErrorKind::Transient,
140 K::PermissionDenied => BackendErrorKind::Transient,
141 K::ReadOnlyFilesystem => BackendErrorKind::Permanent,
142 K::TimedOut => BackendErrorKind::Timeout,
143 _ if is_symlink_loop(e) => BackendErrorKind::Permanent,
144 _ => BackendErrorKind::Transient,
145 }
146}
147
148fn is_symlink_loop(e: &std::io::Error) -> bool {
151 #[cfg(unix)]
152 {
153 e.raw_os_error() == Some(libc::ELOOP)
154 }
155 #[cfg(not(unix))]
156 {
157 let _ = e;
158 false
159 }
160}
161
162fn file_err(e: std::io::Error, what: &str) -> BackendError {
163 BackendError {
164 kind: classify_io(&e),
165 message: format!("{what}: {e}"),
166 source: Some(Box::new(e)),
167 }
168}
169
170fn is_missing(e: &std::io::Error) -> bool {
174 e.kind() == std::io::ErrorKind::NotFound || is_symlink_loop(e)
175}
176
177fn open_opts(read: bool, write: bool) -> fs::OpenOptions {
180 let mut opts = fs::OpenOptions::new();
181 opts.read(read).write(write);
182 #[cfg(unix)]
183 {
184 use std::os::unix::fs::OpenOptionsExt;
185 opts.custom_flags(libc::O_NOFOLLOW);
187 }
188 opts
189}
190
191fn flock_nb(file: &fs::File, exclusive: bool) -> Result<(), BackendError> {
198 #[cfg(unix)]
199 {
200 use rustix::fs::{flock, FlockOperation};
201 let op = if exclusive {
202 FlockOperation::NonBlockingLockExclusive
203 } else {
204 FlockOperation::NonBlockingLockShared
205 };
206 flock(file, op).map_err(|errno| {
207 if errno == rustix::io::Errno::WOULDBLOCK || errno == rustix::io::Errno::AGAIN {
208 BackendError::timeout("file lock contended (held by another process)")
209 } else {
210 file_err(std::io::Error::from(errno), "failed to lock cache file")
211 }
212 })
213 }
214 #[cfg(not(unix))]
215 {
216 let _ = (file, exclusive);
217 Ok(())
218 }
219}
220
221fn unlink_quiet(path: &Path) {
222 let _ = fs::remove_file(path);
223}
224
225fn unlink_if_same_inode(path: &Path, opened: &fs::File) {
233 if still_linked(path, opened) {
234 unlink_quiet(path);
235 }
236}
237
238fn still_linked(path: &Path, opened: &fs::File) -> bool {
241 #[cfg(unix)]
242 {
243 use std::os::unix::fs::MetadataExt;
244 let (Ok(on_disk), Ok(held)) = (fs::symlink_metadata(path), opened.metadata()) else {
245 return false;
246 };
247 on_disk.dev() == held.dev() && on_disk.ino() == held.ino()
248 }
249 #[cfg(not(unix))]
250 {
251 let _ = (path, opened);
252 true
253 }
254}
255
256fn read_entry(path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
259 let mut file = match open_opts(true, false).open(path) {
260 Ok(f) => f,
261 Err(e) if is_missing(&e) => return Ok(None),
262 Err(e) => return Err(file_err(e, "failed to open cache file")),
263 };
264 flock_nb(&file, false)?;
265 let mut data = Vec::new();
266 file.read_to_end(&mut data)
267 .map_err(|e| file_err(e, "failed to read cache file"))?;
268
269 match parse_header(&data) {
270 ParsedHeader::Corrupt | ParsedHeader::Expired => {
271 unlink_if_same_inode(path, &file);
272 Ok(None)
273 }
274 ParsedHeader::Live { .. } => Ok(Some(data.split_off(HEADER_SIZE))),
275 }
276}
277
278fn probe_header(path: &Path) -> Result<Option<u64>, BackendError> {
281 let mut file = match open_opts(true, false).open(path) {
282 Ok(f) => f,
283 Err(e) if is_missing(&e) => return Ok(None),
284 Err(e) => return Err(file_err(e, "failed to open cache file")),
285 };
286 flock_nb(&file, false)?;
287 let (header, filled) =
288 fill_header(&mut file).map_err(|e| file_err(e, "failed to read cache file header"))?;
289 match parse_header(&header[..filled]) {
290 ParsedHeader::Corrupt | ParsedHeader::Expired => {
291 unlink_if_same_inode(path, &file);
292 Ok(None)
293 }
294 ParsedHeader::Live { expiry } => Ok(Some(expiry)),
295 }
296}
297
298fn write_entry(path: &Path, header: [u8; HEADER_SIZE], value: &[u8]) -> Result<(), BackendError> {
299 let temp = temp_path(path);
300
301 let mut opts = open_opts(false, true);
304 opts.create_new(true); #[cfg(unix)]
306 {
307 use std::os::unix::fs::OpenOptionsExt;
308 opts.mode(0o600); }
310 let mut file = opts
311 .open(&temp)
312 .map_err(|e| file_err(e, "failed to create cache temp file"))?;
313
314 let write_all = |file: &mut fs::File| -> std::io::Result<()> {
316 file.write_all(&header)?;
317 file.write_all(value)?;
318 file.sync_all()
320 };
321 let result = write_all(&mut file).and_then(|()| {
322 drop(file);
323 fs::rename(&temp, path)
324 });
325 result.map_err(|e| {
326 unlink_quiet(&temp);
327 file_err(e, "failed to write cache file")
328 })
329}
330
331fn delete_entry(path: &Path) -> Result<bool, BackendError> {
332 match fs::remove_file(path) {
333 Ok(()) => Ok(true),
334 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
335 Err(e) => Err(file_err(e, "failed to delete cache file")),
336 }
337}
338
339fn entry_ttl(path: &Path) -> Result<Option<Duration>, BackendError> {
340 match probe_header(path)? {
341 None | Some(0) => Ok(None),
344 Some(expiry) => Ok(Some(Duration::from_secs(expiry.saturating_sub(now_secs())))),
345 }
346}
347
348fn rewrite_expiry(path: &Path, new_expiry: u64) -> Result<bool, BackendError> {
349 let mut file = match open_opts(true, true).open(path) {
350 Ok(f) => f,
351 Err(e) if is_missing(&e) => return Ok(false),
352 Err(e) => return Err(file_err(e, "failed to open cache file")),
353 };
354 flock_nb(&file, true)?;
355
356 let (header, filled) =
357 fill_header(&mut file).map_err(|e| file_err(e, "failed to read cache file header"))?;
358 match parse_header(&header[..filled]) {
359 ParsedHeader::Corrupt | ParsedHeader::Expired => {
360 unlink_if_same_inode(path, &file);
362 return Ok(false);
363 }
364 ParsedHeader::Live { .. } => {}
365 }
366
367 if !still_linked(path, &file) {
372 return Ok(false);
373 }
374
375 let do_write = |f: &mut fs::File| -> std::io::Result<()> {
379 f.seek(SeekFrom::Start(EXPIRY_OFFSET))?;
380 f.write_all(&new_expiry.to_be_bytes())?;
381 f.sync_all()
382 };
383 do_write(&mut file).map_err(|e| file_err(e, "failed to refresh cache file TTL"))?;
384 Ok(true)
385}
386
387fn temp_path(target: &Path) -> PathBuf {
388 let nanos = SystemTime::now()
393 .duration_since(UNIX_EPOCH)
394 .map(|d| d.as_nanos())
395 .unwrap_or(0);
396 let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
397 let name = target
398 .file_name()
399 .map(|n| n.to_string_lossy().into_owned())
400 .unwrap_or_default();
401 target.with_file_name(format!("{name}.tmp.{}.{nanos}.{seq}", std::process::id()))
402}
403
404fn cleanup_temp_files(cache_dir: &Path) {
406 let Ok(entries) = fs::read_dir(cache_dir) else {
407 return;
408 };
409 for entry in entries.flatten() {
410 let name = entry.file_name();
411 if !name.to_string_lossy().contains(".tmp.") {
412 continue;
413 }
414 let Ok(meta) = entry.path().symlink_metadata() else {
416 continue;
417 };
418 if !meta.is_file() {
419 continue;
420 }
421 let stale = meta
422 .modified()
423 .ok()
424 .and_then(|m| m.elapsed().ok())
425 .is_some_and(|age| age > TEMP_FILE_MAX_AGE);
426 if stale {
427 unlink_quiet(&entry.path());
428 }
429 }
430}
431
432#[derive(Debug, Clone)]
439pub struct FileBackend {
440 cache_dir: PathBuf,
441 lock: Arc<tokio::sync::Mutex<()>>,
445}
446
447impl FileBackend {
448 pub fn builder() -> FileBackendBuilder {
450 FileBackendBuilder::default()
451 }
452
453 #[must_use]
455 pub fn cache_dir(&self) -> &Path {
456 &self.cache_dir
457 }
458
459 fn entry_path(&self, key: &str) -> PathBuf {
462 self.cache_dir.join(crate::metrics::key_hash(key))
463 }
464
465 async fn locked<T: Send + 'static>(
473 &self,
474 f: impl FnOnce() -> Result<T, BackendError> + Send + 'static,
475 ) -> Result<T, BackendError> {
476 let guard = Arc::clone(&self.lock).lock_owned().await;
477 run_blocking(move || {
478 let _guard = guard;
479 f()
480 })
481 .await
482 }
483}
484
485#[cfg(not(target_arch = "wasm32"))]
488#[cfg_attr(not(feature = "unsync"), async_trait)]
489#[cfg_attr(feature = "unsync", async_trait(?Send))]
490impl Backend for FileBackend {
491 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
492 let path = self.entry_path(key);
493 self.locked(move || read_entry(&path)).await
494 }
495
496 async fn set(
497 &self,
498 key: &str,
499 value: Vec<u8>,
500 ttl: Option<Duration>,
501 ) -> Result<(), BackendError> {
502 let expiry = match ttl {
505 None => 0,
506 Some(d) => {
507 let secs = d.as_secs().max(1);
508 if secs > MAX_TTL_SECS {
509 return Err(BackendError::permanent(format!(
510 "TTL {secs}s out of range [1, {MAX_TTL_SECS}] (max 10 years)"
511 )));
512 }
513 now_secs().saturating_add(secs)
514 }
515 };
516
517 let path = self.entry_path(key);
518 self.locked(move || write_entry(&path, build_header(expiry), &value))
519 .await
520 }
521
522 async fn delete(&self, key: &str) -> Result<bool, BackendError> {
523 let path = self.entry_path(key);
524 self.locked(move || delete_entry(&path)).await
525 }
526
527 async fn exists(&self, key: &str) -> Result<bool, BackendError> {
528 let path = self.entry_path(key);
529 self.locked(move || Ok(probe_header(&path)?.is_some()))
530 .await
531 }
532
533 async fn health(&self) -> Result<HealthStatus, BackendError> {
534 let start = std::time::Instant::now();
535
536 let key = "__health_check__";
538 let probe = b"health_check_data".to_vec();
539 self.set(key, probe.clone(), Some(Duration::from_secs(60)))
540 .await?;
541 let read_back = self.get(key).await?;
542 self.delete(key).await?;
543
544 let round_trip_ok = read_back.as_deref() == Some(probe.as_slice());
545 let latency = start.elapsed();
546
547 let mut details = HashMap::new();
548 details.insert(
549 "cache_dir".to_string(),
550 self.cache_dir.display().to_string(),
551 );
552 if !round_trip_ok {
553 details.insert(
554 "error".to_string(),
555 "round-trip verification failed".to_string(),
556 );
557 }
558 Ok(HealthStatus {
559 is_healthy: round_trip_ok,
560 latency_ms: latency.as_secs_f64() * 1000.0,
561 backend_type: "file".to_string(),
562 details,
563 })
564 }
565}
566
567#[cfg(not(target_arch = "wasm32"))]
570#[cfg_attr(not(feature = "unsync"), async_trait)]
571#[cfg_attr(feature = "unsync", async_trait(?Send))]
572impl TtlInspectable for FileBackend {
573 async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError> {
574 let path = self.entry_path(key);
575 self.locked(move || entry_ttl(&path)).await
576 }
577
578 async fn refresh_ttl(&self, key: &str, ttl: Duration) -> Result<bool, BackendError> {
579 let secs = ttl.as_secs();
580 if secs == 0 {
585 return Err(BackendError::permanent(
586 "refresh_ttl requires at least 1 second".to_string(),
587 ));
588 }
589 if secs > MAX_TTL_SECS {
590 return Err(BackendError::permanent(format!(
591 "TTL {secs}s out of range [1, {MAX_TTL_SECS}] (max 10 years)"
592 )));
593 }
594
595 let new_expiry = now_secs().saturating_add(secs);
596 let path = self.entry_path(key);
597 self.locked(move || rewrite_expiry(&path, new_expiry)).await
598 }
599}
600
601#[derive(Default)]
605#[must_use]
606pub struct FileBackendBuilder {
607 cache_dir: Option<PathBuf>,
608}
609
610impl FileBackendBuilder {
611 pub fn cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
614 self.cache_dir = Some(dir.into());
615 self
616 }
617
618 pub fn build(self) -> Result<FileBackend, crate::error::CachekitError> {
632 use crate::error::CachekitError;
633
634 let dir = self
635 .cache_dir
636 .unwrap_or_else(|| std::env::temp_dir().join("cachekit"));
637
638 let create = || -> std::io::Result<()> {
639 let mut builder = fs::DirBuilder::new();
640 builder.recursive(true);
641 #[cfg(unix)]
642 {
643 use std::os::unix::fs::DirBuilderExt;
644 builder.mode(0o700); }
646 builder.create(&dir)
647 };
648 create().map_err(|e| {
649 CachekitError::Config(format!(
650 "failed to create cache directory {}: {e}",
651 dir.display()
652 ))
653 })?;
654
655 let cache_dir = fs::canonicalize(&dir).map_err(|e| {
657 CachekitError::Config(format!(
658 "failed to resolve cache directory {}: {e}",
659 dir.display()
660 ))
661 })?;
662
663 #[cfg(unix)]
665 {
666 use std::os::unix::fs::MetadataExt;
667 let meta = fs::metadata(&cache_dir).map_err(|e| {
668 CachekitError::Config(format!(
669 "failed to stat cache directory {}: {e}",
670 cache_dir.display()
671 ))
672 })?;
673 let euid = rustix::process::geteuid().as_raw();
674 if meta.uid() != euid {
675 return Err(CachekitError::Config(format!(
676 "cache directory {} is owned by uid {} (expected {euid}) — refusing a \
677 directory another local user controls",
678 cache_dir.display(),
679 meta.uid()
680 )));
681 }
682 if meta.mode() & 0o022 != 0 {
683 return Err(CachekitError::Config(format!(
684 "cache directory {} is group/other-writable (mode {:o}) — another local \
685 user could plant or replace cache entries; chmod it to 0700",
686 cache_dir.display(),
687 meta.mode() & 0o777
688 )));
689 }
690 }
691
692 cleanup_temp_files(&cache_dir);
693
694 Ok(FileBackend {
695 cache_dir,
696 lock: Arc::new(tokio::sync::Mutex::new(())),
697 })
698 }
699}
700
701#[cfg(test)]
702#[allow(clippy::expect_used, clippy::unwrap_used)] mod tests {
704 use super::*;
705
706 fn _assert_ttl_inspectable(_b: &dyn TtlInspectable) {}
708
709 #[test]
710 fn file_is_ttl_inspectable() {
711 fn _check(backend: &FileBackend) {
712 _assert_ttl_inspectable(backend);
713 }
714 }
715
716 fn backend_at(dir: &Path) -> FileBackend {
717 FileBackend {
718 cache_dir: dir.to_path_buf(),
719 lock: Arc::new(tokio::sync::Mutex::new(())),
720 }
721 }
722
723 #[test]
724 fn filename_hash_matches_py_blake2b16_hex() {
725 let backend = backend_at(Path::new("/cache"));
727 let path = backend.entry_path("ns:app:func:m.f:args:abc:v1");
728 assert_eq!(
729 path,
730 PathBuf::from("/cache/bcb35ae6f64fa65b2770ab3af631b1ce")
731 );
732 }
733
734 #[test]
735 fn header_layout_is_py_byte_compatible() {
736 let header = build_header(0x0102_0304_0506_0708);
737 assert_eq!(&header[0..2], b"CK");
738 assert_eq!(header[2], 1); assert_eq!(header[3], 0); assert_eq!(&header[4..6], &[0, 0]); assert_eq!(&header[6..14], &[1, 2, 3, 4, 5, 6, 7, 8]); }
743
744 fn write_raw(dir: &Path, name: &str, hex_bytes: &str) -> PathBuf {
748 let path = dir.join(name);
749 fs::write(&path, hex::decode(hex_bytes).unwrap()).unwrap();
750 path
751 }
752
753 #[test]
754 fn py_written_never_expires_entry_reads_back() {
755 let dir = tempfile::tempdir().expect("tempdir");
756 let path = write_raw(
757 dir.path(),
758 "entry",
759 "434b01000000000000000000000068656c6c6f2066726f6d20707974686f6e",
760 );
761 let payload = read_entry(&path).expect("read should not error");
762 assert_eq!(payload.as_deref(), Some(b"hello from python".as_slice()));
763 assert!(path.exists(), "live entry must not be unlinked");
764 }
765
766 #[test]
767 fn py_written_future_expiry_entry_reads_back() {
768 let dir = tempfile::tempdir().expect("tempdir");
769 let path = write_raw(
771 dir.path(),
772 "entry",
773 "434b0100000000000000f48657006672657368",
774 );
775 let payload = read_entry(&path).expect("read should not error");
776 assert_eq!(payload.as_deref(), Some(b"fresh".as_slice()));
777
778 let remaining = entry_ttl(&path)
779 .expect("ttl should not error")
780 .expect("future expiry must report a TTL");
781 assert!(remaining > Duration::from_secs(3600), "TTL: {remaining:?}");
782 }
783
784 #[test]
785 fn py_written_expired_entry_is_unlinked_on_read() {
786 let dir = tempfile::tempdir().expect("tempdir");
787 let path = write_raw(
789 dir.path(),
790 "entry",
791 "434b0100000000000000000000017374616c65",
792 );
793 assert_eq!(read_entry(&path).expect("read should not error"), None);
794 assert!(!path.exists(), "expired entry must be unlinked on read");
795 }
796
797 #[test]
798 fn corrupt_and_short_entries_are_unlinked_on_read() {
799 let dir = tempfile::tempdir().expect("tempdir");
800
801 let bad_magic = write_raw(dir.path(), "bad-magic", "585801000000000000000000000061");
803 assert_eq!(read_entry(&bad_magic).expect("no error"), None);
804 assert!(!bad_magic.exists());
805
806 let bad_version = write_raw(dir.path(), "bad-version", "434b02000000000000000000000061");
808 assert_eq!(read_entry(&bad_version).expect("no error"), None);
809 assert!(!bad_version.exists());
810
811 let short = write_raw(dir.path(), "short", "434b01");
813 assert_eq!(read_entry(&short).expect("no error"), None);
814 assert!(!short.exists());
815 }
816
817 #[test]
818 fn ttl_semantics_match_py() {
819 let dir = tempfile::tempdir().expect("tempdir");
820
821 let never = write_raw(
823 dir.path(),
824 "never",
825 "434b01000000000000000000000068656c6c6f2066726f6d20707974686f6e",
826 );
827 assert_eq!(entry_ttl(&never).expect("no error"), None);
828 assert!(never.exists());
829
830 let expired = write_raw(
832 dir.path(),
833 "expired",
834 "434b0100000000000000000000017374616c65",
835 );
836 assert_eq!(entry_ttl(&expired).expect("no error"), None);
837 assert!(!expired.exists());
838
839 assert_eq!(entry_ttl(&dir.path().join("nope")).expect("no error"), None);
841 }
842
843 #[test]
844 fn rewrite_expiry_semantics_match_py() {
845 let dir = tempfile::tempdir().expect("tempdir");
846
847 let live = write_raw(dir.path(), "live", "434b0100000000000000f48657006672657368");
849 let new_expiry = now_secs() + 42;
850 assert!(rewrite_expiry(&live, new_expiry).expect("no error"));
851 let remaining = entry_ttl(&live).expect("no error").expect("has TTL");
852 assert!(remaining <= Duration::from_secs(42));
853 assert_eq!(
854 read_entry(&live).expect("no error").as_deref(),
855 Some(b"fresh".as_slice()),
856 "payload must survive an in-place expiry rewrite"
857 );
858
859 let expired = write_raw(
861 dir.path(),
862 "expired",
863 "434b0100000000000000000000017374616c65",
864 );
865 assert!(!rewrite_expiry(&expired, new_expiry).expect("no error"));
866 assert!(!expired.exists());
867
868 assert!(!rewrite_expiry(&dir.path().join("nope"), new_expiry).expect("no error"));
870 }
871
872 #[test]
873 fn temp_file_sweep_removes_stale_keeps_fresh() {
874 let dir = tempfile::tempdir().expect("tempdir");
875
876 let stale = dir.path().join("abc.tmp.123.456");
877 fs::write(&stale, b"orphan").expect("write");
878 let two_minutes_ago = SystemTime::now() - Duration::from_secs(120);
879 fs::File::options()
880 .write(true)
881 .open(&stale)
882 .expect("open")
883 .set_modified(two_minutes_ago)
884 .expect("set mtime");
885
886 let fresh = dir.path().join("def.tmp.123.789");
887 fs::write(&fresh, b"in flight").expect("write");
888
889 let entry = dir.path().join("0123abcd");
890 fs::write(&entry, b"not a temp file").expect("write");
891
892 cleanup_temp_files(dir.path());
893
894 assert!(!stale.exists(), "stale temp file must be swept");
895 assert!(fresh.exists(), "fresh temp file must be kept");
896 assert!(entry.exists(), "cache entries must never be swept");
897 }
898
899 #[cfg(unix)]
902 #[test]
903 fn builder_rejects_group_or_other_writable_directory() {
904 use std::os::unix::fs::PermissionsExt;
905
906 let dir = tempfile::tempdir().expect("tempdir");
910 let target = dir.path().join("shared");
911 fs::create_dir(&target).expect("mkdir");
912 fs::set_permissions(&target, fs::Permissions::from_mode(0o770)).expect("chmod");
913
914 let err = FileBackend::builder()
915 .cache_dir(&target)
916 .build()
917 .expect_err("group-writable dir must be rejected");
918 assert!(err.to_string().contains("writable"), "{err}");
919
920 fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).expect("chmod");
922 FileBackend::builder()
923 .cache_dir(&target)
924 .build()
925 .expect("0700 dir must be accepted");
926 }
927
928 #[test]
929 fn temp_paths_never_collide() {
930 let target = Path::new("/cache/abc");
931 let a = temp_path(target);
932 let b = temp_path(target);
933 assert_ne!(a, b, "same-key temp paths must be unique");
934 assert!(a.to_string_lossy().contains(".tmp."), "py sweep pattern");
935 }
936
937 #[cfg(unix)]
938 #[test]
939 fn stale_unlink_decision_spares_replaced_entry() {
940 let dir = tempfile::tempdir().expect("tempdir");
943 let path = write_raw(
944 dir.path(),
945 "entry",
946 "434b0100000000000000000000017374616c65", );
948
949 let stale_fd = open_opts(true, false).open(&path).expect("open");
951
952 write_entry(&path, build_header(0), b"fresh").expect("replace");
954
955 unlink_if_same_inode(&path, &stale_fd);
957 assert_eq!(
958 read_entry(&path).expect("no error").as_deref(),
959 Some(b"fresh".as_slice()),
960 "inode-validated unlink must spare the replaced entry"
961 );
962
963 assert!(!still_linked(&path, &stale_fd));
965 let live_fd = open_opts(true, false).open(&path).expect("open");
966 assert!(still_linked(&path, &live_fd));
967 }
968
969 #[cfg(unix)]
970 #[test]
971 fn flock_contention_surfaces_as_timeout() {
972 use rustix::fs::{flock, FlockOperation};
973
974 let dir = tempfile::tempdir().expect("tempdir");
975 let path = write_raw(
976 dir.path(),
977 "entry",
978 "434b01000000000000000000000068656c6c6f2066726f6d20707974686f6e",
979 );
980
981 let holder = fs::File::open(&path).expect("open");
983 flock(&holder, FlockOperation::NonBlockingLockExclusive).expect("lock");
984
985 let err = read_entry(&path).expect_err("contended read must error");
988 assert_eq!(err.kind, BackendErrorKind::Timeout, "{err}");
989 }
990
991 #[tokio::test]
992 async fn expired_read_racing_fresh_set_never_loses_the_write() {
993 let dir = tempfile::tempdir().expect("tempdir");
997 #[cfg(unix)]
1000 {
1001 use std::os::unix::fs::PermissionsExt;
1002 fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).expect("chmod");
1003 }
1004 let backend = FileBackend::builder()
1005 .cache_dir(dir.path())
1006 .build()
1007 .expect("build");
1008 let key = "raced";
1009 let path = backend.entry_path(key);
1010
1011 for round in 0..50 {
1012 fs::write(
1014 &path,
1015 hex::decode("434b0100000000000000000000017374616c65").unwrap(),
1016 )
1017 .expect("plant expired entry");
1018
1019 let reader = {
1020 let b = backend.clone();
1021 tokio::spawn(async move { b.get(key).await })
1022 };
1023 let writer = {
1024 let b = backend.clone();
1025 tokio::spawn(async move { b.set(key, b"fresh".to_vec(), None).await })
1026 };
1027 reader.await.expect("join").expect("get");
1028 writer.await.expect("join").expect("set");
1029
1030 assert_eq!(
1031 backend.get(key).await.expect("get").as_deref(),
1032 Some(b"fresh".as_slice()),
1033 "round {round}: fresh write lost to a stale expired-unlink"
1034 );
1035 }
1036 }
1037}