Skip to main content

cachekit/backend/
mod.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use async_trait::async_trait;
5
6use crate::error::BackendError;
7use crate::metrics::MetricsProvider;
8
9// ── HealthStatus ─────────────────────────────────────────────────────────────
10
11/// Describes the health of a backend at a point in time.
12#[derive(Debug, Clone)]
13pub struct HealthStatus {
14    /// Whether the backend is considered healthy.
15    pub is_healthy: bool,
16    /// Round-trip latency of the health check in milliseconds.
17    pub latency_ms: f64,
18    /// Human-readable name for this backend implementation.
19    pub backend_type: String,
20    /// Optional key-value details (pool size, version, etc.).
21    pub details: HashMap<String, String>,
22}
23
24// ── Backend trait ─────────────────────────────────────────────────────────────
25
26/// Async cache backend abstraction.
27///
28/// Implementors must be `Send + Sync` on native targets (unless the `unsync`
29/// feature is enabled). On `wasm32` targets or with `unsync`, `Send` is relaxed
30/// (`?Send`) because the runtime is single-threaded.
31#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
32#[async_trait]
33pub trait Backend: Send + Sync {
34    /// Retrieve the raw bytes stored under `key`, or `None` if absent.
35    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
36
37    /// Store `value` under `key`, optionally expiring after `ttl`.
38    async fn set(
39        &self,
40        key: &str,
41        value: Vec<u8>,
42        ttl: Option<Duration>,
43    ) -> Result<(), BackendError>;
44
45    /// Remove `key` and return `true` if it existed.
46    async fn delete(&self, key: &str) -> Result<bool, BackendError>;
47
48    /// Return `true` if `key` exists without fetching the value.
49    async fn exists(&self, key: &str) -> Result<bool, BackendError>;
50
51    /// Return health/status information for this backend.
52    async fn health(&self) -> Result<HealthStatus, BackendError>;
53
54    /// Expose this backend's [`LockableBackend`] capability, if it has one.
55    ///
56    /// Trait objects (`dyn Backend`) cannot be cross-cast to a sibling trait,
57    /// so backends that support distributed locking opt in by overriding this
58    /// to return `Some(self)`. Used by the client's cold-miss single-flight
59    /// for cross-process fill suppression. Default: `None`.
60    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
61        None
62    }
63
64    /// Receive the client's live hit/miss statistics.
65    ///
66    /// `CacheKitBuilder::build` calls this once, on the raw backend before any
67    /// reliability decorator, so a backend that reports telemetry — the
68    /// cachekit.io backends' `X-CacheKit-*` headers — carries real numbers
69    /// with no user plumbing. A provider the user set on the backend's own
70    /// builder must take precedence; implementations keep the first value
71    /// they receive — so one backend instance reports one client, the first
72    /// built over it (build one backend per client, as the presets do, for
73    /// per-client attribution). The provider holds the client's counters
74    /// weakly and reports `None` once that client is gone. Backends with
75    /// nothing to report keep this default no-op.
76    fn attach_metrics(&self, _provider: MetricsProvider) {}
77}
78
79/// Async cache backend abstraction (`?Send` variant).
80///
81/// Active when compiling for `wasm32` or with the `unsync` feature.
82/// Identical API to the `Send + Sync` variant but without thread-safety bounds.
83#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
84#[async_trait(?Send)]
85pub trait Backend {
86    /// Retrieve the raw bytes stored under `key`, or `None` if absent.
87    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
88
89    /// Store `value` under `key`, optionally expiring after `ttl`.
90    async fn set(
91        &self,
92        key: &str,
93        value: Vec<u8>,
94        ttl: Option<Duration>,
95    ) -> Result<(), BackendError>;
96
97    /// Remove `key` and return `true` if it existed.
98    async fn delete(&self, key: &str) -> Result<bool, BackendError>;
99
100    /// Return `true` if `key` exists without fetching the value.
101    async fn exists(&self, key: &str) -> Result<bool, BackendError>;
102
103    /// Return health/status information for this backend.
104    async fn health(&self) -> Result<HealthStatus, BackendError>;
105
106    /// Expose this backend's [`LockableBackend`] capability, if it has one.
107    ///
108    /// Trait objects (`dyn Backend`) cannot be cross-cast to a sibling trait,
109    /// so backends that support distributed locking opt in by overriding this
110    /// to return `Some(self)`. Used by the client's cold-miss single-flight
111    /// for cross-process fill suppression. Default: `None`.
112    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
113        None
114    }
115
116    /// Receive the client's live hit/miss statistics.
117    ///
118    /// `CacheKitBuilder::build` calls this once, on the raw backend before any
119    /// reliability decorator, so a backend that reports telemetry — the
120    /// cachekit.io backends' `X-CacheKit-*` headers — carries real numbers
121    /// with no user plumbing. A provider the user set on the backend's own
122    /// builder must take precedence; implementations keep the first value
123    /// they receive — so one backend instance reports one client, the first
124    /// built over it (build one backend per client, as the presets do, for
125    /// per-client attribution). The provider holds the client's counters
126    /// weakly and reports `None` once that client is gone. Backends with
127    /// nothing to report keep this default no-op.
128    fn attach_metrics(&self, _provider: MetricsProvider) {}
129}
130
131// ── TtlInspectable ───────────────────────────────────────────────────────────
132
133/// Optional extension for backends that can report the remaining TTL of a key.
134#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
135#[async_trait]
136pub trait TtlInspectable: Backend {
137    /// Return the remaining TTL for `key`, or `None` if the key does not exist
138    /// or has no expiry.
139    async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
140
141    /// Refresh the TTL on an existing key. Default: not supported.
142    async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
143        Err(BackendError::permanent(
144            "refresh_ttl not supported by this backend",
145        ))
146    }
147}
148
149/// Optional extension for backends that can report the remaining TTL of a key (`?Send` variant).
150#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
151#[async_trait(?Send)]
152pub trait TtlInspectable: Backend {
153    /// Return the remaining TTL for `key`, or `None` if the key does not exist
154    /// or has no expiry.
155    async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
156
157    /// Refresh the TTL on an existing key. Default: not supported.
158    async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
159        Err(BackendError::permanent(
160            "refresh_ttl not supported by this backend",
161        ))
162    }
163}
164
165// ── LockableBackend ─────────────────────────────────────────────────────────
166
167/// Optional extension for backends that support distributed locking.
168#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
169#[async_trait]
170pub trait LockableBackend: Backend {
171    /// Acquire a distributed lock. Returns lock_id if acquired, None if contested.
172    async fn acquire_lock(
173        &self,
174        key: &str,
175        timeout_ms: u64,
176    ) -> Result<Option<String>, BackendError>;
177    /// Release a distributed lock. Returns true if released.
178    async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
179}
180
181/// Optional extension for backends that support distributed locking (`?Send` variant).
182#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
183#[async_trait(?Send)]
184pub trait LockableBackend: Backend {
185    /// Acquire a distributed lock. Returns lock_id if acquired, None if contested.
186    async fn acquire_lock(
187        &self,
188        key: &str,
189        timeout_ms: u64,
190    ) -> Result<Option<String>, BackendError>;
191    /// Release a distributed lock. Returns true if released.
192    async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
193}
194
195// ── Blocking-pool bridge (file + memcached backends) ─────────────────────────
196
197/// Run sync I/O on tokio's blocking pool so the async executor never stalls.
198#[cfg(all(any(feature = "file", feature = "memcached"), not(feature = "unsync")))]
199pub(crate) async fn run_blocking<T: Send + 'static>(
200    f: impl FnOnce() -> Result<T, crate::error::BackendError> + Send + 'static,
201) -> Result<T, crate::error::BackendError> {
202    tokio::task::spawn_blocking(f).await.map_err(|e| {
203        crate::error::BackendError::permanent(format!("backend blocking task failed: {e}"))
204    })?
205}
206
207/// `unsync` opts into single-threaded runtimes and drops `Send` from
208/// `BackendError`, so results cannot cross `spawn_blocking`. Run the I/O
209/// inline instead — the same sync-in-async trade-off cachekit-py documents.
210#[cfg(all(any(feature = "file", feature = "memcached"), feature = "unsync"))]
211pub(crate) async fn run_blocking<T>(
212    f: impl FnOnce() -> Result<T, crate::error::BackendError>,
213) -> Result<T, crate::error::BackendError> {
214    f()
215}
216
217// ── Cache-key path encoding (CWE-22) ─────────────────────────────────────────
218
219/// Percent-encode a cache key for the `/v1/cache/{key}` path segment, shared by
220/// the native `cachekitio` and wasm `workers` backends (DRY: every CachekitIO
221/// path is built through this one fallible chokepoint).
222///
223/// Almost every key is just [`urlencoding::encode`]. The exception is a key
224/// whose encoded form is one of the **five reserved path segments** — `.`,
225/// `..`, `health`, `ttl`, `lock` — which is **rejected** with a permanent
226/// [`BackendError`] rather than sent. This is the client's half of the protocol
227/// `spec/saas-api.md` § Cache-Key Path Encoding, rule 2.
228///
229/// Two distinct hazards, both landing the app's bearer token on a route the SaaS
230/// `cache-key-validator` never vets (CWE-22):
231///
232/// - **Dot segments (`.`, `..`).** A dot is RFC-3986 *unreserved*, so
233///   `urlencoding::encode("..") == ".."` unchanged, and `reqwest`'s WHATWG URL
234///   parser (rust-url) removes that dot-segment **before the request leaves the
235///   process**: `/v1/cache/..` → `/v1/`, `…/../ttl` → `…/ttl`. Percent-encoding
236///   does not help: WHATWG treats `%2e` / `%2e%2e` (case-insensitive) as
237///   dot-segments too, so `%2E%2E` → `/v1/` and `%2E` → `/v1/cache/` collapse
238///   just the same (verified in `repro_raw_dot_key_escapes_the_cache_prefix`).
239///   Since every representation that `decodeURIComponent`s once back to `.`/`..`
240///   is a WHATWG dot-segment, no encoding both reaches the wire intact and
241///   round-trips — the only safe action is to refuse to build the request.
242/// - **Route tokens (`health`, `ttl`, `lock`).** These are live path tokens at
243///   this level: `/v1/cache/health` IS the health endpoint (see `health_url`),
244///   and a trailing `ttl` / `lock` segment selects a sub-resource. A key of
245///   exactly one of those words routes elsewhere or reads as an empty key, so
246///   the spec reserves them client-side too.
247///
248/// Only an *entirely*-reserved segment is caught: `a:..`, `..a`, `x..y` are
249/// inert and sent per rule 1 with their dots raw. Canonical and interop keys
250/// always contain `:` and never meet this rule, so for every non-reserved key
251/// the output is byte-identical to `urlencoding::encode` — preserving cross-SDK
252/// wire parity. rust-url's uniform rejection matches the cachekit-ts twin;
253/// it diverges from cachekit-py's older `%2E` rewrite
254/// (`src/cachekit/backends/cachekitio/backend.py:247-250` @ `f000ba3`), whose
255/// RFC-3986 client kept `%2E%2E` on the wire — the spec now mandates uniform
256/// client-side rejection on every stack.
257#[cfg(any(feature = "cachekitio", feature = "workers", test))]
258pub(crate) fn encode_key(key: &str) -> Result<std::borrow::Cow<'_, str>, BackendError> {
259    let encoded = urlencoding::encode(key);
260    // spec/saas-api.md § Cache-Key Path Encoding rule 2: reject a key whose
261    // encoded form is exactly one of the five reserved segments.
262    if matches!(encoded.as_ref(), "." | ".." | "health" | "ttl" | "lock") {
263        return Err(BackendError::permanent(
264            "cache key must not be a reserved path segment (`.`, `..`, `health`, \
265             `ttl`, `lock`): the client URL parser or the SaaS router would route \
266             it off the `/v1/cache/{key}` path (CWE-22), so it cannot be \
267             addressed on the wire",
268        ));
269    }
270    Ok(encoded)
271}
272
273// ── Feature-gated backend modules ─────────────────────────────────────────────
274
275/// JSON wire bodies for the SaaS lock/TTL endpoints. Compiled under `test`
276/// unconditionally so the wire-contract round-trip tests always run in CI,
277/// even though only the `workers` backend consumes the structs at runtime.
278#[cfg(any(feature = "workers", test))]
279mod saas_wire;
280
281/// HTTP backend for the cachekit.io SaaS API.
282#[cfg(feature = "cachekitio")]
283pub mod cachekitio;
284#[cfg(feature = "cachekitio")]
285mod cachekitio_lock;
286#[cfg(feature = "cachekitio")]
287mod cachekitio_ttl;
288
289/// Redis backend via the [`fred`](https://crates.io/crates/fred) client.
290#[cfg(feature = "redis")]
291pub mod redis;
292
293/// Memcached backend via the [`rust-memcache`](https://crates.io/crates/memcache) client.
294#[cfg(feature = "memcached")]
295pub mod memcached;
296
297/// Local filesystem backend, byte-compatible with cachekit-py's File backend.
298#[cfg(feature = "file")]
299pub mod file;
300
301/// Cloudflare Workers backend using `worker::Fetch`.
302#[cfg(feature = "workers")]
303pub mod workers;
304
305// ── encode_key unit tests (CWE-22) ───────────────────────────────────────────
306
307#[cfg(test)]
308#[allow(clippy::expect_used)] // test-only: an encoding failure on a safe key should panic loudly
309mod encode_key_tests {
310    use super::encode_key;
311
312    /// The five reserved path segments of spec rule 2 — no wire form is
313    /// transmittable, so a conformant client rejects before building the URL.
314    const RESERVED_SEGMENTS: &[&str] = &[".", "..", "health", "ttl", "lock"];
315
316    /// Non-reserved vectors: canonical key, dot near-misses (`..` embedded, not a
317    /// whole segment), route-token near-misses (`healthy`, `HEALTH`, embedded
318    /// `x/../../health`), reserved chars, `%`, sub-delims, spaces, empty. Mirrors
319    /// the transmittable rows of `protocol/test-vectors/path-encoding.json`.
320    const SAFE_VECTORS: &[&str] = &[
321        "a:..",
322        "..a",
323        "a..",
324        ".hidden",
325        "default:../../admin",
326        "x/../../health",
327        "healthy",
328        "HEALTH",
329        "ttls",
330        "unlock",
331        "ns:default:func:m.f:args:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:",
332        "a b",
333        "k?x=1#f",
334        "100%",
335        "f(x)!*'",
336        "",
337    ];
338
339    #[test]
340    fn reserved_segments_are_rejected() {
341        // spec/saas-api.md rule 2: `.`/`..` collapse in the URL parser and
342        // `health`/`ttl`/`lock` are route tokens; none is addressable on the
343        // `/v1/cache/{key}` path (see
344        // `cachekitio::path_encoding_tests::reserved_segments_rejected_by_every_builder`).
345        for k in RESERVED_SEGMENTS {
346            assert!(
347                encode_key(k).is_err(),
348                "reserved segment {k:?} must be rejected"
349            );
350        }
351        // Near-miss acceptance (`healthy`, `HEALTH`, `x/../../health`, …) is
352        // covered by `SAFE_VECTORS` in the two tests below — a `contains`/
353        // case-insensitive regression there panics on `.expect`.
354    }
355
356    #[test]
357    fn safe_keys_are_byte_identical_to_urlencoding() {
358        // AC-1: nothing but an exact `.`/`..` segment may change encoding, or the
359        // SDKs diverge on the wire.
360        for k in SAFE_VECTORS {
361            let enc = encode_key(k).expect("safe key must encode");
362            assert_eq!(
363                enc,
364                urlencoding::encode(k),
365                "encode_key diverged from urlencoding for {k:?}"
366            );
367        }
368    }
369
370    #[test]
371    fn safe_keys_decode_once_back_to_the_original() {
372        // AC-3: the SaaS validator does a single `decodeURIComponent`; every safe
373        // vector must survive that exact round-trip untouched.
374        for k in SAFE_VECTORS {
375            let enc = encode_key(k).expect("safe key must encode");
376            let decoded = urlencoding::decode(&enc).expect("single decode succeeds");
377            assert_eq!(decoded, *k, "decode-once round-trip changed {k:?}");
378        }
379    }
380}