Skip to main content

cachekit/
intents.rs

1//! Intent-based cache presets.
2//!
3//! Pre-configured factory methods that build a [`CacheKit`] client from a
4//! single declarative call. Each intent sets sensible defaults for a specific
5//! use case and returns a [`CacheKitBuilder`] so callers can override any
6//! setting before building.
7//!
8//! The preset matrix and per-intent resilience contract live in the
9//! crate-level docs (`lib.rs`) — the single rustdoc-rendered copy. This
10//! module is private, so docs here reach source readers only; the per-method
11//! docs below are what docs.rs renders.
12//!
13//! Reliability defaults come from
14//! [`ReliabilityConfig::default()`](crate::reliability::ReliabilityConfig)
15//! (requires the `reliability` feature, on by default). Override via
16//! [`CacheKitBuilder::reliability`];
17//! [`ReliabilityConfig::disabled()`](crate::reliability::ReliabilityConfig::disabled)
18//! turns the stack off entirely.
19
20use crate::client::CacheKit;
21// Only the presets use these, and each preset is gated on a backend feature;
22// gate them (and `wrap`) the same way so lean feature sets lint clean.
23#[cfg(any(
24    feature = "redis",
25    all(feature = "cachekitio", not(target_arch = "wasm32"))
26))]
27use {
28    crate::client::{CacheKitBuilder, SharedBackend},
29    crate::error::CachekitError,
30    std::time::Duration,
31};
32
33// ── SharedBackend wrapping ───────────────────────────────────────────────────
34
35#[cfg(all(
36    any(
37        feature = "redis",
38        all(feature = "cachekitio", not(target_arch = "wasm32"))
39    ),
40    not(any(target_arch = "wasm32", feature = "unsync"))
41))]
42fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend {
43    std::sync::Arc::new(b)
44}
45
46#[cfg(all(
47    any(
48        feature = "redis",
49        all(feature = "cachekitio", not(target_arch = "wasm32"))
50    ),
51    any(target_arch = "wasm32", feature = "unsync")
52))]
53fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend {
54    std::rc::Rc::new(b)
55}
56
57// ── Intent presets ───────────────────────────────────────────────────────────
58
59/// `minimal` builder defaults, split from the eager Redis connect so the
60/// L1-on / SWR-off contract (`protocol/spec/intent-presets.md` § L1 Posture)
61/// is unit-testable without a live server.
62#[cfg(feature = "redis")]
63fn minimal_defaults(backend: SharedBackend) -> CacheKitBuilder {
64    let builder = CacheKitBuilder::default()
65        .backend(backend)
66        .default_ttl(Duration::from_secs(300))
67        .l1_capacity(1000);
68    // Builder default is SWR-on; spec § L1 Posture says minimal MUST NOT.
69    #[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
70    let builder = builder.swr_enabled(false);
71    builder
72}
73
74/// `secure` builder defaults, split from the eager Redis connect so the key
75/// path is unit-testable without a live server. Takes hex-decoded key bytes
76/// only (so `>= 32`, not the raw-bytes exactly-32 rule) and reads no
77/// environment: [`CacheKit::secure`] passes no previous keys.
78#[cfg(all(feature = "redis", feature = "encryption"))]
79fn secure_defaults(
80    master_key: &[u8],
81    previous_keys: &[zeroize::Zeroizing<Vec<u8>>],
82) -> Result<CacheKitBuilder, CachekitError> {
83    let previous: Vec<&[u8]> = previous_keys.iter().map(|k| k.as_slice()).collect();
84    let builder = CacheKitBuilder::default()
85        .default_ttl(Duration::from_secs(600))
86        .l1_capacity(1000)
87        .encryption_from_hex_decoded(master_key, &previous, "default")?;
88    #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
89    let builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
90    Ok(builder)
91}
92
93/// The key handling of [`CacheKit::secure`] — decode the hex key, then the
94/// `secure` defaults — before any Redis I/O; the hex counterpart of
95/// [`secure_env_defaults`].
96#[cfg(all(feature = "redis", feature = "encryption"))]
97fn secure_hex_defaults(master_key_hex: &str) -> Result<CacheKitBuilder, CachekitError> {
98    let master_key = crate::config::decode_master_key_hex(master_key_hex, "master_key_hex")?;
99    secure_defaults(&master_key, &[])
100}
101
102/// The whole environment read of [`CacheKit::secure_from_env`] — the current
103/// key and `CACHEKIT_PREVIOUS_MASTER_KEYS` — resolved into `secure` defaults
104/// before any Redis I/O. The same reader as
105/// [`CacheKitBuilder::encryption_from_env`].
106#[cfg(all(feature = "redis", feature = "encryption"))]
107fn secure_env_defaults() -> Result<CacheKitBuilder, CachekitError> {
108    let (master_key, previous) =
109        crate::config::master_keys_from_env("CacheKit::secure(url, master_key_hex)")?;
110    secure_defaults(&master_key, &previous)
111}
112
113/// Attach an eagerly connected, auto-reconnecting Redis backend to a
114/// `secure` builder. Takes the builder, not the key, so key validation has
115/// already happened: a bad key must not be masked by (or pay for) Redis I/O.
116#[cfg(all(feature = "redis", feature = "encryption"))]
117async fn connect_secure(
118    redis_url: &str,
119    builder: CacheKitBuilder,
120) -> Result<CacheKitBuilder, CachekitError> {
121    let backend = reconnecting_redis(redis_url)?;
122    drop(backend.connect().await?);
123    Ok(builder.backend(wrap(backend)))
124}
125
126/// The unconnected Redis backend of `production` and `secure`: the public
127/// [`auto_reconnect`](crate::backend::redis::RedisBackendBuilder::auto_reconnect)
128/// policy, so a caller building their own backend gets the same one.
129#[cfg(feature = "redis")]
130fn reconnecting_redis(
131    redis_url: &str,
132) -> Result<crate::backend::redis::RedisBackend, CachekitError> {
133    crate::backend::redis::RedisBackend::builder()
134        .url(redis_url)
135        .auto_reconnect()
136        .build()
137}
138
139impl CacheKit {
140    /// **Minimal** — speed-first Redis cache, no extras.
141    ///
142    /// * Backend: Redis (connects eagerly; **fails fast** — a dropped
143    ///   connection is not re-established)
144    /// * L1 cache: **on** (1 000 entries, **no SWR / invalidation**) — an L1
145    ///   hit is served as-is until it expires, so a read may return an entry
146    ///   up to its TTL (300 s by default) after another process changed it
147    ///   in Redis. Chain
148    ///   [`.no_l1()`](CacheKitBuilder::no_l1) to read through every time.
149    /// * Encryption: **no**
150    /// * Reliability: **off** — no retry, no circuit breaker, no
151    ///   backpressure; every backend error propagates on first failure and
152    ///   backend concurrency is unbounded
153    /// * Default TTL: **300 s**
154    ///
155    /// Good for: product catalogs, public data, development.
156    ///
157    /// # Errors
158    ///
159    /// Returns [`CachekitError`] if the URL is invalid or Redis is unreachable.
160    ///
161    /// # Example
162    ///
163    /// ```no_run
164    /// # async fn example() -> Result<(), cachekit::CachekitError> {
165    /// let cache = cachekit::CacheKit::minimal("redis://localhost:6379").await?
166    ///     .namespace("myapp")
167    ///     .build()?;
168    /// # Ok(())
169    /// # }
170    /// ```
171    #[cfg(feature = "redis")]
172    pub async fn minimal(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
173        let backend = crate::backend::redis::RedisBackend::builder()
174            .url(redis_url)
175            .build()?;
176        drop(backend.connect().await?);
177
178        Ok(minimal_defaults(wrap(backend)))
179    }
180
181    /// **Production** — reliability-first Redis cache with L1.
182    ///
183    /// * Backend: Redis (connects eagerly, failing fast if unreachable;
184    ///   **auto-reconnects** after a dropped connection with exponential
185    ///   backoff, 100 ms → 30 s, retrying indefinitely)
186    /// * L1 cache: **on** (1 000 entries)
187    /// * Encryption: **no**
188    /// * Reliability: **on** — retry with backoff + jitter, circuit
189    ///   breaker, backpressure (max 100 concurrent backend ops)
190    /// * Default TTL: **600 s**
191    ///
192    /// Good for: user sessions, API responses, production services.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`CachekitError`] if the URL is invalid or Redis is unreachable.
197    ///
198    /// # Example
199    ///
200    /// ```no_run
201    /// # async fn example() -> Result<(), cachekit::CachekitError> {
202    /// let cache = cachekit::CacheKit::production("redis://localhost:6379").await?
203    ///     .namespace("api")
204    ///     .build()?;
205    /// # Ok(())
206    /// # }
207    /// ```
208    #[cfg(feature = "redis")]
209    pub async fn production(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
210        let backend = reconnecting_redis(redis_url)?;
211        drop(backend.connect().await?);
212
213        let builder = CacheKitBuilder::default()
214            .backend(wrap(backend))
215            .default_ttl(Duration::from_secs(600))
216            .l1_capacity(1000);
217        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
218        let builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
219        Ok(builder)
220    }
221
222    /// **Secure** — zero-knowledge encrypted Redis cache.
223    ///
224    /// * Backend: Redis (connects eagerly, failing fast if unreachable;
225    ///   **auto-reconnects** after a dropped connection with exponential
226    ///   backoff, 100 ms → 30 s, retrying indefinitely)
227    /// * L1 cache: **on** (1 000 entries, stores ciphertext)
228    /// * Encryption: **AES-256-GCM** with HKDF-SHA256, on every value read
229    ///   and write, not only those through the
230    ///   [`secure_cache()`](CacheKit::secure_cache) handle (see
231    ///   [Encryption](CacheKit#encryption))
232    /// * Reliability: **on** — retry with backoff + jitter, circuit
233    ///   breaker, backpressure (max 100 concurrent backend ops)
234    /// * Default TTL: **600 s**
235    /// * Tenant ID: `"default"` for both key derivation and AAD (override via
236    ///   [`.encryption()`](CacheKitBuilder::encryption))
237    ///
238    /// Good for: PII, payments, GDPR/HIPAA-sensitive data.
239    ///
240    /// `master_key_hex` is the master key as a **hex string** — the same
241    /// string `CACHEKIT_MASTER_KEY` holds, decoded to the same key bytes by
242    /// every CacheKit SDK. This is the cross-SDK-portable key path. Use
243    /// exactly **32 bytes (64 hex chars)**: shorter is rejected, and longer
244    /// is accepted here but not by every SDK. Generate one with
245    /// `openssl rand -hex 32`.
246    ///
247    /// Use [`CacheKit::secure_from_env`] to read the key from
248    /// `CACHEKIT_MASTER_KEY`. There is no raw-bytes preset: exactly 32
249    /// decoded key bytes go to [`CacheKitBuilder::encryption_from_bytes`],
250    /// which rejects any other length — including the 64 ASCII bytes of a
251    /// hex string, which would derive a key no other SDK derives.
252    ///
253    /// The key is validated **before** any Redis connection is attempted — a
254    /// bad key is a deterministic local error, never masked by (or paying
255    /// for) network I/O.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`CachekitError::Config`] if `master_key_hex` is not hex or
260    /// decodes to fewer than 32 bytes, or if the URL is invalid; another
261    /// [`CachekitError`] if Redis is unreachable.
262    ///
263    /// # Example
264    ///
265    /// ```no_run
266    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
267    /// // 64 hex chars (32 bytes) from your secret store — `openssl rand -hex 32`.
268    /// let master_key_hex = std::env::var("APP_CACHE_MASTER_KEY")?;
269    /// let cache = cachekit::CacheKit::secure("redis://localhost:6379", &master_key_hex)
270    ///     .await?
271    ///     .build()?;
272    /// // Encrypted before it leaves the process; L1 holds the ciphertext.
273    /// cache.set("user:42:ssn", &"123-45-6789").await?;
274    /// # Ok(())
275    /// # }
276    /// ```
277    #[cfg(all(feature = "redis", feature = "encryption"))]
278    pub async fn secure(
279        redis_url: &str,
280        master_key_hex: &str,
281    ) -> Result<CacheKitBuilder, CachekitError> {
282        connect_secure(redis_url, secure_hex_defaults(master_key_hex)?).await
283    }
284
285    /// **Secure**, keys from the environment — [`CacheKit::secure`] with the
286    /// hex key read from `CACHEKIT_MASTER_KEY`, plus decrypt-only rotation
287    /// keys from `CACHEKIT_PREVIOUS_MASTER_KEYS`.
288    ///
289    /// Reads exactly two variables:
290    ///
291    /// * `CACHEKIT_MASTER_KEY` (required) — the current key; writes encrypt
292    ///   under it. Same hex decoding as [`secure`](CacheKit::secure), so the
293    ///   same value derives the same key bytes as every other CacheKit SDK.
294    ///   Use exactly **32 bytes (64 hex chars)**, the only length every SDK
295    ///   accepts.
296    /// * `CACHEKIT_PREVIOUS_MASTER_KEYS` (optional) — comma-separated hex
297    ///   keys, at most 3, tried in order when the current key fails to
298    ///   decrypt. Unset or blank means none. Validated exactly as
299    ///   [`CachekitConfig::from_env`](crate::CachekitConfig::from_env)
300    ///   validates it; the current key must not appear in the list.
301    ///
302    /// Otherwise the identical preset to [`secure`](CacheKit::secure), tenant
303    /// `"default"` for every key. Never falls back to plaintext.
304    ///
305    /// # Errors
306    ///
307    /// Returns [`CachekitError::Config`] — all before any Redis I/O — when
308    /// `CACHEKIT_MASTER_KEY` is unset, empty, not hex, or shorter than 32
309    /// bytes, or when `CACHEKIT_PREVIOUS_MASTER_KEYS` is set but not valid
310    /// UTF-8, or is non-blank with an empty or invalid entry, more than 3
311    /// entries, or a repeat of the current key.
312    /// Otherwise as [`secure`](CacheKit::secure).
313    ///
314    /// # Example
315    ///
316    /// ```no_run
317    /// # async fn example() -> Result<(), cachekit::CachekitError> {
318    /// let cache = cachekit::CacheKit::secure_from_env("redis://localhost:6379")
319    ///     .await?
320    ///     .build()?;
321    /// cache.set("user:42:ssn", &"123-45-6789").await?; // encrypted
322    /// // Reads served by each CACHEKIT_PREVIOUS_MASTER_KEYS entry, for rotation.
323    /// let drain = cache.secure_cache()?.previous_key_hits();
324    /// # Ok(())
325    /// # }
326    /// ```
327    #[cfg(all(feature = "redis", feature = "encryption"))]
328    pub async fn secure_from_env(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
329        connect_secure(redis_url, secure_env_defaults()?).await
330    }
331
332    /// **CachekitIO** — managed SaaS cache, zero infrastructure.
333    ///
334    /// * Backend: [cachekit.io](https://cachekit.io) HTTP API
335    /// * L1 cache: **on** (1 000 entries)
336    /// * Encryption: **no** (add via
337    ///   [`.encryption()`](CacheKitBuilder::encryption), which then encrypts
338    ///   every value read and write)
339    /// * Reliability: **on** — retry with backoff + jitter, circuit
340    ///   breaker, backpressure (max 100 concurrent backend ops)
341    /// * Default TTL: **3 600 s**
342    ///
343    /// Good for: serverless, edge compute, managed caching without Redis.
344    ///
345    /// Use [`CacheKit::io_from_env`] to read the key from `CACHEKIT_API_KEY`.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`CachekitError::Config`] if `api_key` is empty.
350    ///
351    /// # Example
352    ///
353    /// ```no_run
354    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
355    /// let api_key = std::env::var("CACHEKIT_API_KEY")?;
356    /// let cache = cachekit::CacheKit::io(&api_key)?
357    ///     .namespace("edge")
358    ///     .build()?;
359    /// # Ok(())
360    /// # }
361    /// ```
362    #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
363    pub fn io(api_key: &str) -> Result<CacheKitBuilder, CachekitError> {
364        let backend = crate::backend::cachekitio::CachekitIO::builder()
365            .api_key(api_key)
366            .build()?;
367
368        let builder = CacheKitBuilder::default()
369            .backend(wrap(backend))
370            .default_ttl(Duration::from_secs(3600))
371            .l1_capacity(1000);
372        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
373        let builder = {
374            let mut builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
375            builder.retry_deadlines = Some(crate::reliability::CACHEKITIO_DEADLINES);
376            builder
377        };
378        Ok(builder)
379    }
380
381    /// **CachekitIO**, API key from the environment — [`CacheKit::io`] with
382    /// the key read from `CACHEKIT_API_KEY`.
383    ///
384    /// Identical preset to [`io`](CacheKit::io). Reads **only**
385    /// `CACHEKIT_API_KEY`, never `CACHEKIT_MASTER_KEY`: like every
386    /// constructor but `secure`, it does not encrypt unless you chain an
387    /// encryption call such as
388    /// [`.encryption_from_env()`](CacheKitBuilder::encryption_from_env).
389    ///
390    /// # Errors
391    ///
392    /// Returns [`CachekitError::Config`] when `CACHEKIT_API_KEY` is unset or
393    /// empty.
394    ///
395    /// # Example
396    ///
397    /// ```no_run
398    /// # fn example() -> Result<(), cachekit::CachekitError> {
399    /// let cache = cachekit::CacheKit::io_from_env()?
400    ///     .namespace("edge")
401    ///     .build()?;
402    /// # Ok(())
403    /// # }
404    /// ```
405    #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
406    pub fn io_from_env() -> Result<CacheKitBuilder, CachekitError> {
407        let api_key = std::env::var("CACHEKIT_API_KEY")
408            .ok()
409            .map(zeroize::Zeroizing::new)
410            .filter(|k| !k.is_empty())
411            .ok_or_else(|| {
412                CachekitError::Config(
413                    "CACHEKIT_API_KEY is unset or empty: set it or pass the key to CacheKit::io(api_key)"
414                        .to_owned(),
415                )
416            })?;
417        Self::io(&api_key)
418    }
419}
420
421#[cfg(all(test, feature = "redis", feature = "l1"))]
422#[allow(clippy::expect_used)] // test-only: a failing build here should panic loudly
423mod tests {
424    /// `RedisBackend::build()` does not connect, so the preset's defaults
425    /// are observable without a live server.
426    #[test]
427    fn minimal_enables_l1_without_swr() {
428        let backend = crate::backend::redis::RedisBackend::builder()
429            .url("redis://localhost:6379")
430            .build()
431            .expect("valid URL");
432        let cache = super::minimal_defaults(super::wrap(backend))
433            .build()
434            .expect("minimal defaults must build");
435
436        assert!(
437            cache.l1.is_some(),
438            "spec/intent-presets.md § L1 Posture: minimal MUST enable L1"
439        );
440        #[cfg(all(not(feature = "unsync"), not(target_arch = "wasm32")))]
441        assert!(
442            !cache.swr_enabled,
443            "spec/intent-presets.md § L1 Posture: minimal MUST NOT enable SWR"
444        );
445    }
446}
447
448#[cfg(all(test, feature = "redis", feature = "encryption"))]
449#[allow(clippy::expect_used)] // test-only: a failing build here should panic loudly
450mod secure_tests {
451    // protocol test-vectors/encryption.json `default_tenant.vectors[0]`
452    // (unchanged since v1.2.0; the vendored copy is tests/vectors/encryption.json)
453    // (`default_tenant_interop`): spec/intent-presets.md § Master Key Input
454    // rule 5 — with no tenant configured, the preset must derive and bind AAD
455    // under the literal "default", byte-for-byte with every other SDK.
456    const MASTER_KEY_HEX: &str = "6161616161616161616161616161616161616161616161616161616161616161";
457    const CACHE_KEY: &str =
458        "users:get_user:61598716255080080f6456eb065c2e51badfaa4320b0efe97469c29cffee8875";
459    const CIPHERTEXT_HEX: &str =
460        "0d0e0f1011121314151617183c29af318238925ee76d081934adce133c0b4a7c5eb5704102b04582dcbf278ffd"; // pragma: allowlist secret
461    const PLAINTEXT_HEX: &str = "82a36167651ea46e616d65a5616c696365"; // pragma: allowlist secret
462
463    fn assert_decrypts_default_tenant_vector(builder: crate::CacheKitBuilder) {
464        let layer = builder
465            .encryption
466            .expect("secure preset must configure encryption");
467        assert_eq!(layer.tenant_id(), "default");
468        let ciphertext = hex::decode(CIPHERTEXT_HEX).expect("vector hex");
469        let plaintext = layer
470            .decrypt(&ciphertext, CACHE_KEY)
471            .expect("default-tenant vector must decrypt");
472        assert_eq!(hex::encode(plaintext), PLAINTEXT_HEX);
473    }
474
475    /// § Encryption Activation rule 1 on the preset: plain `set` on a
476    /// `secure` client puts only ciphertext in the backend and in L1, and
477    /// plain `get` decrypts it. Driven through `secure_defaults` so it runs
478    /// without Redis.
479    #[tokio::test]
480    async fn plain_set_on_secure_preset_stores_ciphertext() {
481        const VALUE: &str = "ssn 123-45-6789";
482        let backend = crate::backend::MemoryBackend::default();
483        let master_key = hex::decode(MASTER_KEY_HEX).expect("vector hex");
484        let cache = super::secure_defaults(&master_key, &[])
485            .expect("valid key")
486            .backend(super::wrap(backend.clone()))
487            .build()
488            .expect("secure defaults must build");
489
490        cache.set(CACHE_KEY, &VALUE).await.expect("plain set");
491
492        let stored = backend
493            .store
494            .lock()
495            .await
496            .get(CACHE_KEY)
497            .cloned()
498            .expect("plain set reached the backend");
499        let plaintext = crate::serializer::serialize(&VALUE).expect("serializes");
500        assert_ne!(stored, plaintext, "backend holds plaintext");
501        let independent = crate::EncryptionLayer::new(&master_key, "default").expect("valid key");
502        assert_eq!(
503            independent
504                .decrypt(&stored, CACHE_KEY)
505                .expect("backend bytes are ciphertext under the preset's key and tenant"),
506            plaintext
507        );
508        #[cfg(feature = "l1")]
509        assert_eq!(
510            cache
511                .l1
512                .as_ref()
513                .expect("secure preset enables L1")
514                .get_shared(CACHE_KEY)
515                .as_deref(),
516            Some(stored.as_slice()),
517            "L1 holds the same ciphertext as the backend"
518        );
519
520        assert_eq!(
521            cache.get::<String>(CACHE_KEY).await.expect("plain get"),
522            Some(VALUE.to_owned())
523        );
524    }
525
526    #[test]
527    fn hex_path_decrypts_default_tenant_vector() {
528        assert_decrypts_default_tenant_vector(
529            super::secure_defaults(&hex::decode(MASTER_KEY_HEX).expect("vector hex"), &[])
530                .expect("valid key"),
531        );
532    }
533
534    /// Rule 3: the hex path accepts a key decoding to more than 32 bytes;
535    /// the raw-bytes exactly-32 rule must not leak into it.
536    #[test]
537    fn hex_path_accepts_48_byte_key() {
538        let long = [0x61u8; 48];
539        assert!(super::secure_defaults(&long, &[]).is_ok());
540        let previous = [zeroize::Zeroizing::new(vec![0x62u8; 48])];
541        assert!(super::secure_defaults(&long, &previous).is_ok());
542    }
543
544    #[test]
545    #[serial_test::serial]
546    fn env_path_accepts_48_byte_keys() {
547        let (k2, k1) = ("22".repeat(48), "11".repeat(48));
548        assert!(secure_env_defaults_with(Some(&k2), Some(&k1)).is_ok());
549        assert!(secure_env_defaults_with(Some(&"22".repeat(32)), None).is_ok());
550    }
551
552    /// Run `f` with each variable set (`Some`) or removed (`None`),
553    /// restoring every one before anything can panic.
554    fn with_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
555        let saved: Vec<_> = vars
556            .iter()
557            .map(|(var, _)| (*var, std::env::var_os(var)))
558            .collect();
559        for (var, val) in vars {
560            match val {
561                Some(v) => std::env::set_var(var, v),
562                None => std::env::remove_var(var),
563            }
564        }
565        let out = f();
566        for (var, val) in saved {
567            match val {
568                Some(v) => std::env::set_var(var, v),
569                None => std::env::remove_var(var),
570            }
571        }
572        out
573    }
574
575    /// Run `secure_env_defaults` under the given env.
576    fn secure_env_defaults_with(
577        master: Option<&str>,
578        previous: Option<&str>,
579    ) -> Result<crate::CacheKitBuilder, crate::CachekitError> {
580        with_env(
581            &[
582                ("CACHEKIT_MASTER_KEY", master),
583                ("CACHEKIT_PREVIOUS_MASTER_KEYS", previous),
584            ],
585            super::secure_env_defaults,
586        )
587    }
588
589    #[test]
590    #[serial_test::serial]
591    fn env_path_decrypts_default_tenant_vector() {
592        assert_decrypts_default_tenant_vector(
593            secure_env_defaults_with(Some(MASTER_KEY_HEX), None).expect("valid key"),
594        );
595    }
596
597    /// The env constructor with no tenant: `.encryption_from_env()` on the
598    /// builder `from_env()` returns resolves "default", and a namespace on
599    /// that builder never becomes the tenant.
600    #[cfg(feature = "cachekitio")]
601    #[test]
602    #[serial_test::serial]
603    fn from_env_encryption_from_env_decrypts_default_tenant_vector(
604    ) -> Result<(), crate::CachekitError> {
605        let builder = with_env(
606            &[
607                ("CACHEKIT_MASTER_KEY", Some(MASTER_KEY_HEX)),
608                ("CACHEKIT_PREVIOUS_MASTER_KEYS", None),
609                ("CACHEKIT_API_KEY", Some("test-key")),
610                ("CACHEKIT_API_URL", None),
611            ],
612            || {
613                crate::CacheKit::from_env()?
614                    .namespace("my-app")
615                    .encryption_from_env()
616            },
617        )?;
618        assert_decrypts_default_tenant_vector(builder);
619        Ok(())
620    }
621
622    /// An entry written under k1 must still decrypt after the
623    /// env-var rotation runbook promotes k2 and moves k1 to the previous list.
624    #[test]
625    #[serial_test::serial]
626    fn env_path_decrypts_under_previous_key_after_rotation() {
627        let k2 = "22".repeat(32);
628        let rotated = secure_env_defaults_with(Some(&k2), Some(MASTER_KEY_HEX))
629            .expect("valid rotation config");
630        // The default-tenant vector was written under k1 (MASTER_KEY_HEX).
631        assert_decrypts_default_tenant_vector(rotated);
632
633        // Control: without the previous key, k2 alone fails closed.
634        let layer = secure_env_defaults_with(Some(&k2), None)
635            .expect("valid key")
636            .encryption
637            .expect("secure preset must configure encryption");
638        let ciphertext = hex::decode(CIPHERTEXT_HEX).expect("vector hex");
639        assert!(layer.decrypt(&ciphertext, CACHE_KEY).is_err());
640    }
641
642    /// protocol `encryption.json` 1.5.0 `master_key_input.accept_vectors`:
643    /// `master_key_every_hex_digit` (a leading 00 byte, bytes above 7f and
644    /// every hex digit in both places) and `master_key_first_byte_80` (the top
645    /// bit set), each sealed under tenant "default". Read from the copy
646    /// `tests/master_key_input_tests.rs` pins.
647    struct AcceptRow {
648        name: String,
649        master_key_hex: String,
650        cache_key: String,
651        ciphertext: Vec<u8>,
652        plaintext_hex: String,
653    }
654
655    fn accept_rows() -> Vec<AcceptRow> {
656        let all: serde_json::Value =
657            serde_json::from_str(include_str!("../tests/vectors/encryption.json"))
658                .expect("vendored vector file must be valid JSON");
659        all["master_key_input"]["accept_vectors"]
660            .as_array()
661            .expect("accept_vectors must be an array")
662            .iter()
663            .map(|row| {
664                let get = |k: &str| row[k].as_str().expect("accept row field").to_owned();
665                AcceptRow {
666                    name: get("name"),
667                    master_key_hex: get("master_key_hex"),
668                    cache_key: get("cache_key"),
669                    ciphertext: hex::decode(get("ciphertext_hex")).expect("vector hex"),
670                    plaintext_hex: get("plaintext_hex"),
671                }
672            })
673            .collect()
674    }
675
676    fn assert_decrypts_accept_row(builder: &crate::CacheKitBuilder, row: &AcceptRow) {
677        let layer = builder
678            .encryption
679            .as_ref()
680            .expect("secure preset must configure encryption");
681        assert_eq!(layer.tenant_id(), "default");
682        let plaintext = layer
683            .decrypt(&row.ciphertext, &row.cache_key)
684            .map(hex::encode)
685            .map_err(|e| format!("{e:?}"));
686        assert_eq!(
687            plaintext.as_deref(),
688            Ok(row.plaintext_hex.as_str()),
689            "{} must decrypt",
690            row.name
691        );
692    }
693
694    /// Everything `CacheKit::secure` does before it connects, with no tenant.
695    #[test]
696    fn hex_path_decrypts_master_key_input_accept_rows() {
697        for row in accept_rows() {
698            let builder = super::secure_hex_defaults(&row.master_key_hex).expect("valid key");
699            assert_decrypts_accept_row(&builder, &row);
700        }
701    }
702
703    #[test]
704    #[serial_test::serial]
705    fn env_path_decrypts_master_key_input_accept_rows() {
706        for row in accept_rows() {
707            let builder =
708                secure_env_defaults_with(Some(&row.master_key_hex), None).expect("valid key");
709            assert_decrypts_accept_row(&builder, &row);
710        }
711    }
712
713    /// PRE-51: each `master_key_input.accept_vectors` row (current key) and
714    /// `default_tenant.vectors[0]` (previous key), all sealed under tenant
715    /// "default", read through one client. A client that ignores
716    /// `CACHEKIT_PREVIOUS_MASTER_KEYS` reads only the first.
717    #[test]
718    #[serial_test::serial]
719    fn env_path_reads_accept_rows_and_default_tenant_vector_across_rotation() {
720        for row in accept_rows() {
721            let builder = secure_env_defaults_with(Some(&row.master_key_hex), Some(MASTER_KEY_HEX))
722                .expect("valid rotation config");
723            assert_decrypts_accept_row(&builder, &row);
724            assert_decrypts_default_tenant_vector(builder);
725        }
726    }
727
728    #[test]
729    #[serial_test::serial]
730    fn env_path_blank_previous_keys_is_unset() {
731        assert_decrypts_default_tenant_vector(
732            secure_env_defaults_with(Some(MASTER_KEY_HEX), Some("  ")).expect("blank is unset"),
733        );
734    }
735
736    /// Set but unreadable is not unset: a non-UTF-8 value must fail, never
737    /// silently drop the decrypt-only keys.
738    #[cfg(unix)]
739    #[test]
740    #[serial_test::serial]
741    fn env_path_rejects_non_utf8_previous_keys() {
742        use std::os::unix::ffi::OsStringExt;
743        let saved = std::env::var_os("CACHEKIT_PREVIOUS_MASTER_KEYS");
744        std::env::set_var(
745            "CACHEKIT_PREVIOUS_MASTER_KEYS",
746            std::ffi::OsString::from_vec(vec![0x31, 0xff, 0x31]),
747        );
748        let saved_master = std::env::var_os("CACHEKIT_MASTER_KEY");
749        std::env::set_var("CACHEKIT_MASTER_KEY", MASTER_KEY_HEX);
750        let resolved = super::secure_env_defaults();
751        for (var, val) in [
752            ("CACHEKIT_PREVIOUS_MASTER_KEYS", saved),
753            ("CACHEKIT_MASTER_KEY", saved_master),
754        ] {
755            match val {
756                Some(v) => std::env::set_var(var, v),
757                None => std::env::remove_var(var),
758            }
759        }
760        assert!(
761            matches!(
762                &resolved,
763                Err(crate::CachekitError::Config(msg)) if msg.contains("CACHEKIT_PREVIOUS_MASTER_KEYS")
764            ),
765            "non-UTF-8 previous keys must be a Config error naming the variable; got {:?}",
766            resolved.err()
767        );
768    }
769
770    #[test]
771    #[serial_test::serial]
772    fn env_path_rejects_invalid_previous_keys() {
773        let k2 = "22".repeat(32);
774        let four = ["11", "33", "44", "55"].map(|b| b.repeat(32)).join(",");
775        for (previous, why) in [
776            ("not-hex", "invalid hex"),
777            (&*format!("{MASTER_KEY_HEX},"), "empty entry"),
778            (&*"11".repeat(31), "short key"),
779            (&*four, "more than 3"),
780            (&*k2, "current key repeated"),
781        ] {
782            assert!(
783                matches!(
784                    secure_env_defaults_with(Some(&k2), Some(previous)),
785                    Err(crate::CachekitError::Config(_))
786                ),
787                "{why} must be a Config error"
788            );
789        }
790    }
791}
792
793#[cfg(all(
794    test,
795    feature = "cachekitio",
796    feature = "reliability",
797    not(target_arch = "wasm32")
798))]
799#[allow(clippy::expect_used)] // test-only
800mod io_deadline_tests {
801    use crate::reliability::CACHEKITIO_DEADLINES;
802    use crate::CacheKit;
803
804    #[test]
805    fn io_sets_the_cachekitio_retry_deadlines() {
806        let builder = CacheKit::io("ck_test_key").expect("io builds");
807        assert_eq!(builder.retry_deadlines, Some(CACHEKITIO_DEADLINES));
808    }
809
810    /// A backend swapped in after `io` must not inherit budgets sized for
811    /// cachekit.io.
812    #[test]
813    fn replacing_the_backend_drops_the_deadlines() {
814        let other = crate::backend::cachekitio::CachekitIO::builder()
815            .api_key("ck_test_key")
816            .build()
817            .expect("builds");
818        let builder = CacheKit::io("ck_test_key")
819            .expect("io builds")
820            .backend(super::wrap(other));
821        assert_eq!(builder.retry_deadlines, None);
822    }
823}
824
825#[cfg(all(test, feature = "redis"))]
826#[allow(clippy::expect_used)] // test-only
827mod redis_reconnect_tests {
828    use fred::prelude::ClientLike;
829
830    use crate::backend::redis::RedisBackend;
831
832    /// `production` and `secure` build their backend with the same reconnect
833    /// policy and unresponsive check a caller gets from the public builder.
834    #[test]
835    fn presets_and_the_public_builder_share_one_reconnect_policy() {
836        let url = "redis://localhost:6379";
837        let preset = super::reconnecting_redis(url).expect("builds");
838        let public = RedisBackend::builder()
839            .url(url)
840            .auto_reconnect()
841            .build()
842            .expect("builds");
843
844        let policy = preset.client().client_reconnect_policy();
845        assert!(policy.is_some(), "presets reconnect");
846        assert_eq!(policy, public.client().client_reconnect_policy());
847        assert_eq!(
848            preset.client().connection_config().unresponsive.max_timeout,
849            public.client().connection_config().unresponsive.max_timeout
850        );
851        assert_eq!(
852            preset.client().perf_config().default_command_timeout,
853            public.client().perf_config().default_command_timeout
854        );
855    }
856}