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