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