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 ///
83 /// `CachekitIO` and `WorkersCachekitIO` return `true` on every successful
84 /// delete, whether or not the key existed: the server does not report
85 /// existence on `DELETE`.
86 async fn delete(&self, key: &str) -> Result<bool, BackendError>;
87
88 /// Return `true` if `key` exists without fetching the value.
89 async fn exists(&self, key: &str) -> Result<bool, BackendError>;
90
91 /// Return health/status information for this backend.
92 async fn health(&self) -> Result<HealthStatus, BackendError>;
93
94 /// Expose this backend's [`LockableBackend`] capability, if it has one.
95 ///
96 /// Trait objects (`dyn Backend`) cannot be cross-cast to a sibling trait,
97 /// so backends that support distributed locking opt in by overriding this
98 /// to return `Some(self)`. Used by the client's cold-miss single-flight
99 /// for cross-process fill suppression. Default: `None`.
100 fn as_lockable(&self) -> Option<&dyn LockableBackend> {
101 None
102 }
103
104 /// Receive the client's live hit/miss statistics.
105 ///
106 /// `CacheKitBuilder::build` calls this once, on the raw backend before any
107 /// reliability decorator, so a backend that reports telemetry — the
108 /// cachekit.io backends' `X-CacheKit-*` headers — carries real numbers
109 /// with no user plumbing. A provider the user set on the backend's own
110 /// builder must take precedence; implementations keep the first value
111 /// they receive — so one backend instance reports one client, the first
112 /// built over it (build one backend per client, as the presets do, for
113 /// per-client attribution). The provider holds the client's counters
114 /// weakly and reports `None` once that client is gone. Backends with
115 /// nothing to report keep this default no-op.
116 fn attach_metrics(&self, _provider: MetricsProvider) {}
117}
118
119/// Async cache backend abstraction (`?Send` variant).
120///
121/// Active when compiling for `wasm32` or with the `unsync` feature.
122/// Identical API to the `Send + Sync` variant but without thread-safety bounds.
123#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
124#[async_trait(?Send)]
125pub trait Backend {
126 /// Retrieve the raw bytes stored under `key`, or `None` if absent.
127 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError>;
128
129 /// Retrieve `key` together with the server's [`Freshness`] for it.
130 ///
131 /// Client plumbing for the L1 backfill, not part of the documented surface.
132 /// The default wraps [`Self::get`] as a fresh read with no bound, so a
133 /// backend without a freshness signal keeps today's behaviour. A backend
134 /// that wraps another must forward this method, or the signal is lost.
135 #[doc(hidden)]
136 async fn get_with_freshness(
137 &self,
138 key: &str,
139 ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
140 Ok(self
141 .get(key)
142 .await?
143 .map(|bytes| (bytes, Freshness::default())))
144 }
145
146 /// Store `value` under `key`, optionally expiring after `ttl`.
147 async fn set(
148 &self,
149 key: &str,
150 value: Vec<u8>,
151 ttl: Option<Duration>,
152 ) -> Result<(), BackendError>;
153
154 /// Remove `key` and return `true` if it existed.
155 ///
156 /// `CachekitIO` and `WorkersCachekitIO` return `true` on every successful
157 /// delete, whether or not the key existed: the server does not report
158 /// existence on `DELETE`.
159 async fn delete(&self, key: &str) -> Result<bool, BackendError>;
160
161 /// Return `true` if `key` exists without fetching the value.
162 async fn exists(&self, key: &str) -> Result<bool, BackendError>;
163
164 /// Return health/status information for this backend.
165 async fn health(&self) -> Result<HealthStatus, BackendError>;
166
167 /// Expose this backend's [`LockableBackend`] capability, if it has one.
168 ///
169 /// Trait objects (`dyn Backend`) cannot be cross-cast to a sibling trait,
170 /// so backends that support distributed locking opt in by overriding this
171 /// to return `Some(self)`. Used by the client's cold-miss single-flight
172 /// for cross-process fill suppression. Default: `None`.
173 fn as_lockable(&self) -> Option<&dyn LockableBackend> {
174 None
175 }
176
177 /// Receive the client's live hit/miss statistics.
178 ///
179 /// `CacheKitBuilder::build` calls this once, on the raw backend before any
180 /// reliability decorator, so a backend that reports telemetry — the
181 /// cachekit.io backends' `X-CacheKit-*` headers — carries real numbers
182 /// with no user plumbing. A provider the user set on the backend's own
183 /// builder must take precedence; implementations keep the first value
184 /// they receive — so one backend instance reports one client, the first
185 /// built over it (build one backend per client, as the presets do, for
186 /// per-client attribution). The provider holds the client's counters
187 /// weakly and reports `None` once that client is gone. Backends with
188 /// nothing to report keep this default no-op.
189 fn attach_metrics(&self, _provider: MetricsProvider) {}
190}
191
192// ── TtlInspectable ───────────────────────────────────────────────────────────
193
194/// Optional extension for backends that can report the remaining TTL of a key.
195#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
196#[async_trait]
197pub trait TtlInspectable: Backend {
198 /// Return the remaining TTL for `key`, or `None` if the key does not exist
199 /// or has no expiry.
200 async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
201
202 /// Refresh the TTL on an existing key. Default: not supported.
203 async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
204 Err(BackendError::permanent(
205 "refresh_ttl not supported by this backend",
206 ))
207 }
208}
209
210/// Optional extension for backends that can report the remaining TTL of a key (`?Send` variant).
211#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
212#[async_trait(?Send)]
213pub trait TtlInspectable: Backend {
214 /// Return the remaining TTL for `key`, or `None` if the key does not exist
215 /// or has no expiry.
216 async fn ttl(&self, key: &str) -> Result<Option<Duration>, BackendError>;
217
218 /// Refresh the TTL on an existing key. Default: not supported.
219 async fn refresh_ttl(&self, _key: &str, _ttl: Duration) -> Result<bool, BackendError> {
220 Err(BackendError::permanent(
221 "refresh_ttl not supported by this backend",
222 ))
223 }
224}
225
226// ── LockableBackend ─────────────────────────────────────────────────────────
227
228/// Optional extension for backends that support distributed locking.
229#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
230#[async_trait]
231pub trait LockableBackend: Backend {
232 /// Acquire a distributed lock. Returns lock_id if acquired, None if contested.
233 async fn acquire_lock(
234 &self,
235 key: &str,
236 timeout_ms: u64,
237 ) -> Result<Option<String>, BackendError>;
238 /// Release a distributed lock. Returns true if released. Must release
239 /// only if `lock_id` still owns the lock: a late or duplicate release
240 /// must never free a lock acquired since. Return `Ok(false)` when
241 /// `lock_id` no longer owns the lock; `Err` only when the outcome is
242 /// unknown. The single-flight takes `Ok` as answered and never re-sends
243 /// it, and re-sends after an `Err` on the key's next lock attempt.
244 async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
245}
246
247/// Optional extension for backends that support distributed locking (`?Send` variant).
248#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
249#[async_trait(?Send)]
250pub trait LockableBackend: Backend {
251 /// Acquire a distributed lock. Returns lock_id if acquired, None if contested.
252 async fn acquire_lock(
253 &self,
254 key: &str,
255 timeout_ms: u64,
256 ) -> Result<Option<String>, BackendError>;
257 /// Release a distributed lock. Returns true if released. Must release
258 /// only if `lock_id` still owns the lock: a late or duplicate release
259 /// must never free a lock acquired since. Return `Ok(false)` when
260 /// `lock_id` no longer owns the lock; `Err` only when the outcome is
261 /// unknown. The single-flight takes `Ok` as answered and never re-sends
262 /// it, and re-sends after an `Err` on the key's next lock attempt.
263 async fn release_lock(&self, key: &str, lock_id: &str) -> Result<bool, BackendError>;
264}
265
266// ── Blocking-pool bridge (file + memcached backends) ─────────────────────────
267
268/// Run sync I/O on tokio's blocking pool so the async executor never stalls.
269#[cfg(all(any(feature = "file", feature = "memcached"), not(feature = "unsync")))]
270pub(crate) async fn run_blocking<T: Send + 'static>(
271 f: impl FnOnce() -> Result<T, crate::error::BackendError> + Send + 'static,
272) -> Result<T, crate::error::BackendError> {
273 tokio::task::spawn_blocking(f).await.map_err(|e| {
274 crate::error::BackendError::permanent(format!("backend blocking task failed: {e}"))
275 })?
276}
277
278/// `unsync` opts into single-threaded runtimes and drops `Send` from
279/// `BackendError`, so results cannot cross `spawn_blocking`. Run the I/O
280/// inline instead — the same sync-in-async trade-off cachekit-py documents.
281#[cfg(all(any(feature = "file", feature = "memcached"), feature = "unsync"))]
282pub(crate) async fn run_blocking<T>(
283 f: impl FnOnce() -> Result<T, crate::error::BackendError>,
284) -> Result<T, crate::error::BackendError> {
285 f()
286}
287
288// ── Cache-key path encoding (CWE-22) ─────────────────────────────────────────
289
290/// Percent-encode a cache key for the `/v1/cache/{key}` path segment, shared by
291/// the native `cachekitio` and wasm `workers` backends (DRY: every CachekitIO
292/// path is built through this one fallible chokepoint).
293///
294/// Almost every key is just [`urlencoding::encode`]. The exceptions are the
295/// empty key and a key whose encoded form is one of the **five reserved path
296/// segments** — `.`, `..`, `health`, `ttl`, `lock` — which are **rejected** with
297/// a permanent [`BackendError`] rather than sent. This is the client's half of
298/// the protocol `spec/saas-api.md` § Cache-Key Path Encoding, rule 2.
299///
300/// Three distinct hazards, each taking the request off the key's own
301/// `/v1/cache/{key}` path and sending the app's bearer token somewhere the SaaS
302/// `cache-key-validator` never vets (CWE-22):
303///
304/// - **The empty key.** It encodes to an empty segment, so `/v1/cache/{key}`
305/// becomes `/v1/cache/` and `/v1/cache/{key}/ttl` becomes `/v1/cache//ttl`,
306/// neither of which addresses a stored entry.
307/// - **Dot segments (`.`, `..`).** A dot is RFC-3986 *unreserved*, so
308/// `urlencoding::encode("..") == ".."` unchanged, and `reqwest`'s WHATWG URL
309/// parser (rust-url) removes that dot-segment **before the request leaves the
310/// process**: `/v1/cache/..` → `/v1/`, `…/../ttl` → `…/ttl`. Percent-encoding
311/// does not help: WHATWG treats `%2e` / `%2e%2e` (case-insensitive) as
312/// dot-segments too, so `%2E%2E` → `/v1/` and `%2E` → `/v1/cache/` collapse
313/// just the same (verified in `repro_raw_dot_key_escapes_the_cache_prefix`).
314/// Since every representation that `decodeURIComponent`s once back to `.`/`..`
315/// is a WHATWG dot-segment, no encoding both reaches the wire intact and
316/// round-trips — the only safe action is to refuse to build the request.
317/// - **Route tokens (`health`, `ttl`, `lock`).** These are live path tokens at
318/// this level: `/v1/cache/health` IS the health endpoint (see `health_url`),
319/// and a trailing `ttl` / `lock` segment selects a sub-resource. A key of
320/// exactly one of those words routes elsewhere or reads as an empty key, so
321/// the spec reserves them client-side too.
322///
323/// Only an *entirely*-reserved segment is caught: `a:..`, `..a`, `x..y` are
324/// inert and sent per rule 1 with their dots raw. Canonical and interop keys
325/// always contain `:` and never meet this rule, so for every non-reserved key
326/// the output is byte-identical to `urlencoding::encode` — the same bytes as
327/// cachekit-py; cachekit-ts may leave `! * ' ( )` raw, which decodes to the same
328/// key (spec rule 4). rust-url's uniform rejection matches the cachekit-ts twin;
329/// it diverges from cachekit-py's older `%2E` rewrite
330/// (`src/cachekit/backends/cachekitio/backend.py:247-250` @ `f000ba3`), whose
331/// RFC-3986 client kept `%2E%2E` on the wire — the spec now mandates uniform
332/// client-side rejection on every stack.
333#[cfg(any(feature = "cachekitio", feature = "workers", test))]
334pub(crate) fn encode_key(key: &str) -> Result<std::borrow::Cow<'_, str>, BackendError> {
335 let encoded = urlencoding::encode(key);
336 // spec/saas-api.md § Cache-Key Path Encoding rule 2: reject the empty key
337 // and a key whose encoded form is exactly one of the five reserved segments.
338 if matches!(
339 encoded.as_ref(),
340 "" | "." | ".." | "health" | "ttl" | "lock"
341 ) {
342 return Err(BackendError::permanent(
343 "cache key must not be empty or a reserved path segment (`.`, `..`, \
344 `health`, `ttl`, `lock`): the request path would leave \
345 `/v1/cache/{key}` (CWE-22), so it cannot be addressed on the wire",
346 ));
347 }
348 Ok(encoded)
349}
350
351/// Whether a `DELETE /v1/cache/{key}` status is a success. The server answers
352/// `200` whether or not the key existed and never `404` (`spec/saas-api.md`),
353/// so success carries no existence signal and every other status, `404`
354/// included, takes the caller's error path. Shared by the native and Workers
355/// backends so their mappings cannot drift.
356#[cfg(any(feature = "cachekitio", feature = "workers", test))]
357pub(crate) fn delete_succeeded(status: u16) -> bool {
358 matches!(status, 200 | 204)
359}
360
361/// The whole seconds a write's TTL goes on the wire as. `spec/saas-api.md`
362/// says a positive sub-second TTL MUST be ceiled to 1, never truncated to 0.
363/// From 1 s up it truncates, so the wire TTL stays an upper bound on the
364/// requested one and the writer's L1 copy can be bounded by it. Zero stays
365/// zero: TTL 0 is an error, and the server rejects it with `400`.
366#[cfg(any(
367 feature = "cachekitio",
368 feature = "workers",
369 feature = "l1",
370 feature = "tracing",
371 test
372))]
373pub(crate) fn ttl_wire_secs(ttl: Duration) -> u64 {
374 if ttl.is_zero() {
375 return 0;
376 }
377 ttl.as_secs().max(1)
378}
379
380/// The `PUT /v1/cache/{key}` header that carries a write's TTL in whole
381/// seconds ([`ttl_wire_secs`]). `spec/saas-api.md` says SDKs MUST send
382/// `X-CacheKit-TTL` only: the legacy `X-TTL` goes away in protocol 2.0, and a
383/// write that sends only it would then be stored with no expiry. Shared by the
384/// native and Workers backends so their wire forms cannot drift.
385#[cfg(any(feature = "cachekitio", feature = "workers", test))]
386pub(crate) fn ttl_header(ttl: Duration) -> (&'static str, String) {
387 ("X-CacheKit-TTL", ttl_wire_secs(ttl).to_string())
388}
389
390/// Per-attempt budget for every cachekit.io request but a write, from connect
391/// through the last body byte: the protocol's `CACHEKIT_TIMEOUT` default
392/// (`spec/saas-api.md`). It was 30 s, so under the retrying presets a request
393/// the service accepted and never answered held a call for ~90 s before it
394/// failed open. Shared by the native and Workers backends so their budgets
395/// cannot drift.
396#[cfg(any(
397 all(feature = "cachekitio", not(target_arch = "wasm32")),
398 feature = "workers"
399))]
400pub(crate) const READ_TIMEOUT: Duration = Duration::from_secs(5);
401
402/// Per-attempt budget for a write (`set`, `delete`): twice the read budget,
403/// because a write carries the payload and the service commits it before it
404/// answers, so a slow but healthy write must not time out and be re-sent.
405#[cfg(any(
406 all(feature = "cachekitio", not(target_arch = "wasm32")),
407 feature = "workers"
408))]
409pub(crate) const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
410
411/// A builder's configured timeout, or `default` when unset. Zero is a config
412/// error naming `setter`: fred reads a zero command timeout as "none", and a
413/// zero HTTP budget fails every request.
414#[cfg(any(
415 feature = "redis",
416 all(feature = "cachekitio", not(target_arch = "wasm32"))
417))]
418pub(crate) fn nonzero_timeout(
419 setter: &str,
420 value: Option<Duration>,
421 default: Duration,
422) -> Result<Duration, crate::error::CachekitError> {
423 let timeout = value.unwrap_or(default);
424 if timeout.is_zero() {
425 return Err(crate::error::CachekitError::Config(format!(
426 "{setter} must be greater than zero"
427 )));
428 }
429 Ok(timeout)
430}
431
432// ── Feature-gated backend modules ─────────────────────────────────────────────
433
434/// JSON wire bodies for the SaaS lock/TTL endpoints. Compiled under `test`
435/// unconditionally so the wire-contract round-trip tests always run in CI,
436/// even though only the `workers` backend consumes the structs at runtime.
437#[cfg(any(feature = "workers", test))]
438mod saas_wire;
439
440/// HTTP backend for the cachekit.io SaaS API.
441#[cfg(feature = "cachekitio")]
442pub mod cachekitio;
443#[cfg(feature = "cachekitio")]
444mod cachekitio_lock;
445#[cfg(feature = "cachekitio")]
446mod cachekitio_ttl;
447
448/// Redis backend via the [`fred`](https://crates.io/crates/fred) client.
449#[cfg(feature = "redis")]
450pub mod redis;
451
452/// Memcached backend via the [`rust-memcache`](https://crates.io/crates/memcache) client.
453#[cfg(feature = "memcached")]
454pub mod memcached;
455
456/// Local filesystem backend, byte-compatible with cachekit-py's File backend.
457#[cfg(feature = "file")]
458pub mod file;
459
460/// Cloudflare Workers backend using `worker::Fetch`.
461#[cfg(feature = "workers")]
462pub mod workers;
463
464// ── Path-encoding protocol vectors (test-only) ───────────────────────────────
465
466/// Loader for `tests/vectors/path-encoding.json`, vendored verbatim from
467/// cachekit-io/protocol `test-vectors/path-encoding.json` 1.2.0 (merge commit
468/// `f35635445a6a7461f93061aa51332e4f0d25c614`). Do not edit the JSON here;
469/// change it upstream and re-vendor, then update `SHA256`.
470///
471/// Every path-encoding conformance assertion reads its keys from this file, so
472/// a row added upstream reaches the rs suite with no test edit. The only
473/// rs-local list is `RS_NEAR_MISSES`, which can add acceptances but never hide
474/// a reject row.
475#[cfg(test)]
476#[allow(clippy::expect_used)] // test-only: a malformed vendored fixture should panic loudly
477pub(crate) mod path_encoding_vectors {
478 use serde::Deserialize;
479
480 const JSON: &str = include_str!("../../tests/vectors/path-encoding.json");
481
482 /// sha256 of the vendored file, pinned so a local edit cannot drift from the
483 /// protocol copy unnoticed.
484 const SHA256: &str = "807af0d39ccf3f5afda65577787ac5adcd01ff6617468060161c84267fa7e0cb"; // pragma: allowlist secret
485
486 /// One fixture row. `deny_unknown_fields` makes a new row field upstream
487 /// fail loudly here rather than be silently ignored by the tests.
488 #[derive(Deserialize)]
489 #[serde(deny_unknown_fields)]
490 pub(crate) struct Vector {
491 pub(crate) key: String,
492 pub(crate) encoded: Option<String>,
493 #[serde(default)]
494 pub(crate) reject: bool,
495 // Declared only so `deny_unknown_fields` accepts them. `decoded` and the
496 // `encodeURIComponent` alternates are verified by the protocol's own CI.
497 #[serde(rename = "decoded")]
498 _decoded: Option<String>,
499 #[serde(rename = "encoded_alternates", default)]
500 _encoded_alternates: Vec<String>,
501 #[serde(rename = "note")]
502 _note: String,
503 }
504
505 #[derive(Deserialize)]
506 struct Fixture {
507 vectors: Vec<Vector>,
508 }
509
510 fn all() -> Vec<Vector> {
511 serde_json::from_str::<Fixture>(JSON)
512 .expect("vendored path-encoding.json must match the fixture schema")
513 .vectors
514 }
515
516 /// Keys spec rule 2 reserves: a conformant client refuses to build a URL.
517 pub(crate) fn reject_keys() -> Vec<String> {
518 all()
519 .into_iter()
520 .filter(|v| v.reject)
521 .map(|v| v.key)
522 .collect()
523 }
524
525 /// Rows a conformant client sends.
526 pub(crate) fn transmittable() -> Vec<Vector> {
527 all().into_iter().filter(|v| !v.reject).collect()
528 }
529
530 /// rs-local, acceptance-only regressions that are NOT fixture rows: route-token
531 /// near-misses that a prefix, case-insensitive or `contains` guard would
532 /// wrongly reject. The fixture has none of those; its `x/../../health` row
533 /// covers the suffix case, and `a:..` / `..a` cover the dot cases.
534 pub(crate) const RS_NEAR_MISSES: &[&str] = &["healthy", "HEALTH", "ttls", "unlock"];
535
536 #[test]
537 fn vendored_fixture_matches_the_pinned_sha256() {
538 use sha2::{Digest, Sha256};
539 assert_eq!(
540 hex::encode(Sha256::digest(JSON.as_bytes())),
541 SHA256,
542 "tests/vectors/path-encoding.json differs from the pinned protocol copy: \
543 re-vendor it from protocol and update SHA256"
544 );
545 }
546}
547
548// ── encode_key unit tests (CWE-22) ───────────────────────────────────────────
549
550#[cfg(test)]
551#[allow(clippy::expect_used)] // test-only: an encoding failure on a safe key should panic loudly
552mod encode_key_tests {
553 use super::encode_key;
554 use super::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
555
556 #[test]
557 fn fixture_reject_rows_are_rejected() {
558 // spec/saas-api.md rule 2: the empty key, `.`/`..` and the route tokens
559 // are not addressable on `/v1/cache/{key}` (builder-level coverage:
560 // `cachekitio::path_encoding_tests::reserved_segments_rejected_by_every_builder`).
561 let keys = reject_keys();
562 assert!(!keys.is_empty(), "fixture has no reject rows");
563 for k in &keys {
564 assert!(
565 encode_key(k).is_err(),
566 "reserved key {k:?} must be rejected"
567 );
568 }
569 }
570
571 #[test]
572 fn fixture_transmittable_rows_encode_to_the_reference_form() {
573 // The fixture accepts `[encoded] + encoded_alternates`; rs holds itself to
574 // `encoded`, the reference form, so its wire bytes stay identical to
575 // cachekit-py's (the alternates cover cachekit-ts's raw `! * ' ( )`).
576 let rows = transmittable();
577 assert!(!rows.is_empty(), "fixture has no transmittable rows");
578 for row in &rows {
579 let enc = encode_key(&row.key).expect("transmittable fixture key must encode");
580 assert_eq!(
581 Some(enc.as_ref()),
582 row.encoded.as_deref(),
583 "encode_key({:?}) is not the fixture's reference form",
584 row.key
585 );
586 }
587 }
588
589 #[test]
590 fn near_misses_are_not_rejected() {
591 for k in RS_NEAR_MISSES {
592 assert!(encode_key(k).is_ok(), "near-miss key {k:?} must encode");
593 }
594 }
595}
596
597// ── delete_succeeded unit tests ──────────────────────────────────────────────
598
599#[cfg(test)]
600mod delete_status_tests {
601 use super::delete_succeeded;
602
603 #[test]
604 fn delete_success_is_200_or_204() {
605 assert!(delete_succeeded(200));
606 assert!(delete_succeeded(204));
607 }
608
609 #[test]
610 fn delete_404_is_not_a_miss() {
611 // The server never answers DELETE with 404, so it is an error, not `Ok(false)`.
612 assert!(!delete_succeeded(404));
613 for status in [201, 400, 401, 403, 429, 500, 503] {
614 assert!(!delete_succeeded(status), "{status}");
615 }
616 }
617}
618
619// ── ttl_header unit tests ────────────────────────────────────────────────────
620
621#[cfg(test)]
622mod ttl_header_tests {
623 use std::time::Duration;
624
625 use super::{ttl_header, ttl_wire_secs};
626
627 #[test]
628 fn ttl_header_is_the_canonical_name_in_whole_seconds() {
629 assert_eq!(
630 ttl_header(Duration::from_millis(60_900)),
631 ("X-CacheKit-TTL", "60".to_owned())
632 );
633 }
634
635 #[test]
636 fn ttl_header_sends_a_sub_second_ttl_as_one() {
637 assert_eq!(
638 ttl_header(Duration::from_millis(500)),
639 ("X-CacheKit-TTL", "1".to_owned())
640 );
641 }
642
643 #[test]
644 fn wire_secs_ceil_sub_second_and_truncate_the_rest() {
645 assert_eq!(ttl_wire_secs(Duration::from_millis(500)), 1);
646 assert_eq!(ttl_wire_secs(Duration::from_nanos(1)), 1);
647 assert_eq!(ttl_wire_secs(Duration::from_secs(60)), 60);
648 assert_eq!(ttl_wire_secs(Duration::from_millis(60_900)), 60);
649 }
650
651 #[test]
652 fn wire_secs_keep_zero_as_zero() {
653 assert_eq!(ttl_wire_secs(Duration::ZERO), 0);
654 }
655}
656
657// ── In-memory backend for crate-internal tests ───────────────────────────────
658
659/// In-memory [`Backend`] for crate-internal tests that must see the bytes
660/// that actually reached the backend. Clones share one store.
661#[cfg(test)]
662#[allow(dead_code)] // its tests are feature-gated; builds without them still compile it
663#[derive(Clone, Default)]
664pub(crate) struct MemoryBackend {
665 pub(crate) store: std::sync::Arc<tokio::sync::Mutex<HashMap<String, Vec<u8>>>>,
666}
667
668#[cfg(test)]
669#[cfg_attr(not(any(target_arch = "wasm32", feature = "unsync")), async_trait)]
670#[cfg_attr(any(target_arch = "wasm32", feature = "unsync"), async_trait(?Send))]
671impl Backend for MemoryBackend {
672 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
673 Ok(self.store.lock().await.get(key).cloned())
674 }
675
676 async fn set(
677 &self,
678 key: &str,
679 value: Vec<u8>,
680 _ttl: Option<Duration>,
681 ) -> Result<(), BackendError> {
682 self.store.lock().await.insert(key.to_owned(), value);
683 Ok(())
684 }
685
686 async fn delete(&self, key: &str) -> Result<bool, BackendError> {
687 Ok(self.store.lock().await.remove(key).is_some())
688 }
689
690 async fn exists(&self, key: &str) -> Result<bool, BackendError> {
691 Ok(self.store.lock().await.contains_key(key))
692 }
693
694 async fn health(&self) -> Result<HealthStatus, BackendError> {
695 Ok(HealthStatus {
696 is_healthy: true,
697 latency_ms: 0.0,
698 backend_type: "memory".to_owned(),
699 details: HashMap::new(),
700 })
701 }
702}