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}