use std::collections::HashMap;
use std::time::Duration;
use async_trait::async_trait;
use crate::error::BackendError;
use crate::metrics::MetricsProvider;
#[derive(Debug, Clone)]
pub struct HealthStatus {
pub is_healthy: bool,
pub latency_ms: f64,
pub backend_type: String,
pub details: HashMap<String, String>,
}
#[doc(hidden)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Freshness {
pub is_stale: bool,
pub fresh_for: Option<Duration>,
}
#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
#[async_trait]
pub trait Backend: Send + Sync {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
#[doc(hidden)]
async fn get_with_freshness(
&self,
key: &str,
) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
Ok(self
.get(key)
.await?
.map(|bytes| (bytes, Freshness::default())))
}
async fn set(
&self,
key: &str,
value: Vec<u8>,
ttl: Option<Duration>,
) -> Result<(), BackendError>;
async fn delete(&self, key: &str) -> Result<bool, BackendError>;
async fn exists(&self, key: &str) -> Result<bool, BackendError>;
async fn health(&self) -> Result<HealthStatus, BackendError>;
fn as_lockable(&self) -> Option<&dyn LockableBackend> {
None
}
fn attach_metrics(&self, _provider: MetricsProvider) {}
}
#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
#[async_trait(?Send)]
pub trait Backend {
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
#[doc(hidden)]
async fn get_with_freshness(
&self,
key: &str,
) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
Ok(self
.get(key)
.await?
.map(|bytes| (bytes, Freshness::default())))
}
async fn set(
&self,
key: &str,
value: Vec<u8>,
ttl: Option<Duration>,
) -> Result<(), BackendError>;
async fn delete(&self, key: &str) -> Result<bool, BackendError>;
async fn exists(&self, key: &str) -> Result<bool, BackendError>;
async fn health(&self) -> Result<HealthStatus, BackendError>;
fn as_lockable(&self) -> Option<&dyn LockableBackend> {
None
}
fn attach_metrics(&self, _provider: MetricsProvider) {}
}
#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
#[async_trait]
pub trait TtlInspectable: Backend {
async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
Err(BackendError::permanent(
"refresh_ttl not supported by this backend",
))
}
}
#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
#[async_trait(?Send)]
pub trait TtlInspectable: Backend {
async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
Err(BackendError::permanent(
"refresh_ttl not supported by this backend",
))
}
}
#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
#[async_trait]
pub trait LockableBackend: Backend {
async fn acquire_lock(
&self,
key: &str,
timeout_ms: u64,
) -> Result<Option<String>, BackendError>;
async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
}
#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
#[async_trait(?Send)]
pub trait LockableBackend: Backend {
async fn acquire_lock(
&self,
key: &str,
timeout_ms: u64,
) -> Result<Option<String>, BackendError>;
async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
}
#[cfg(all(any(feature = "file", feature = "memcached"), not(feature = "unsync")))]
pub(crate) async fn run_blocking<T: Send + 'static>(
f: impl FnOnce() -> Result<T, crate::error::BackendError> + Send + 'static,
) -> Result<T, crate::error::BackendError> {
tokio::task::spawn_blocking(f).await.map_err(|e| {
crate::error::BackendError::permanent(format!("backend blocking task failed: {e}"))
})?
}
#[cfg(all(any(feature = "file", feature = "memcached"), feature = "unsync"))]
pub(crate) async fn run_blocking<T>(
f: impl FnOnce() -> Result<T, crate::error::BackendError>,
) -> Result<T, crate::error::BackendError> {
f()
}
#[cfg(any(feature = "cachekitio", feature = "workers", test))]
pub(crate) fn encode_key(key: &str) -> Result<std::borrow::Cow<'_, str>, BackendError> {
let encoded = urlencoding::encode(key);
if matches!(
encoded.as_ref(),
"" | "." | ".." | "health" | "ttl" | "lock"
) {
return Err(BackendError::permanent(
"cache key must not be empty or a reserved path segment (`.`, `..`, \
`health`, `ttl`, `lock`): the request path would leave \
`/v1/cache/{key}` (CWE-22), so it cannot be addressed on the wire",
));
}
Ok(encoded)
}
#[cfg(any(feature = "workers", test))]
mod saas_wire;
#[cfg(feature = "cachekitio")]
pub mod cachekitio;
#[cfg(feature = "cachekitio")]
mod cachekitio_lock;
#[cfg(feature = "cachekitio")]
mod cachekitio_ttl;
#[cfg(feature = "redis")]
pub mod redis;
#[cfg(feature = "memcached")]
pub mod memcached;
#[cfg(feature = "file")]
pub mod file;
#[cfg(feature = "workers")]
pub mod workers;
#[cfg(test)]
#[allow(clippy::expect_used)] pub(crate) mod path_encoding_vectors {
use serde::Deserialize;
const JSON: &str = include_str!("../../tests/vectors/path-encoding.json");
const SHA256: &str = "8f6fd4be5440da9cf4bbb1a112cb89c410c4e46734c7d8a9c023eaa034727ee3";
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Vector {
pub(crate) key: String,
pub(crate) encoded: Option<String>,
#[serde(default)]
pub(crate) reject: bool,
#[serde(rename = "decoded")]
_decoded: Option<String>,
#[serde(rename = "encoded_alternates", default)]
_encoded_alternates: Vec<String>,
#[serde(rename = "note")]
_note: String,
}
#[derive(Deserialize)]
struct Fixture {
vectors: Vec<Vector>,
}
fn all() -> Vec<Vector> {
serde_json::from_str::<Fixture>(JSON)
.expect("vendored path-encoding.json must match the fixture schema")
.vectors
}
pub(crate) fn reject_keys() -> Vec<String> {
all()
.into_iter()
.filter(|v| v.reject)
.map(|v| v.key)
.collect()
}
pub(crate) fn transmittable() -> Vec<Vector> {
all().into_iter().filter(|v| !v.reject).collect()
}
pub(crate) const RS_NEAR_MISSES: &[&str] = &["healthy", "HEALTH", "ttls", "unlock"];
#[test]
fn vendored_fixture_matches_the_pinned_sha256() {
use sha2::{Digest, Sha256};
assert_eq!(
hex::encode(Sha256::digest(JSON.as_bytes())),
SHA256,
"tests/vectors/path-encoding.json differs from the pinned protocol copy: \
re-vendor it from protocol and update SHA256"
);
}
}
#[cfg(test)]
#[allow(clippy::expect_used)] mod encode_key_tests {
use super::encode_key;
use super::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
#[test]
fn fixture_reject_rows_are_rejected() {
let keys = reject_keys();
assert!(!keys.is_empty(), "fixture has no reject rows");
for k in &keys {
assert!(
encode_key(k).is_err(),
"reserved key {k:?} must be rejected"
);
}
}
#[test]
fn fixture_transmittable_rows_encode_to_the_reference_form() {
let rows = transmittable();
assert!(!rows.is_empty(), "fixture has no transmittable rows");
for row in &rows {
let enc = encode_key(&row.key).expect("transmittable fixture key must encode");
assert_eq!(
Some(enc.as_ref()),
row.encoded.as_deref(),
"encode_key({:?}) is not the fixture's reference form",
row.key
);
}
}
#[test]
fn near_misses_are_not_rejected() {
for k in RS_NEAR_MISSES {
assert!(encode_key(k).is_ok(), "near-miss key {k:?} must encode");
}
}
}