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// ── Freshness ────────────────────────────────────────────────────────────────
25
26/// The server's freshness for one read: the protocol's `X-CacheKit-Freshness`
27/// label and `X-CacheKit-Fresh-For` bound (`spec/saas-api.md` § Remaining
28/// Freshness). The client reads it to bound the L1 backfill.
29///
30/// The default is a fresh read with no server bound, which leaves the client's
31/// configured local TTL unchanged.
32#[doc(hidden)]
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct Freshness {
35    /// The read was labelled stale, or with a token the client does not know.
36    /// The L1 must not backfill it.
37    pub is_stale: bool,
38    /// Remaining server freshness, or `None` when the header was absent. Zero
39    /// forbids the backfill.
40    pub fresh_for: Option<Duration>,
41}
42
43// ── Backend trait ─────────────────────────────────────────────────────────────
44
45/// Async cache backend abstraction.
46///
47/// Implementors must be `Send + Sync` on native targets (unless the `unsync`
48/// feature is enabled). On `wasm32` targets or with `unsync`, `Send` is relaxed
49/// (`?Send`) because the runtime is single-threaded.
50#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
51#[async_trait]
52pub trait Backend: Send + Sync {
53    /// Retrieve the raw bytes stored under `key`, or `None` if absent.
54    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
55
56    /// Retrieve `key` together with the server's [`Freshness`] for it.
57    ///
58    /// Client plumbing for the L1 backfill, not part of the documented surface.
59    /// The default wraps [`Self::get`] as a fresh read with no bound, so a
60    /// backend without a freshness signal keeps today's behaviour. A backend
61    /// that wraps another must forward this method, or the signal is lost.
62    #[doc(hidden)]
63    async fn get_with_freshness(
64        &self,
65        key: &str,
66    ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
67        Ok(self
68            .get(key)
69            .await?
70            .map(|bytes| (bytes, Freshness::default())))
71    }
72
73    /// Store `value` under `key`, optionally expiring after `ttl`.
74    async fn set(
75        &self,
76        key: &str,
77        value: Vec<u8>,
78        ttl: Option<Duration>,
79    ) -> Result<(), BackendError>;
80
81    /// Remove `key` and return `true` if it existed.
82    async fn delete(&self, key: &str) -> Result<bool, BackendError>;
83
84    /// Return `true` if `key` exists without fetching the value.
85    async fn exists(&self, key: &str) -> Result<bool, BackendError>;
86
87    /// Return health/status information for this backend.
88    async fn health(&self) -> Result<HealthStatus, BackendError>;
89
90    /// Expose this backend's [`LockableBackend`] capability, if it has one.
91    ///
92    /// Trait objects (`dyn Backend`) cannot be cross-cast to a sibling trait,
93    /// so backends that support distributed locking opt in by overriding this
94    /// to return `Some(self)`. Used by the client's cold-miss single-flight
95    /// for cross-process fill suppression. Default: `None`.
96    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
97        None
98    }
99
100    /// Receive the client's live hit/miss statistics.
101    ///
102    /// `CacheKitBuilder::build` calls this once, on the raw backend before any
103    /// reliability decorator, so a backend that reports telemetry — the
104    /// cachekit.io backends' `X-CacheKit-*` headers — carries real numbers
105    /// with no user plumbing. A provider the user set on the backend's own
106    /// builder must take precedence; implementations keep the first value
107    /// they receive — so one backend instance reports one client, the first
108    /// built over it (build one backend per client, as the presets do, for
109    /// per-client attribution). The provider holds the client's counters
110    /// weakly and reports `None` once that client is gone. Backends with
111    /// nothing to report keep this default no-op.
112    fn attach_metrics(&self, _provider: MetricsProvider) {}
113}
114
115/// Async cache backend abstraction (`?Send` variant).
116///
117/// Active when compiling for `wasm32` or with the `unsync` feature.
118/// Identical API to the `Send + Sync` variant but without thread-safety bounds.
119#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
120#[async_trait(?Send)]
121pub trait Backend {
122    /// Retrieve the raw bytes stored under `key`, or `None` if absent.
123    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
124
125    /// Retrieve `key` together with the server's [`Freshness`] for it.
126    ///
127    /// Client plumbing for the L1 backfill, not part of the documented surface.
128    /// The default wraps [`Self::get`] as a fresh read with no bound, so a
129    /// backend without a freshness signal keeps today's behaviour. A backend
130    /// that wraps another must forward this method, or the signal is lost.
131    #[doc(hidden)]
132    async fn get_with_freshness(
133        &self,
134        key: &str,
135    ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
136        Ok(self
137            .get(key)
138            .await?
139            .map(|bytes| (bytes, Freshness::default())))
140    }
141
142    /// Store `value` under `key`, optionally expiring after `ttl`.
143    async fn set(
144        &self,
145        key: &str,
146        value: Vec<u8>,
147        ttl: Option<Duration>,
148    ) -> Result<(), BackendError>;
149
150    /// Remove `key` and return `true` if it existed.
151    async fn delete(&self, key: &str) -> Result<bool, BackendError>;
152
153    /// Return `true` if `key` exists without fetching the value.
154    async fn exists(&self, key: &str) -> Result<bool, BackendError>;
155
156    /// Return health/status information for this backend.
157    async fn health(&self) -> Result<HealthStatus, BackendError>;
158
159    /// Expose this backend's [`LockableBackend`] capability, if it has one.
160    ///
161    /// Trait objects (`dyn Backend`) cannot be cross-cast to a sibling trait,
162    /// so backends that support distributed locking opt in by overriding this
163    /// to return `Some(self)`. Used by the client's cold-miss single-flight
164    /// for cross-process fill suppression. Default: `None`.
165    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
166        None
167    }
168
169    /// Receive the client's live hit/miss statistics.
170    ///
171    /// `CacheKitBuilder::build` calls this once, on the raw backend before any
172    /// reliability decorator, so a backend that reports telemetry — the
173    /// cachekit.io backends' `X-CacheKit-*` headers — carries real numbers
174    /// with no user plumbing. A provider the user set on the backend's own
175    /// builder must take precedence; implementations keep the first value
176    /// they receive — so one backend instance reports one client, the first
177    /// built over it (build one backend per client, as the presets do, for
178    /// per-client attribution). The provider holds the client's counters
179    /// weakly and reports `None` once that client is gone. Backends with
180    /// nothing to report keep this default no-op.
181    fn attach_metrics(&self, _provider: MetricsProvider) {}
182}
183
184// ── TtlInspectable ───────────────────────────────────────────────────────────
185
186/// Optional extension for backends that can report the remaining TTL of a key.
187#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
188#[async_trait]
189pub trait TtlInspectable: Backend {
190    /// Return the remaining TTL for `key`, or `None` if the key does not exist
191    /// or has no expiry.
192    async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
193
194    /// Refresh the TTL on an existing key. Default: not supported.
195    async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
196        Err(BackendError::permanent(
197            "refresh_ttl not supported by this backend",
198        ))
199    }
200}
201
202/// Optional extension for backends that can report the remaining TTL of a key (`?Send` variant).
203#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
204#[async_trait(?Send)]
205pub trait TtlInspectable: Backend {
206    /// Return the remaining TTL for `key`, or `None` if the key does not exist
207    /// or has no expiry.
208    async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
209
210    /// Refresh the TTL on an existing key. Default: not supported.
211    async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
212        Err(BackendError::permanent(
213            "refresh_ttl not supported by this backend",
214        ))
215    }
216}
217
218// ── LockableBackend ─────────────────────────────────────────────────────────
219
220/// Optional extension for backends that support distributed locking.
221#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
222#[async_trait]
223pub trait LockableBackend: Backend {
224    /// Acquire a distributed lock. Returns lock_id if acquired, None if contested.
225    async fn acquire_lock(
226        &self,
227        key: &str,
228        timeout_ms: u64,
229    ) -> Result<Option<String>, BackendError>;
230    /// Release a distributed lock. Returns true if released.
231    async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
232}
233
234/// Optional extension for backends that support distributed locking (`?Send` variant).
235#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
236#[async_trait(?Send)]
237pub trait LockableBackend: Backend {
238    /// Acquire a distributed lock. Returns lock_id if acquired, None if contested.
239    async fn acquire_lock(
240        &self,
241        key: &str,
242        timeout_ms: u64,
243    ) -> Result<Option<String>, BackendError>;
244    /// Release a distributed lock. Returns true if released.
245    async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
246}
247
248// ── Blocking-pool bridge (file + memcached backends) ─────────────────────────
249
250/// Run sync I/O on tokio's blocking pool so the async executor never stalls.
251#[cfg(all(any(feature = "file", feature = "memcached"), not(feature = "unsync")))]
252pub(crate) async fn run_blocking<T: Send + 'static>(
253    f: impl FnOnce() -> Result<T, crate::error::BackendError> + Send + 'static,
254) -> Result<T, crate::error::BackendError> {
255    tokio::task::spawn_blocking(f).await.map_err(|e| {
256        crate::error::BackendError::permanent(format!("backend blocking task failed: {e}"))
257    })?
258}
259
260/// `unsync` opts into single-threaded runtimes and drops `Send` from
261/// `BackendError`, so results cannot cross `spawn_blocking`. Run the I/O
262/// inline instead — the same sync-in-async trade-off cachekit-py documents.
263#[cfg(all(any(feature = "file", feature = "memcached"), feature = "unsync"))]
264pub(crate) async fn run_blocking<T>(
265    f: impl FnOnce() -> Result<T, crate::error::BackendError>,
266) -> Result<T, crate::error::BackendError> {
267    f()
268}
269
270// ── Cache-key path encoding (CWE-22) ─────────────────────────────────────────
271
272/// Percent-encode a cache key for the `/v1/cache/{key}` path segment, shared by
273/// the native `cachekitio` and wasm `workers` backends (DRY: every CachekitIO
274/// path is built through this one fallible chokepoint).
275///
276/// Almost every key is just [`urlencoding::encode`]. The exceptions are the
277/// empty key and a key whose encoded form is one of the **five reserved path
278/// segments** — `.`, `..`, `health`, `ttl`, `lock` — which are **rejected** with
279/// a permanent [`BackendError`] rather than sent. This is the client's half of
280/// the protocol `spec/saas-api.md` § Cache-Key Path Encoding, rule 2.
281///
282/// Three distinct hazards, each taking the request off the key's own
283/// `/v1/cache/{key}` path and sending the app's bearer token somewhere the SaaS
284/// `cache-key-validator` never vets (CWE-22):
285///
286/// - **The empty key.** It encodes to an empty segment, so `/v1/cache/{key}`
287///   becomes `/v1/cache/` and `/v1/cache/{key}/ttl` becomes `/v1/cache//ttl`,
288///   neither of which addresses a stored entry.
289/// - **Dot segments (`.`, `..`).** A dot is RFC-3986 *unreserved*, so
290///   `urlencoding::encode("..") == ".."` unchanged, and `reqwest`'s WHATWG URL
291///   parser (rust-url) removes that dot-segment **before the request leaves the
292///   process**: `/v1/cache/..` → `/v1/`, `…/../ttl` → `…/ttl`. Percent-encoding
293///   does not help: WHATWG treats `%2e` / `%2e%2e` (case-insensitive) as
294///   dot-segments too, so `%2E%2E` → `/v1/` and `%2E` → `/v1/cache/` collapse
295///   just the same (verified in `repro_raw_dot_key_escapes_the_cache_prefix`).
296///   Since every representation that `decodeURIComponent`s once back to `.`/`..`
297///   is a WHATWG dot-segment, no encoding both reaches the wire intact and
298///   round-trips — the only safe action is to refuse to build the request.
299/// - **Route tokens (`health`, `ttl`, `lock`).** These are live path tokens at
300///   this level: `/v1/cache/health` IS the health endpoint (see `health_url`),
301///   and a trailing `ttl` / `lock` segment selects a sub-resource. A key of
302///   exactly one of those words routes elsewhere or reads as an empty key, so
303///   the spec reserves them client-side too.
304///
305/// Only an *entirely*-reserved segment is caught: `a:..`, `..a`, `x..y` are
306/// inert and sent per rule 1 with their dots raw. Canonical and interop keys
307/// always contain `:` and never meet this rule, so for every non-reserved key
308/// the output is byte-identical to `urlencoding::encode` — the same bytes as
309/// cachekit-py; cachekit-ts may leave `! * ' ( )` raw, which decodes to the same
310/// key (spec rule 4). rust-url's uniform rejection matches the cachekit-ts twin;
311/// it diverges from cachekit-py's older `%2E` rewrite
312/// (`src/cachekit/backends/cachekitio/backend.py:247-250` @ `f000ba3`), whose
313/// RFC-3986 client kept `%2E%2E` on the wire — the spec now mandates uniform
314/// client-side rejection on every stack.
315#[cfg(any(feature = "cachekitio", feature = "workers", test))]
316pub(crate) fn encode_key(key: &str) -> Result<std::borrow::Cow<'_, str>, BackendError> {
317    let encoded = urlencoding::encode(key);
318    // spec/saas-api.md § Cache-Key Path Encoding rule 2: reject the empty key
319    // and a key whose encoded form is exactly one of the five reserved segments.
320    if matches!(
321        encoded.as_ref(),
322        "" | "." | ".." | "health" | "ttl" | "lock"
323    ) {
324        return Err(BackendError::permanent(
325            "cache key must not be empty or a reserved path segment (`.`, `..`, \
326             `health`, `ttl`, `lock`): the request path would leave \
327             `/v1/cache/{key}` (CWE-22), so it cannot be addressed on the wire",
328        ));
329    }
330    Ok(encoded)
331}
332
333// ── Feature-gated backend modules ─────────────────────────────────────────────
334
335/// JSON wire bodies for the SaaS lock/TTL endpoints. Compiled under `test`
336/// unconditionally so the wire-contract round-trip tests always run in CI,
337/// even though only the `workers` backend consumes the structs at runtime.
338#[cfg(any(feature = "workers", test))]
339mod saas_wire;
340
341/// HTTP backend for the cachekit.io SaaS API.
342#[cfg(feature = "cachekitio")]
343pub mod cachekitio;
344#[cfg(feature = "cachekitio")]
345mod cachekitio_lock;
346#[cfg(feature = "cachekitio")]
347mod cachekitio_ttl;
348
349/// Redis backend via the [`fred`](https://crates.io/crates/fred) client.
350#[cfg(feature = "redis")]
351pub mod redis;
352
353/// Memcached backend via the [`rust-memcache`](https://crates.io/crates/memcache) client.
354#[cfg(feature = "memcached")]
355pub mod memcached;
356
357/// Local filesystem backend, byte-compatible with cachekit-py's File backend.
358#[cfg(feature = "file")]
359pub mod file;
360
361/// Cloudflare Workers backend using `worker::Fetch`.
362#[cfg(feature = "workers")]
363pub mod workers;
364
365// ── Path-encoding protocol vectors (test-only) ───────────────────────────────
366
367/// Loader for `tests/vectors/path-encoding.json`, vendored verbatim from
368/// cachekit-io/protocol `test-vectors/path-encoding.json` 1.1.0 (merge commit
369/// `774281b09892a064feee6049ee29beb62f068804`). Do not edit the JSON here;
370/// change it upstream and re-vendor, then update `SHA256`.
371///
372/// Every path-encoding conformance assertion reads its keys from this file, so
373/// a row added upstream reaches the rs suite with no test edit. The only
374/// rs-local list is `RS_NEAR_MISSES`, which can add acceptances but never hide
375/// a reject row.
376#[cfg(test)]
377#[allow(clippy::expect_used)] // test-only: a malformed vendored fixture should panic loudly
378pub(crate) mod path_encoding_vectors {
379    use serde::Deserialize;
380
381    const JSON: &str = include_str!("../../tests/vectors/path-encoding.json");
382
383    /// sha256 of the vendored file, pinned so a local edit cannot drift from the
384    /// protocol copy unnoticed.
385    const SHA256: &str = "8f6fd4be5440da9cf4bbb1a112cb89c410c4e46734c7d8a9c023eaa034727ee3"; // pragma: allowlist secret
386
387    /// One fixture row. `deny_unknown_fields` makes a new row field upstream
388    /// fail loudly here rather than be silently ignored by the tests.
389    #[derive(Deserialize)]
390    #[serde(deny_unknown_fields)]
391    pub(crate) struct Vector {
392        pub(crate) key: String,
393        pub(crate) encoded: Option<String>,
394        #[serde(default)]
395        pub(crate) reject: bool,
396        // Declared only so `deny_unknown_fields` accepts them. `decoded` and the
397        // `encodeURIComponent` alternates are verified by the protocol's own CI.
398        #[serde(rename = "decoded")]
399        _decoded: Option<String>,
400        #[serde(rename = "encoded_alternates", default)]
401        _encoded_alternates: Vec<String>,
402        #[serde(rename = "note")]
403        _note: String,
404    }
405
406    #[derive(Deserialize)]
407    struct Fixture {
408        vectors: Vec<Vector>,
409    }
410
411    fn all() -> Vec<Vector> {
412        serde_json::from_str::<Fixture>(JSON)
413            .expect("vendored path-encoding.json must match the fixture schema")
414            .vectors
415    }
416
417    /// Keys spec rule 2 reserves: a conformant client refuses to build a URL.
418    pub(crate) fn reject_keys() -> Vec<String> {
419        all()
420            .into_iter()
421            .filter(|v| v.reject)
422            .map(|v| v.key)
423            .collect()
424    }
425
426    /// Rows a conformant client sends.
427    pub(crate) fn transmittable() -> Vec<Vector> {
428        all().into_iter().filter(|v| !v.reject).collect()
429    }
430
431    /// rs-local, acceptance-only regressions that are NOT fixture rows: route-token
432    /// near-misses that a prefix, case-insensitive or `contains` guard would
433    /// wrongly reject. The fixture has none of those; its `x/../../health` row
434    /// covers the suffix case, and `a:..` / `..a` cover the dot cases.
435    pub(crate) const RS_NEAR_MISSES: &[&str] = &["healthy", "HEALTH", "ttls", "unlock"];
436
437    #[test]
438    fn vendored_fixture_matches_the_pinned_sha256() {
439        use sha2::{Digest, Sha256};
440        assert_eq!(
441            hex::encode(Sha256::digest(JSON.as_bytes())),
442            SHA256,
443            "tests/vectors/path-encoding.json differs from the pinned protocol copy: \
444             re-vendor it from protocol and update SHA256"
445        );
446    }
447}
448
449// ── encode_key unit tests (CWE-22) ───────────────────────────────────────────
450
451#[cfg(test)]
452#[allow(clippy::expect_used)] // test-only: an encoding failure on a safe key should panic loudly
453mod encode_key_tests {
454    use super::encode_key;
455    use super::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
456
457    #[test]
458    fn fixture_reject_rows_are_rejected() {
459        // spec/saas-api.md rule 2: the empty key, `.`/`..` and the route tokens
460        // are not addressable on `/v1/cache/{key}` (builder-level coverage:
461        // `cachekitio::path_encoding_tests::reserved_segments_rejected_by_every_builder`).
462        let keys = reject_keys();
463        assert!(!keys.is_empty(), "fixture has no reject rows");
464        for k in &keys {
465            assert!(
466                encode_key(k).is_err(),
467                "reserved key {k:?} must be rejected"
468            );
469        }
470    }
471
472    #[test]
473    fn fixture_transmittable_rows_encode_to_the_reference_form() {
474        // The fixture accepts `[encoded] + encoded_alternates`; rs holds itself to
475        // `encoded`, the reference form, so its wire bytes stay identical to
476        // cachekit-py's (the alternates cover cachekit-ts's raw `! * ' ( )`).
477        let rows = transmittable();
478        assert!(!rows.is_empty(), "fixture has no transmittable rows");
479        for row in &rows {
480            let enc = encode_key(&row.key).expect("transmittable fixture key must encode");
481            assert_eq!(
482                Some(enc.as_ref()),
483                row.encoded.as_deref(),
484                "encode_key({:?}) is not the fixture's reference form",
485                row.key
486            );
487        }
488    }
489
490    #[test]
491    fn near_misses_are_not_rejected() {
492        for k in RS_NEAR_MISSES {
493            assert!(encode_key(k).is_ok(), "near-miss key {k:?} must encode");
494        }
495    }
496}