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}