use std::collections::HashMap;
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use blake2::{digest::consts::U16, Blake2b, Digest};
use crate::backend::{run_blocking, Backend, HealthStatus, TtlInspectable};
use crate::error::{BackendError, BackendErrorKind};
type Blake2b128 = Blake2b<U16>;
const MAGIC: &[u8; 2] = b"CK";
const FORMAT_VERSION: u8 = 1;
const HEADER_SIZE: usize = 14;
const EXPIRY_OFFSET: u64 = 6;
const MAX_TTL_SECS: u64 = 10 * 365 * 24 * 60 * 60;
const TEMP_FILE_MAX_AGE: Duration = Duration::from_secs(60);
static TEMP_SEQ: AtomicU64 = AtomicU64::new(0);
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn build_header(expiry_unix_secs: u64) -> [u8; HEADER_SIZE] {
let mut h = [0u8; HEADER_SIZE];
h[0..2].copy_from_slice(MAGIC);
h[2] = FORMAT_VERSION;
h[6..14].copy_from_slice(&expiry_unix_secs.to_be_bytes());
h
}
enum ParsedHeader {
Corrupt,
Expired,
Live { expiry: u64 },
}
fn parse_header(bytes: &[u8]) -> ParsedHeader {
if bytes.len() < HEADER_SIZE || &bytes[0..2] != MAGIC || bytes[2] != FORMAT_VERSION {
return ParsedHeader::Corrupt;
}
let mut expiry_be = [0u8; 8];
expiry_be.copy_from_slice(&bytes[6..14]);
let expiry = u64::from_be_bytes(expiry_be);
if expiry > 0 && now_secs() > expiry {
return ParsedHeader::Expired;
}
ParsedHeader::Live { expiry }
}
fn fill_header(file: &mut fs::File) -> std::io::Result<([u8; HEADER_SIZE], usize)> {
let mut header = [0u8; HEADER_SIZE];
let mut filled = 0;
while filled < HEADER_SIZE {
match file.read(&mut header[filled..]) {
Ok(0) => break, Ok(n) => filled += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok((header, filled))
}
fn classify_io(e: &std::io::Error) -> BackendErrorKind {
use std::io::ErrorKind as K;
match e.kind() {
K::StorageFull => BackendErrorKind::Transient,
K::PermissionDenied => BackendErrorKind::Transient,
K::ReadOnlyFilesystem => BackendErrorKind::Permanent,
K::TimedOut => BackendErrorKind::Timeout,
_ if is_symlink_loop(e) => BackendErrorKind::Permanent,
_ => BackendErrorKind::Transient,
}
}
fn is_symlink_loop(e: &std::io::Error) -> bool {
#[cfg(unix)]
{
e.raw_os_error() == Some(libc::ELOOP)
}
#[cfg(not(unix))]
{
let _ = e;
false
}
}
fn file_err(e: std::io::Error, what: &str) -> BackendError {
BackendError {
kind: classify_io(&e),
message: format!("{what}: {e}"),
source: Some(Box::new(e)),
}
}
fn is_missing(e: &std::io::Error) -> bool {
e.kind() == std::io::ErrorKind::NotFound || is_symlink_loop(e)
}
fn open_opts(read: bool, write: bool) -> fs::OpenOptions {
let mut opts = fs::OpenOptions::new();
opts.read(read).write(write);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
opts
}
fn flock_nb(file: &fs::File, exclusive: bool) -> Result<(), BackendError> {
#[cfg(unix)]
{
use rustix::fs::{flock, FlockOperation};
let op = if exclusive {
FlockOperation::NonBlockingLockExclusive
} else {
FlockOperation::NonBlockingLockShared
};
flock(file, op).map_err(|errno| {
if errno == rustix::io::Errno::WOULDBLOCK || errno == rustix::io::Errno::AGAIN {
BackendError::timeout("file lock contended (held by another process)")
} else {
file_err(std::io::Error::from(errno), "failed to lock cache file")
}
})
}
#[cfg(not(unix))]
{
let _ = (file, exclusive);
Ok(())
}
}
fn unlink_quiet(path: &Path) {
let _ = fs::remove_file(path);
}
fn unlink_if_same_inode(path: &Path, opened: &fs::File) {
if still_linked(path, opened) {
unlink_quiet(path);
}
}
fn still_linked(path: &Path, opened: &fs::File) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let (Ok(on_disk), Ok(held)) = (fs::symlink_metadata(path), opened.metadata()) else {
return false;
};
on_disk.dev() == held.dev() && on_disk.ino() == held.ino()
}
#[cfg(not(unix))]
{
let _ = (path, opened);
true
}
}
fn read_entry(path: &Path) -> Result<Option<Vec<u8>>, BackendError> {
let mut file = match open_opts(true, false).open(path) {
Ok(f) => f,
Err(e) if is_missing(&e) => return Ok(None),
Err(e) => return Err(file_err(e, "failed to open cache file")),
};
flock_nb(&file, false)?;
let mut data = Vec::new();
file.read_to_end(&mut data)
.map_err(|e| file_err(e, "failed to read cache file"))?;
match parse_header(&data) {
ParsedHeader::Corrupt | ParsedHeader::Expired => {
unlink_if_same_inode(path, &file);
Ok(None)
}
ParsedHeader::Live { .. } => Ok(Some(data.split_off(HEADER_SIZE))),
}
}
fn probe_header(path: &Path) -> Result<Option<u64>, BackendError> {
let mut file = match open_opts(true, false).open(path) {
Ok(f) => f,
Err(e) if is_missing(&e) => return Ok(None),
Err(e) => return Err(file_err(e, "failed to open cache file")),
};
flock_nb(&file, false)?;
let (header, filled) =
fill_header(&mut file).map_err(|e| file_err(e, "failed to read cache file header"))?;
match parse_header(&header[..filled]) {
ParsedHeader::Corrupt | ParsedHeader::Expired => {
unlink_if_same_inode(path, &file);
Ok(None)
}
ParsedHeader::Live { expiry } => Ok(Some(expiry)),
}
}
fn write_entry(path: &Path, header: [u8; HEADER_SIZE], value: &[u8]) -> Result<(), BackendError> {
let temp = temp_path(path);
let mut opts = open_opts(false, true);
opts.create_new(true); #[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600); }
let mut file = opts
.open(&temp)
.map_err(|e| file_err(e, "failed to create cache temp file"))?;
let write_all = |file: &mut fs::File| -> std::io::Result<()> {
file.write_all(&header)?;
file.write_all(value)?;
file.sync_all()
};
let result = write_all(&mut file).and_then(|()| {
drop(file);
fs::rename(&temp, path)
});
result.map_err(|e| {
unlink_quiet(&temp);
file_err(e, "failed to write cache file")
})
}
fn delete_entry(path: &Path) -> Result<bool, BackendError> {
match fs::remove_file(path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(file_err(e, "failed to delete cache file")),
}
}
fn entry_ttl(path: &Path) -> Result<Option<Duration>, BackendError> {
match probe_header(path)? {
None | Some(0) => Ok(None),
Some(expiry) => Ok(Some(Duration::from_secs(expiry.saturating_sub(now_secs())))),
}
}
fn rewrite_expiry(path: &Path, new_expiry: u64) -> Result<bool, BackendError> {
let mut file = match open_opts(true, true).open(path) {
Ok(f) => f,
Err(e) if is_missing(&e) => return Ok(false),
Err(e) => return Err(file_err(e, "failed to open cache file")),
};
flock_nb(&file, true)?;
let (header, filled) =
fill_header(&mut file).map_err(|e| file_err(e, "failed to read cache file header"))?;
match parse_header(&header[..filled]) {
ParsedHeader::Corrupt | ParsedHeader::Expired => {
unlink_if_same_inode(path, &file);
return Ok(false);
}
ParsedHeader::Live { .. } => {}
}
if !still_linked(path, &file) {
return Ok(false);
}
let do_write = |f: &mut fs::File| -> std::io::Result<()> {
f.seek(SeekFrom::Start(EXPIRY_OFFSET))?;
f.write_all(&new_expiry.to_be_bytes())?;
f.sync_all()
};
do_write(&mut file).map_err(|e| file_err(e, "failed to refresh cache file TTL"))?;
Ok(true)
}
fn temp_path(target: &Path) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let seq = TEMP_SEQ.fetch_add(1, Ordering::Relaxed);
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
target.with_file_name(format!("{name}.tmp.{}.{nanos}.{seq}", std::process::id()))
}
fn cleanup_temp_files(cache_dir: &Path) {
let Ok(entries) = fs::read_dir(cache_dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
if !name.to_string_lossy().contains(".tmp.") {
continue;
}
let Ok(meta) = entry.path().symlink_metadata() else {
continue;
};
if !meta.is_file() {
continue;
}
let stale = meta
.modified()
.ok()
.and_then(|m| m.elapsed().ok())
.is_some_and(|age| age > TEMP_FILE_MAX_AGE);
if stale {
unlink_quiet(&entry.path());
}
}
}
#[derive(Debug, Clone)]
pub struct FileBackend {
cache_dir: PathBuf,
lock: Arc<tokio::sync::Mutex<()>>,
}
impl FileBackend {
pub fn builder() -> FileBackendBuilder {
FileBackendBuilder::default()
}
#[must_use]
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
fn entry_path(&self, key: &str) -> PathBuf {
let mut hasher = Blake2b128::new();
hasher.update(key.as_bytes());
self.cache_dir.join(hex::encode(hasher.finalize()))
}
async fn locked<T: Send + 'static>(
&self,
f: impl FnOnce() -> Result<T, BackendError> + Send + 'static,
) -> Result<T, BackendError> {
let guard = Arc::clone(&self.lock).lock_owned().await;
run_blocking(move || {
let _guard = guard;
f()
})
.await
}
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg_attr(not(feature = "unsync"), async_trait)]
#[cfg_attr(feature = "unsync", async_trait(?Send))]
impl Backend for FileBackend {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
let path = self.entry_path(key);
self.locked(move || read_entry(&path)).await
}
async fn set(
&self,
key: &str,
value: Vec<u8>,
ttl: Option<Duration>,
) -> Result<(), BackendError> {
let expiry = match ttl {
None => 0,
Some(d) => {
let secs = d.as_secs().max(1);
if secs > MAX_TTL_SECS {
return Err(BackendError::permanent(format!(
"TTL {secs}s out of range [1, {MAX_TTL_SECS}] (max 10 years)"
)));
}
now_secs().saturating_add(secs)
}
};
let path = self.entry_path(key);
self.locked(move || write_entry(&path, build_header(expiry), &value))
.await
}
async fn delete(&self, key: &str) -> Result<bool, BackendError> {
let path = self.entry_path(key);
self.locked(move || delete_entry(&path)).await
}
async fn exists(&self, key: &str) -> Result<bool, BackendError> {
let path = self.entry_path(key);
self.locked(move || Ok(probe_header(&path)?.is_some()))
.await
}
async fn health(&self) -> Result<HealthStatus, BackendError> {
let start = std::time::Instant::now();
let key = "__health_check__";
let probe = b"health_check_data".to_vec();
self.set(key, probe.clone(), Some(Duration::from_secs(60)))
.await?;
let read_back = self.get(key).await?;
self.delete(key).await?;
let round_trip_ok = read_back.as_deref() == Some(probe.as_slice());
let latency = start.elapsed();
let mut details = HashMap::new();
details.insert(
"cache_dir".to_string(),
self.cache_dir.display().to_string(),
);
if !round_trip_ok {
details.insert(
"error".to_string(),
"round-trip verification failed".to_string(),
);
}
Ok(HealthStatus {
is_healthy: round_trip_ok,
latency_ms: latency.as_secs_f64() * 1000.0,
backend_type: "file".to_string(),
details,
})
}
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg_attr(not(feature = "unsync"), async_trait)]
#[cfg_attr(feature = "unsync", async_trait(?Send))]
impl TtlInspectable for FileBackend {
async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError> {
let path = self.entry_path(key);
self.locked(move || entry_ttl(&path)).await
}
async fn refresh_ttl(&self, key: &str, ttl: Duration) -> Result<bool, BackendError> {
let secs = ttl.as_secs();
if secs == 0 {
return Err(BackendError::permanent(
"refresh_ttl requires at least 1 second".to_string(),
));
}
if secs > MAX_TTL_SECS {
return Err(BackendError::permanent(format!(
"TTL {secs}s out of range [1, {MAX_TTL_SECS}] (max 10 years)"
)));
}
let new_expiry = now_secs().saturating_add(secs);
let path = self.entry_path(key);
self.locked(move || rewrite_expiry(&path, new_expiry)).await
}
}
#[derive(Default)]
#[must_use]
pub struct FileBackendBuilder {
cache_dir: Option<PathBuf>,
}
impl FileBackendBuilder {
pub fn cache_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(dir.into());
self
}
pub fn build(self) -> Result<FileBackend, crate::error::CachekitError> {
use crate::error::CachekitError;
let dir = self
.cache_dir
.unwrap_or_else(|| std::env::temp_dir().join("cachekit"));
let create = || -> std::io::Result<()> {
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700); }
builder.create(&dir)
};
create().map_err(|e| {
CachekitError::Config(format!(
"failed to create cache directory {}: {e}",
dir.display()
))
})?;
let cache_dir = fs::canonicalize(&dir).map_err(|e| {
CachekitError::Config(format!(
"failed to resolve cache directory {}: {e}",
dir.display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let meta = fs::metadata(&cache_dir).map_err(|e| {
CachekitError::Config(format!(
"failed to stat cache directory {}: {e}",
cache_dir.display()
))
})?;
let euid = rustix::process::geteuid().as_raw();
if meta.uid() != euid {
return Err(CachekitError::Config(format!(
"cache directory {} is owned by uid {} (expected {euid}) — refusing a \
directory another local user controls",
cache_dir.display(),
meta.uid()
)));
}
if meta.mode() & 0o022 != 0 {
return Err(CachekitError::Config(format!(
"cache directory {} is group/other-writable (mode {:o}) — another local \
user could plant or replace cache entries; chmod it to 0700",
cache_dir.display(),
meta.mode() & 0o777
)));
}
}
cleanup_temp_files(&cache_dir);
Ok(FileBackend {
cache_dir,
lock: Arc::new(tokio::sync::Mutex::new(())),
})
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)] mod tests {
use super::*;
fn _assert_ttl_inspectable(_b: &dyn TtlInspectable) {}
#[test]
fn file_is_ttl_inspectable() {
fn _check(backend: &FileBackend) {
_assert_ttl_inspectable(backend);
}
}
fn backend_at(dir: &Path) -> FileBackend {
FileBackend {
cache_dir: dir.to_path_buf(),
lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
#[test]
fn filename_hash_matches_py_blake2b16_hex() {
let backend = backend_at(Path::new("/cache"));
let path = backend.entry_path("ns:app:func:m.f:args:abc:v1");
assert_eq!(
path,
PathBuf::from("/cache/bcb35ae6f64fa65b2770ab3af631b1ce")
);
}
#[test]
fn header_layout_is_py_byte_compatible() {
let header = build_header(0x0102_0304_0506_0708);
assert_eq!(&header[0..2], b"CK");
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]); }
fn write_raw(dir: &Path, name: &str, hex_bytes: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, hex::decode(hex_bytes).unwrap()).unwrap();
path
}
#[test]
fn py_written_never_expires_entry_reads_back() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_raw(
dir.path(),
"entry",
"434b01000000000000000000000068656c6c6f2066726f6d20707974686f6e",
);
let payload = read_entry(&path).expect("read should not error");
assert_eq!(payload.as_deref(), Some(b"hello from python".as_slice()));
assert!(path.exists(), "live entry must not be unlinked");
}
#[test]
fn py_written_future_expiry_entry_reads_back() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_raw(
dir.path(),
"entry",
"434b0100000000000000f48657006672657368",
);
let payload = read_entry(&path).expect("read should not error");
assert_eq!(payload.as_deref(), Some(b"fresh".as_slice()));
let remaining = entry_ttl(&path)
.expect("ttl should not error")
.expect("future expiry must report a TTL");
assert!(remaining > Duration::from_secs(3600), "TTL: {remaining:?}");
}
#[test]
fn py_written_expired_entry_is_unlinked_on_read() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_raw(
dir.path(),
"entry",
"434b0100000000000000000000017374616c65",
);
assert_eq!(read_entry(&path).expect("read should not error"), None);
assert!(!path.exists(), "expired entry must be unlinked on read");
}
#[test]
fn corrupt_and_short_entries_are_unlinked_on_read() {
let dir = tempfile::tempdir().expect("tempdir");
let bad_magic = write_raw(dir.path(), "bad-magic", "585801000000000000000000000061");
assert_eq!(read_entry(&bad_magic).expect("no error"), None);
assert!(!bad_magic.exists());
let bad_version = write_raw(dir.path(), "bad-version", "434b02000000000000000000000061");
assert_eq!(read_entry(&bad_version).expect("no error"), None);
assert!(!bad_version.exists());
let short = write_raw(dir.path(), "short", "434b01");
assert_eq!(read_entry(&short).expect("no error"), None);
assert!(!short.exists());
}
#[test]
fn ttl_semantics_match_py() {
let dir = tempfile::tempdir().expect("tempdir");
let never = write_raw(
dir.path(),
"never",
"434b01000000000000000000000068656c6c6f2066726f6d20707974686f6e",
);
assert_eq!(entry_ttl(&never).expect("no error"), None);
assert!(never.exists());
let expired = write_raw(
dir.path(),
"expired",
"434b0100000000000000000000017374616c65",
);
assert_eq!(entry_ttl(&expired).expect("no error"), None);
assert!(!expired.exists());
assert_eq!(entry_ttl(&dir.path().join("nope")).expect("no error"), None);
}
#[test]
fn rewrite_expiry_semantics_match_py() {
let dir = tempfile::tempdir().expect("tempdir");
let live = write_raw(dir.path(), "live", "434b0100000000000000f48657006672657368");
let new_expiry = now_secs() + 42;
assert!(rewrite_expiry(&live, new_expiry).expect("no error"));
let remaining = entry_ttl(&live).expect("no error").expect("has TTL");
assert!(remaining <= Duration::from_secs(42));
assert_eq!(
read_entry(&live).expect("no error").as_deref(),
Some(b"fresh".as_slice()),
"payload must survive an in-place expiry rewrite"
);
let expired = write_raw(
dir.path(),
"expired",
"434b0100000000000000000000017374616c65",
);
assert!(!rewrite_expiry(&expired, new_expiry).expect("no error"));
assert!(!expired.exists());
assert!(!rewrite_expiry(&dir.path().join("nope"), new_expiry).expect("no error"));
}
#[test]
fn temp_file_sweep_removes_stale_keeps_fresh() {
let dir = tempfile::tempdir().expect("tempdir");
let stale = dir.path().join("abc.tmp.123.456");
fs::write(&stale, b"orphan").expect("write");
let two_minutes_ago = SystemTime::now() - Duration::from_secs(120);
fs::File::options()
.write(true)
.open(&stale)
.expect("open")
.set_modified(two_minutes_ago)
.expect("set mtime");
let fresh = dir.path().join("def.tmp.123.789");
fs::write(&fresh, b"in flight").expect("write");
let entry = dir.path().join("0123abcd");
fs::write(&entry, b"not a temp file").expect("write");
cleanup_temp_files(dir.path());
assert!(!stale.exists(), "stale temp file must be swept");
assert!(fresh.exists(), "fresh temp file must be kept");
assert!(entry.exists(), "cache entries must never be swept");
}
#[cfg(unix)]
#[test]
fn builder_rejects_group_or_other_writable_directory() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
let target = dir.path().join("shared");
fs::create_dir(&target).expect("mkdir");
fs::set_permissions(&target, fs::Permissions::from_mode(0o770)).expect("chmod");
let err = FileBackend::builder()
.cache_dir(&target)
.build()
.expect_err("group-writable dir must be rejected");
assert!(err.to_string().contains("writable"), "{err}");
fs::set_permissions(&target, fs::Permissions::from_mode(0o700)).expect("chmod");
FileBackend::builder()
.cache_dir(&target)
.build()
.expect("0700 dir must be accepted");
}
#[test]
fn temp_paths_never_collide() {
let target = Path::new("/cache/abc");
let a = temp_path(target);
let b = temp_path(target);
assert_ne!(a, b, "same-key temp paths must be unique");
assert!(a.to_string_lossy().contains(".tmp."), "py sweep pattern");
}
#[cfg(unix)]
#[test]
fn stale_unlink_decision_spares_replaced_entry() {
let dir = tempfile::tempdir().expect("tempdir");
let path = write_raw(
dir.path(),
"entry",
"434b0100000000000000000000017374616c65", );
let stale_fd = open_opts(true, false).open(&path).expect("open");
write_entry(&path, build_header(0), b"fresh").expect("replace");
unlink_if_same_inode(&path, &stale_fd);
assert_eq!(
read_entry(&path).expect("no error").as_deref(),
Some(b"fresh".as_slice()),
"inode-validated unlink must spare the replaced entry"
);
assert!(!still_linked(&path, &stale_fd));
let live_fd = open_opts(true, false).open(&path).expect("open");
assert!(still_linked(&path, &live_fd));
}
#[cfg(unix)]
#[test]
fn flock_contention_surfaces_as_timeout() {
use rustix::fs::{flock, FlockOperation};
let dir = tempfile::tempdir().expect("tempdir");
let path = write_raw(
dir.path(),
"entry",
"434b01000000000000000000000068656c6c6f2066726f6d20707974686f6e",
);
let holder = fs::File::open(&path).expect("open");
flock(&holder, FlockOperation::NonBlockingLockExclusive).expect("lock");
let err = read_entry(&path).expect_err("contended read must error");
assert_eq!(err.kind, BackendErrorKind::Timeout, "{err}");
}
#[tokio::test]
async fn expired_read_racing_fresh_set_never_loses_the_write() {
let dir = tempfile::tempdir().expect("tempdir");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).expect("chmod");
}
let backend = FileBackend::builder()
.cache_dir(dir.path())
.build()
.expect("build");
let key = "raced";
let path = backend.entry_path(key);
for round in 0..50 {
fs::write(
&path,
hex::decode("434b0100000000000000000000017374616c65").unwrap(),
)
.expect("plant expired entry");
let reader = {
let b = backend.clone();
tokio::spawn(async move { b.get(key).await })
};
let writer = {
let b = backend.clone();
tokio::spawn(async move { b.set(key, b"fresh".to_vec(), None).await })
};
reader.await.expect("join").expect("get");
writer.await.expect("join").expect("set");
assert_eq!(
backend.get(key).await.expect("get").as_deref(),
Some(b"fresh".as_slice()),
"round {round}: fresh write lost to a stale expired-unlink"
);
}
}
}