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