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