Skip to main content

cachekit/
client.rs

1use std::borrow::Cow;
2use std::sync::Arc;
3use std::time::Duration;
4
5use bytes::Bytes;
6
7use serde::{de::DeserializeOwned, Serialize};
8
9use crate::backend::Backend;
10#[cfg(feature = "l1")]
11use crate::backend::{ttl_wire_secs, Freshness};
12use crate::error::CachekitError;
13use crate::metrics::{CacheCounters, L1Stats, ReadOutcome};
14use crate::serializer;
15
16// ── SharedBackend type alias ──────────────────────────────────────────────────
17
18/// Reference-counted pointer to a heap-allocated backend.
19///
20/// On native targets (without `unsync`) we require `Send + Sync` via `Arc`.
21/// On `wasm32` or with the `unsync` feature, `Rc` is used instead — the runtime
22/// is single-threaded so `Send` bounds are unnecessary.
23#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
24pub type SharedBackend = std::sync::Arc<dyn Backend>;
25
26/// Reference-counted pointer to a heap-allocated backend (`?Send` variant).
27#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
28pub type SharedBackend = std::rc::Rc<dyn Backend>;
29
30// ── SharedFlight type alias ──────────────────────────────────────────────────
31
32/// Reference-counted pointer to the single-flight map, so client clones share
33/// fill-dedup state (two clones racing a cold miss must collapse to one fill).
34#[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
35pub(crate) type SharedFlight = std::sync::Arc<crate::flight::FlightMap>;
36
37#[cfg(any(target_arch = "wasm32", feature = "unsync"))]
38pub(crate) type SharedFlight = std::rc::Rc<crate::flight::FlightMap>;
39
40/// Separate same-key ordering from single-flight: a refresh holds the flight
41/// lock while computing, then takes this lock only for its version-checked
42/// commit. Direct reads/writes/deletes take the same mutation lock.
43#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
44type SharedMutations = std::sync::Arc<crate::flight::MutationMap>;
45
46// ── SharedEncryption type alias ──────────────────────────────────────────────
47
48/// Reference-counted pointer to the encryption layer.
49///
50/// On native targets (without `unsync`) `Arc` is used (requires `Sync`).
51/// On `wasm32` or with `unsync`, `Rc` is used — avoids the `!Sync` problem
52/// caused by `Cell<u64>` inside cachekit-core's nonce counter.
53#[cfg(all(
54    feature = "encryption",
55    not(any(target_arch = "wasm32", feature = "unsync"))
56))]
57type SharedEncryption = std::sync::Arc<crate::encryption::EncryptionLayer>;
58
59#[cfg(all(
60    feature = "encryption",
61    any(target_arch = "wasm32", feature = "unsync")
62))]
63type SharedEncryption = std::rc::Rc<crate::encryption::EncryptionLayer>;
64
65// ── Key validation ────────────────────────────────────────────────────────────
66
67const MAX_KEY_BYTES: usize = 1024;
68
69/// Maximum TTL for L1 entries populated from L2 cache hits.
70/// Uses a short ceiling to limit staleness when the original TTL is unknown.
71/// A server `X-CacheKit-Fresh-For` bounds it further, and a stale read or a
72/// zero bound is not backfilled at all — see [`l1_backfill_ttl`].
73///
74/// Reconciliation with stale-while-revalidate: a backfilled entry's SWR
75/// freshness window derives from this capped TTL (window = ratio × entry
76/// TTL), **not** from the write-path TTL — the cap is the staleness bound
77/// for L2-derived data, deliberately kept. SWR removes the cap's expiry
78/// cliff instead: past ~ratio × 30 s the entry is served stale while one
79/// background refresh re-executes the origin. If no newer mutation replaced
80/// the entry, that refresh writes both layers and renews L1 hard expiry with
81/// the caller's full TTL. Without SWR the entry simply hard-expires at the cap
82/// and the next read blocks on L2, as before.
83#[cfg(feature = "l1")]
84const L1_BACKFILL_TTL_SECS: u64 = 30;
85
86/// L1 lifetime for a backfill from an L2 hit, or `None` when the read must not
87/// be backfilled (`spec/saas-api.md` § Remaining Freshness). A stale label or a
88/// zero `Fresh-For` forbids it; a present `Fresh-For` is a hard bound under the
89/// 30 s cap, which SWR cannot outlive because moka drops the entry at its TTL.
90/// With no header the cap applies unchanged.
91#[cfg(feature = "l1")]
92fn l1_backfill_ttl(default_ttl: Duration, freshness: Freshness) -> Option<Duration> {
93    let cap = std::cmp::min(default_ttl, Duration::from_secs(L1_BACKFILL_TTL_SECS));
94    let ttl = freshness
95        .fresh_for
96        .map_or(cap, |fresh_for| cap.min(fresh_for));
97    (!freshness.is_stale && !ttl.is_zero()).then_some(ttl)
98}
99
100fn validate_key(key: &str) -> Result<(), CachekitError> {
101    if key.is_empty() {
102        return Err(CachekitError::InvalidKey(
103            "key must not be empty".to_owned(),
104        ));
105    }
106    if key.len() > MAX_KEY_BYTES {
107        return Err(CachekitError::InvalidKey(format!(
108            "key is {} bytes (limit: {MAX_KEY_BYTES})",
109            key.len()
110        )));
111    }
112    if has_control_byte(key.as_bytes()) {
113        // Rejection only: find the first offending byte to name it.
114        if let Some(b) = key.bytes().find(|&b| is_control_byte(b)) {
115            return Err(CachekitError::InvalidKey(format!(
116                "key contains illegal control character 0x{b:02X}"
117            )));
118        }
119    }
120    Ok(())
121}
122
123/// A byte no key may hold: a control character below 0x20, or DEL.
124const fn is_control_byte(b: u8) -> bool {
125    (b < 0x20) | (b == 0x7F)
126}
127
128/// Whether any byte of `bytes` is a control byte.
129///
130/// Every key on every operation passes through here, so it reads 16 bytes at
131/// a time: a block's compares are folded with no early exit, which the
132/// compiler turns into a few SIMD instructions where a byte loop spends a
133/// branch per byte. When the length is not a multiple of 16, the last block
134/// overlaps the one before it instead of leaving a byte-at-a-time tail.
135fn has_control_byte(bytes: &[u8]) -> bool {
136    fn any_control(block: &[u8]) -> bool {
137        block
138            .iter()
139            .fold(false, |found, &b| found | is_control_byte(b))
140    }
141    if bytes.len() < 16 {
142        return any_control(bytes);
143    }
144    bytes.chunks_exact(16).any(any_control) || any_control(&bytes[bytes.len() - 16..])
145}
146
147// ── Stale-while-revalidate ───────────────────────────────────────────────────
148//
149// SWR needs an L1 to age entries in and a spawnable (`Send`) runtime for the
150// background refresh — native, non-`unsync` targets with the `l1` feature.
151// Everywhere else the SWR read path degrades to the plain read path and
152// `SwrRead::Stale` is never produced.
153
154/// Outcome of an SWR-aware typed read — see [`CacheKit::interop_get_swr`].
155#[derive(Debug, Clone, PartialEq)]
156pub enum SwrRead<T> {
157    /// Cache hit within the freshness window (or an L2 hit): use directly.
158    Fresh(T),
159    /// L1 hit past the freshness threshold but before hard expiry: use the
160    /// value now, and schedule a background refresh (the `#[cachekit]` macro
161    /// re-executes the function, deduplicated on the [`CacheKit::single_flight`]
162    /// locks without waiting on them). The token
163    /// makes refresh completion conditional: a newer set or delete wins.
164    Stale(T, SwrToken),
165    /// No usable entry: fall through to a normal blocking miss + fill.
166    Miss,
167}
168
169/// Mutation version captured by an SWR stale read.
170///
171/// Pass this back only through the `#[cachekit]`-generated refresh path. It
172/// prevents an older background computation from overwriting a newer write
173/// or resurrecting a deleted entry on this client or any of its clones.
174#[derive(Clone)]
175pub struct SwrToken {
176    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
177    state: std::sync::Arc<crate::flight::MutationState>,
178    version: u64,
179}
180
181impl std::fmt::Debug for SwrToken {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("SwrToken")
184            .field("version", &self.version)
185            .finish_non_exhaustive()
186    }
187}
188
189impl PartialEq for SwrToken {
190    fn eq(&self, other: &Self) -> bool {
191        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
192        {
193            self.version == other.version && std::sync::Arc::ptr_eq(&self.state, &other.state)
194        }
195        #[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
196        {
197            self.version == other.version
198        }
199    }
200}
201
202impl Eq for SwrToken {}
203
204// ── CacheKit ─────────────────────────────────────────────────────────────────
205
206/// Cache client with optional L1 in-process cache layer.
207///
208/// `Clone` is cheap and shares everything: backend, L1 cache, single-flight
209/// state, and encryption layer. Clones exist so `'static` background work
210/// (e.g. the SWR refresh spawned by `#[cachekit]`) can hold the client
211/// without borrowing it.
212///
213/// # Encryption
214///
215/// A client built with encryption configured (by any of the builder's
216/// encryption methods, such as
217/// [`encryption_from_env`](CacheKitBuilder::encryption_from_env), or the
218/// `secure` / `secure_from_env` preset) encrypts every
219/// value it writes and decrypts every value it reads — [`Self::get`],
220/// [`Self::interop_get`], [`Self::interop_get_swr`], [`Self::set`] and
221/// [`Self::set_with_ttl`] — and L1 holds ciphertext. Such a client has no
222/// plaintext value path, and the `SecureCache` handle from `secure_cache()`
223/// reads and writes the same entries. [`Self::delete`] and [`Self::exists`]
224/// carry no value and are the same either way.
225#[derive(Clone)]
226pub struct CacheKit {
227    backend: SharedBackend,
228    default_ttl: Duration,
229    namespace: Option<String>,
230    max_payload_bytes: usize,
231    flight: SharedFlight,
232    /// Read counters behind [`Self::stats`]; the backend holds a
233    /// `MetricsProvider` over the same allocation.
234    counters: Arc<CacheCounters>,
235
236    /// The reliability stack's breaker, when configured — kept so
237    /// [`Self::circuit_state`] can read it through the type-erased backend.
238    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
239    breaker: Option<Arc<crate::reliability::CircuitBreaker>>,
240
241    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
242    mutations: SharedMutations,
243
244    #[cfg(feature = "l1")]
245    pub(crate) l1: Option<crate::l1::L1Cache>,
246
247    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
248    pub(crate) swr_enabled: bool,
249
250    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
251    swr_threshold_ratio: f64,
252
253    #[cfg(feature = "encryption")]
254    encryption: Option<SharedEncryption>,
255}
256
257impl CacheKit {
258    /// Create a new builder.
259    pub fn builder() -> CacheKitBuilder {
260        CacheKitBuilder::default()
261    }
262
263    /// Build from environment variables via [`crate::config::CachekitConfig::from_env`].
264    ///
265    /// Creates a [`crate::backend::cachekitio::CachekitIO`] backend from the
266    /// config. Requires the `cachekitio` feature.
267    ///
268    /// `CACHEKIT_MASTER_KEY` does not make the client encrypt: a key in the
269    /// environment is a key source, never a switch (protocol
270    /// `spec/intent-presets.md` § Encryption Activation, rule 2). State the
271    /// intent instead — encrypt with the environment's keys, or don't:
272    ///
273    /// ```no_run
274    /// # fn demo() -> Result<(), cachekit::CachekitError> {
275    /// let encrypted = cachekit::CacheKit::from_env()?
276    ///     .encryption_from_env()?
277    ///     .build()?;
278    /// let plaintext = cachekit::CacheKit::from_env()?.no_encryption().build()?;
279    /// # Ok(()) }
280    /// ```
281    ///
282    /// Releases up to 0.11 encrypted whenever the variable was set. So that
283    /// such a deployment cannot silently start writing plaintext, a builder
284    /// from `from_env()` that saw `CACHEKIT_MASTER_KEY` and states neither
285    /// intent fails at [`CacheKitBuilder::build`] with
286    /// [`CachekitError::Config`]. This check is transitional and goes away
287    /// in a later release; without the variable, `from_env()?.build()?`
288    /// builds a plaintext client.
289    ///
290    /// A set key is validated here: one that is empty, not hex, shorter than
291    /// 32 bytes or not UTF-8 is a [`CachekitError::Config`].
292    ///
293    /// The default TTL is 300 s, and no environment variable changes it. Chain
294    /// [`CacheKitBuilder::default_ttl`] on the returned builder, or pass a TTL
295    /// per call with [`CacheKit::set_with_ttl`].
296    #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
297    pub fn from_env() -> Result<CacheKitBuilder, CachekitError> {
298        use crate::backend::cachekitio::CachekitIO;
299        use crate::config::CachekitConfig;
300
301        let config = CachekitConfig::from_env()?;
302
303        let api_key_z = config
304            .api_key
305            .ok_or_else(|| CachekitError::Config("CACHEKIT_API_KEY is required".to_owned()))?;
306
307        let backend = CachekitIO::builder()
308            .api_key(api_key_z.as_str())
309            .api_url(config.api_url)
310            .build()
311            .map_err(|e| CachekitError::Config(e.to_string()))?;
312
313        #[cfg(not(feature = "unsync"))]
314        let shared: SharedBackend = std::sync::Arc::new(backend);
315        #[cfg(feature = "unsync")]
316        let shared: SharedBackend = std::rc::Rc::new(backend);
317
318        let mut builder = CacheKitBuilder::default()
319            .backend(shared)
320            .default_ttl(config.default_ttl)
321            .max_payload_bytes(config.max_payload_bytes)
322            .l1_capacity(config.l1_capacity);
323
324        if let Some(ns) = config.namespace {
325            builder = builder.namespace(ns);
326        }
327        builder.env_master_key = config.master_key.is_some();
328
329        Ok(builder)
330    }
331
332    // ── Namespacing ───────────────────────────────────────────────────────────
333
334    /// `key` behind the client's namespace prefix. Without a namespace the
335    /// storage key is `key` itself, borrowed: an L1 hit then allocates
336    /// nothing for its key.
337    fn namespaced_key<'k>(&self, key: &'k str) -> Cow<'k, str> {
338        match &self.namespace {
339            Some(ns) => Cow::Owned(format!("{ns}:{key}")),
340            None => Cow::Borrowed(key),
341        }
342    }
343
344    /// Validate key and return the namespaced version.
345    fn resolve_key<'k>(&self, key: &'k str) -> Result<Cow<'k, str>, CachekitError> {
346        validate_key(key)?;
347        Ok(self.namespaced_key(key))
348    }
349
350    // ── L1 helpers ───────────────────────────────────────────────────────────
351
352    /// Try L1 cache first. Returns Some(bytes) on hit.
353    #[cfg(feature = "l1")]
354    fn l1_get(&self, full_key: &str) -> Option<Bytes> {
355        self.l1.as_ref().and_then(|l1| l1.get_shared(full_key))
356    }
357
358    /// Populate L1 from an L2 hit, for no longer than [`l1_backfill_ttl`] allows.
359    #[cfg(feature = "l1")]
360    fn l1_backfill(&self, full_key: &str, bytes: Bytes, freshness: Freshness) {
361        if let (Some(l1), Some(ttl)) = (&self.l1, l1_backfill_ttl(self.default_ttl, freshness)) {
362            l1.set_shared(full_key, bytes, ttl);
363        }
364    }
365
366    /// Write-through to L1 after a write the backend accepted. The copy lives
367    /// no longer than the TTL the write sent on the wire ([`ttl_wire_secs`]):
368    /// `spec/saas-api.md` forbids serving it past `min(local_ttl, effective_ttl)`,
369    /// and the wire truncates a fractional TTL from 1 s up (1.5 s goes as 1).
370    #[cfg(feature = "l1")]
371    fn l1_set(&self, full_key: &str, bytes: Bytes, ttl: Duration) {
372        if let Some(ref l1) = self.l1 {
373            let wire = Duration::from_secs(ttl_wire_secs(ttl));
374            l1.set_shared(full_key, bytes, ttl.min(wire));
375        }
376    }
377
378    /// Invalidate L1 entry.
379    #[cfg(feature = "l1")]
380    fn l1_delete(&self, full_key: &str) {
381        if let Some(ref l1) = self.l1 {
382            l1.delete(full_key);
383        }
384    }
385
386    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
387    async fn lock_l1_mutation(&self, full_key: &str) -> Option<crate::flight::MutationGuard> {
388        if self.l1.is_some() {
389            Some(self.mutations.lock(full_key).await)
390        } else {
391            None
392        }
393    }
394
395    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
396    async fn complete_swr_bytes(
397        &self,
398        key: &str,
399        bytes: Vec<u8>,
400        ttl: Duration,
401        token: SwrToken,
402    ) -> Result<bool, CachekitError> {
403        Self::validate_ttl(ttl)?;
404        self.check_payload_size(bytes.len())?;
405        let full_key = self.resolve_key(key)?;
406        let Some(mutation) = self.lock_l1_mutation(&full_key).await else {
407            return Ok(false);
408        };
409
410        // Check before touching L2. The mutation state lives independently of
411        // the moka entry, so hard expiry or capacity eviction does not look
412        // like an explicit set/delete and waste a valid origin result.
413        if !mutation.is_current(&token.state, token.version) {
414            return Ok(false);
415        }
416        let l1_bytes = Bytes::copy_from_slice(&bytes);
417        self.backend.set(&full_key, bytes, Some(ttl)).await?;
418        self.l1_set(&full_key, l1_bytes, ttl);
419        mutation.advance();
420        crate::metrics::trace_write(&full_key, ttl);
421        Ok(true)
422    }
423
424    #[cfg(not(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32"))))]
425    async fn complete_swr_bytes(
426        &self,
427        _key: &str,
428        _bytes: Vec<u8>,
429        _ttl: Duration,
430        _token: SwrToken,
431    ) -> Result<bool, CachekitError> {
432        // `SwrRead::Stale` is unreachable on this build, so there is no
433        // versioned entry a caller could legitimately complete.
434        Ok(false)
435    }
436
437    /// Rejects a zero TTL.
438    fn validate_ttl(ttl: Duration) -> Result<(), CachekitError> {
439        if ttl.is_zero() {
440            return Err(CachekitError::Config(
441                "TTL must be greater than zero".to_owned(),
442            ));
443        }
444        Ok(())
445    }
446
447    // ── Public operations ─────────────────────────────────────────────────────
448
449    /// Retrieve and deserialize a value stored under `key`.
450    ///
451    /// Returns `None` if the key does not exist.
452    /// Checks L1 cache before hitting the backend.
453    ///
454    /// # Errors
455    ///
456    /// On a client with encryption configured, returns
457    /// [`CachekitError::Encryption`] — not a miss — if the stored entry fails
458    /// decryption. The key's L1 copy is dropped first and the backend entry
459    /// is left in place, so the next read reaches the backend: a hit once the
460    /// entry is replaced with ciphertext this client can decrypt, a miss once
461    /// it expires or is deleted. A decrypted entry that does not decode as `T`
462    /// is [`CachekitError::Serialization`] naming only `T`, never quoting the
463    /// value.
464    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CachekitError> {
465        #[cfg(feature = "encryption")]
466        if let Some(secure) = self.encrypting() {
467            return secure.get(key).await;
468        }
469        match self.get_bytes(key).await? {
470            Some(bytes) => Ok(Some(serializer::deserialize(&bytes)?)),
471            None => Ok(None),
472        }
473    }
474
475    /// Retrieve and deserialize an interop-mode value stored under `key`.
476    ///
477    /// Identical to [`Self::get`] except the payload is decoded with
478    /// [`crate::interop::deserialize`]. Both consume exactly one MessagePack
479    /// document and reject trailing bytes (interop/v1 spec MUST); this one
480    /// also names a Python-SDK-internal CK frame with a specific diagnostic
481    /// instead of a generic trailing-bytes error.
482    ///
483    /// Use with keys from [`crate::interop::interop_key`] on a client
484    /// **without** a namespace prefix. There is no interop-specific write
485    /// method: [`Self::set`] already writes plain MessagePack (no ByteStorage
486    /// envelope), which is the interop value format. On a client with
487    /// encryption configured the stored bytes are AES-GCM ciphertext of that
488    /// MessagePack, decrypted here before decoding: in interop mode the
489    /// AES-GCM plaintext is the plain MessagePack value bytes, so the AAD
490    /// (v0x03, `format="msgpack"`, `compressed="False"`) verifies cross-SDK
491    /// unchanged.
492    ///
493    /// Unlike this read, [`Self::set`] / [`Self::set_with_ttl`] do not check
494    /// the client namespace: on a client with [`CacheKitBuilder::namespace`]
495    /// set they return `Ok` and store the entry under
496    /// `{namespace}:{interop_key}`, which no interop reader addresses. Interop
497    /// writes need a client without a namespace.
498    ///
499    /// # Errors
500    ///
501    /// Returns [`CachekitError::Config`] if the client was built with
502    /// [`CacheKitBuilder::namespace`]: the prefix
503    /// would rewrite the storage key to `{prefix}:{interop_key}`, which no
504    /// other SDK computes — every cross-SDK entry would silently miss. Interop
505    /// keys carry their own namespace segment; failing loudly here beats a
506    /// 100% miss rate that looks like a cold cache. With encryption
507    /// configured, an entry that fails decryption is
508    /// [`CachekitError::Encryption`], with the key's L1 copy dropped as for
509    /// [`Self::get`].
510    pub async fn interop_get<T: DeserializeOwned>(
511        &self,
512        key: &str,
513    ) -> Result<Option<T>, CachekitError> {
514        #[cfg(feature = "encryption")]
515        if let Some(secure) = self.encrypting() {
516            return secure.interop_get(key).await;
517        }
518        self.reject_namespaced_interop()?;
519        match self.get_bytes(key).await? {
520            Some(bytes) => Ok(Some(crate::interop::deserialize(&bytes)?)),
521            None => Ok(None),
522        }
523    }
524
525    /// Interop keys must reach the backend verbatim; a client namespace prefix
526    /// would silently produce storage keys no other SDK computes.
527    fn reject_namespaced_interop(&self) -> Result<(), CachekitError> {
528        match self.namespace {
529            None => Ok(()),
530            Some(_) => Err(CachekitError::Config(
531                "interop reads require a client without a namespace prefix: .namespace() \
532                 would store interop entries under {prefix}:{interop_key}, \
533                 which other SDKs never compute (interop keys already carry a namespace \
534                 segment) — use a dedicated non-namespaced client for interop entries"
535                    .to_owned(),
536            )),
537        }
538    }
539
540    /// Retrieve and deserialize an interop-mode value with SWR classification.
541    ///
542    /// Identical to [`Self::interop_get`] except an L1 hit is classified
543    /// against the client's stale-while-revalidate freshness window:
544    ///
545    /// - [`SwrRead::Fresh`] — L1 hit within `swr_threshold_ratio` of the
546    ///   entry's TTL (±10% jitter), or any L2 hit. Use directly.
547    /// - [`SwrRead::Stale`] — L1 hit past the threshold but **before hard
548    ///   expiry**: the value is returned without touching the backend or
549    ///   origin, and the caller should schedule exactly one background
550    ///   refresh. The `#[cachekit]` macro dedups it on the cold-miss
551    ///   [`Self::single_flight`] locks without waiting for another worker's
552    ///   fill: a refresh that finds either lock held, or whose lock call
553    ///   fails, stands down and the stale value keeps being served. The
554    ///   accompanying [`SwrToken`] makes completion conditional,
555    ///   so a newer set/delete always wins.
556    /// - [`SwrRead::Miss`] — nothing usable anywhere: normal blocking miss.
557    ///
558    /// A hard-expired L1 entry is a [`SwrRead::Miss`], never `Stale` — moka
559    /// drops entries at their TTL, so SWR cannot serve past hard expiry.
560    ///
561    /// With SWR disabled ([`CacheKitBuilder::swr_enabled`]`(false)`), without
562    /// the `l1` feature, on wasm32, or under `unsync`, this behaves exactly
563    /// like [`Self::interop_get`]: hits are `Fresh`, `Stale` is never
564    /// produced.
565    ///
566    /// On a client with encryption configured, staleness is judged on the L1
567    /// **ciphertext** entry (freshness metadata never exposes plaintext),
568    /// then the value is decrypted as for [`Self::interop_get`].
569    ///
570    /// # Errors
571    ///
572    /// Same as [`Self::interop_get`] (including the namespaced-client
573    /// rejection).
574    pub async fn interop_get_swr<T: DeserializeOwned>(
575        &self,
576        key: &str,
577    ) -> Result<SwrRead<T>, CachekitError> {
578        #[cfg(feature = "encryption")]
579        if let Some(secure) = self.encrypting() {
580            return secure.interop_get_swr(key).await;
581        }
582        self.reject_namespaced_interop()?;
583        match self.get_bytes_swr(key).await? {
584            SwrRead::Fresh(b) => Ok(SwrRead::Fresh(crate::interop::deserialize(&b)?)),
585            SwrRead::Stale(b, token) => Ok(SwrRead::Stale(crate::interop::deserialize(&b)?, token)),
586            SwrRead::Miss => Ok(SwrRead::Miss),
587        }
588    }
589
590    /// Fetch raw payload bytes with SWR classification: an L1 hit is split
591    /// into fresh vs stale against the configured freshness window; on L1
592    /// miss this defers to [`Self::get_bytes`] (L2 + backfill), whose hit is
593    /// always fresh.
594    async fn get_bytes_swr(&self, key: &str) -> Result<SwrRead<Bytes>, CachekitError> {
595        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
596        if self.swr_enabled {
597            if let Some(ref l1) = self.l1 {
598                let full_key = self.resolve_key(key)?;
599                match l1.get_with_swr_shared(&full_key, self.swr_threshold_ratio) {
600                    crate::l1::SharedSwrRead::Fresh(bytes) => {
601                        self.counters.record(ReadOutcome::L1Hit, &full_key);
602                        self.check_payload_size(bytes.len())?;
603                        return Ok(SwrRead::Fresh(bytes));
604                    }
605                    crate::l1::SharedSwrRead::Stale(_) => {
606                        // Token capture and the stale snapshot must be atomic
607                        // relative to explicit mutations. Re-check after
608                        // taking the per-key guard; a write may have landed
609                        // between the optimistic classification and here.
610                        let mutation = self.mutations.lock(&full_key).await;
611                        match l1.get_with_swr_shared(&full_key, self.swr_threshold_ratio) {
612                            crate::l1::SharedSwrRead::Fresh(bytes) => {
613                                self.counters.record(ReadOutcome::L1Hit, &full_key);
614                                self.check_payload_size(bytes.len())?;
615                                return Ok(SwrRead::Fresh(bytes));
616                            }
617                            crate::l1::SharedSwrRead::Stale(bytes) => {
618                                self.counters.record(ReadOutcome::L1Stale, &full_key);
619                                self.check_payload_size(bytes.len())?;
620                                let (state, version) = mutation.snapshot();
621                                return Ok(SwrRead::Stale(bytes, SwrToken { state, version }));
622                            }
623                            crate::l1::SharedSwrRead::Miss => {}
624                        }
625                    }
626                    // Absent or hard-expired: fall through to the normal
627                    // read path (the redundant L1 re-check there is a cheap
628                    // in-process miss).
629                    crate::l1::SharedSwrRead::Miss => {}
630                }
631            }
632        }
633
634        Ok(match self.get_bytes(key).await? {
635            Some(bytes) => SwrRead::Fresh(bytes),
636            None => SwrRead::Miss,
637        })
638    }
639
640    /// Fetch raw payload bytes for `key` (L1, then L2 with L1 backfill).
641    async fn get_bytes(&self, key: &str) -> Result<Option<Bytes>, CachekitError> {
642        let full_key = self.resolve_key(key)?;
643
644        // L1 hit
645        #[cfg(feature = "l1")]
646        if let Some(bytes) = self.l1_get(&full_key) {
647            self.counters.record(ReadOutcome::L1Hit, &full_key);
648            self.check_payload_size(bytes.len())?;
649            return Ok(Some(bytes));
650        }
651
652        // Serialize an L2 read/backfill with same-key writes. Re-check L1
653        // after taking the lock because another operation may have filled it
654        // between the optimistic read above and lock acquisition.
655        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
656        let _mutation = self.lock_l1_mutation(&full_key).await;
657
658        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
659        if let Some(bytes) = self.l1_get(&full_key) {
660            self.counters.record(ReadOutcome::L1Hit, &full_key);
661            self.check_payload_size(bytes.len())?;
662            return Ok(Some(bytes));
663        }
664
665        // L2 backend
666        let (bytes, freshness) = match self.backend.get_with_freshness(&full_key).await? {
667            Some(hit) => {
668                self.counters.record(ReadOutcome::L2Hit, &full_key);
669                hit
670            }
671            None => {
672                self.counters.record(ReadOutcome::Miss, &full_key);
673                return Ok(None);
674            }
675        };
676
677        self.check_payload_size(bytes.len())?;
678        // Takes over the Vec's allocation: no copy, and L1 shares it below.
679        let bytes = Bytes::from(bytes);
680
681        // Populate L1 on L2 hit, bounded by the cap and the server's freshness
682        #[cfg(feature = "l1")]
683        self.l1_backfill(&full_key, bytes.clone(), freshness);
684        #[cfg(not(feature = "l1"))]
685        let _ = freshness;
686
687        Ok(Some(bytes))
688    }
689
690    /// Serialize and store `value` under `key` using the client's default TTL.
691    ///
692    /// Encrypts first on a client with encryption configured — see
693    /// [`Self::set_with_ttl`].
694    pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), CachekitError> {
695        self.set_with_ttl(key, value, self.default_ttl).await
696    }
697
698    /// Serialize and store `value` under `key` with an explicit `ttl`.
699    ///
700    /// On a client with encryption configured the value is encrypted first:
701    /// the backend and L1 receive only the AES-256-GCM ciphertext, and the
702    /// payload limit applies to the ciphertext.
703    ///
704    /// A positive sub-second `ttl` is sent to the backend as 1 second.
705    ///
706    /// Returns [`CachekitError::Config`] if `ttl` is zero.
707    ///
708    /// The client namespace is not checked here: an interop key written on a
709    /// namespaced client lands under `{namespace}:{interop_key}`, which no
710    /// interop reader addresses — see [`Self::interop_get`].
711    pub async fn set_with_ttl<T: Serialize>(
712        &self,
713        key: &str,
714        value: &T,
715        ttl: Duration,
716    ) -> Result<(), CachekitError> {
717        #[cfg(feature = "encryption")]
718        if let Some(secure) = self.encrypting() {
719            return secure.set_with_ttl(key, value, ttl).await;
720        }
721        Self::validate_ttl(ttl)?;
722
723        let bytes = serializer::serialize(value)?;
724        self.check_payload_size(bytes.len())?;
725
726        let full_key = self.resolve_key(key)?;
727
728        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
729        let mutation = self.lock_l1_mutation(&full_key).await;
730
731        // Invalidate older refresh tokens before the first backend await, so
732        // cancellation cannot leave an applied/attempted write vulnerable to
733        // a stale background result.
734        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
735        if let Some(ref mutation) = mutation {
736            mutation.advance();
737        }
738
739        // L1 needs its own copy: the backend consumes the Vec.
740        #[cfg(feature = "l1")]
741        {
742            let l1_bytes = Bytes::copy_from_slice(&bytes);
743            self.backend.set(&full_key, bytes, Some(ttl)).await?;
744            self.l1_set(&full_key, l1_bytes, ttl);
745        }
746        #[cfg(not(feature = "l1"))]
747        {
748            self.backend.set(&full_key, bytes, Some(ttl)).await?;
749        }
750
751        crate::metrics::trace_write(&full_key, ttl);
752        Ok(())
753    }
754
755    /// Commit an SWR refresh only if its stale-read token is still current,
756    /// encrypted when the client has encryption configured. Macro plumbing;
757    /// ordinary writes use [`Self::set_with_ttl`].
758    #[doc(hidden)]
759    pub async fn __complete_swr_refresh<T: Serialize>(
760        &self,
761        key: &str,
762        value: &T,
763        ttl: Duration,
764        token: SwrToken,
765    ) -> Result<bool, CachekitError> {
766        #[cfg(feature = "encryption")]
767        if let Some(secure) = self.encrypting() {
768            return secure.__complete_swr_refresh(key, value, ttl, token).await;
769        }
770        let bytes = serializer::serialize(value)?;
771        self.complete_swr_bytes(key, bytes, ttl, token).await
772    }
773
774    /// Delete `key` and return `true` if it existed.
775    ///
776    /// `CachekitIO` and `WorkersCachekitIO` return `true` on every successful
777    /// delete, whether or not the key existed: the server does not report
778    /// existence on `DELETE`.
779    ///
780    /// Invalidates the L1 entry regardless of the backend result.
781    pub async fn delete(&self, key: &str) -> Result<bool, CachekitError> {
782        let full_key = self.resolve_key(key)?;
783
784        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
785        let mutation = self.lock_l1_mutation(&full_key).await;
786
787        // Invalidate before L1 changes or backend I/O so task cancellation
788        // cannot let an older refresh resurrect this key.
789        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
790        if let Some(ref mutation) = mutation {
791            mutation.advance();
792        }
793
794        // Invalidate L1 first so callers never read a stale value even if the
795        // backend delete fails partway through.
796        #[cfg(feature = "l1")]
797        self.l1_delete(&full_key);
798
799        let existed = self.backend.delete(&full_key).await?;
800        crate::metrics::trace_delete(&full_key, existed);
801        Ok(existed)
802    }
803
804    /// Return `true` if `key` exists without fetching the value.
805    ///
806    /// A live L1 entry answers without a backend call. That check does not
807    /// count as a use of the entry for L1 eviction; only reads do.
808    pub async fn exists(&self, key: &str) -> Result<bool, CachekitError> {
809        let full_key = self.resolve_key(key)?;
810
811        // Check L1 first — avoids a network round-trip for warm entries.
812        #[cfg(feature = "l1")]
813        if self.l1.as_ref().is_some_and(|l1| l1.contains(&full_key)) {
814            return Ok(true);
815        }
816
817        Ok(self.backend.exists(&full_key).await?)
818    }
819
820    // ── Observability ─────────────────────────────────────────────────────────
821
822    /// Live hit/miss counters for this client and all of its clones.
823    ///
824    /// Every value read — `get`, `interop_get`, their SWR variants, and the
825    /// [`SecureCache`] equivalents — is counted by where it was served from.
826    /// `exists` and reads that fail with a backend error are not counted. The
827    /// same counters feed the cachekit.io backends' `X-CacheKit-*` telemetry
828    /// headers, so for a backend used by this client alone, what you see here
829    /// is what the SaaS sees (a backend shared by several clients reports the
830    /// first client built over it — see [`Backend::attach_metrics`]).
831    ///
832    /// ```no_run
833    /// # fn example(cache: &cachekit::CacheKit) {
834    /// let stats = cache.stats();
835    /// let reads = stats.l1_hits + stats.l2_hits + stats.misses;
836    /// eprintln!("L1 hit rate {:.1}% over {reads} reads", stats.l1_hit_rate() * 100.0);
837    /// # }
838    /// ```
839    pub fn stats(&self) -> L1Stats {
840        self.counters.snapshot()
841    }
842
843    /// Number of entries currently held in L1, or `None` when L1 is disabled
844    /// (or the `l1` feature is off).
845    ///
846    /// Exact at the time of the call: moka's pending expirations and
847    /// evictions are applied first, which makes this a housekeeping call, not
848    /// a free counter read — poll it, don't put it on a hot path.
849    pub fn l1_entry_count(&self) -> Option<u64> {
850        #[cfg(feature = "l1")]
851        let count = self.l1.as_ref().map(crate::l1::L1Cache::entry_count);
852        #[cfg(not(feature = "l1"))]
853        let count = None;
854        count
855    }
856
857    /// Current circuit-breaker state, or `None` when this client has no
858    /// breaker (reliability disabled, or configured with
859    /// `circuit_breaker: None`).
860    ///
861    /// Reads the live state: an open circuit whose `open_timeout` has elapsed
862    /// reports [`CircuitState::HalfOpen`](crate::CircuitState::HalfOpen). With
863    /// the `tracing` feature every transition is also emitted on the
864    /// `cachekit::reliability` target — `warn` when the circuit opens, `info`
865    /// for half-open and closed.
866    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
867    pub fn circuit_state(&self) -> Option<crate::reliability::CircuitState> {
868        self.breaker.as_ref().map(|breaker| breaker.state())
869    }
870
871    // ── Single-flight ─────────────────────────────────────────────────────────
872
873    /// Begin a cold-miss single-flight for `key` (see [`crate::flight`]).
874    ///
875    /// Call after a cache miss, before computing the value. Concurrent
876    /// in-process fills of the same key are collapsed to one; with the
877    /// `reliability` feature and a lock-capable backend (CachekitIO, Redis),
878    /// fills are also suppressed across processes via a distributed fill
879    /// lock. The `#[cachekit]` macro does this automatically.
880    ///
881    /// The key is namespaced like every cache operation but not validated —
882    /// this call is infallible; an invalid key simply fails later at the
883    /// actual cache operation.
884    pub async fn single_flight(&self, key: &str) -> crate::flight::SingleFlight {
885        let full_key = self.namespaced_key(key);
886        crate::flight::SingleFlight::acquire(&self.flight, &self.backend, &full_key).await
887    }
888
889    /// Begin the single-flight for an SWR background refresh, or `None` when
890    /// another worker already holds this key's in-process flight or
891    /// distributed fill lock, or the lock call fails. Unlike
892    /// [`Self::single_flight`] it never waits for another worker's fill.
893    /// Macro plumbing for `#[cachekit]` — not public API.
894    #[doc(hidden)]
895    pub async fn __refresh_flight(&self, key: &str) -> Option<crate::flight::SingleFlight> {
896        let full_key = self.namespaced_key(key);
897        crate::flight::SingleFlight::try_lead(&self.flight, &self.backend, &full_key).await
898    }
899
900    // ── Secure cache ─────────────────────────────────────────────────────────
901
902    /// Return a [`SecureCache`] handle that encrypts all values before storage.
903    ///
904    /// L1 stores **ciphertext** (not plaintext) to preserve the zero-knowledge
905    /// property across all cache layers.
906    ///
907    /// The handle encrypts exactly what this client's own value methods
908    /// already encrypt (see [Encryption](CacheKit#encryption)), and the two
909    /// read each other's entries. What it adds is the guarantee: code that
910    /// must never run unencrypted asks for the handle, which fails on a
911    /// client without encryption, and it carries the
912    /// [`previous_key_hits`](SecureCache::previous_key_hits) rotation signal.
913    ///
914    /// # Errors
915    /// Returns `CachekitError::Config` if no encryption layer is configured.
916    /// Configure encryption via [`CacheKitBuilder::encryption`] /
917    /// [`CacheKitBuilder::encryption_from_env`] /
918    /// [`CacheKitBuilder::encryption_from_bytes`], or start from the
919    /// `CacheKit::secure(url, master_key_hex)` /
920    /// `CacheKit::secure_from_env(url)` preset (`redis` feature).
921    #[cfg(feature = "encryption")]
922    pub fn secure_cache(&self) -> Result<SecureCache<'_>, CachekitError> {
923        self.encrypting().ok_or_else(|| {
924            CachekitError::Config(
925                "encryption not configured: use the CacheKit::secure(url, master_key_hex) \
926                 preset, CacheKit::secure_from_env(url) to read the hex key from \
927                 CACHEKIT_MASTER_KEY, or call .encryption() on the builder \
928                 (.encryption_from_env() to read CACHEKIT_MASTER_KEY)"
929                    .to_owned(),
930            )
931        })
932    }
933
934    /// The encrypting handle when encryption is configured. Every value
935    /// method routes through it first, so a configured key leaves no
936    /// plaintext path (protocol `spec/intent-presets.md` § Encryption
937    /// Activation, rule 1).
938    #[cfg(feature = "encryption")]
939    fn encrypting(&self) -> Option<SecureCache<'_>> {
940        self.encryption.as_deref().map(|encryption| SecureCache {
941            client: self,
942            encryption,
943        })
944    }
945
946    // ── Private helpers ───────────────────────────────────────────────────────
947
948    fn check_payload_size(&self, size: usize) -> Result<(), CachekitError> {
949        if size > self.max_payload_bytes {
950            return Err(CachekitError::PayloadTooLarge {
951                size,
952                limit: self.max_payload_bytes,
953            });
954        }
955        Ok(())
956    }
957}
958
959// ── SecureCache ──────────────────────────────────────────────────────────────
960
961/// Encrypted cache handle returned by [`CacheKit::secure_cache()`].
962///
963/// All values are serialized, then encrypted with AES-256-GCM before storage.
964/// L1 stores ciphertext to maintain zero-knowledge guarantees. The client's
965/// own value methods route through these same methods once encryption is
966/// configured, so the handle and the client read each other's entries.
967#[cfg(feature = "encryption")]
968pub struct SecureCache<'a> {
969    client: &'a CacheKit,
970    encryption: &'a crate::encryption::EncryptionLayer,
971}
972
973#[cfg(feature = "encryption")]
974impl std::fmt::Debug for SecureCache<'_> {
975    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
976        f.debug_struct("SecureCache")
977            .field("tenant_id", &self.encryption.tenant_id())
978            .finish()
979    }
980}
981
982#[cfg(feature = "encryption")]
983impl SecureCache<'_> {
984    /// Rotation drain signal; see
985    /// [`EncryptionLayer::previous_key_hits`](crate::EncryptionLayer::previous_key_hits).
986    pub fn previous_key_hits(&self) -> Vec<u64> {
987        self.encryption.previous_key_hits()
988    }
989
990    /// Whether AES is hardware-accelerated on this host (informational); see
991    /// [`EncryptionLayer::hardware_acceleration_enabled`](crate::EncryptionLayer::hardware_acceleration_enabled).
992    pub fn hardware_acceleration_enabled(&self) -> bool {
993        self.encryption.hardware_acceleration_enabled()
994    }
995
996    /// Encrypt and store `value` under `key` using the client's default TTL.
997    pub async fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<(), CachekitError> {
998        self.set_with_ttl(key, value, self.client.default_ttl).await
999    }
1000
1001    /// Encrypt and store `value` under `key` with an explicit `ttl`.
1002    ///
1003    /// The client namespace is not checked here: an interop key written on a
1004    /// namespaced client lands under `{namespace}:{interop_key}`, which no
1005    /// interop reader addresses — see [`CacheKit::interop_get`].
1006    pub async fn set_with_ttl<T: Serialize>(
1007        &self,
1008        key: &str,
1009        value: &T,
1010        ttl: Duration,
1011    ) -> Result<(), CachekitError> {
1012        CacheKit::validate_ttl(ttl)?;
1013        let (full_key, ciphertext) = self.seal(key, value)?;
1014        // Size-check what is actually persisted (nonce + ciphertext + tag).
1015        // The get paths check the stored ciphertext length, so checking the
1016        // plaintext here would let a value within 28 bytes of the limit write
1017        // successfully and then fail EVERY subsequent read with PayloadTooLarge.
1018        self.client.check_payload_size(ciphertext.len())?;
1019
1020        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1021        let mutation = self.client.lock_l1_mutation(&full_key).await;
1022
1023        // Match the plain write path: invalidate older refresh tokens before
1024        // the first backend await, including on cancellation or failure.
1025        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1026        if let Some(ref mutation) = mutation {
1027            mutation.advance();
1028        }
1029
1030        // L1 needs its own copy: the backend consumes the Vec.
1031        #[cfg(feature = "l1")]
1032        {
1033            let l1_bytes = Bytes::copy_from_slice(&ciphertext);
1034            self.client
1035                .backend
1036                .set(&full_key, ciphertext, Some(ttl))
1037                .await?;
1038            self.client.l1_set(&full_key, l1_bytes, ttl);
1039        }
1040        #[cfg(not(feature = "l1"))]
1041        {
1042            self.client
1043                .backend
1044                .set(&full_key, ciphertext, Some(ttl))
1045                .await?;
1046        }
1047
1048        crate::metrics::trace_write(&full_key, ttl);
1049        Ok(())
1050    }
1051
1052    /// Commit an encrypted SWR refresh only if its stale-read token is still
1053    /// current. Macro plumbing; ordinary writes use [`Self::set_with_ttl`].
1054    #[doc(hidden)]
1055    pub async fn __complete_swr_refresh<T: Serialize>(
1056        &self,
1057        key: &str,
1058        value: &T,
1059        ttl: Duration,
1060        token: SwrToken,
1061    ) -> Result<bool, CachekitError> {
1062        let (_, ciphertext) = self.seal(key, value)?;
1063        self.client
1064            .complete_swr_bytes(key, ciphertext, ttl, token)
1065            .await
1066    }
1067
1068    /// Serialize and encrypt `value` for `key`, returning the key this client
1069    /// passes to its backend (`.namespace()` prefix included) with the
1070    /// ciphertext. Every write encrypts here, so the AAD binds that key and an
1071    /// entry copied to another namespace fails to decrypt.
1072    fn seal<T: Serialize>(&self, key: &str, value: &T) -> Result<(String, Vec<u8>), CachekitError> {
1073        let full_key = self.client.resolve_key(key)?;
1074        let plaintext = serializer::serialize(value)?;
1075        let ciphertext = self.encryption.encrypt(&plaintext, &full_key)?;
1076        Ok((full_key.into_owned(), ciphertext))
1077    }
1078
1079    /// Retrieve, decrypt, and deserialize a value stored under `key`.
1080    ///
1081    /// Checks L1 (which holds ciphertext) before the backend.
1082    ///
1083    /// # Errors
1084    ///
1085    /// Returns [`CachekitError::Encryption`] if the stored entry fails
1086    /// decryption. The key's L1 copy is dropped first and the backend entry
1087    /// is left in place, so the next read reaches the backend: a hit once the
1088    /// entry is replaced with ciphertext this client can decrypt, a miss once
1089    /// it expires or is deleted. A decrypted entry that does not decode as `T`
1090    /// is [`CachekitError::Serialization`] naming only `T`: the decoder's own
1091    /// message would quote the plaintext.
1092    pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, CachekitError> {
1093        match self.get_plaintext(key).await? {
1094            Some(plaintext) => Ok(Some(redact_decode(serializer::deserialize(&plaintext))?)),
1095            None => Ok(None),
1096        }
1097    }
1098
1099    /// Retrieve, decrypt, and deserialize an interop-mode value stored under `key`.
1100    ///
1101    /// Identical to [`Self::get`] except the decrypted plaintext is decoded
1102    /// with [`crate::interop::deserialize`] — exactly one MessagePack document,
1103    /// trailing bytes rejected (interop/v1 spec MUST), as in [`Self::get`]. In interop mode the
1104    /// AES-GCM plaintext is the plain MessagePack value bytes, so the AAD
1105    /// (v0x03, `format="msgpack"`, `compressed="False"`) verifies cross-SDK
1106    /// unchanged.
1107    ///
1108    /// # Errors
1109    ///
1110    /// Returns [`CachekitError::Config`] on a namespace-prefixed client — see
1111    /// [`CacheKit::interop_get`] — and [`CachekitError::Encryption`] if the
1112    /// stored entry fails decryption, with the key's L1 copy dropped as for
1113    /// [`Self::get`].
1114    pub async fn interop_get<T: DeserializeOwned>(
1115        &self,
1116        key: &str,
1117    ) -> Result<Option<T>, CachekitError> {
1118        self.client.reject_namespaced_interop()?;
1119        match self.get_plaintext(key).await? {
1120            Some(plaintext) => Ok(Some(redact_decode(crate::interop::deserialize(
1121                &plaintext,
1122            ))?)),
1123            None => Ok(None),
1124        }
1125    }
1126
1127    /// Retrieve, decrypt, and deserialize an interop-mode value with SWR
1128    /// classification. The secure twin of [`CacheKit::interop_get_swr`]:
1129    /// staleness is judged on the L1 **ciphertext** entry (zero-knowledge is
1130    /// preserved — freshness metadata never exposes plaintext), then the
1131    /// value is decrypted and decoded per [`Self::interop_get`].
1132    ///
1133    /// # Errors
1134    ///
1135    /// Same as [`Self::interop_get`] — the secure path fails closed on every
1136    /// backend and decryption error.
1137    pub async fn interop_get_swr<T: DeserializeOwned>(
1138        &self,
1139        key: &str,
1140    ) -> Result<SwrRead<T>, CachekitError> {
1141        self.client.reject_namespaced_interop()?;
1142        match self.client.get_bytes_swr(key).await? {
1143            SwrRead::Fresh(ct) => Ok(SwrRead::Fresh(redact_decode(crate::interop::deserialize(
1144                &self.decrypt_or_evict(&ct, key)?,
1145            ))?)),
1146            SwrRead::Stale(ct, token) => Ok(SwrRead::Stale(
1147                redact_decode(crate::interop::deserialize(
1148                    &self.decrypt_or_evict(&ct, key)?,
1149                ))?,
1150                token,
1151            )),
1152            SwrRead::Miss => Ok(SwrRead::Miss),
1153        }
1154    }
1155
1156    /// Fetch ciphertext (L1, then L2 with L1 backfill) and decrypt it.
1157    ///
1158    /// Ciphertext retrieval delegates to [`CacheKit::get_bytes`], which returns
1159    /// the stored bytes untransformed — for a secure cache exactly the AES-GCM
1160    /// ciphertext, so decrypt receives the same bytes the backend holds.
1161    async fn get_plaintext(&self, key: &str) -> Result<Option<Vec<u8>>, CachekitError> {
1162        match self.client.get_bytes(key).await? {
1163            Some(ciphertext) => Ok(Some(self.decrypt_or_evict(&ciphertext, key)?)),
1164            None => Ok(None),
1165        }
1166    }
1167
1168    /// Decrypt ciphertext read for `key` against the key this client passes to
1169    /// its backend (`.namespace()` prefix included); on failure, drop the key's L1 copy before propagating the
1170    /// error.
1171    ///
1172    /// An L2 hit is backfilled into L1 before it is decrypted, so an entry
1173    /// that fails authentication would otherwise keep failing from L1 after
1174    /// the backend entry is fixed or deleted. The backend entry itself is
1175    /// left untouched as evidence.
1176    fn decrypt_or_evict(&self, ciphertext: &[u8], key: &str) -> Result<Vec<u8>, CachekitError> {
1177        let full_key = self.client.resolve_key(key)?;
1178        self.encryption
1179            .decrypt(ciphertext, &full_key)
1180            .inspect_err(|_| {
1181                #[cfg(feature = "l1")]
1182                self.client.l1_delete(&full_key);
1183            })
1184    }
1185
1186    /// Delete an encrypted key. Behaves identically to [`CacheKit::delete`].
1187    pub async fn delete(&self, key: &str) -> Result<bool, CachekitError> {
1188        self.client.delete(key).await
1189    }
1190
1191    /// Check if an encrypted key exists. Behaves identically to [`CacheKit::exists`].
1192    pub async fn exists(&self, key: &str) -> Result<bool, CachekitError> {
1193        self.client.exists(key).await
1194    }
1195}
1196
1197/// Decoding decrypted plaintext keeps the `Serialization` class, which
1198/// `#[cachekit]` reads as a miss, but replaces the decoder's message: serde
1199/// quotes the offending value (`invalid type: string "…", expected struct
1200/// …`), and that value is the plaintext encryption exists to protect
1201/// (CWE-532).
1202#[cfg(feature = "encryption")]
1203fn redact_decode<T>(decoded: Result<T, CachekitError>) -> Result<T, CachekitError> {
1204    decoded.map_err(|e| match e {
1205        CachekitError::Serialization(_) => CachekitError::Serialization(format!(
1206            "decrypted entry does not decode as {}",
1207            std::any::type_name::<T>()
1208        )),
1209        other => other,
1210    })
1211}
1212
1213// ── CacheKitBuilder ───────────────────────────────────────────────────────────
1214
1215/// Fluent builder for [`CacheKit`].
1216#[derive(Default)]
1217#[must_use]
1218pub struct CacheKitBuilder {
1219    backend: Option<SharedBackend>,
1220    default_ttl: Option<Duration>,
1221    namespace: Option<String>,
1222    max_payload_bytes: Option<usize>,
1223
1224    #[cfg(feature = "l1")]
1225    l1_capacity: Option<usize>,
1226
1227    #[cfg(feature = "l1")]
1228    no_l1: bool,
1229
1230    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1231    swr_enabled: Option<bool>,
1232
1233    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1234    swr_threshold_ratio: Option<f64>,
1235
1236    // pub(crate): the `secure` preset's key-path tests read the layer.
1237    #[cfg(feature = "encryption")]
1238    pub(crate) encryption: Option<SharedEncryption>,
1239
1240    /// [`Self::no_encryption`] was called.
1241    no_encryption: bool,
1242
1243    /// `CacheKit::from_env` saw `CACHEKIT_MASTER_KEY`: [`Self::build`]
1244    /// refuses until an encryption call or [`Self::no_encryption`] states
1245    /// the intent (the transitional gate documented on `from_env`).
1246    env_master_key: bool,
1247
1248    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1249    reliability: Option<crate::reliability::ReliabilityConfig>,
1250
1251    /// Retry deadlines for the backend `CacheKit::io` built; cleared by
1252    /// [`Self::backend`] so a replaced backend never inherits them.
1253    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1254    pub(crate) retry_deadlines: Option<crate::reliability::RetryDeadlines>,
1255}
1256
1257#[cfg(not(feature = "encryption"))]
1258fn encryption_feature_missing(what: &str) -> CachekitError {
1259    CachekitError::Config(format!(
1260        "{what} needs the `encryption` cargo feature, which this build of \
1261         cachekit-rs was compiled without; enable it to encrypt"
1262    ))
1263}
1264
1265impl CacheKitBuilder {
1266    /// Set the storage backend.
1267    pub fn backend(mut self, backend: SharedBackend) -> Self {
1268        self.backend = Some(backend);
1269        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1270        {
1271            self.retry_deadlines = None;
1272        }
1273        self
1274    }
1275
1276    /// Override the default TTL (used when no per-call TTL is specified).
1277    pub fn default_ttl(mut self, ttl: Duration) -> Self {
1278        self.default_ttl = Some(ttl);
1279        self
1280    }
1281
1282    /// Set a namespace prefix. All keys will be stored as `{namespace}:{key}`.
1283    pub fn namespace(mut self, ns: impl Into<String>) -> Self {
1284        self.namespace = Some(ns.into());
1285        self
1286    }
1287
1288    /// Set the maximum accepted payload size in bytes.
1289    pub fn max_payload_bytes(mut self, limit: usize) -> Self {
1290        self.max_payload_bytes = Some(limit);
1291        self
1292    }
1293
1294    /// Set the L1 cache capacity (max entries).
1295    #[cfg(feature = "l1")]
1296    pub fn l1_capacity(mut self, capacity: usize) -> Self {
1297        self.l1_capacity = Some(capacity);
1298        self
1299    }
1300
1301    /// Disable the L1 cache entirely.
1302    #[cfg(feature = "l1")]
1303    pub fn no_l1(mut self) -> Self {
1304        self.no_l1 = true;
1305        self
1306    }
1307
1308    /// Enable or disable L1 stale-while-revalidate (default: **enabled**,
1309    /// matching the Python and TypeScript SDKs).
1310    ///
1311    /// With SWR on, an L1 hit older than `swr_threshold_ratio` of its TTL is
1312    /// still served immediately, and the `#[cachekit]` macro schedules at
1313    /// most one background refresh (deduplicated on the
1314    /// [`CacheKit::single_flight`] locks, in-process and — on lock-capable
1315    /// backends — across processes). A refresh that finds either lock held,
1316    /// or whose lock call fails, stands down instead of waiting, and the
1317    /// stale value keeps being served. A hard-expired entry is never served:
1318    /// it falls through to a normal blocking miss.
1319    ///
1320    /// Native targets only: this knob does not exist on wasm32, under the
1321    /// `unsync` feature, or without `l1` — calling it there is a compile
1322    /// error rather than a silent no-op.
1323    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1324    pub fn swr_enabled(mut self, enabled: bool) -> Self {
1325        self.swr_enabled = Some(enabled);
1326        self
1327    }
1328
1329    /// Set the SWR freshness threshold as a fraction of each L1 entry's TTL
1330    /// (default: **0.5**, matching the Python and TypeScript SDKs).
1331    ///
1332    /// An entry is *fresh* until it has lived `ratio × TTL` (±10% jitter,
1333    /// drawn once when the entry is inserted, to de-synchronise refreshes
1334    /// across processes), then *stale* — served immediately with a background
1335    /// refresh — until hard expiry. Mirrors cachekit-py's
1336    /// `swr_threshold_ratio` semantics (elapsed-lifetime fraction). Must be in
1337    /// `(0.0, 1.0]`; validated at [`Self::build`].
1338    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1339    pub fn swr_threshold_ratio(mut self, ratio: f64) -> Self {
1340        self.swr_threshold_ratio = Some(ratio);
1341        self
1342    }
1343
1344    // Stubs for when the l1 feature is disabled — still compile cleanly.
1345    /// No-op: the `l1` feature is disabled, so there is no L1 to size.
1346    #[cfg(not(feature = "l1"))]
1347    pub fn l1_capacity(self, _capacity: usize) -> Self {
1348        self
1349    }
1350
1351    /// No-op: the `l1` feature is disabled, so L1 is already off.
1352    #[cfg(not(feature = "l1"))]
1353    pub fn no_l1(self) -> Self {
1354        self
1355    }
1356
1357    /// Wrap the backend in the reliability stack (retry with exponential
1358    /// backoff + jitter, circuit breaker, backpressure) — see
1359    /// [`crate::reliability`].
1360    ///
1361    /// Enabled by default with production settings by the `production`,
1362    /// `secure`, and `io` intent presets; off for `minimal` and for
1363    /// manually-built clients. To opt a preset out, pass
1364    /// [`ReliabilityConfig::disabled()`](crate::reliability::ReliabilityConfig::disabled)
1365    /// — a disabled config applies no wrapping at all.
1366    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1367    pub fn reliability(mut self, config: crate::reliability::ReliabilityConfig) -> Self {
1368        self.reliability = Some(config);
1369        self
1370    }
1371
1372    /// Configure encryption from raw master key bytes and tenant ID.
1373    ///
1374    /// The master key must be exactly 32 raw bytes; anything else is a
1375    /// [`CachekitError::Config`]. Hex-encoded keys go to [`Self::encryption`]
1376    /// (or [`Self::encryption_with_previous`] to rotate) — their ASCII bytes
1377    /// are 64 bytes long and rejected here.
1378    /// Keys are derived per-tenant via HKDF-SHA256.
1379    ///
1380    /// The built client encrypts every value read and write, not only those
1381    /// through the [`secure_cache()`](CacheKit::secure_cache) handle; see
1382    /// [Encryption](CacheKit#encryption). Calling any encryption method
1383    /// again replaces the earlier configuration.
1384    ///
1385    /// Without the `encryption` cargo feature this method, and every other
1386    /// builder encryption method, returns [`CachekitError::Config`]: a build
1387    /// that cannot encrypt never accepts a key.
1388    #[cfg(feature = "encryption")]
1389    pub fn encryption_from_bytes(
1390        mut self,
1391        master_key: &[u8],
1392        tenant_id: &str,
1393    ) -> Result<Self, CachekitError> {
1394        let layer = crate::encryption::EncryptionLayer::new(master_key, tenant_id)?;
1395        self.encryption = Some(SharedEncryption::new(layer));
1396        Ok(self)
1397    }
1398
1399    /// Configure encryption with decrypt-only previous master keys for
1400    /// key rotation.
1401    ///
1402    /// Writes encrypt under `master_key`; reads attempt it first, then each
1403    /// key in `previous_keys` sequentially (attempt order = slice order).
1404    /// At most 3 previous keys; supplying more is a config error, never
1405    /// truncated. See [`crate::encryption::EncryptionLayer::with_previous_keys`].
1406    ///
1407    /// Every key, current and previous, must be exactly 32 raw bytes. For
1408    /// hex keys, use the hex counterpart, [`Self::encryption_with_previous`].
1409    ///
1410    /// Encrypts every value read and write, as for
1411    /// [`Self::encryption_from_bytes`].
1412    #[cfg(feature = "encryption")]
1413    pub fn encryption_from_bytes_with_previous(
1414        mut self,
1415        master_key: &[u8],
1416        previous_keys: &[&[u8]],
1417        tenant_id: &str,
1418    ) -> Result<Self, CachekitError> {
1419        let layer = crate::encryption::EncryptionLayer::with_previous_keys(
1420            master_key,
1421            previous_keys,
1422            tenant_id,
1423        )?;
1424        self.encryption = Some(SharedEncryption::new(layer));
1425        Ok(self)
1426    }
1427
1428    /// Configure encryption from a hex-encoded master key string.
1429    ///
1430    /// Hex-decodes with the same decoder as [`CacheKit::from_env`] and the
1431    /// `secure` preset. The key must decode to at least 32 bytes; use exactly
1432    /// 32 (64 hex chars), the only length every SDK accepts.
1433    ///
1434    /// Encrypts every value read and write, as for
1435    /// [`Self::encryption_from_bytes`].
1436    #[cfg(feature = "encryption")]
1437    pub fn encryption(self, hex_key: &str, tenant_id: &str) -> Result<Self, CachekitError> {
1438        let bytes = crate::config::decode_master_key_hex(hex_key, "master key")?;
1439        self.encryption_from_hex_decoded(&bytes, &[], tenant_id)
1440    }
1441
1442    /// Configure encryption from hex-encoded keys, with decrypt-only previous
1443    /// master keys for key rotation.
1444    ///
1445    /// The hex counterpart of [`Self::encryption_from_bytes_with_previous`].
1446    /// Every key, current and previous, is decoded as for [`Self::encryption`]
1447    /// and must decode to at least 32 bytes, so a key longer than 32 bytes
1448    /// that [`Self::encryption`] accepted can be listed as a previous key
1449    /// under the tenant it was written for (the env variables always derive
1450    /// for tenant `"default"`). Use exactly 32 bytes (64 hex chars) for new
1451    /// keys, the only length every SDK accepts.
1452    ///
1453    /// Writes encrypt under `hex_key`; reads attempt it first, then each key
1454    /// in `previous_hex_keys` sequentially (attempt order = slice order).
1455    /// At most 3 previous keys, and the current key must not be among them;
1456    /// either violation is a [`CachekitError::Config`], never truncated.
1457    ///
1458    /// ```no_run
1459    /// # fn demo(backend: cachekit::client::SharedBackend, k2_hex: &str, k1_hex: &str)
1460    /// #     -> Result<(), cachekit::CachekitError> {
1461    /// let cache = cachekit::CacheKit::builder()
1462    ///     .backend(backend)
1463    ///     .encryption_with_previous(k2_hex, &[k1_hex], "tenant-a")?
1464    ///     .build()?;
1465    /// # Ok(()) }
1466    /// ```
1467    ///
1468    /// Encrypts every value read and write, as for
1469    /// [`Self::encryption_from_bytes`].
1470    #[cfg(feature = "encryption")]
1471    pub fn encryption_with_previous(
1472        self,
1473        hex_key: &str,
1474        previous_hex_keys: &[&str],
1475        tenant_id: &str,
1476    ) -> Result<Self, CachekitError> {
1477        let current = crate::config::decode_master_key_hex(hex_key, "master key")?;
1478        let previous = previous_hex_keys
1479            .iter()
1480            .enumerate()
1481            .map(|(i, key)| {
1482                crate::config::decode_master_key_hex(key, &format!("previous master key {i}"))
1483            })
1484            .collect::<Result<Vec<_>, _>>()?;
1485        let previous: Vec<&[u8]> = previous.iter().map(|key| key.as_slice()).collect();
1486        self.encryption_from_hex_decoded(&current, &previous, tenant_id)
1487    }
1488
1489    /// Configure encryption from the environment: the hex key in
1490    /// `CACHEKIT_MASTER_KEY`, plus any decrypt-only rotation keys in
1491    /// `CACHEKIT_PREVIOUS_MASTER_KEYS`, for tenant `"default"`.
1492    ///
1493    /// The explicit call that turns a `CACHEKIT_MASTER_KEY` into encryption:
1494    /// no constructor, [`CacheKit::from_env`] included, encrypts because the
1495    /// variable is set. Keys are decoded and validated as for
1496    /// [`Self::encryption_with_previous`] and [`CacheKit::from_env`].
1497    ///
1498    /// ```no_run
1499    /// # fn demo() -> Result<(), cachekit::CachekitError> {
1500    /// let cache = cachekit::CacheKit::from_env()?
1501    ///     .encryption_from_env()?
1502    ///     .build()?;
1503    /// # Ok(()) }
1504    /// ```
1505    ///
1506    /// Encrypts every value read and write, as for
1507    /// [`Self::encryption_from_bytes`].
1508    ///
1509    /// # Errors
1510    ///
1511    /// [`CachekitError::Config`] when `CACHEKIT_MASTER_KEY` is unset, empty,
1512    /// not UTF-8, not hex or shorter than 32 bytes, or when
1513    /// `CACHEKIT_PREVIOUS_MASTER_KEYS` is malformed: asked to encrypt, the
1514    /// builder never falls back to plaintext.
1515    #[cfg(feature = "encryption")]
1516    pub fn encryption_from_env(self) -> Result<Self, CachekitError> {
1517        let (master_key, previous) = crate::config::master_keys_from_env(
1518            ".encryption_with_previous(hex_key, previous_hex_keys, \"default\")",
1519        )?;
1520        let previous: Vec<&[u8]> = previous.iter().map(|key| key.as_slice()).collect();
1521        self.encryption_from_hex_decoded(&master_key, &previous, "default")
1522    }
1523
1524    /// Shared tail of the hex paths: keys already validated by
1525    /// `decode_master_key_hex`, so the `>= 32` floor applies, not the
1526    /// raw-bytes exactly-32 rule.
1527    #[cfg(feature = "encryption")]
1528    pub(crate) fn encryption_from_hex_decoded(
1529        mut self,
1530        master_key: &[u8],
1531        previous_keys: &[&[u8]],
1532        tenant_id: &str,
1533    ) -> Result<Self, CachekitError> {
1534        let layer = crate::encryption::EncryptionLayer::from_hex_decoded_keys(
1535            master_key,
1536            previous_keys,
1537            tenant_id,
1538        )?;
1539        self.encryption = Some(SharedEncryption::new(layer));
1540        Ok(self)
1541    }
1542
1543    // Without the `encryption` feature the builder methods still exist, so
1544    // feature-agnostic code compiles, but each one is an error: accepting a
1545    // key and building a plaintext client is the silent downgrade protocol
1546    // `spec/intent-presets.md` § Encryption Activation rule 1 forbids.
1547
1548    /// Always [`CachekitError::Config`]: this build has no `encryption`
1549    /// feature.
1550    #[cfg(not(feature = "encryption"))]
1551    pub fn encryption_from_bytes(
1552        self,
1553        _master_key: &[u8],
1554        _tenant_id: &str,
1555    ) -> Result<Self, CachekitError> {
1556        Err(encryption_feature_missing(".encryption_from_bytes()"))
1557    }
1558
1559    /// Always [`CachekitError::Config`]: this build has no `encryption`
1560    /// feature.
1561    #[cfg(not(feature = "encryption"))]
1562    pub fn encryption_from_bytes_with_previous(
1563        self,
1564        _master_key: &[u8],
1565        _previous_keys: &[&[u8]],
1566        _tenant_id: &str,
1567    ) -> Result<Self, CachekitError> {
1568        Err(encryption_feature_missing(
1569            ".encryption_from_bytes_with_previous()",
1570        ))
1571    }
1572
1573    /// Always [`CachekitError::Config`]: this build has no `encryption`
1574    /// feature.
1575    #[cfg(not(feature = "encryption"))]
1576    pub fn encryption(self, _hex_key: &str, _tenant_id: &str) -> Result<Self, CachekitError> {
1577        Err(encryption_feature_missing(".encryption()"))
1578    }
1579
1580    /// Always [`CachekitError::Config`]: this build has no `encryption`
1581    /// feature.
1582    #[cfg(not(feature = "encryption"))]
1583    pub fn encryption_with_previous(
1584        self,
1585        _hex_key: &str,
1586        _previous_hex_keys: &[&str],
1587        _tenant_id: &str,
1588    ) -> Result<Self, CachekitError> {
1589        Err(encryption_feature_missing(".encryption_with_previous()"))
1590    }
1591
1592    /// Always [`CachekitError::Config`]: this build has no `encryption`
1593    /// feature.
1594    #[cfg(not(feature = "encryption"))]
1595    pub fn encryption_from_env(self) -> Result<Self, CachekitError> {
1596        Err(encryption_feature_missing(".encryption_from_env()"))
1597    }
1598
1599    /// State that this client does not encrypt, even with
1600    /// `CACHEKIT_MASTER_KEY` set: the explicit opt-out of protocol
1601    /// `spec/intent-presets.md` § Encryption Activation, rule 3.
1602    ///
1603    /// No client encrypts without an encryption call, so this changes
1604    /// nothing on its own. It answers the transitional check on a builder
1605    /// from [`CacheKit::from_env`] that saw `CACHEKIT_MASTER_KEY`, and it
1606    /// records the intent at the call site. Combined with any encryption
1607    /// call, or the `secure` preset, [`Self::build`] returns
1608    /// [`CachekitError::Config`]: an opt-out never quietly undoes one.
1609    pub fn no_encryption(mut self) -> Self {
1610        self.no_encryption = true;
1611        self
1612    }
1613
1614    /// Finalise and build the [`CacheKit`] client.
1615    ///
1616    /// Returns an error if no backend was provided, if
1617    /// [`Self::no_encryption`] was combined with an encryption call, or if
1618    /// the builder came from [`CacheKit::from_env`] with
1619    /// `CACHEKIT_MASTER_KEY` set and states no encryption intent.
1620    pub fn build(self) -> Result<CacheKit, CachekitError> {
1621        #[cfg(feature = "encryption")]
1622        let encrypting = self.encryption.is_some();
1623        #[cfg(not(feature = "encryption"))]
1624        let encrypting = false;
1625        if self.no_encryption && encrypting {
1626            return Err(CachekitError::Config(
1627                ".no_encryption() conflicts with an encryption call (or the secure preset) \
1628                 on the same builder: keep one"
1629                    .to_owned(),
1630            ));
1631        }
1632        if self.env_master_key && !encrypting && !self.no_encryption {
1633            #[cfg(feature = "encryption")]
1634            let msg = "CACHEKIT_MASTER_KEY is set, and CacheKit::from_env() no longer encrypts \
1635                       because of it: chain .encryption_from_env() to encrypt with it, or \
1636                       .no_encryption() to build a plaintext client";
1637            #[cfg(not(feature = "encryption"))]
1638            let msg = "CACHEKIT_MASTER_KEY is set, but this build of cachekit-rs has no \
1639                       `encryption` cargo feature and cannot encrypt with it: chain \
1640                       .no_encryption() to build a plaintext client, or enable the feature \
1641                       to encrypt";
1642            return Err(CachekitError::Config(msg.to_owned()));
1643        }
1644
1645        let backend = self.backend.ok_or_else(|| {
1646            CachekitError::Config("a backend must be provided via .backend()".to_owned())
1647        })?;
1648
1649        // Validate namespace if provided
1650        if let Some(ref ns) = self.namespace {
1651            if ns.is_empty() {
1652                return Err(CachekitError::Config("namespace cannot be empty".into()));
1653            }
1654            if ns.len() > 255 {
1655                return Err(CachekitError::Config("namespace exceeds 255 bytes".into()));
1656            }
1657            if !ns.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
1658                return Err(CachekitError::Config(
1659                    "namespace must be ASCII printable".into(),
1660                ));
1661            }
1662        }
1663
1664        #[cfg(feature = "l1")]
1665        let l1 = if self.no_l1 {
1666            None
1667        } else {
1668            let capacity = self.l1_capacity.unwrap_or(1000);
1669            Some(crate::l1::L1Cache::new(capacity))
1670        };
1671
1672        // SWR defaults mirror the sibling SDKs: enabled, threshold ratio 0.5,
1673        // ratio validated in (0.0, 1.0] exactly like py's L1CacheConfig.
1674        #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1675        let swr_threshold_ratio = {
1676            let ratio = self.swr_threshold_ratio.unwrap_or(0.5);
1677            if !(ratio > 0.0 && ratio <= 1.0) {
1678                return Err(CachekitError::Config(format!(
1679                    "swr_threshold_ratio must be in (0.0, 1.0]; got {ratio}"
1680                )));
1681            }
1682            ratio
1683        };
1684
1685        // Hand the backend a live view of this client's read counters so the
1686        // SaaS telemetry headers report real numbers with no user plumbing.
1687        // Attached to the raw backend, before the reliability decorator, so
1688        // no forwarding is needed; a provider set on the backend's own
1689        // builder wins (see `Backend::attach_metrics`).
1690        #[cfg(feature = "l1")]
1691        let l1_enabled = l1.is_some();
1692        #[cfg(not(feature = "l1"))]
1693        let l1_enabled = false;
1694        let counters = Arc::new(CacheCounters::new(l1_enabled));
1695        backend.attach_metrics(counters.provider());
1696
1697        // Apply the reliability stack last so it decorates the final backend.
1698        // A disabled config is the documented opt-out: skip the (no-op)
1699        // decorator entirely. The layer check lives on ReliabilityConfig
1700        // itself so a future layer can't be missed here (this gate
1701        // shipped that exact bug once already).
1702        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1703        let (backend, breaker) = match self.reliability {
1704            Some(config) if !config.is_disabled() => {
1705                crate::reliability::wrap_reliable(backend, config, self.retry_deadlines)
1706            }
1707            _ => (backend, None),
1708        };
1709
1710        Ok(CacheKit {
1711            backend,
1712            default_ttl: self.default_ttl.unwrap_or(Duration::from_secs(300)),
1713            namespace: self.namespace,
1714            max_payload_bytes: self.max_payload_bytes.unwrap_or(5 * 1024 * 1024),
1715            flight: SharedFlight::default(),
1716            counters,
1717
1718            #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
1719            breaker,
1720
1721            #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1722            mutations: SharedMutations::default(),
1723
1724            #[cfg(feature = "l1")]
1725            l1,
1726
1727            #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1728            swr_enabled: self.swr_enabled.unwrap_or(true),
1729
1730            #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
1731            swr_threshold_ratio,
1732
1733            #[cfg(feature = "encryption")]
1734            encryption: self.encryption,
1735        })
1736    }
1737}
1738
1739#[cfg(test)]
1740mod validate_key_tests {
1741    use super::{validate_key, MAX_KEY_BYTES};
1742    use crate::error::CachekitError;
1743
1744    /// The byte loop `validate_key` ran before it checked 16 bytes at a time:
1745    /// the behaviour it must keep, error text included.
1746    fn byte_loop(key: &str) -> Result<(), String> {
1747        if key.is_empty() {
1748            return Err("key must not be empty".to_owned());
1749        }
1750        if key.len() > MAX_KEY_BYTES {
1751            return Err(format!(
1752                "key is {} bytes (limit: {MAX_KEY_BYTES})",
1753                key.len()
1754            ));
1755        }
1756        for b in key.bytes() {
1757            if b < 0x20 || b == 0x7F {
1758                return Err(format!("key contains illegal control character 0x{b:02X}"));
1759            }
1760        }
1761        Ok(())
1762    }
1763
1764    fn check(key: &str) {
1765        let got = validate_key(key).map_err(|e| match e {
1766            CachekitError::InvalidKey(msg) => msg,
1767            other => format!("not InvalidKey: {other:?}"),
1768        });
1769        assert_eq!(got, byte_loop(key), "key {key:?}");
1770    }
1771
1772    /// Every ASCII character, plus a 2-, 3- and 4-byte character (whose bytes
1773    /// are all 0x80 or more), at every position of every key length from 0
1774    /// to 48: every block boundary and overlap of the 16-byte check.
1775    #[test]
1776    fn same_verdict_and_message_as_the_byte_loop() {
1777        let fillers: Vec<char> = (0u8..0x80)
1778            .map(char::from)
1779            .chain(['é', '€', '😀'])
1780            .collect();
1781        for len in 0..=48 {
1782            for pos in 0..len {
1783                for &c in &fillers {
1784                    let mut key: String = "k".repeat(pos);
1785                    key.push(c);
1786                    key.push_str(&"k".repeat(len - pos - 1));
1787                    check(&key);
1788                }
1789            }
1790            check(&"k".repeat(len));
1791        }
1792    }
1793
1794    /// With two offending bytes the message names the first, wherever the
1795    /// two fall relative to the blocks.
1796    #[test]
1797    fn names_the_first_offending_byte() {
1798        for len in 2..=48 {
1799            for first in 0..len {
1800                for second in first + 1..len {
1801                    let mut key = vec![b'k'; len];
1802                    key[first] = 0x7F;
1803                    key[second] = 0x01;
1804                    check(&String::from_utf8_lossy(&key));
1805                }
1806            }
1807        }
1808    }
1809
1810    /// Near the length limit, where the last block overlaps the most.
1811    #[test]
1812    fn long_keys_around_the_limit() {
1813        for len in MAX_KEY_BYTES - 40..=MAX_KEY_BYTES + 1 {
1814            for pos in (0..len).filter(|p| *p < 40 || *p % 16 < 2 || *p % 16 > 13 || len - p < 40) {
1815                for c in ['\u{0}', '\u{1F}', ' ', '~', '\u{7F}', 'é'] {
1816                    let mut key: String = "k".repeat(pos);
1817                    key.push(c);
1818                    key.push_str(&"k".repeat(len - pos - 1));
1819                    check(&key);
1820                }
1821            }
1822        }
1823    }
1824}
1825
1826#[cfg(all(test, feature = "l1"))]
1827mod backfill_ttl_tests {
1828    use std::time::Duration;
1829
1830    use super::l1_backfill_ttl;
1831    use crate::backend::Freshness;
1832
1833    const DEFAULT_TTL: Duration = Duration::from_secs(300);
1834
1835    fn fresh_for(secs: u64) -> Freshness {
1836        Freshness {
1837            is_stale: false,
1838            fresh_for: Some(Duration::from_secs(secs)),
1839        }
1840    }
1841
1842    /// (e) No header: today's `min(default_ttl, 30 s)`, unchanged.
1843    #[test]
1844    fn absent_header_keeps_the_30s_cap() {
1845        let absent = Freshness::default();
1846        assert_eq!(
1847            l1_backfill_ttl(DEFAULT_TTL, absent),
1848            Some(Duration::from_secs(30))
1849        );
1850        assert_eq!(
1851            l1_backfill_ttl(Duration::from_secs(10), absent),
1852            Some(Duration::from_secs(10))
1853        );
1854    }
1855
1856    #[test]
1857    fn fresh_for_bounds_but_never_lifts_the_cap() {
1858        assert_eq!(
1859            l1_backfill_ttl(DEFAULT_TTL, fresh_for(5)),
1860            Some(Duration::from_secs(5))
1861        );
1862        assert_eq!(
1863            l1_backfill_ttl(DEFAULT_TTL, fresh_for(3600)),
1864            Some(Duration::from_secs(30))
1865        );
1866    }
1867
1868    #[test]
1869    fn stale_or_zero_forbids_the_backfill() {
1870        assert_eq!(l1_backfill_ttl(DEFAULT_TTL, fresh_for(0)), None);
1871        let stale = |fresh_for| Freshness {
1872            is_stale: true,
1873            fresh_for,
1874        };
1875        assert_eq!(l1_backfill_ttl(DEFAULT_TTL, stale(None)), None);
1876        assert_eq!(
1877            l1_backfill_ttl(DEFAULT_TTL, stale(Some(Duration::from_secs(60)))),
1878            None
1879        );
1880    }
1881}
1882
1883// SWR staleness exists only on native builds with L1 (see tests/swr_tests.rs).
1884#[cfg(all(
1885    test,
1886    feature = "encryption",
1887    feature = "l1",
1888    not(feature = "unsync"),
1889    not(target_arch = "wasm32")
1890))]
1891#[allow(clippy::unwrap_used)] // test-only: a failed setup step should panic loudly
1892mod secure_l1_tests {
1893    use std::time::Duration;
1894
1895    use super::{CacheKit, SharedBackend, SwrRead};
1896    use crate::backend::MemoryBackend;
1897    use crate::error::CachekitError;
1898
1899    /// The Stale arm reads straight from L1, and every public write on an
1900    /// encrypted client stores decryptable ciphertext there, so the
1901    /// undecryptable bytes are planted in L1 directly.
1902    #[tokio::test]
1903    async fn secure_interop_get_swr_evicts_stale_l1_after_decrypt_failure() {
1904        let backend = MemoryBackend::default();
1905        let shared: SharedBackend = std::sync::Arc::new(backend.clone());
1906        let client = CacheKit::builder()
1907            .backend(shared)
1908            .l1_capacity(100)
1909            .swr_threshold_ratio(0.001)
1910            .encryption_from_bytes(&[7u8; 32], "test-tenant")
1911            .unwrap()
1912            .build()
1913            .unwrap();
1914        client.l1_set(
1915            "poisoned-stale",
1916            bytes::Bytes::from_static(b"not ciphertext"),
1917            Duration::from_secs(60),
1918        );
1919        // Real time, not tokio's paused clock: L1 ages entries by std
1920        // Instant. threshold = 0.001 × 60 s = 60 ms (±10%); hard expiry 60 s.
1921        tokio::time::sleep(Duration::from_millis(200)).await;
1922
1923        let secure = client.secure_cache().unwrap();
1924        let err = secure
1925            .interop_get_swr::<String>("poisoned-stale")
1926            .await
1927            .unwrap_err();
1928        assert!(matches!(err, CachekitError::Encryption(_)), "got: {err:?}");
1929        // Served from L1, so the entry had not hard-expired into the L2 path.
1930        let stats = client.stats();
1931        assert_eq!((stats.l1_hits, stats.l2_hits), (1, 0), "{stats:?}");
1932
1933        // Nothing in the backend: a surviving L1 copy would keep failing; an
1934        // evicted one yields a miss.
1935        assert!(backend.store.lock().await.is_empty());
1936        assert!(matches!(
1937            secure.interop_get_swr::<String>("poisoned-stale").await,
1938            Ok(SwrRead::Miss)
1939        ));
1940    }
1941}