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
211    /// * Reliability: **on** — retry with backoff + jitter, circuit
212    ///   breaker, backpressure (max 100 concurrent backend ops)
213    /// * Default TTL: **600 s**
214    /// * Tenant ID: `"default"` for both key derivation and AAD (override via
215    ///   [`.encryption()`](CacheKitBuilder::encryption))
216    ///
217    /// Good for: PII, payments, GDPR/HIPAA-sensitive data.
218    ///
219    /// `master_key_hex` is the master key as a **hex string** — the same
220    /// string `CACHEKIT_MASTER_KEY` holds, decoded to the same key bytes by
221    /// every CacheKit SDK. This is the cross-SDK-portable key path. Use
222    /// exactly **32 bytes (64 hex chars)**: shorter is rejected, and longer
223    /// is accepted here but not by every SDK. Generate one with
224    /// `openssl rand -hex 32`.
225    ///
226    /// Use [`CacheKit::secure_from_env`] to read the key from
227    /// `CACHEKIT_MASTER_KEY`. There is no raw-bytes preset: exactly 32
228    /// decoded key bytes go to [`CacheKitBuilder::encryption_from_bytes`],
229    /// which rejects any other length — including the 64 ASCII bytes of a
230    /// hex string, which would derive a key no other SDK derives.
231    ///
232    /// The key is validated **before** any Redis connection is attempted — a
233    /// bad key is a deterministic local error, never masked by (or paying
234    /// for) network I/O.
235    ///
236    /// # Errors
237    ///
238    /// Returns [`CachekitError::Config`] if `master_key_hex` is not hex or
239    /// decodes to fewer than 32 bytes, or if the URL is invalid; another
240    /// [`CachekitError`] if Redis is unreachable.
241    ///
242    /// # Example
243    ///
244    /// ```no_run
245    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
246    /// // 64 hex chars (32 bytes) from your secret store — `openssl rand -hex 32`.
247    /// let master_key_hex = std::env::var("APP_CACHE_MASTER_KEY")?;
248    /// let cache = cachekit::CacheKit::secure("redis://localhost:6379", &master_key_hex)
249    ///     .await?
250    ///     .build()?;
251    /// let secure = cache.secure_cache()?;
252    /// # Ok(())
253    /// # }
254    /// ```
255    #[cfg(all(feature = "redis", feature = "encryption"))]
256    pub async fn secure(
257        redis_url: &str,
258        master_key_hex: &str,
259    ) -> Result<CacheKitBuilder, CachekitError> {
260        let master_key = crate::config::decode_master_key_hex(master_key_hex, "master_key_hex")?;
261        connect_secure(redis_url, secure_defaults(&master_key, &[])?).await
262    }
263
264    /// **Secure**, keys from the environment — [`CacheKit::secure`] with the
265    /// hex key read from `CACHEKIT_MASTER_KEY`, plus decrypt-only rotation
266    /// keys from `CACHEKIT_PREVIOUS_MASTER_KEYS`.
267    ///
268    /// Reads exactly two variables:
269    ///
270    /// * `CACHEKIT_MASTER_KEY` (required) — the current key; writes encrypt
271    ///   under it. Same hex decoding as [`secure`](CacheKit::secure), so the
272    ///   same value derives the same key bytes as every other CacheKit SDK.
273    ///   Use exactly **32 bytes (64 hex chars)**, the only length every SDK
274    ///   accepts.
275    /// * `CACHEKIT_PREVIOUS_MASTER_KEYS` (optional) — comma-separated hex
276    ///   keys, at most 3, tried in order when the current key fails to
277    ///   decrypt. Unset or blank means none. Validated exactly as
278    ///   [`CachekitConfig::from_env`](crate::CachekitConfig::from_env)
279    ///   validates it; the current key must not appear in the list.
280    ///
281    /// Otherwise the identical preset to [`secure`](CacheKit::secure), tenant
282    /// `"default"` for every key. Never falls back to plaintext.
283    ///
284    /// # Errors
285    ///
286    /// Returns [`CachekitError::Config`] — all before any Redis I/O — when
287    /// `CACHEKIT_MASTER_KEY` is unset, empty, not hex, or shorter than 32
288    /// bytes, or when `CACHEKIT_PREVIOUS_MASTER_KEYS` is set but not valid
289    /// UTF-8, or is non-blank with an empty or invalid entry, more than 3
290    /// entries, or a repeat of the current key.
291    /// Otherwise as [`secure`](CacheKit::secure).
292    ///
293    /// # Example
294    ///
295    /// ```no_run
296    /// # async fn example() -> Result<(), cachekit::CachekitError> {
297    /// let cache = cachekit::CacheKit::secure_from_env("redis://localhost:6379")
298    ///     .await?
299    ///     .build()?;
300    /// let secure = cache.secure_cache()?;
301    /// # Ok(())
302    /// # }
303    /// ```
304    #[cfg(all(feature = "redis", feature = "encryption"))]
305    pub async fn secure_from_env(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
306        connect_secure(redis_url, secure_env_defaults()?).await
307    }
308
309    /// **CachekitIO** — managed SaaS cache, zero infrastructure.
310    ///
311    /// * Backend: [cachekit.io](https://cachekit.io) HTTP API
312    /// * L1 cache: **on** (1 000 entries)
313    /// * Encryption: **no** (add via
314    ///   [`.encryption()`](CacheKitBuilder::encryption))
315    /// * Reliability: **on** — retry with backoff + jitter, circuit
316    ///   breaker, backpressure (max 100 concurrent backend ops)
317    /// * Default TTL: **3 600 s**
318    ///
319    /// Good for: serverless, edge compute, managed caching without Redis.
320    ///
321    /// Use [`CacheKit::io_from_env`] to read the key from `CACHEKIT_API_KEY`.
322    ///
323    /// # Errors
324    ///
325    /// Returns [`CachekitError::Config`] if `api_key` is empty.
326    ///
327    /// # Example
328    ///
329    /// ```no_run
330    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
331    /// let api_key = std::env::var("CACHEKIT_API_KEY")?;
332    /// let cache = cachekit::CacheKit::io(&api_key)?
333    ///     .namespace("edge")
334    ///     .build()?;
335    /// # Ok(())
336    /// # }
337    /// ```
338    #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
339    pub fn io(api_key: &str) -> Result<CacheKitBuilder, CachekitError> {
340        let backend = crate::backend::cachekitio::CachekitIO::builder()
341            .api_key(api_key)
342            .build()?;
343
344        let builder = CacheKitBuilder::default()
345            .backend(wrap(backend))
346            .default_ttl(Duration::from_secs(3600))
347            .l1_capacity(1000);
348        #[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
349        let builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
350        Ok(builder)
351    }
352
353    /// **CachekitIO**, API key from the environment — [`CacheKit::io`] with
354    /// the key read from `CACHEKIT_API_KEY`.
355    ///
356    /// Identical preset to [`io`](CacheKit::io). Reads **only**
357    /// `CACHEKIT_API_KEY` — unlike [`CacheKit::from_env`], never
358    /// `CACHEKIT_MASTER_KEY`, so no encryption is activated.
359    ///
360    /// # Errors
361    ///
362    /// Returns [`CachekitError::Config`] when `CACHEKIT_API_KEY` is unset or
363    /// empty.
364    ///
365    /// # Example
366    ///
367    /// ```no_run
368    /// # fn example() -> Result<(), cachekit::CachekitError> {
369    /// let cache = cachekit::CacheKit::io_from_env()?
370    ///     .namespace("edge")
371    ///     .build()?;
372    /// # Ok(())
373    /// # }
374    /// ```
375    #[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
376    pub fn io_from_env() -> Result<CacheKitBuilder, CachekitError> {
377        let api_key = std::env::var("CACHEKIT_API_KEY")
378            .ok()
379            .map(zeroize::Zeroizing::new)
380            .filter(|k| !k.is_empty())
381            .ok_or_else(|| {
382                CachekitError::Config(
383                    "CACHEKIT_API_KEY is unset or empty: set it or pass the key to CacheKit::io(api_key)"
384                        .to_owned(),
385                )
386            })?;
387        Self::io(&api_key)
388    }
389}
390
391#[cfg(all(test, feature = "redis", feature = "l1"))]
392#[allow(clippy::expect_used)] // test-only: a failing build here should panic loudly
393mod tests {
394    /// `RedisBackend::build()` does not connect, so the preset's defaults
395    /// are observable without a live server.
396    #[test]
397    fn minimal_enables_l1_without_swr() {
398        let backend = crate::backend::redis::RedisBackend::builder()
399            .url("redis://localhost:6379")
400            .build()
401            .expect("valid URL");
402        let cache = super::minimal_defaults(super::wrap(backend))
403            .build()
404            .expect("minimal defaults must build");
405
406        assert!(
407            cache.l1.is_some(),
408            "spec/intent-presets.md § L1 Posture: minimal MUST enable L1"
409        );
410        #[cfg(all(not(feature = "unsync"), not(target_arch = "wasm32")))]
411        assert!(
412            !cache.swr_enabled,
413            "spec/intent-presets.md § L1 Posture: minimal MUST NOT enable SWR"
414        );
415    }
416}
417
418#[cfg(all(test, feature = "redis", feature = "encryption"))]
419#[allow(clippy::expect_used)] // test-only: a failing build here should panic loudly
420mod secure_tests {
421    // protocol test-vectors/encryption.json v1.2.0, `default_tenant.vectors[0]`
422    // (`default_tenant_interop`): spec/intent-presets.md § Master Key Input
423    // rule 5 — with no tenant configured, the preset must derive and bind AAD
424    // under the literal "default", byte-for-byte with every other SDK.
425    const MASTER_KEY_HEX: &str = "6161616161616161616161616161616161616161616161616161616161616161";
426    const CACHE_KEY: &str =
427        "users:get_user:61598716255080080f6456eb065c2e51badfaa4320b0efe97469c29cffee8875";
428    const CIPHERTEXT_HEX: &str =
429        "0d0e0f1011121314151617183c29af318238925ee76d081934adce133c0b4a7c5eb5704102b04582dcbf278ffd"; // pragma: allowlist secret
430    const PLAINTEXT_HEX: &str = "82a36167651ea46e616d65a5616c696365"; // pragma: allowlist secret
431
432    fn assert_decrypts_default_tenant_vector(builder: crate::CacheKitBuilder) {
433        let layer = builder
434            .encryption
435            .expect("secure preset must configure encryption");
436        assert_eq!(layer.tenant_id(), "default");
437        let ciphertext = hex::decode(CIPHERTEXT_HEX).expect("vector hex");
438        let plaintext = layer
439            .decrypt(&ciphertext, CACHE_KEY)
440            .expect("default-tenant vector must decrypt");
441        assert_eq!(hex::encode(plaintext), PLAINTEXT_HEX);
442    }
443
444    #[test]
445    fn hex_path_decrypts_default_tenant_vector() {
446        assert_decrypts_default_tenant_vector(
447            super::secure_defaults(&hex::decode(MASTER_KEY_HEX).expect("vector hex"), &[])
448                .expect("valid key"),
449        );
450    }
451
452    /// Rule 3: the hex path accepts a key decoding to more than 32 bytes;
453    /// the raw-bytes exactly-32 rule must not leak into it.
454    #[test]
455    fn hex_path_accepts_48_byte_key() {
456        let long = [0x61u8; 48];
457        assert!(super::secure_defaults(&long, &[]).is_ok());
458        let previous = [zeroize::Zeroizing::new(vec![0x62u8; 48])];
459        assert!(super::secure_defaults(&long, &previous).is_ok());
460    }
461
462    #[test]
463    #[serial_test::serial]
464    fn env_path_accepts_48_byte_keys() {
465        let (k2, k1) = ("22".repeat(48), "11".repeat(48));
466        assert!(secure_env_defaults_with(Some(&k2), Some(&k1)).is_ok());
467        assert!(secure_env_defaults_with(Some(&"22".repeat(32)), None).is_ok());
468    }
469
470    /// Run `secure_env_defaults` under the given env, restoring both
471    /// variables before anything can panic.
472    fn secure_env_defaults_with(
473        master: Option<&str>,
474        previous: Option<&str>,
475    ) -> Result<crate::CacheKitBuilder, crate::CachekitError> {
476        const VARS: [&str; 2] = ["CACHEKIT_MASTER_KEY", "CACHEKIT_PREVIOUS_MASTER_KEYS"];
477        let saved = VARS.map(std::env::var_os);
478        for (var, val) in VARS.iter().zip([master, previous]) {
479            match val {
480                Some(v) => std::env::set_var(var, v),
481                None => std::env::remove_var(var),
482            }
483        }
484        let resolved = super::secure_env_defaults();
485        for (var, val) in VARS.iter().zip(saved) {
486            match val {
487                Some(v) => std::env::set_var(var, v),
488                None => std::env::remove_var(var),
489            }
490        }
491        resolved
492    }
493
494    #[test]
495    #[serial_test::serial]
496    fn env_path_decrypts_default_tenant_vector() {
497        assert_decrypts_default_tenant_vector(
498            secure_env_defaults_with(Some(MASTER_KEY_HEX), None).expect("valid key"),
499        );
500    }
501
502    /// LAB-6591: an entry written under k1 must still decrypt after the
503    /// env-var rotation runbook promotes k2 and moves k1 to the previous list.
504    #[test]
505    #[serial_test::serial]
506    fn env_path_decrypts_under_previous_key_after_rotation() {
507        let k2 = "22".repeat(32);
508        let rotated = secure_env_defaults_with(Some(&k2), Some(MASTER_KEY_HEX))
509            .expect("valid rotation config");
510        // The default-tenant vector was written under k1 (MASTER_KEY_HEX).
511        assert_decrypts_default_tenant_vector(rotated);
512
513        // Control: without the previous key, k2 alone fails closed.
514        let layer = secure_env_defaults_with(Some(&k2), None)
515            .expect("valid key")
516            .encryption
517            .expect("secure preset must configure encryption");
518        let ciphertext = hex::decode(CIPHERTEXT_HEX).expect("vector hex");
519        assert!(layer.decrypt(&ciphertext, CACHE_KEY).is_err());
520    }
521
522    #[test]
523    #[serial_test::serial]
524    fn env_path_blank_previous_keys_is_unset() {
525        assert_decrypts_default_tenant_vector(
526            secure_env_defaults_with(Some(MASTER_KEY_HEX), Some("  ")).expect("blank is unset"),
527        );
528    }
529
530    /// Set but unreadable is not unset: a non-UTF-8 value must fail, never
531    /// silently drop the decrypt-only keys.
532    #[cfg(unix)]
533    #[test]
534    #[serial_test::serial]
535    fn env_path_rejects_non_utf8_previous_keys() {
536        use std::os::unix::ffi::OsStringExt;
537        let saved = std::env::var_os("CACHEKIT_PREVIOUS_MASTER_KEYS");
538        std::env::set_var(
539            "CACHEKIT_PREVIOUS_MASTER_KEYS",
540            std::ffi::OsString::from_vec(vec![0x31, 0xff, 0x31]),
541        );
542        let saved_master = std::env::var_os("CACHEKIT_MASTER_KEY");
543        std::env::set_var("CACHEKIT_MASTER_KEY", MASTER_KEY_HEX);
544        let resolved = super::secure_env_defaults();
545        for (var, val) in [
546            ("CACHEKIT_PREVIOUS_MASTER_KEYS", saved),
547            ("CACHEKIT_MASTER_KEY", saved_master),
548        ] {
549            match val {
550                Some(v) => std::env::set_var(var, v),
551                None => std::env::remove_var(var),
552            }
553        }
554        assert!(
555            matches!(
556                &resolved,
557                Err(crate::CachekitError::Config(msg)) if msg.contains("CACHEKIT_PREVIOUS_MASTER_KEYS")
558            ),
559            "non-UTF-8 previous keys must be a Config error naming the variable; got {:?}",
560            resolved.err()
561        );
562    }
563
564    #[test]
565    #[serial_test::serial]
566    fn env_path_rejects_invalid_previous_keys() {
567        let k2 = "22".repeat(32);
568        let four = ["11", "33", "44", "55"].map(|b| b.repeat(32)).join(",");
569        for (previous, why) in [
570            ("not-hex", "invalid hex"),
571            (&*format!("{MASTER_KEY_HEX},"), "empty entry"),
572            (&*"11".repeat(31), "short key"),
573            (&*four, "more than 3"),
574            (&*k2, "current key repeated"),
575        ] {
576            assert!(
577                matches!(
578                    secure_env_defaults_with(Some(&k2), Some(previous)),
579                    Err(crate::CachekitError::Config(_))
580                ),
581                "{why} must be a Config error"
582            );
583        }
584    }
585}